diff --git a/CHANGELOG.md b/CHANGELOG.md index e0d1d478a..be5ce8ce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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 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/examples/dictionary.py b/examples/dictionary.py index 737bcb283..8ef47f3bb 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -11,7 +11,7 @@ from rich.markdown import Markdown from textual.app import App, ComposeResult from textual.containers import Content -from textual.widgets import Static, Input +from textual.widgets import Input, Static class DictionaryApp(App): @@ -41,7 +41,12 @@ class DictionaryApp(App): """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" 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: markdown = self.make_word_markdown(results) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index d14f0ddc4..f1035688e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -128,4 +128,4 @@ def arrange( placements.extend(layout_placements) - return placements, arrange_widgets, scroll_spacing + return DockArrangeResult(placements, arrange_widgets, scroll_spacing) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5489c8687..245eb3f34 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -167,6 +167,7 @@ class Compositor: def __init__(self) -> None: # A mapping of Widget on to its "render location" (absolute position / depth) self.map: CompositorMap = {} + self._full_map: CompositorMap | None = None self._layers: list[tuple[Widget, MapGeometry]] | None = None # All widgets considered in the arrangement @@ -241,29 +242,27 @@ 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 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.copy() + old_map = self.map old_widgets = old_map.keys() 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 = old_widgets - new_widgets + new_widgets = map.keys() # Replace map and widgets self.map = map + self._full_map = map self.widgets = widgets # Contains widgets + geometry for every widget that changed (added, removed, or updated) @@ -272,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 = { @@ -291,12 +284,80 @@ 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) -> 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 def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: """Get a mapping of widgets on to region and clip. @@ -322,9 +383,9 @@ class Compositor: return self._visible_widgets def _arrange_root( - self, root: Widget, size: Size + self, root: Widget, size: Size, visible_only: bool = True ) -> tuple[CompositorMap, set[Widget]]: - """Arrange a widgets children based on its layout attribute. + """Arrange a widget's children based on its layout attribute. Args: root: Top level widget. @@ -337,6 +398,7 @@ class Compositor: map: CompositorMap = {} widgets: set[Widget] = set() + add_new_widget = widgets.add layer_order: int = 0 def add_widget( @@ -362,7 +424,7 @@ class Compositor: visible = visibility == "visible" if visible: - widgets.add(widget) + add_new_widget(widget) styles_offset = widget.styles.offset layout_offset = ( styles_offset.resolve(region.size, clip.size) @@ -389,69 +451,75 @@ class Compositor: if widget.is_container: # Arrange the layout - placements, arranged_widgets, spacing = widget._arrange( - child_region.size - ) + arrange_result = widget._arrange(child_region.size) + arranged_widgets = arrange_result.widgets + spacing = arrange_result.spacing widgets.update(arranged_widgets) - if placements: - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = ( - placement_offset - widget.scroll_offset + 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 + placement_offset = container_region.offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get + + # Add all the widgets + for sub_region, margin, sub_widget, z, fixed in reversed( + placements + ): + # Combine regions with children to calculate the "virtual size" + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union( + sub_region.grow(spacing + margin) + ) + widget_region = sub_region + placement_scroll_offset + + widget_order = ( + *order, + get_layer_index(sub_widget.layer, 0), + z, + layer_order, ) - _layers = widget.layers - layers_to_index = { - layer_name: index - for index, layer_name in enumerate(_layers) - } - get_layer_index = layers_to_index.get + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + layer_order, + sub_clip, + visible, + ) - # Add all the widgets - for sub_region, margin, sub_widget, z, fixed in reversed( - placements - ): - # Combine regions with children to calculate the "virtual size" - if fixed: - widget_region = sub_region + placement_offset - else: - total_region = total_region.union( - sub_region.grow(spacing + margin) - ) - widget_region = sub_region + placement_scroll_offset - - widget_order = ( - *order, - get_layer_index(sub_widget.layer, 0), - z, - layer_order, - ) - - add_widget( - sub_widget, - sub_region, - widget_region, - widget_order, - layer_order, - sub_clip, - visible, - ) - layer_order -= 1 + layer_order -= 1 if visible: # Add any scrollbars - for chrome_widget, chrome_region in widget._arrange_scrollbars( - container_region - ): - map[chrome_widget] = _MapGeometry( - chrome_region + layout_offset, - order, - clip, - container_size, - container_size, - chrome_region, - ) + if any(widget.scrollbars_enabled): + for chrome_widget, chrome_region in widget._arrange_scrollbars( + container_region + ): + map[chrome_widget] = _MapGeometry( + chrome_region + layout_offset, + order, + clip, + container_size, + container_size, + chrome_region, + ) map[widget] = _MapGeometry( region + layout_offset, @@ -519,7 +587,10 @@ class Compositor: try: return self.map[widget].region.offset except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget].region.offset + except KeyError: + raise errors.NoWidget("Widget is not in layout") def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under a given coordinate. @@ -601,10 +672,15 @@ class Compositor: Widget's composition information. """ + if self.root is None or not self.map: + raise errors.NoWidget("Widget is not in layout") try: region = self.map[widget] except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget] + except KeyError: + raise errors.NoWidget("Widget is not in layout") else: return region @@ -788,6 +864,7 @@ class Compositor: widget: Widget to update. """ + self._full_map = None regions: list[Region] = [] add_region = regions.append get_widget = self.visible_widgets.__getitem__ diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 5123d832e..f7ab16312 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,8 +1,10 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, NamedTuple +from ._spatial_map import SpatialMap from .geometry import Region, Size, Spacing if TYPE_CHECKING: @@ -11,7 +13,55 @@ if TYPE_CHECKING: from .widget import 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): @@ -61,7 +111,7 @@ class Layout(ABC): width = 0 else: # 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( [ placement.region.right + placement.margin.right @@ -89,7 +139,7 @@ class Layout(ABC): height = 0 else: # Use a height of zero to ignore relative heights - placements, _, _ = widget._arrange(Size(width, 0)) + placements = widget._arrange(Size(width, 0)).placements height = max( [ placement.region.bottom + placement.margin.bottom diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py new file mode 100644 index 000000000..93e8c4004 --- /dev/null +++ b/src/textual/_spatial_map.py @@ -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 diff --git a/src/textual/app.py b/src/textual/app.py index 6bca62824..ce9ad5d0e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1783,6 +1783,7 @@ class App(Generic[ReturnType], DOMNode): await child._close_messages() async def _shutdown(self) -> None: + self._begin_update() # Prevents any layout / repaint while shutting down driver = self._driver self._running = False if driver is not None: @@ -1908,7 +1909,6 @@ class App(Generic[ReturnType], DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Compose): - self.log(event) screen = Screen(id="_default") self._register(self, screen) self._screen_stack.append(screen) diff --git a/src/textual/messages.py b/src/textual/messages.py index fcfe2ad2c..882fe887a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -45,12 +45,24 @@ class Update(Message, verbose=True): @rich.repr.auto class Layout(Message, verbose=True): + """Sent by Textual when a layout is required.""" + def can_replace(self, message: Message) -> bool: 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 class InvokeLater(Message, verbose=True, bubble=False): + """Sent by Textual to invoke a callback.""" + def __init__(self, sender: MessagePump, callback: CallbackType) -> None: self.callback = callback super().__init__(sender) diff --git a/src/textual/screen.py b/src/textual/screen.py index ef73c8a63..3fdb09fbf 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: @@ -370,7 +360,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) @@ -419,7 +414,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: @@ -427,35 +424,64 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() + ResizeEvent = events.Resize try: - hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide - Show = events.Show + if scroll: + exposed_widgets = self._compositor.reflow_visible(self, size) + if exposed_widgets: + layers = self._compositor.layers - for widget in hidden: - widget.post_message_no_wait(Hide(self)) + 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) + Hide = events.Hide + Show = events.Show - # 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 + for widget in hidden: + widget.post_message_no_wait(Hide(self)) - 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) - ) + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized - for widget in shown: - widget.post_message_no_wait(Show(self)) + 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)) except Exception as error: self.app._handle_exception(error) @@ -480,6 +506,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/scroll_view.py b/src/textual/scroll_view.py index 53c6c238a..1f834e839 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -69,14 +69,18 @@ class ScrollView(Widget): return self.virtual_size.height def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True + ) -> bool: """Called when size is updated. Args: size: New size. virtual_size: New virtual 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: self.refresh() @@ -90,6 +94,9 @@ class ScrollView(Widget): self._container_size = size - self.styles.gutter.totals self._scroll_update(virtual_size) self.scroll_to(self.scroll_x, self.scroll_y, animate=False) + return True + else: + return False def render(self) -> RenderableType: """Render the scrollable region (if `render_lines` is not implemented). diff --git a/src/textual/widget.py b/src/textual/widget.py index a16a6bf60..869b7e764 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -38,6 +38,7 @@ from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task +from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -243,6 +244,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 @@ -262,8 +264,9 @@ class Widget(DOMNode): self._content_width_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._cached_arrangement: DockArrangeResult | None = None + self._arrangement_cache: FIFOCache[ + tuple[Size, int], DockArrangeResult + ] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -477,14 +480,11 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - if ( - self._arrangement_cache_key == cache_key - and self._cached_arrangement is not None - ): - return self._cached_arrangement + cached_result = self._arrangement_cache.get(cache_key) + if cached_result is not None: + return cached_result - self._arrangement_cache_key = cache_key - arrangement = self._cached_arrangement = arrange( + arrangement = self._arrangement_cache[cache_key] = arrange( self, self._nodes, size, self.screen.size ) @@ -492,7 +492,7 @@ class Widget(DOMNode): def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._cached_arrangement = None + self._arrangement_cache.clear() def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. @@ -1728,7 +1728,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, @@ -1760,7 +1760,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, @@ -1794,7 +1794,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, @@ -1828,7 +1828,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, @@ -2164,14 +2164,18 @@ class Widget(DOMNode): self._update_styles() def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True + ) -> bool: """Called when the widget's size is updated. Args: size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). + layout: Perform layout if required. + + Returns: + True if anything changed, or False if nothing changed. """ if ( self._size != size @@ -2179,11 +2183,16 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - self.virtual_size = virtual_size + if layout: + self.virtual_size = virtual_size + else: + self._reactive_virtual_size = virtual_size self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) - self.refresh() + return True + else: + return False def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. @@ -2294,7 +2303,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( @@ -2321,8 +2330,7 @@ class Widget(DOMNode): repaint: Repaint the widget (will call render() again). Defaults to True. layout: Also layout widgets in the view. Defaults to False. """ - - if layout: + if layout and not self._layout_required: self._layout_required = True for ancestor in self.ancestors: if not isinstance(ancestor, Widget): @@ -2403,6 +2411,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/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index d4bf01333..970e4465b 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -2,6 +2,7 @@ from __future__ import annotations from itertools import cycle +from rich.console import RenderableType from typing_extensions import Literal from .. import events @@ -61,10 +62,10 @@ class Placeholder(Widget): overflow: hidden; color: $text; } - Placeholder.-text { padding: 1; } + """ # Consecutive placeholders get assigned consecutive colors. @@ -73,7 +74,7 @@ class Placeholder(Widget): variant: Reactive[PlaceholderVariant] = reactive("default") - _renderables: dict[PlaceholderVariant, RenderResult] + _renderables: dict[PlaceholderVariant, str] @classmethod def reset_color_cycle(cls) -> None: @@ -119,7 +120,7 @@ class Placeholder(Widget): while next(self._variants_cycle) != self.variant: pass - def render(self) -> RenderResult: + def render(self) -> RenderableType: return self._renderables[self.variant] def cycle_variant(self) -> None: @@ -147,6 +148,6 @@ class Placeholder(Widget): def on_resize(self, event: events.Resize) -> None: """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": - self.refresh(layout=True) + self.refresh(layout=False) 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..413ca4cad --- /dev/null +++ b/tests/test_spatial_map.py @@ -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", + ] 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