From 11d10db1ab9cc723a5f12bf333d22f9b00d5e9a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 10:42:42 +0000 Subject: [PATCH] fast path for scrolling --- CHANGELOG.md | 10 +++ docs/examples/styles/height_comparison.css | 2 +- src/textual/_compositor.py | 68 +++++++++++++---- src/textual/_spatial_map.py | 87 ++++++++++++++++++++++ src/textual/messages.py | 6 ++ src/textual/screen.py | 76 ++++++++++--------- src/textual/widget.py | 14 ++-- tests/test_arrange.py | 49 ++++++------ tests/test_spatial_map.py | 8 ++ tests/test_visibility_change.py | 3 - 10 files changed, 237 insertions(+), 86 deletions(-) create mode 100644 src/textual/_spatial_map.py create mode 100644 tests/test_spatial_map.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b83ccf89..3513cba79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.12.0] - Unreleased + +### Changed + +- Scrolling by page now adds to current position. + +### Removed + +- Removed `screen.visible_widgets` and `screen.widgets` + ## [0.11.0] - 2023-02-15 ### Added diff --git a/docs/examples/styles/height_comparison.css b/docs/examples/styles/height_comparison.css index 10902dda5..d5da04f78 100644 --- a/docs/examples/styles/height_comparison.css +++ b/docs/examples/styles/height_comparison.css @@ -28,12 +28,12 @@ Screen { layers: ruler; + overflow: hidden; } Ruler { layer: ruler; dock: right; - overflow: hidden; width: 1; background: $accent; } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f6df38018..5502675bf 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -243,7 +243,7 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden shown and resized widgets. + Hidden, shown and resized widgets. """ self._cuts = None self._layers = None @@ -257,15 +257,10 @@ class Compositor: old_map = self.map old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size, visible_only=True) + map, widgets = self._arrange_root(parent, size) new_widgets = map.keys() - # Newly visible widgets - shown_widgets = new_widgets - old_widgets - # Newly hidden widgets - hidden_widgets = self.widgets - widgets - # Replace map and widgets self.map = map self.widgets = widgets @@ -276,13 +271,7 @@ class Compositor: # Widgets in both new and old common_widgets = old_widgets & new_widgets - # Widgets with changed size - resized_widgets = { - widget - for widget, (region, *_) in changes - if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) - } - + # Mark dirty regions. screen_region = size.region if screen_region not in self._dirty_regions: regions = { @@ -295,12 +284,63 @@ class Compositor: } self._dirty_regions.update(regions) + resized_widgets = { + widget + for widget, (region, *_) in changes + if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) + } + # Newly visible widgets + shown_widgets = new_widgets - old_widgets + # Newly hidden widgets + hidden_widgets = self.widgets - widgets return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) + def reflow_visible(self, parent: Widget, size: Size) -> None: + """Reflow only the visible children. + + This is a fast-path for scrolling. + + Args: + parent: The root widget. + size: Size of the area to be filled. + + """ + self._cuts = None + self._layers = None + self._layers_visible = None + self._visible_widgets = None + self._full_map = None + self.root = parent + self.size = size + + # Keep a copy of the old map because we're going to compare it with the update + old_map = self.map + map, widgets = self._arrange_root(parent, size, visible_only=True) + + # Replace map and widgets + self.map = map + self.widgets = widgets + + # Contains widgets + geometry for every widget that changed (added, removed, or updated) + changes = map.items() ^ old_map.items() + + # Mark dirty regions. + screen_region = size.region + if screen_region not in self._dirty_regions: + regions = { + region + for region in ( + map_geometry.clip.intersection(map_geometry.region) + for _, map_geometry in changes + ) + if region + } + self._dirty_regions.update(regions) + @property def full_map(self) -> CompositorMap: if self.root is None or not self.map: diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py new file mode 100644 index 000000000..480e665ad --- /dev/null +++ b/src/textual/_spatial_map.py @@ -0,0 +1,87 @@ +from collections import defaultdict +from itertools import product +from typing import Generic, Iterable, TypeVar + +from .geometry import Region + +ValueType = TypeVar("ValueType") + + +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with a rectangular regions + in Euclidean space, and efficiently queried. + + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[tuple[int, int], list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid(self, region: Region) -> Iterable[tuple[int, int]]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid squares (tuple of 2 values). + """ + x1, y1, width, height = region + x2 = x1 + width + y2 = y1 + height + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, 1 + x2 // grid_width), + range(y1 // grid_height, 1 + y2 // grid_height), + ) + + def insert( + self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] + ) -> None: + """Insert values in to the Spatial map. + + Args: + regions_and_values: An iterable of Regions and values. + """ + append_fixed = self._fixed.append + get_grid_list = self._map.__getitem__ + _region_to_grid = self._region_to_grid + total_region = self.total_region + for region, fixed, value in regions_and_values: + total_region = total_region.union(region) + if fixed: + append_fixed(value) + else: + for grid in _region_to_grid(region): + get_grid_list(grid).append(value) + self.total_region = total_region + + def get_values_in_region(self, region: Region) -> list[ValueType]: + """Get a set of values that are under a given region. + + Note that this may return some false positives. + + Args: + region: A region. + + Returns: + A set of values under the region. + """ + results: list[ValueType] = self._fixed.copy() + add_results = results.extend + get_grid_values = self._map.get + for grid in self._region_to_grid(region): + grid_values = get_grid_values(grid) + if grid_values is not None: + add_results(grid_values) + unique_values = list(dict.fromkeys(results)) + return unique_values diff --git a/src/textual/messages.py b/src/textual/messages.py index fcfe2ad2c..4e14c8902 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -49,6 +49,12 @@ class Layout(Message, verbose=True): return isinstance(message, Layout) +@rich.repr.auto +class UpdateScroll(Message, verbose=True): + def can_replace(self, message: Message) -> bool: + return isinstance(message, UpdateScroll) + + @rich.repr.auto class InvokeLater(Message, verbose=True, bubble=False): def __init__(self, sender: MessagePump, callback: CallbackType) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c768aab63..59e622caf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,16 +80,6 @@ class Screen(Widget): ) return self._update_timer - # @property - # def widgets(self) -> list[Widget]: - # """Get all widgets.""" - # return list(self._compositor.map.keys()) - - # @property - # def visible_widgets(self) -> list[Widget]: - # """Get a list of visible widgets.""" - # return list(self._compositor.visible_widgets) - def render(self) -> RenderableType: background = self.styles.background if background.is_transparent: @@ -377,7 +367,12 @@ class Screen(Widget): if self._layout_required: self._refresh_layout() self._layout_required = False + self._scroll_required = False self._dirty_widgets.clear() + elif self._scroll_required: + self._refresh_layout(scroll=True) + self._scroll_required = False + if self._repaint_required: self._dirty_widgets.clear() self._dirty_widgets.add(self) @@ -426,7 +421,9 @@ class Screen(Widget): self._callbacks.append(callback) self.check_idle() - def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: + def _refresh_layout( + self, size: Size | None = None, full: bool = False, scroll: bool = False + ) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size if not size: @@ -435,34 +432,37 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() try: - hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide - Show = events.Show + if scroll: + self._compositor.reflow_visible(self, size) + else: + hidden, shown, resized = self._compositor.reflow(self, size) + Hide = events.Hide + Show = events.Show - for widget in hidden: - widget.post_message_no_wait(Hide(self)) + for widget in hidden: + widget.post_message_no_wait(Hide(self)) - # We want to send a resize event to widgets that were just added or change since last layout - send_resize = shown | resized - ResizeEvent = events.Resize + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized + ResizeEvent = events.Resize - layers = self._compositor.layers - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - widget._size_updated(region.size, virtual_size, container_size) - if widget in send_resize: - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + if widget in send_resize: + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) - for widget in shown: - widget.post_message_no_wait(Show(self)) + for widget in shown: + widget.post_message_no_wait(Show(self)) except Exception as error: self.app._handle_exception(error) @@ -487,6 +487,12 @@ class Screen(Widget): self._layout_required = True self.check_idle() + async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: + message.stop() + message.prevent_default() + self._scroll_required = True + self.check_idle() + def _screen_resized(self, size: Size): """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..165cd4ca4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -240,6 +240,7 @@ class Widget(DOMNode): self._container_size = Size(0, 0) self._layout_required = False self._repaint_required = False + self._scroll_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None @@ -1710,7 +1711,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - self.container_size.height, + y=self.scroll_y - self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1742,7 +1743,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.container_size.height, + y=self.scroll_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1776,7 +1777,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x - self.container_size.width, + x=self.scroll_x - self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -1810,7 +1811,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x + self.container_size.width, + x=self.scroll_x + self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -2264,7 +2265,7 @@ class Widget(DOMNode): def _refresh_scroll(self) -> None: """Refreshes the scroll position.""" - self._layout_required = True + self._scroll_required = True self.check_idle() def refresh( @@ -2373,6 +2374,9 @@ class Widget(DOMNode): except NoScreen: pass else: + if self._scroll_required: + self._scroll_required = False + screen.post_message_no_wait(messages.UpdateScroll(self)) if self._repaint_required: self._repaint_required = False screen.post_message_no_wait(messages.Update(self, self)) diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 31e030b1b..582f54bc1 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -9,10 +9,10 @@ from textual.widget import Widget def test_arrange_empty(): container = Widget(id="container") - placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) - assert placements == [] - assert widgets == set() - assert spacing == Spacing(0, 0, 0, 0) + result = arrange(container, [], Size(80, 24), Size(80, 24)) + assert result.placements == [] + assert result.widgets == set() + assert result.spacing == Spacing(0, 0, 0, 0) def test_arrange_dock_top(): @@ -22,17 +22,16 @@ def test_arrange_dock_top(): header.styles.dock = "top" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + + assert result.placements == [ WidgetPlacement( Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(1, 0, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(1, 0, 0, 0) def test_arrange_dock_left(): @@ -42,17 +41,15 @@ def test_arrange_dock_left(): header.styles.dock = "left" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 0, 10) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 0, 10) def test_arrange_dock_right(): @@ -62,17 +59,15 @@ def test_arrange_dock_right(): header.styles.dock = "right" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 10, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 10, 0, 0) def test_arrange_dock_bottom(): @@ -82,17 +77,15 @@ def test_arrange_dock_bottom(): header.styles.dock = "bottom" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 1, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 1, 0) def test_arrange_dock_badly(): diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py new file mode 100644 index 000000000..18fd16648 --- /dev/null +++ b/tests/test_spatial_map.py @@ -0,0 +1,8 @@ +from textual._spatial_map import SpatialMap +from textual.geometry import Region + + +def test_region_to_grid(): + spatial_map = SpatialMap() + + assert list(spatial_map._region_to_grid(Region(0, 0, 10, 10))) == [(0, 0)] diff --git a/tests/test_visibility_change.py b/tests/test_visibility_change.py index b06ea0e17..f79f18a6f 100644 --- a/tests/test_visibility_change.py +++ b/tests/test_visibility_change.py @@ -26,21 +26,18 @@ class VisibleTester(App[None]): async def test_visibility_changes() -> None: """Test changing visibility via code and CSS.""" async with VisibleTester().run_test() as pilot: - assert len(pilot.app.screen.visible_widgets) == 5 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is True assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-code").styles.visibility = "hidden" await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 4 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-css").set_class(True, "hidden") await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 3 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is False