Merge pull request #1823 from Textualize/optimize-scroll

Optimize scroll with a Spatial Map
This commit is contained in:
Will McGugan
2023-02-21 10:41:04 +00:00
committed by GitHub
16 changed files with 538 additions and 177 deletions

View File

@@ -5,7 +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/).
## Unreleased
## [0.12.0] - Unreleased
### Changed
- Scrolling by page now adds to current position.
### Removed
- Removed `screen.visible_widgets` and `screen.widgets`
### 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

@@ -11,7 +11,7 @@ from rich.markdown import Markdown
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.containers import Content from textual.containers import Content
from textual.widgets import Static, Input from textual.widgets import Input, Static
class DictionaryApp(App): class DictionaryApp(App):
@@ -41,7 +41,12 @@ class DictionaryApp(App):
"""Looks up a word.""" """Looks up a word."""
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
results = (await client.get(url)).json() response = await client.get(url)
try:
results = response.json()
except Exception:
self.query_one("#results", Static).update(response.text)
return
if word == self.query_one(Input).value: if word == self.query_one(Input).value:
markdown = self.make_word_markdown(results) markdown = self.make_word_markdown(results)

View File

@@ -128,4 +128,4 @@ def arrange(
placements.extend(layout_placements) placements.extend(layout_placements)
return placements, arrange_widgets, scroll_spacing return DockArrangeResult(placements, arrange_widgets, scroll_spacing)

View File

@@ -167,6 +167,7 @@ class Compositor:
def __init__(self) -> None: def __init__(self) -> None:
# A mapping of Widget on to its "render location" (absolute position / depth) # A mapping of Widget on to its "render location" (absolute position / depth)
self.map: CompositorMap = {} self.map: CompositorMap = {}
self._full_map: CompositorMap | None = None
self._layers: list[tuple[Widget, MapGeometry]] | None = None self._layers: list[tuple[Widget, MapGeometry]] | None = None
# All widgets considered in the arrangement # All widgets considered in the arrangement
@@ -241,29 +242,27 @@ 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
self._layers_visible = None self._layers_visible = None
self._visible_widgets = None self._visible_widgets = None
self._full_map = None
self.root = parent self.root = parent
self.size = size self.size = size
# Keep a copy of the old map because we're going to compare it with the update # Keep a copy of the old map because we're going to compare it with the update
old_map = self.map.copy() old_map = self.map
old_widgets = old_map.keys() old_widgets = old_map.keys()
map, widgets = self._arrange_root(parent, size) map, widgets = self._arrange_root(parent, size)
new_widgets = map.keys()
# Newly visible widgets new_widgets = map.keys()
shown_widgets = new_widgets - old_widgets
# Newly hidden widgets
hidden_widgets = old_widgets - new_widgets
# Replace map and widgets # Replace map and widgets
self.map = map self.map = map
self._full_map = map
self.widgets = widgets self.widgets = widgets
# Contains widgets + geometry for every widget that changed (added, removed, or updated) # Contains widgets + geometry for every widget that changed (added, removed, or updated)
@@ -272,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 = {
@@ -291,12 +284,80 @@ 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) -> set[Widget]:
"""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.
Returns:
Set of widgets that were exposed by the scroll.
"""
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)
exposed_widgets = map.keys() - old_map.keys()
# 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)
return exposed_widgets
@property
def full_map(self) -> CompositorMap:
"""Lazily built compositor map that covers all widgets."""
if self.root is None or not self.map:
return {}
if self._full_map is None:
map, widgets = self._arrange_root(self.root, self.size, visible_only=False)
self._full_map = map
return self._full_map
@property @property
def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
"""Get a mapping of widgets on to region and clip. """Get a mapping of widgets on to region and clip.
@@ -322,9 +383,9 @@ class Compositor:
return self._visible_widgets return self._visible_widgets
def _arrange_root( def _arrange_root(
self, root: Widget, size: Size self, root: Widget, size: Size, visible_only: bool = True
) -> tuple[CompositorMap, set[Widget]]: ) -> tuple[CompositorMap, set[Widget]]:
"""Arrange a widgets children based on its layout attribute. """Arrange a widget's children based on its layout attribute.
Args: Args:
root: Top level widget. root: Top level widget.
@@ -337,6 +398,7 @@ class Compositor:
map: CompositorMap = {} map: CompositorMap = {}
widgets: set[Widget] = set() widgets: set[Widget] = set()
add_new_widget = widgets.add
layer_order: int = 0 layer_order: int = 0
def add_widget( def add_widget(
@@ -362,7 +424,7 @@ class Compositor:
visible = visibility == "visible" visible = visibility == "visible"
if visible: if visible:
widgets.add(widget) add_new_widget(widget)
styles_offset = widget.styles.offset styles_offset = widget.styles.offset
layout_offset = ( layout_offset = (
styles_offset.resolve(region.size, clip.size) styles_offset.resolve(region.size, clip.size)
@@ -389,22 +451,26 @@ class Compositor:
if widget.is_container: if widget.is_container:
# Arrange the layout # Arrange the layout
placements, arranged_widgets, spacing = widget._arrange( arrange_result = widget._arrange(child_region.size)
child_region.size arranged_widgets = arrange_result.widgets
) spacing = arrange_result.spacing
widgets.update(arranged_widgets) widgets.update(arranged_widgets)
if placements: if visible_only:
placements = arrange_result.get_visible_placements(
container_size.region + widget.scroll_offset
)
else:
placements = arrange_result.placements
total_region = total_region.union(arrange_result.total_region)
# An offset added to all placements # An offset added to all placements
placement_offset = container_region.offset placement_offset = container_region.offset
placement_scroll_offset = ( placement_scroll_offset = placement_offset - widget.scroll_offset
placement_offset - widget.scroll_offset
)
_layers = widget.layers _layers = widget.layers
layers_to_index = { layers_to_index = {
layer_name: index layer_name: index for index, layer_name in enumerate(_layers)
for index, layer_name in enumerate(_layers)
} }
get_layer_index = layers_to_index.get get_layer_index = layers_to_index.get
@@ -437,10 +503,12 @@ class Compositor:
sub_clip, sub_clip,
visible, visible,
) )
layer_order -= 1 layer_order -= 1
if visible: if visible:
# Add any scrollbars # Add any scrollbars
if any(widget.scrollbars_enabled):
for chrome_widget, chrome_region in widget._arrange_scrollbars( for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region container_region
): ):
@@ -518,6 +586,9 @@ class Compositor:
"""Get the offset of a widget.""" """Get the offset of a widget."""
try: try:
return self.map[widget].region.offset return self.map[widget].region.offset
except KeyError:
try:
return self.full_map[widget].region.offset
except KeyError: except KeyError:
raise errors.NoWidget("Widget is not in layout") raise errors.NoWidget("Widget is not in layout")
@@ -601,8 +672,13 @@ class Compositor:
Widget's composition information. Widget's composition information.
""" """
if self.root is None or not self.map:
raise errors.NoWidget("Widget is not in layout")
try: try:
region = self.map[widget] region = self.map[widget]
except KeyError:
try:
return self.full_map[widget]
except KeyError: except KeyError:
raise errors.NoWidget("Widget is not in layout") raise errors.NoWidget("Widget is not in layout")
else: else:
@@ -788,6 +864,7 @@ class Compositor:
widget: Widget to update. widget: Widget to update.
""" """
self._full_map = None
regions: list[Region] = [] regions: list[Region] = []
add_region = regions.append add_region = regions.append
get_widget = self.visible_widgets.__getitem__ get_widget = self.visible_widgets.__getitem__

View File

@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, NamedTuple from typing import TYPE_CHECKING, ClassVar, NamedTuple
from ._spatial_map import SpatialMap
from .geometry import Region, Size, Spacing from .geometry import Region, Size, Spacing
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -11,7 +13,55 @@ if TYPE_CHECKING:
from .widget import Widget from .widget import Widget
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]"
@dataclass
class DockArrangeResult:
placements: list[WidgetPlacement]
"""A `WidgetPlacement` for every widget to describe it's location on screen."""
widgets: set[Widget]
"""A set of widgets in the arrangement."""
spacing: Spacing
"""Shared spacing around the widgets."""
_spatial_map: SpatialMap[WidgetPlacement] | None = None
@property
def spatial_map(self) -> SpatialMap[WidgetPlacement]:
"""A lazy-calculated spatial map."""
if self._spatial_map is None:
self._spatial_map = SpatialMap()
self._spatial_map.insert(
(
placement.region.grow(placement.margin),
placement.fixed,
placement,
)
for placement in self.placements
)
return self._spatial_map
@property
def total_region(self) -> Region:
"""The total area occupied by the arrangement.
Returns:
A Region.
"""
return self.spatial_map.total_region
def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
"""Get the placements visible within the given region.
Args:
region: A region.
Returns:
Set of placements.
"""
visible_placements = self.spatial_map.get_values_in_region(region)
return visible_placements
class WidgetPlacement(NamedTuple): class WidgetPlacement(NamedTuple):
@@ -61,7 +111,7 @@ class Layout(ABC):
width = 0 width = 0
else: else:
# Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway
placements, _, _ = widget._arrange(Size(0, 0)) placements = widget._arrange(Size(0, 0)).placements
width = max( width = max(
[ [
placement.region.right + placement.margin.right placement.region.right + placement.margin.right
@@ -89,7 +139,7 @@ class Layout(ABC):
height = 0 height = 0
else: else:
# Use a height of zero to ignore relative heights # Use a height of zero to ignore relative heights
placements, _, _ = widget._arrange(Size(width, 0)) placements = widget._arrange(Size(width, 0)).placements
height = max( height = max(
[ [
placement.region.bottom + placement.margin.bottom placement.region.bottom + placement.margin.bottom

103
src/textual/_spatial_map.py Normal file
View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from collections import defaultdict
from itertools import product
from typing import Generic, Iterable, TypeVar
from typing_extensions import TypeAlias
from .geometry import Region
ValueType = TypeVar("ValueType")
GridCoordinate: TypeAlias = "tuple[int, int]"
class SpatialMap(Generic[ValueType]):
"""A spatial map allows for data to be associated with rectangular regions
in Euclidean space, and efficiently queried.
When the SpatialMap is populated, a reference to each value is placed into one or
more buckets associated with a regular grid that covers 2D space.
The SpatialMap is able to quickly retrieve the values under a given "window" region
by combining the values in the grid squares under the visible area.
"""
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[GridCoordinate, list[ValueType]] = defaultdict(list)
self._fixed: list[ValueType] = []
def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]:
"""Get the grid squares under a region.
Args:
region: A region.
Returns:
Iterable of grid coordinates (tuple of 2 values).
"""
# (x1, y1) is the coordinate of the top left cell
# (x2, y2) is the coordinate of the bottom right cell
x1, y1, width, height = region
x2 = x1 + width - 1
y2 = y1 + height - 1
grid_width, grid_height = self._grid_size
return product(
range(x1 // grid_width, x2 // grid_width + 1),
range(y1 // grid_height, y2 // grid_height + 1),
)
def insert(
self, regions_and_values: Iterable[tuple[Region, bool, ValueType]]
) -> None:
"""Insert values into the Spatial map.
Values are associated with their region in Euclidean space, and a boolean that
indicates fixed regions. Fixed regions don't scroll and are always visible.
Args:
regions_and_values: An iterable of (REGION, FIXED, VALUE).
"""
append_fixed = self._fixed.append
get_grid_list = self._map.__getitem__
_region_to_grid = self._region_to_grid_coordinates
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 superset of all the values that intersect with a given region.
Note that this may return false positives.
Args:
region: A region.
Returns:
Values under the region.
"""
results: list[ValueType] = self._fixed.copy()
add_results = results.extend
get_grid_values = self._map.get
for grid_coordinate in self._region_to_grid_coordinates(region):
grid_values = get_grid_values(grid_coordinate)
if grid_values is not None:
add_results(grid_values)
unique_values = list(dict.fromkeys(results))
return unique_values

View File

@@ -1783,6 +1783,7 @@ class App(Generic[ReturnType], DOMNode):
await child._close_messages() await child._close_messages()
async def _shutdown(self) -> None: async def _shutdown(self) -> None:
self._begin_update() # Prevents any layout / repaint while shutting down
driver = self._driver driver = self._driver
self._running = False self._running = False
if driver is not None: if driver is not None:
@@ -1908,7 +1909,6 @@ class App(Generic[ReturnType], DOMNode):
# Handle input events that haven't been forwarded # Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App # If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Compose): if isinstance(event, events.Compose):
self.log(event)
screen = Screen(id="_default") screen = Screen(id="_default")
self._register(self, screen) self._register(self, screen)
self._screen_stack.append(screen) self._screen_stack.append(screen)

View File

@@ -45,12 +45,24 @@ class Update(Message, verbose=True):
@rich.repr.auto @rich.repr.auto
class Layout(Message, verbose=True): class Layout(Message, verbose=True):
"""Sent by Textual when a layout is required."""
def can_replace(self, message: Message) -> bool: def can_replace(self, message: Message) -> bool:
return isinstance(message, Layout) return isinstance(message, Layout)
@rich.repr.auto
class UpdateScroll(Message, verbose=True):
"""Sent by Textual when a scroll update is required."""
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):
"""Sent by Textual to invoke a callback."""
def __init__(self, sender: MessagePump, callback: CallbackType) -> None: def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
self.callback = callback self.callback = callback
super().__init__(sender) super().__init__(sender)

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:
@@ -370,7 +360,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)
@@ -419,7 +414,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:
@@ -427,7 +424,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()
ResizeEvent = events.Resize
try: try:
if scroll:
exposed_widgets = self._compositor.reflow_visible(self, size)
if exposed_widgets:
layers = self._compositor.layers
for widget, (
region,
_order,
_clip,
virtual_size,
container_size,
_,
) in layers:
if widget in exposed_widgets:
if widget._size_updated(
region.size,
virtual_size,
container_size,
layout=False,
):
widget.post_message_no_wait(
ResizeEvent(
self,
region.size,
virtual_size,
container_size,
)
)
else:
hidden, shown, resized = self._compositor.reflow(self, size) hidden, shown, resized = self._compositor.reflow(self, size)
Hide = events.Hide Hide = events.Hide
Show = events.Show Show = events.Show
@@ -437,7 +464,6 @@ class Screen(Widget):
# 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
layers = self._compositor.layers layers = self._compositor.layers
for widget, ( for widget, (
@@ -480,6 +506,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

@@ -69,14 +69,18 @@ class ScrollView(Widget):
return self.virtual_size.height return self.virtual_size.height
def _size_updated( def _size_updated(
self, size: Size, virtual_size: Size, container_size: Size self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
) -> None: ) -> bool:
"""Called when size is updated. """Called when size is updated.
Args: Args:
size: New size. size: New size.
virtual_size: New virtual size. virtual_size: New virtual size.
container_size: New container size. container_size: New container size.
layout: Perform layout if required.
Returns:
True if anything changed, or False if nothing changed.
""" """
if self._size != size or container_size != container_size: if self._size != size or container_size != container_size:
self.refresh() self.refresh()
@@ -90,6 +94,9 @@ class ScrollView(Widget):
self._container_size = size - self.styles.gutter.totals self._container_size = size - self.styles.gutter.totals
self._scroll_update(virtual_size) self._scroll_update(virtual_size)
self.scroll_to(self.scroll_x, self.scroll_y, animate=False) self.scroll_to(self.scroll_x, self.scroll_y, animate=False)
return True
else:
return False
def render(self) -> RenderableType: def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented). """Render the scrollable region (if `render_lines` is not implemented).

View File

@@ -38,6 +38,7 @@ from . import errors, events, messages
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange from ._arrange import DockArrangeResult, arrange
from ._asyncio import create_task from ._asyncio import create_task
from ._cache import FIFOCache
from ._context import active_app from ._context import active_app
from ._easing import DEFAULT_SCROLL_EASING from ._easing import DEFAULT_SCROLL_EASING
from ._layout import Layout from ._layout import Layout
@@ -243,6 +244,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
@@ -262,8 +264,9 @@ class Widget(DOMNode):
self._content_width_cache: tuple[object, int] = (None, 0) self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0)
self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) self._arrangement_cache: FIFOCache[
self._cached_arrangement: DockArrangeResult | None = None tuple[Size, int], DockArrangeResult
] = FIFOCache(4)
self._styles_cache = StylesCache() self._styles_cache = StylesCache()
self._rich_style_cache: dict[str, tuple[Style, Style]] = {} self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
@@ -477,14 +480,11 @@ class Widget(DOMNode):
assert self.is_container assert self.is_container
cache_key = (size, self._nodes._updates) cache_key = (size, self._nodes._updates)
if ( cached_result = self._arrangement_cache.get(cache_key)
self._arrangement_cache_key == cache_key if cached_result is not None:
and self._cached_arrangement is not None return cached_result
):
return self._cached_arrangement
self._arrangement_cache_key = cache_key arrangement = self._arrangement_cache[cache_key] = arrange(
arrangement = self._cached_arrangement = arrange(
self, self._nodes, size, self.screen.size self, self._nodes, size, self.screen.size
) )
@@ -492,7 +492,7 @@ class Widget(DOMNode):
def _clear_arrangement_cache(self) -> None: def _clear_arrangement_cache(self) -> None:
"""Clear arrangement cache, forcing a new arrange operation.""" """Clear arrangement cache, forcing a new arrange operation."""
self._cached_arrangement = None self._arrangement_cache.clear()
def _get_virtual_dom(self) -> Iterable[Widget]: def _get_virtual_dom(self) -> Iterable[Widget]:
"""Get widgets not part of the DOM. """Get widgets not part of the DOM.
@@ -1728,7 +1728,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,
@@ -1760,7 +1760,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,
@@ -1794,7 +1794,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,
@@ -1828,7 +1828,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,
@@ -2164,14 +2164,18 @@ class Widget(DOMNode):
self._update_styles() self._update_styles()
def _size_updated( def _size_updated(
self, size: Size, virtual_size: Size, container_size: Size self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
) -> None: ) -> bool:
"""Called when the widget's size is updated. """Called when the widget's size is updated.
Args: Args:
size: Screen size. size: Screen size.
virtual_size: Virtual (scrollable) size. virtual_size: Virtual (scrollable) size.
container_size: Container size (size of parent). container_size: Container size (size of parent).
layout: Perform layout if required.
Returns:
True if anything changed, or False if nothing changed.
""" """
if ( if (
self._size != size self._size != size
@@ -2179,11 +2183,16 @@ class Widget(DOMNode):
or self._container_size != container_size or self._container_size != container_size
): ):
self._size = size self._size = size
if layout:
self.virtual_size = virtual_size self.virtual_size = virtual_size
else:
self._reactive_virtual_size = virtual_size
self._container_size = container_size self._container_size = container_size
if self.is_scrollable: if self.is_scrollable:
self._scroll_update(virtual_size) self._scroll_update(virtual_size)
self.refresh() return True
else:
return False
def _scroll_update(self, virtual_size: Size) -> None: def _scroll_update(self, virtual_size: Size) -> None:
"""Update scrollbars visibility and dimensions. """Update scrollbars visibility and dimensions.
@@ -2294,7 +2303,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(
@@ -2321,8 +2330,7 @@ class Widget(DOMNode):
repaint: Repaint the widget (will call render() again). Defaults to True. repaint: Repaint the widget (will call render() again). Defaults to True.
layout: Also layout widgets in the view. Defaults to False. layout: Also layout widgets in the view. Defaults to False.
""" """
if layout and not self._layout_required:
if layout:
self._layout_required = True self._layout_required = True
for ancestor in self.ancestors: for ancestor in self.ancestors:
if not isinstance(ancestor, Widget): if not isinstance(ancestor, Widget):
@@ -2403,6 +2411,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

@@ -2,6 +2,7 @@ from __future__ import annotations
from itertools import cycle from itertools import cycle
from rich.console import RenderableType
from typing_extensions import Literal from typing_extensions import Literal
from .. import events from .. import events
@@ -61,10 +62,10 @@ class Placeholder(Widget):
overflow: hidden; overflow: hidden;
color: $text; color: $text;
} }
Placeholder.-text { Placeholder.-text {
padding: 1; padding: 1;
} }
""" """
# Consecutive placeholders get assigned consecutive colors. # Consecutive placeholders get assigned consecutive colors.
@@ -73,7 +74,7 @@ class Placeholder(Widget):
variant: Reactive[PlaceholderVariant] = reactive("default") variant: Reactive[PlaceholderVariant] = reactive("default")
_renderables: dict[PlaceholderVariant, RenderResult] _renderables: dict[PlaceholderVariant, str]
@classmethod @classmethod
def reset_color_cycle(cls) -> None: def reset_color_cycle(cls) -> None:
@@ -119,7 +120,7 @@ class Placeholder(Widget):
while next(self._variants_cycle) != self.variant: while next(self._variants_cycle) != self.variant:
pass pass
def render(self) -> RenderResult: def render(self) -> RenderableType:
return self._renderables[self.variant] return self._renderables[self.variant]
def cycle_variant(self) -> None: def cycle_variant(self) -> None:
@@ -147,6 +148,6 @@ class Placeholder(Widget):
def on_resize(self, event: events.Resize) -> None: def on_resize(self, event: events.Resize) -> None:
"""Update the placeholder "size" variant with the new placeholder size.""" """Update the placeholder "size" variant with the new placeholder size."""
self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*self.size) self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*event.size)
if self.variant == "size": if self.variant == "size":
self.refresh(layout=True) self.refresh(layout=False)

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():

64
tests/test_spatial_map.py Normal file
View File

@@ -0,0 +1,64 @@
import pytest
from textual._spatial_map import SpatialMap
from textual.geometry import Region
@pytest.mark.parametrize(
"region,grid",
[
(
Region(0, 0, 10, 10),
[
(0, 0),
],
),
(
Region(10, 10, 10, 10),
[
(1, 1),
],
),
(
Region(0, 0, 11, 11),
[(0, 0), (0, 1), (1, 0), (1, 1)],
),
(
Region(5, 5, 15, 3),
[(0, 0), (1, 0)],
),
(
Region(5, 5, 2, 15),
[(0, 0), (0, 1)],
),
],
)
def test_region_to_grid(region, grid):
spatial_map = SpatialMap(10, 10)
assert list(spatial_map._region_to_grid_coordinates(region)) == grid
def test_get_values_in_region() -> None:
spatial_map: SpatialMap[str] = SpatialMap(20, 10)
spatial_map.insert(
[
(Region(10, 5, 5, 5), False, "foo"),
(Region(5, 20, 5, 5), False, "bar"),
(Region(0, 0, 40, 1), True, "title"),
]
)
assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == [
"title",
"foo",
]
assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["title", "foo"]
assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == ["title"]
assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["title", "bar"]
assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == [
"title",
"foo",
"bar",
]

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