fast path for scrolling

This commit is contained in:
Will McGugan
2023-02-17 10:42:42 +00:00
parent 06fa8d7e8e
commit 11d10db1ab
10 changed files with 237 additions and 86 deletions

View File

@@ -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/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). 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 ## [0.11.0] - 2023-02-15
### Added ### Added

View File

@@ -28,12 +28,12 @@
Screen { Screen {
layers: ruler; layers: ruler;
overflow: hidden;
} }
Ruler { Ruler {
layer: ruler; layer: ruler;
dock: right; dock: right;
overflow: hidden;
width: 1; width: 1;
background: $accent; background: $accent;
} }

View File

@@ -243,7 +243,7 @@ class Compositor:
size: Size of the area to be filled. size: Size of the area to be filled.
Returns: Returns:
Hidden shown and resized widgets. Hidden, shown and resized widgets.
""" """
self._cuts = None self._cuts = None
self._layers = None self._layers = None
@@ -257,15 +257,10 @@ class Compositor:
old_map = self.map old_map = self.map
old_widgets = old_map.keys() 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() 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 # Replace map and widgets
self.map = map self.map = map
self.widgets = widgets self.widgets = widgets
@@ -276,13 +271,7 @@ class Compositor:
# Widgets in both new and old # Widgets in both new and old
common_widgets = old_widgets & new_widgets common_widgets = old_widgets & new_widgets
# Widgets with changed size # Mark dirty regions.
resized_widgets = {
widget
for widget, (region, *_) in changes
if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
}
screen_region = size.region screen_region = size.region
if screen_region not in self._dirty_regions: if screen_region not in self._dirty_regions:
regions = { regions = {
@@ -295,12 +284,63 @@ class Compositor:
} }
self._dirty_regions.update(regions) 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( return ReflowResult(
hidden=hidden_widgets, hidden=hidden_widgets,
shown=shown_widgets, shown=shown_widgets,
resized=resized_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 @property
def full_map(self) -> CompositorMap: def full_map(self) -> CompositorMap:
if self.root is None or not self.map: if self.root is None or not self.map:

View File

@@ -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

View File

@@ -49,6 +49,12 @@ class Layout(Message, verbose=True):
return isinstance(message, Layout) 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 @rich.repr.auto
class InvokeLater(Message, verbose=True, bubble=False): class InvokeLater(Message, verbose=True, bubble=False):
def __init__(self, sender: MessagePump, callback: CallbackType) -> None: def __init__(self, sender: MessagePump, callback: CallbackType) -> None:

View File

@@ -80,16 +80,6 @@ class Screen(Widget):
) )
return self._update_timer 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: def render(self) -> RenderableType:
background = self.styles.background background = self.styles.background
if background.is_transparent: if background.is_transparent:
@@ -377,7 +367,12 @@ class Screen(Widget):
if self._layout_required: if self._layout_required:
self._refresh_layout() self._refresh_layout()
self._layout_required = False self._layout_required = False
self._scroll_required = False
self._dirty_widgets.clear() self._dirty_widgets.clear()
elif self._scroll_required:
self._refresh_layout(scroll=True)
self._scroll_required = False
if self._repaint_required: if self._repaint_required:
self._dirty_widgets.clear() self._dirty_widgets.clear()
self._dirty_widgets.add(self) self._dirty_widgets.add(self)
@@ -426,7 +421,9 @@ class Screen(Widget):
self._callbacks.append(callback) self._callbacks.append(callback)
self.check_idle() 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).""" """Refresh the layout (can change size and positions of widgets)."""
size = self.outer_size if size is None else size size = self.outer_size if size is None else size
if not size: if not size:
@@ -435,34 +432,37 @@ class Screen(Widget):
self._compositor.update_widgets(self._dirty_widgets) self._compositor.update_widgets(self._dirty_widgets)
self.update_timer.pause() self.update_timer.pause()
try: try:
hidden, shown, resized = self._compositor.reflow(self, size) if scroll:
Hide = events.Hide self._compositor.reflow_visible(self, size)
Show = events.Show else:
hidden, shown, resized = self._compositor.reflow(self, size)
Hide = events.Hide
Show = events.Show
for widget in hidden: for widget in hidden:
widget.post_message_no_wait(Hide(self)) 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 # We want to send a resize event to widgets that were just added or change since last layout
send_resize = shown | resized send_resize = shown | resized
ResizeEvent = events.Resize ResizeEvent = events.Resize
layers = self._compositor.layers layers = self._compositor.layers
for widget, ( for widget, (
region, region,
_order, _order,
_clip, _clip,
virtual_size, virtual_size,
container_size, container_size,
_, _,
) in layers: ) in layers:
widget._size_updated(region.size, virtual_size, container_size) widget._size_updated(region.size, virtual_size, container_size)
if widget in send_resize: if widget in send_resize:
widget.post_message_no_wait( widget.post_message_no_wait(
ResizeEvent(self, region.size, virtual_size, container_size) ResizeEvent(self, region.size, virtual_size, container_size)
) )
for widget in shown: for widget in shown:
widget.post_message_no_wait(Show(self)) widget.post_message_no_wait(Show(self))
except Exception as error: except Exception as error:
self.app._handle_exception(error) self.app._handle_exception(error)
@@ -487,6 +487,12 @@ class Screen(Widget):
self._layout_required = True self._layout_required = True
self.check_idle() 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): def _screen_resized(self, size: Size):
"""Called by App when the screen is resized.""" """Called by App when the screen is resized."""
self._refresh_layout(size, full=True) self._refresh_layout(size, full=True)

View File

@@ -240,6 +240,7 @@ class Widget(DOMNode):
self._container_size = Size(0, 0) self._container_size = Size(0, 0)
self._layout_required = False self._layout_required = False
self._repaint_required = False self._repaint_required = False
self._scroll_required = False
self._default_layout = VerticalLayout() self._default_layout = VerticalLayout()
self._animate: BoundAnimator | None = None self._animate: BoundAnimator | None = None
self.highlight_style: Style | None = None self.highlight_style: Style | None = None
@@ -1710,7 +1711,7 @@ class Widget(DOMNode):
""" """
return self.scroll_to( return self.scroll_to(
y=self.scroll_target_y - self.container_size.height, y=self.scroll_y - self.container_size.height,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,
@@ -1742,7 +1743,7 @@ class Widget(DOMNode):
""" """
return self.scroll_to( return self.scroll_to(
y=self.scroll_target_y + self.container_size.height, y=self.scroll_y + self.container_size.height,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,
@@ -1776,7 +1777,7 @@ class Widget(DOMNode):
if speed is None and duration is None: if speed is None and duration is None:
duration = 0.3 duration = 0.3
return self.scroll_to( return self.scroll_to(
x=self.scroll_target_x - self.container_size.width, x=self.scroll_x - self.container_size.width,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,
@@ -1810,7 +1811,7 @@ class Widget(DOMNode):
if speed is None and duration is None: if speed is None and duration is None:
duration = 0.3 duration = 0.3
return self.scroll_to( return self.scroll_to(
x=self.scroll_target_x + self.container_size.width, x=self.scroll_x + self.container_size.width,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,
@@ -2264,7 +2265,7 @@ class Widget(DOMNode):
def _refresh_scroll(self) -> None: def _refresh_scroll(self) -> None:
"""Refreshes the scroll position.""" """Refreshes the scroll position."""
self._layout_required = True self._scroll_required = True
self.check_idle() self.check_idle()
def refresh( def refresh(
@@ -2373,6 +2374,9 @@ class Widget(DOMNode):
except NoScreen: except NoScreen:
pass pass
else: else:
if self._scroll_required:
self._scroll_required = False
screen.post_message_no_wait(messages.UpdateScroll(self))
if self._repaint_required: if self._repaint_required:
self._repaint_required = False self._repaint_required = False
screen.post_message_no_wait(messages.Update(self, self)) screen.post_message_no_wait(messages.Update(self, self))

View File

@@ -9,10 +9,10 @@ from textual.widget import Widget
def test_arrange_empty(): def test_arrange_empty():
container = Widget(id="container") container = Widget(id="container")
placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) result = arrange(container, [], Size(80, 24), Size(80, 24))
assert placements == [] assert result.placements == []
assert widgets == set() assert result.widgets == set()
assert spacing == Spacing(0, 0, 0, 0) assert result.spacing == Spacing(0, 0, 0, 0)
def test_arrange_dock_top(): def test_arrange_dock_top():
@@ -22,17 +22,16 @@ def test_arrange_dock_top():
header.styles.dock = "top" header.styles.dock = "top"
header.styles.height = "1" header.styles.height = "1"
placements, widgets, spacing = arrange( result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
container, [child, header], Size(80, 24), Size(80, 24)
) assert result.placements == [
assert placements == [
WidgetPlacement( WidgetPlacement(
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
), ),
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
] ]
assert widgets == {child, header} assert result.widgets == {child, header}
assert spacing == Spacing(1, 0, 0, 0) assert result.spacing == Spacing(1, 0, 0, 0)
def test_arrange_dock_left(): def test_arrange_dock_left():
@@ -42,17 +41,15 @@ def test_arrange_dock_left():
header.styles.dock = "left" header.styles.dock = "left"
header.styles.width = "10" header.styles.width = "10"
placements, widgets, spacing = arrange( result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
container, [child, header], Size(80, 24), Size(80, 24) assert result.placements == [
)
assert placements == [
WidgetPlacement( WidgetPlacement(
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
), ),
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
] ]
assert widgets == {child, header} assert result.widgets == {child, header}
assert spacing == Spacing(0, 0, 0, 10) assert result.spacing == Spacing(0, 0, 0, 10)
def test_arrange_dock_right(): def test_arrange_dock_right():
@@ -62,17 +59,15 @@ def test_arrange_dock_right():
header.styles.dock = "right" header.styles.dock = "right"
header.styles.width = "10" header.styles.width = "10"
placements, widgets, spacing = arrange( result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
container, [child, header], Size(80, 24), Size(80, 24) assert result.placements == [
)
assert placements == [
WidgetPlacement( WidgetPlacement(
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
), ),
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
] ]
assert widgets == {child, header} assert result.widgets == {child, header}
assert spacing == Spacing(0, 10, 0, 0) assert result.spacing == Spacing(0, 10, 0, 0)
def test_arrange_dock_bottom(): def test_arrange_dock_bottom():
@@ -82,17 +77,15 @@ def test_arrange_dock_bottom():
header.styles.dock = "bottom" header.styles.dock = "bottom"
header.styles.height = "1" header.styles.height = "1"
placements, widgets, spacing = arrange( result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
container, [child, header], Size(80, 24), Size(80, 24) assert result.placements == [
)
assert placements == [
WidgetPlacement( WidgetPlacement(
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
), ),
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
] ]
assert widgets == {child, header} assert result.widgets == {child, header}
assert spacing == Spacing(0, 0, 1, 0) assert result.spacing == Spacing(0, 0, 1, 0)
def test_arrange_dock_badly(): def test_arrange_dock_badly():

View File

@@ -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)]

View File

@@ -26,21 +26,18 @@ class VisibleTester(App[None]):
async def test_visibility_changes() -> None: async def test_visibility_changes() -> None:
"""Test changing visibility via code and CSS.""" """Test changing visibility via code and CSS."""
async with VisibleTester().run_test() as pilot: 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("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").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 assert pilot.app.query_one("#hide-via-css").visible is True
pilot.app.query_one("#hide-via-code").styles.visibility = "hidden" pilot.app.query_one("#hide-via-code").styles.visibility = "hidden"
await pilot.pause(0) 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("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-code").visible is False
assert pilot.app.query_one("#hide-via-css").visible is True assert pilot.app.query_one("#hide-via-css").visible is True
pilot.app.query_one("#hide-via-css").set_class(True, "hidden") pilot.app.query_one("#hide-via-css").set_class(True, "hidden")
await pilot.pause(0) 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("#keep").visible is True
assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-code").visible is False
assert pilot.app.query_one("#hide-via-css").visible is False assert pilot.app.query_one("#hide-via-css").visible is False