From 5ceccc53d221ab242bbde9bb3b7619283bb8f308 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 15 Feb 2023 18:21:48 +0000 Subject: [PATCH 01/31] profile --- src/textual/_arrange.py | 2 +- src/textual/_compositor.py | 8 ++++---- src/textual/_layout.py | 17 +++++++++++++++-- src/textual/widget.py | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) 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..a81f0b99f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,6 +25,7 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last +from ._profile import timer from .geometry import NULL_OFFSET, Offset, Region, Size from .strip import Strip @@ -389,12 +390,11 @@ 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) + placements, arranged_widgets, spacing = arrange_result.unpack() widgets.update(arranged_widgets) - if placements: + with timer("placements"): # An offset added to all placements placement_offset = container_region.offset placement_scroll_offset = ( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 5123d832e..f1e698d4d 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,9 +1,12 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, NamedTuple -from .geometry import Region, Size, Spacing +from ._cache import FIFOCache +from ._profile import timer +from .geometry import Offset, Region, Size, Spacing if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -11,7 +14,17 @@ if TYPE_CHECKING: from .widget import Widget ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" -DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" +# DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" + + +@dataclass +class DockArrangeResult: + placements: list[WidgetPlacement] + widgets: set[Widget] + spacing: Spacing + + def unpack(self) -> tuple[list[WidgetPlacement], set[Widget], Spacing]: + return (self.placements, self.widgets, self.spacing) class WidgetPlacement(NamedTuple): diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..a866c5c02 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,6 +41,7 @@ from ._asyncio import create_task from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout +from ._profile import timer from ._segment_tools import align_lines from ._styles_cache import StylesCache from .actions import SkipAction From 35c07fc668635bc50c935cf0aa0ee72d064faa79 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 13:47:03 +0000 Subject: [PATCH 02/31] spatial map --- src/textual/_compositor.py | 42 +++++++++++++++++++++++--------------- src/textual/_layout.py | 37 +++++++++++++++++++++++++++------ src/textual/widget.py | 31 ++++++++++++++-------------- 3 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index a81f0b99f..38db0f49c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -391,9 +391,16 @@ class Compositor: if widget.is_container: # Arrange the layout arrange_result = widget._arrange(child_region.size) - placements, arranged_widgets, spacing = arrange_result.unpack() + arranged_widgets = arrange_result.widgets + spacing = arrange_result.spacing widgets.update(arranged_widgets) + placements, visible_placements = arrange_result.get_placements( + widget.size.region + widget.scroll_offset + ) + print("len(placements)", len(placements)) + total_region = total_region.union(arrange_result.total_region) + with timer("placements"): # An offset added to all placements placement_offset = container_region.offset @@ -409,9 +416,10 @@ class Compositor: get_layer_index = layers_to_index.get # Add all the widgets - for sub_region, margin, sub_widget, z, fixed in reversed( - placements - ): + for placement in reversed(placements): + sub_region, margin, sub_widget, z, fixed = placement + if placement not in visible_placements: + continue # Combine regions with children to calculate the "virtual size" if fixed: widget_region = sub_region + placement_offset @@ -435,23 +443,25 @@ class Compositor: widget_order, layer_order, sub_clip, - visible, + visible and placement in visible_placements, ) + 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, diff --git a/src/textual/_layout.py b/src/textual/_layout.py index f1e698d4d..2d3ab6b9a 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -4,9 +4,9 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, NamedTuple -from ._cache import FIFOCache from ._profile import timer -from .geometry import Offset, Region, Size, Spacing +from ._spatial_map import SpatialMap +from .geometry import Region, Size, Spacing if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -23,8 +23,33 @@ class DockArrangeResult: widgets: set[Widget] spacing: Spacing - def unpack(self) -> tuple[list[WidgetPlacement], set[Widget], Spacing]: - return (self.placements, self.widgets, self.spacing) + _spatial_map: SpatialMap[WidgetPlacement] | None = None + + @property + def spatial_map(self) -> SpatialMap[WidgetPlacement]: + if self._spatial_map is None: + self._spatial_map = SpatialMap() + with timer("insert many"): + self._spatial_map.insert_many( + ( + placement.region.grow(placement.margin), + placement.fixed, + placement, + ) + for placement in self.placements + ) + + return self._spatial_map + + @property + def total_region(self) -> Region: + return self.spatial_map.total_region + + def get_placements( + self, region: Region + ) -> tuple[list[WidgetPlacement], set[WidgetPlacement]]: + visible_placements = self.spatial_map.get_values_in_region(region) + return self.placements, visible_placements class WidgetPlacement(NamedTuple): @@ -74,7 +99,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 @@ -102,7 +127,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/widget.py b/src/textual/widget.py index a866c5c02..22165bd01 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 @@ -260,8 +261,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]] = {} @@ -279,7 +281,7 @@ class Widget(DOMNode): self._add_children(*children) - virtual_size = Reactive(Size(0, 0), layout=True) + virtual_size = Reactive(Size(0, 0), repaint=False, layout=True) auto_width = Reactive(True) auto_height = Reactive(True) has_focus = Reactive(False) @@ -474,22 +476,18 @@ 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( - self, self._nodes, size, self.screen.size - ) + arrangement = arrange(self, self._nodes, size, self.screen.size) + self._arrangement_cache[cache_key] = arrangement return arrangement 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. @@ -2149,10 +2147,11 @@ class Widget(DOMNode): or self.virtual_size != virtual_size or self._container_size != container_size ): + old_virtual_size = self.virtual_size self._size = size - self.virtual_size = virtual_size + self._reactive_virtual_size = virtual_size self._container_size = container_size - if self.is_scrollable: + if self.is_scrollable and old_virtual_size != self.virtual_size: self._scroll_update(virtual_size) self.refresh() @@ -2295,6 +2294,8 @@ class Widget(DOMNode): if layout: self._layout_required = True + + print("LAYOUT") for ancestor in self.ancestors: if not isinstance(ancestor, Widget): break From 5d88807131897b1ff38a585c543417638406bf7c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 17:24:36 +0000 Subject: [PATCH 03/31] no hide --- src/textual/_compositor.py | 122 +++++++++++++++++++++---------------- src/textual/_layout.py | 35 +++++++---- src/textual/app.py | 1 - src/textual/screen.py | 19 +++--- src/textual/widget.py | 4 +- 5 files changed, 106 insertions(+), 75 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 38db0f49c..c183676e5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -168,6 +168,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 @@ -248,6 +249,7 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None + self._full_map = None self.root = parent self.size = size @@ -255,7 +257,8 @@ class Compositor: old_map = self.map.copy() old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size) + map, widgets = self._arrange_root(parent, size, visible_only=True) + new_widgets = map.keys() # Newly visible widgets @@ -293,11 +296,21 @@ class Compositor: self._dirty_regions.update(regions) return ReflowResult( - hidden=hidden_widgets, + hidden=set(), shown=shown_widgets, resized=resized_widgets, ) + @property + def full_map(self) -> CompositorMap: + 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. @@ -323,7 +336,7 @@ 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. @@ -395,58 +408,55 @@ class Compositor: spacing = arrange_result.spacing widgets.update(arranged_widgets) - placements, visible_placements = arrange_result.get_placements( - widget.size.region + widget.scroll_offset - ) - print("len(placements)", len(placements)) + if visible_only: + placements = arrange_result.get_visible_placements( + widget.size.region + widget.scroll_offset + ) + else: + placements = arrange_result.placements total_region = total_region.union(arrange_result.total_region) - with timer("placements"): - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = ( - placement_offset - widget.scroll_offset + # 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 placement in reversed(placements): - sub_region, margin, sub_widget, z, fixed = placement - if placement not in visible_placements: - continue - # 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 and placement in visible_placements, - ) - - layer_order -= 1 + layer_order -= 1 if visible: # Add any scrollbars @@ -529,7 +539,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. @@ -611,10 +624,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 diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 2d3ab6b9a..eb3b1967f 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -27,29 +27,40 @@ class DockArrangeResult: @property def spatial_map(self) -> SpatialMap[WidgetPlacement]: + """A lazy-calculated spatial map.""" if self._spatial_map is None: self._spatial_map = SpatialMap() - with timer("insert many"): - self._spatial_map.insert_many( - ( - placement.region.grow(placement.margin), - placement.fixed, - placement, - ) - for placement in self.placements + self._spatial_map.insert_many( + ( + 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_placements( - self, region: Region - ) -> tuple[list[WidgetPlacement], set[WidgetPlacement]]: + 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 self.placements, visible_placements + return visible_placements class WidgetPlacement(NamedTuple): diff --git a/src/textual/app.py b/src/textual/app.py index bf33e07cd..33b887e09 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1903,7 +1903,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/screen.py b/src/textual/screen.py index d1354a106..c768aab63 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,15 +80,15 @@ class Screen(Widget): ) return self._update_timer - @property - def widgets(self) -> list[Widget]: - """Get all widgets.""" - return list(self._compositor.map.keys()) + # @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) + # @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 @@ -363,6 +363,9 @@ class Screen(Widget): self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) self.log.debug(widget, "was focused") + import traceback + + traceback.print_stack() async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) diff --git a/src/textual/widget.py b/src/textual/widget.py index 22165bd01..bbf134cec 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -480,7 +480,7 @@ class Widget(DOMNode): if cached_result is not None: return cached_result - arrangement = arrange(self, self._nodes, size, self.screen.size) + arrangement = arrange(self, self._nodes, size, size) self._arrangement_cache[cache_key] = arrangement return arrangement @@ -1741,7 +1741,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, From 50c8d07beaa2ddfcd09d174bda8008dda9dac6cc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 18:00:15 +0000 Subject: [PATCH 04/31] restore hidden --- src/textual/_compositor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c183676e5..f6df38018 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -254,7 +254,7 @@ class Compositor: 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, visible_only=True) @@ -264,7 +264,7 @@ class Compositor: # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets - hidden_widgets = old_widgets - new_widgets + hidden_widgets = self.widgets - widgets # Replace map and widgets self.map = map @@ -296,7 +296,7 @@ class Compositor: self._dirty_regions.update(regions) return ReflowResult( - hidden=set(), + hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) From ba49907f1014a974af95e3e29bbdc0f4912d9293 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 18:01:31 +0000 Subject: [PATCH 05/31] Rename insert many to insert --- src/textual/_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index eb3b1967f..fe2086008 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -30,7 +30,7 @@ class DockArrangeResult: """A lazy-calculated spatial map.""" if self._spatial_map is None: self._spatial_map = SpatialMap() - self._spatial_map.insert_many( + self._spatial_map.insert( ( placement.region.grow(placement.margin), placement.fixed, From 06fa8d7e8e1d424b7a5fbecb370362847ec236d3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 09:35:39 +0000 Subject: [PATCH 06/31] layout cache --- src/textual/widget.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index bbf134cec..0842aff93 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -38,11 +38,9 @@ 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 -from ._profile import timer from ._segment_tools import align_lines from ._styles_cache import StylesCache from .actions import SkipAction @@ -261,9 +259,8 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache: FIFOCache[ - tuple[Size, int], DockArrangeResult - ] = FIFOCache(4) + self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) + self._cached_arrangement: DockArrangeResult | None = None self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -281,7 +278,7 @@ class Widget(DOMNode): self._add_children(*children) - virtual_size = Reactive(Size(0, 0), repaint=False, layout=True) + virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) auto_height = Reactive(True) has_focus = Reactive(False) @@ -476,18 +473,22 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - cached_result = self._arrangement_cache.get(cache_key) - if cached_result is not None: - return cached_result + if ( + self._arrangement_cache_key == cache_key + and self._cached_arrangement is not None + ): + return self._cached_arrangement - arrangement = arrange(self, self._nodes, size, size) - self._arrangement_cache[cache_key] = arrangement + self._arrangement_cache_key = cache_key + arrangement = self._cached_arrangement = arrange( + self, self._nodes, size, self.screen.size + ) return arrangement def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._arrangement_cache.clear() + self._cached_arrangement = None def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. @@ -1741,7 +1742,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_y + self.container_size.height, + y=self.scroll_target_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -2147,11 +2148,10 @@ class Widget(DOMNode): or self.virtual_size != virtual_size or self._container_size != container_size ): - old_virtual_size = self.virtual_size self._size = size - self._reactive_virtual_size = virtual_size + self.virtual_size = virtual_size self._container_size = container_size - if self.is_scrollable and old_virtual_size != self.virtual_size: + if self.is_scrollable: self._scroll_update(virtual_size) self.refresh() @@ -2294,8 +2294,6 @@ class Widget(DOMNode): if layout: self._layout_required = True - - print("LAYOUT") for ancestor in self.ancestors: if not isinstance(ancestor, Widget): break From 11d10db1ab9cc723a5f12bf333d22f9b00d5e9a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 10:42:42 +0000 Subject: [PATCH 07/31] fast path for scrolling --- CHANGELOG.md | 10 +++ docs/examples/styles/height_comparison.css | 2 +- src/textual/_compositor.py | 68 +++++++++++++---- src/textual/_spatial_map.py | 87 ++++++++++++++++++++++ src/textual/messages.py | 6 ++ src/textual/screen.py | 76 ++++++++++--------- src/textual/widget.py | 14 ++-- tests/test_arrange.py | 49 ++++++------ tests/test_spatial_map.py | 8 ++ tests/test_visibility_change.py | 3 - 10 files changed, 237 insertions(+), 86 deletions(-) create mode 100644 src/textual/_spatial_map.py create mode 100644 tests/test_spatial_map.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b83ccf89..3513cba79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.12.0] - Unreleased + +### Changed + +- Scrolling by page now adds to current position. + +### Removed + +- Removed `screen.visible_widgets` and `screen.widgets` + ## [0.11.0] - 2023-02-15 ### Added diff --git a/docs/examples/styles/height_comparison.css b/docs/examples/styles/height_comparison.css index 10902dda5..d5da04f78 100644 --- a/docs/examples/styles/height_comparison.css +++ b/docs/examples/styles/height_comparison.css @@ -28,12 +28,12 @@ Screen { layers: ruler; + overflow: hidden; } Ruler { layer: ruler; dock: right; - overflow: hidden; width: 1; background: $accent; } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f6df38018..5502675bf 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -243,7 +243,7 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden shown and resized widgets. + Hidden, shown and resized widgets. """ self._cuts = None self._layers = None @@ -257,15 +257,10 @@ class Compositor: old_map = self.map old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size, visible_only=True) + map, widgets = self._arrange_root(parent, size) new_widgets = map.keys() - # Newly visible widgets - shown_widgets = new_widgets - old_widgets - # Newly hidden widgets - hidden_widgets = self.widgets - widgets - # Replace map and widgets self.map = map self.widgets = widgets @@ -276,13 +271,7 @@ class Compositor: # Widgets in both new and old common_widgets = old_widgets & new_widgets - # Widgets with changed size - resized_widgets = { - widget - for widget, (region, *_) in changes - if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) - } - + # Mark dirty regions. screen_region = size.region if screen_region not in self._dirty_regions: regions = { @@ -295,12 +284,63 @@ class Compositor: } self._dirty_regions.update(regions) + resized_widgets = { + widget + for widget, (region, *_) in changes + if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) + } + # Newly visible widgets + shown_widgets = new_widgets - old_widgets + # Newly hidden widgets + hidden_widgets = self.widgets - widgets return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) + def reflow_visible(self, parent: Widget, size: Size) -> None: + """Reflow only the visible children. + + This is a fast-path for scrolling. + + Args: + parent: The root widget. + size: Size of the area to be filled. + + """ + self._cuts = None + self._layers = None + self._layers_visible = None + self._visible_widgets = None + self._full_map = None + self.root = parent + self.size = size + + # Keep a copy of the old map because we're going to compare it with the update + old_map = self.map + map, widgets = self._arrange_root(parent, size, visible_only=True) + + # Replace map and widgets + self.map = map + self.widgets = widgets + + # Contains widgets + geometry for every widget that changed (added, removed, or updated) + changes = map.items() ^ old_map.items() + + # Mark dirty regions. + screen_region = size.region + if screen_region not in self._dirty_regions: + regions = { + region + for region in ( + map_geometry.clip.intersection(map_geometry.region) + for _, map_geometry in changes + ) + if region + } + self._dirty_regions.update(regions) + @property def full_map(self) -> CompositorMap: if self.root is None or not self.map: diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py new file mode 100644 index 000000000..480e665ad --- /dev/null +++ b/src/textual/_spatial_map.py @@ -0,0 +1,87 @@ +from collections import defaultdict +from itertools import product +from typing import Generic, Iterable, TypeVar + +from .geometry import Region + +ValueType = TypeVar("ValueType") + + +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with a rectangular regions + in Euclidean space, and efficiently queried. + + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[tuple[int, int], list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid(self, region: Region) -> Iterable[tuple[int, int]]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid squares (tuple of 2 values). + """ + x1, y1, width, height = region + x2 = x1 + width + y2 = y1 + height + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, 1 + x2 // grid_width), + range(y1 // grid_height, 1 + y2 // grid_height), + ) + + def insert( + self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] + ) -> None: + """Insert values in to the Spatial map. + + Args: + regions_and_values: An iterable of Regions and values. + """ + append_fixed = self._fixed.append + get_grid_list = self._map.__getitem__ + _region_to_grid = self._region_to_grid + total_region = self.total_region + for region, fixed, value in regions_and_values: + total_region = total_region.union(region) + if fixed: + append_fixed(value) + else: + for grid in _region_to_grid(region): + get_grid_list(grid).append(value) + self.total_region = total_region + + def get_values_in_region(self, region: Region) -> list[ValueType]: + """Get a set of values that are under a given region. + + Note that this may return some false positives. + + Args: + region: A region. + + Returns: + A set of values under the region. + """ + results: list[ValueType] = self._fixed.copy() + add_results = results.extend + get_grid_values = self._map.get + for grid in self._region_to_grid(region): + grid_values = get_grid_values(grid) + if grid_values is not None: + add_results(grid_values) + unique_values = list(dict.fromkeys(results)) + return unique_values diff --git a/src/textual/messages.py b/src/textual/messages.py index fcfe2ad2c..4e14c8902 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -49,6 +49,12 @@ class Layout(Message, verbose=True): return isinstance(message, Layout) +@rich.repr.auto +class UpdateScroll(Message, verbose=True): + def can_replace(self, message: Message) -> bool: + return isinstance(message, UpdateScroll) + + @rich.repr.auto class InvokeLater(Message, verbose=True, bubble=False): def __init__(self, sender: MessagePump, callback: CallbackType) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c768aab63..59e622caf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,16 +80,6 @@ class Screen(Widget): ) return self._update_timer - # @property - # def widgets(self) -> list[Widget]: - # """Get all widgets.""" - # return list(self._compositor.map.keys()) - - # @property - # def visible_widgets(self) -> list[Widget]: - # """Get a list of visible widgets.""" - # return list(self._compositor.visible_widgets) - def render(self) -> RenderableType: background = self.styles.background if background.is_transparent: @@ -377,7 +367,12 @@ class Screen(Widget): if self._layout_required: self._refresh_layout() self._layout_required = False + self._scroll_required = False self._dirty_widgets.clear() + elif self._scroll_required: + self._refresh_layout(scroll=True) + self._scroll_required = False + if self._repaint_required: self._dirty_widgets.clear() self._dirty_widgets.add(self) @@ -426,7 +421,9 @@ class Screen(Widget): self._callbacks.append(callback) self.check_idle() - def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: + def _refresh_layout( + self, size: Size | None = None, full: bool = False, scroll: bool = False + ) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size if not size: @@ -435,34 +432,37 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() try: - hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide - Show = events.Show + if scroll: + self._compositor.reflow_visible(self, size) + else: + hidden, shown, resized = self._compositor.reflow(self, size) + Hide = events.Hide + Show = events.Show - for widget in hidden: - widget.post_message_no_wait(Hide(self)) + for widget in hidden: + widget.post_message_no_wait(Hide(self)) - # We want to send a resize event to widgets that were just added or change since last layout - send_resize = shown | resized - ResizeEvent = events.Resize + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized + ResizeEvent = events.Resize - layers = self._compositor.layers - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - widget._size_updated(region.size, virtual_size, container_size) - if widget in send_resize: - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + if widget in send_resize: + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) - for widget in shown: - widget.post_message_no_wait(Show(self)) + for widget in shown: + widget.post_message_no_wait(Show(self)) except Exception as error: self.app._handle_exception(error) @@ -487,6 +487,12 @@ class Screen(Widget): self._layout_required = True self.check_idle() + async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: + message.stop() + message.prevent_default() + self._scroll_required = True + self.check_idle() + def _screen_resized(self, size: Size): """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..165cd4ca4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -240,6 +240,7 @@ class Widget(DOMNode): self._container_size = Size(0, 0) self._layout_required = False self._repaint_required = False + self._scroll_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None @@ -1710,7 +1711,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - self.container_size.height, + y=self.scroll_y - self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1742,7 +1743,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.container_size.height, + y=self.scroll_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1776,7 +1777,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x - self.container_size.width, + x=self.scroll_x - self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -1810,7 +1811,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x + self.container_size.width, + x=self.scroll_x + self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -2264,7 +2265,7 @@ class Widget(DOMNode): def _refresh_scroll(self) -> None: """Refreshes the scroll position.""" - self._layout_required = True + self._scroll_required = True self.check_idle() def refresh( @@ -2373,6 +2374,9 @@ class Widget(DOMNode): except NoScreen: pass else: + if self._scroll_required: + self._scroll_required = False + screen.post_message_no_wait(messages.UpdateScroll(self)) if self._repaint_required: self._repaint_required = False screen.post_message_no_wait(messages.Update(self, self)) diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 31e030b1b..582f54bc1 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -9,10 +9,10 @@ from textual.widget import Widget def test_arrange_empty(): container = Widget(id="container") - placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) - assert placements == [] - assert widgets == set() - assert spacing == Spacing(0, 0, 0, 0) + result = arrange(container, [], Size(80, 24), Size(80, 24)) + assert result.placements == [] + assert result.widgets == set() + assert result.spacing == Spacing(0, 0, 0, 0) def test_arrange_dock_top(): @@ -22,17 +22,16 @@ def test_arrange_dock_top(): header.styles.dock = "top" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + + assert result.placements == [ WidgetPlacement( Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(1, 0, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(1, 0, 0, 0) def test_arrange_dock_left(): @@ -42,17 +41,15 @@ def test_arrange_dock_left(): header.styles.dock = "left" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 0, 10) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 0, 10) def test_arrange_dock_right(): @@ -62,17 +59,15 @@ def test_arrange_dock_right(): header.styles.dock = "right" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 10, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 10, 0, 0) def test_arrange_dock_bottom(): @@ -82,17 +77,15 @@ def test_arrange_dock_bottom(): header.styles.dock = "bottom" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 1, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 1, 0) def test_arrange_dock_badly(): diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py new file mode 100644 index 000000000..18fd16648 --- /dev/null +++ b/tests/test_spatial_map.py @@ -0,0 +1,8 @@ +from textual._spatial_map import SpatialMap +from textual.geometry import Region + + +def test_region_to_grid(): + spatial_map = SpatialMap() + + assert list(spatial_map._region_to_grid(Region(0, 0, 10, 10))) == [(0, 0)] diff --git a/tests/test_visibility_change.py b/tests/test_visibility_change.py index b06ea0e17..f79f18a6f 100644 --- a/tests/test_visibility_change.py +++ b/tests/test_visibility_change.py @@ -26,21 +26,18 @@ class VisibleTester(App[None]): async def test_visibility_changes() -> None: """Test changing visibility via code and CSS.""" async with VisibleTester().run_test() as pilot: - assert len(pilot.app.screen.visible_widgets) == 5 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is True assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-code").styles.visibility = "hidden" await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 4 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-css").set_class(True, "hidden") await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 3 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is False From edf00a7d0b9349ecae074b1d82783490fd10d76f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 11:36:01 +0000 Subject: [PATCH 08/31] fix for spatial calculation --- src/textual/_compositor.py | 2 +- src/textual/_spatial_map.py | 8 ++++---- tests/test_spatial_map.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5502675bf..6282402a8 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -450,7 +450,7 @@ class Compositor: if visible_only: placements = arrange_result.get_visible_placements( - widget.size.region + widget.scroll_offset + container_size.region + widget.scroll_offset ) else: placements = arrange_result.placements diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 480e665ad..574a72bf2 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -35,13 +35,13 @@ class SpatialMap(Generic[ValueType]): Iterable of grid squares (tuple of 2 values). """ x1, y1, width, height = region - x2 = x1 + width - y2 = y1 + height + x2 = x1 + width - 1 + y2 = y1 + height - 1 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), + range(x1 // grid_width, x2 // grid_width + 1), + range(y1 // grid_height, y2 // grid_height + 1), ) def insert( diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index 18fd16648..adbbb9330 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -1,8 +1,33 @@ +import pytest + from textual._spatial_map import SpatialMap from textual.geometry import Region -def test_region_to_grid(): - spatial_map = SpatialMap() +@pytest.mark.parametrize( + "region,grid", + [ + ( + Region(0, 0, 10, 10), + [ + (0, 0), + ], + ), + ( + 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(Region(0, 0, 10, 10))) == [(0, 0)] + assert list(spatial_map._region_to_grid(region)) == grid From c65e52be53217986a3050b9c925c410e136c92b8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:36:35 +0000 Subject: [PATCH 09/31] tests --- src/textual/_spatial_map.py | 2 +- tests/test_spatial_map.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 574a72bf2..bdfb94d57 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -68,7 +68,7 @@ class SpatialMap(Generic[ValueType]): 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. + Note that this may return false positives. Args: region: A region. diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index adbbb9330..ddbf890fb 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -13,6 +13,12 @@ from textual.geometry import Region (0, 0), ], ), + ( + Region(10, 10, 10, 10), + [ + (1, 1), + ], + ), ( Region(0, 0, 11, 11), [(0, 0), (0, 1), (1, 0), (1, 1)], @@ -31,3 +37,20 @@ def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) assert list(spatial_map._region_to_grid(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"), + ] + ) + + assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == ["foo"] + assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["foo"] + assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == [] + assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["bar"] + assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == ["foo", "bar"] From 29ce098fc8c95ee736b99339eb884d8149f122d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:47:26 +0000 Subject: [PATCH 10/31] docstrings on events --- src/textual/_compositor.py | 3 ++- src/textual/_layout.py | 1 - src/textual/messages.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6282402a8..3f8b8347c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -249,7 +249,6 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None - self._full_map = None self.root = parent self.size = size @@ -263,6 +262,7 @@ class Compositor: # 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) @@ -343,6 +343,7 @@ class Compositor: @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: diff --git a/src/textual/_layout.py b/src/textual/_layout.py index fe2086008..12e97d7d9 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from .widget import Widget ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" -# DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" @dataclass diff --git a/src/textual/messages.py b/src/textual/messages.py index 4e14c8902..882fe887a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -45,18 +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) From 79aee7612ef6dab4d22b1c1efc0ec0d7b6f7ae1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:47:58 +0000 Subject: [PATCH 11/31] remove debug --- src/textual/screen.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 59e622caf..c5cb91061 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -353,9 +353,6 @@ class Screen(Widget): self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) self.log.debug(widget, "was focused") - import traceback - - traceback.print_stack() async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) From a140730abef9b5255563e8f52dae31527db6a36a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:51:57 +0000 Subject: [PATCH 12/31] unused imports --- src/textual/_compositor.py | 3 +-- src/textual/_layout.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3f8b8347c..d655c5f39 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,7 +25,6 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last -from ._profile import timer from .geometry import NULL_OFFSET, Offset, Region, Size from .strip import Strip @@ -243,7 +242,7 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden, shown and resized widgets. + Hidden, shown, and resized widgets. """ self._cuts = None self._layers = None diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 12e97d7d9..f65541bae 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,10 +1,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, NamedTuple -from ._profile import timer from ._spatial_map import SpatialMap from .geometry import Region, Size, Spacing From 4912360d76eed459037316763dd8b7851d8e4af6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:56:01 +0000 Subject: [PATCH 13/31] docs --- src/textual/_spatial_map.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index bdfb94d57..68f646bf5 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -34,6 +34,8 @@ class SpatialMap(Generic[ValueType]): Returns: Iterable of grid squares (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 @@ -49,8 +51,11 @@ class SpatialMap(Generic[ValueType]): ) -> None: """Insert values in to 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 Regions and values. + regions_and_values: An iterable of (REGION, FIXED, VALUE). """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ @@ -74,7 +79,7 @@ class SpatialMap(Generic[ValueType]): region: A region. Returns: - A set of values under the region. + Values under the region. """ results: list[ValueType] = self._fixed.copy() add_results = results.extend From 9310d3dc785e71be484e92c97d05b68dbfe00509 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 13:23:39 +0000 Subject: [PATCH 14/31] annotations --- src/textual/_spatial_map.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 68f646bf5..a231e07af 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections import defaultdict from itertools import product from typing import Generic, Iterable, TypeVar From 9df43e20ceab4dbdb40ab3f618b4083a6993f2d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 14:09:39 +0000 Subject: [PATCH 15/31] Fix merge error --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557540cb4..f36d87b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Removed `screen.visible_widgets` and `screen.widgets` ## [0.11.1] - 2023-02-17 ->>>>>>> main ### Fixed From 17c4e40f7c8fd127faae9aafbba5d8d2984022d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:38:30 +0000 Subject: [PATCH 16/31] Update src/textual/_compositor.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_compositor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d655c5f39..2027b9fb2 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -378,7 +378,7 @@ class Compositor: def _arrange_root( 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. From af554ca1c4d08768aee24a6da5ea165a4a8ad60f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:40:22 +0000 Subject: [PATCH 17/31] Update src/textual/_spatial_map.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_spatial_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index a231e07af..bbd814b70 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -51,7 +51,7 @@ class SpatialMap(Generic[ValueType]): def insert( self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] ) -> None: - """Insert values in to the Spatial map. + """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. From c61e3bab1a2385e7091646afcad06d68a772e126 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:54:11 +0000 Subject: [PATCH 18/31] Update src/textual/_spatial_map.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_spatial_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index bbd814b70..835cc9904 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -73,7 +73,7 @@ class SpatialMap(Generic[ValueType]): 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. + """Get a superset of all the values that intersect with a given region. Note that this may return false positives. From c0c49978bdeb67c55da98eafe89c465721b1441e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:43:02 +0000 Subject: [PATCH 19/31] scrollbar fixes --- src/textual/_compositor.py | 5 ++++- src/textual/_spatial_map.py | 5 ++++- src/textual/screen.py | 18 ++++++++++++++++-- src/textual/widget.py | 19 +++++++++---------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 2027b9fb2..f85f7fbaa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -298,7 +298,7 @@ class Compositor: resized=resized_widgets, ) - def reflow_visible(self, parent: Widget, size: Size) -> None: + def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]: """Reflow only the visible children. This is a fast-path for scrolling. @@ -320,6 +320,7 @@ class Compositor: old_map = self.map map, widgets = self._arrange_root(parent, size, visible_only=True) + exposed_widgets = widgets - self.widgets # Replace map and widgets self.map = map self.widgets = widgets @@ -340,6 +341,8 @@ class Compositor: } self._dirty_regions.update(regions) + return exposed_widgets + @property def full_map(self) -> CompositorMap: """Lazily built compositor map that covers all widgets.""" diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 835cc9904..916602fbe 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -10,9 +10,12 @@ ValueType = TypeVar("ValueType") class SpatialMap(Generic[ValueType]): - """A spatial map allows for data to be associated with a rectangular regions + """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 in a bucket associated + with a regular grid that covers 2D space. + """ def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c5cb91061..6f19a75aa 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -428,9 +428,24 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() + ResizeEvent = events.Resize try: if scroll: - self._compositor.reflow_visible(self, size) + 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: + widget._size_updated(region.size, virtual_size, container_size) + 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 @@ -441,7 +456,6 @@ class Screen(Widget): # We want to send a resize event to widgets that were just added or change since last layout send_resize = shown | resized - ResizeEvent = events.Resize layers = self._compositor.layers for widget, ( diff --git a/src/textual/widget.py b/src/textual/widget.py index 165cd4ca4..ca98dc51e 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 @@ -260,8 +261,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]] = {} @@ -474,14 +476,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 ) @@ -489,7 +488,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. From 0ac7eef4b57966b99d3b86c1a12fe9964655752f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 18:11:55 +0000 Subject: [PATCH 20/31] docstrings and types --- src/textual/_spatial_map.py | 22 ++++++++++++++-------- tests/test_spatial_map.py | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 916602fbe..d31a7ee90 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -4,17 +4,23 @@ 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 in a bucket associated - with a regular grid that covers 2D space. + 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. """ @@ -27,17 +33,17 @@ class SpatialMap(Generic[ValueType]): """ self._grid_size = (grid_width, grid_height) self.total_region = Region() - self._map: defaultdict[tuple[int, int], list[ValueType]] = defaultdict(list) + self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) self._fixed: list[ValueType] = [] - def _region_to_grid(self, region: Region) -> Iterable[tuple[int, int]]: + def _region_to_grid_coordinate(self, region: Region) -> Iterable[GridCoordinate]: """Get the grid squares under a region. Args: region: A region. Returns: - Iterable of grid squares (tuple of 2 values). + 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 @@ -64,7 +70,7 @@ class SpatialMap(Generic[ValueType]): """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ - _region_to_grid = self._region_to_grid + _region_to_grid = self._region_to_grid_coordinate total_region = self.total_region for region, fixed, value in regions_and_values: total_region = total_region.union(region) @@ -89,8 +95,8 @@ class SpatialMap(Generic[ValueType]): 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) + for grid_coordinate in self._region_to_grid_coordinate(region): + grid_values = get_grid_values(grid_coordinate) if grid_values is not None: add_results(grid_values) unique_values = list(dict.fromkeys(results)) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index ddbf890fb..80a0eda1c 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -36,7 +36,7 @@ from textual.geometry import Region def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) - assert list(spatial_map._region_to_grid(region)) == grid + assert list(spatial_map._region_to_grid_coordinate(region)) == grid def test_get_values_in_region() -> None: From 31c8fb2818ce9de45c17b615254d9e1a87737c1d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 18:14:13 +0000 Subject: [PATCH 21/31] tests --- tests/test_spatial_map.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index 80a0eda1c..a8b0fb0d6 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -46,11 +46,19 @@ def test_get_values_in_region() -> None: [ (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)) == ["foo"] - assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["foo"] - assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == [] - assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["bar"] - assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == ["foo", "bar"] + 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", + ] From 9e94046cc6a4d63220ba8732aa394871ea44e96f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 22:35:00 +0000 Subject: [PATCH 22/31] fix for events not sent on scroll --- src/textual/_compositor.py | 5 +++-- src/textual/screen.py | 33 ++++++++++++++++++----------- src/textual/widget.py | 10 +++++---- src/textual/widgets/_placeholder.py | 11 +++++----- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f85f7fbaa..d399fc554 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -320,7 +320,7 @@ class Compositor: old_map = self.map map, widgets = self._arrange_root(parent, size, visible_only=True) - exposed_widgets = widgets - self.widgets + exposed_widgets = map.keys() - old_map.keys() # Replace map and widgets self.map = map self.widgets = widgets @@ -394,6 +394,7 @@ class Compositor: map: CompositorMap = {} widgets: set[Widget] = set() + add_new_widget = widgets.add layer_order: int = 0 def add_widget( @@ -419,7 +420,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) diff --git a/src/textual/screen.py b/src/textual/screen.py index 6f19a75aa..48c4face7 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from rich.style import Style from . import errors, events, messages from ._callback import invoke from ._compositor import Compositor, MapGeometry +from ._profile import timer from ._types import CallbackType from .css.match import match from .css.parse import parse_selectors @@ -434,18 +435,26 @@ class Screen(Widget): 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: - widget._size_updated(region.size, virtual_size, container_size) - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + with timer("size events"): + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + if widget in exposed_widgets: + widget._size_updated( + region.size, + virtual_size, + container_size, + ) + 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 diff --git a/src/textual/widget.py b/src/textual/widget.py index ca98dc51e..7668e79b9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2142,6 +2142,7 @@ class Widget(DOMNode): size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). + refresh: Also refresh. """ if ( self._size != size @@ -2149,11 +2150,13 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - self.virtual_size = virtual_size + if self.virtual_size: + 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() def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. @@ -2291,8 +2294,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): 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) From f5d64ebe8cb33ff76b1a2bd3d4eff43d2c489fbb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 22:47:08 +0000 Subject: [PATCH 23/31] fix for scrolling and events --- src/textual/screen.py | 1 + src/textual/widget.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 48c4face7..4c20dfb8f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -449,6 +449,7 @@ class Screen(Widget): region.size, virtual_size, container_size, + layout=False, ) widget.post_message_no_wait( ResizeEvent( diff --git a/src/textual/widget.py b/src/textual/widget.py index 7668e79b9..194d8e4e4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2134,7 +2134,7 @@ class Widget(DOMNode): self.app.update_styles(self) 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: """Called when the widget's size is updated. @@ -2142,7 +2142,7 @@ class Widget(DOMNode): size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). - refresh: Also refresh. + layout: Perform layout if required. """ if ( self._size != size @@ -2150,7 +2150,7 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - if self.virtual_size: + if layout: self.virtual_size = virtual_size else: self._reactive_virtual_size = virtual_size From d8e17e98c272ca10fffa6025a64f28224a69f5e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:29:27 +0000 Subject: [PATCH 24/31] size updated bool --- src/textual/screen.py | 37 ++++++++++++++++++++----------------- src/textual/widget.py | 8 +++++++- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 4c20dfb8f..0377f8034 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -435,25 +435,28 @@ class Screen(Widget): exposed_widgets = self._compositor.reflow_visible(self, size) if exposed_widgets: layers = self._compositor.layers - with timer("size events"): - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - if widget in exposed_widgets: - widget._size_updated( - region.size, - virtual_size, - container_size, - layout=False, - ) + + 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 + self, + region.size, + virtual_size, + container_size, ) ) else: diff --git a/src/textual/widget.py b/src/textual/widget.py index 194d8e4e4..12e33ec35 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2135,7 +2135,7 @@ class Widget(DOMNode): def _size_updated( self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True - ) -> None: + ) -> bool: """Called when the widget's size is updated. Args: @@ -2143,6 +2143,9 @@ class Widget(DOMNode): 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 @@ -2157,6 +2160,9 @@ class Widget(DOMNode): self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) + return True + else: + return False def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. From 8e9d99cb22d50629f0abdf4795a6ca28bb0d8400 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:56:31 +0000 Subject: [PATCH 25/31] fix for other size_updated --- src/textual/scroll_view.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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). From 4d1a3a5dc9428df598c9631494d111b9fa067923 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:42:42 +0000 Subject: [PATCH 26/31] speed up shutdown --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 33b887e09..3185247e9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1778,6 +1778,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: From b6272a3b594c3163dfc2f527d97329d2b673ae8f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 17:57:48 +0000 Subject: [PATCH 27/31] fix dictionary example going down --- examples/dictionary.py | 8 ++++++-- src/textual/_compositor.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 737bcb283..04183170c 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,11 @@ 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) + if response.status_code % 100 != 2: + self.query_one("#results", Static).update(response.text) + return + results = response.json() if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d399fc554..e9965db8b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -248,6 +248,7 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None + self._full_map = None self.root = parent self.size = size @@ -860,6 +861,7 @@ class Compositor: widget: Widget to update. """ + self._full_map = None regions: list[Region] = [] add_region = regions.append get_widget = self.visible_widgets.__getitem__ From 665efa2d052b837fc4986e3931eee0b849bc3f1e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 18:01:25 +0000 Subject: [PATCH 28/31] error handling in dictionary --- examples/dictionary.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 04183170c..8ef47f3bb 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -42,10 +42,11 @@ class DictionaryApp(App): url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" async with httpx.AsyncClient() as client: response = await client.get(url) - if response.status_code % 100 != 2: + try: + results = response.json() + except Exception: self.query_one("#results", Static).update(response.text) return - results = response.json() if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) From e4b38f23413b48ce9c06d61c13ef059176adc511 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 09:58:05 +0000 Subject: [PATCH 29/31] Rename --- src/textual/_spatial_map.py | 6 +++--- tests/test_spatial_map.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index d31a7ee90..93e8c4004 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -36,7 +36,7 @@ class SpatialMap(Generic[ValueType]): self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) self._fixed: list[ValueType] = [] - def _region_to_grid_coordinate(self, region: Region) -> Iterable[GridCoordinate]: + def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]: """Get the grid squares under a region. Args: @@ -70,7 +70,7 @@ class SpatialMap(Generic[ValueType]): """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ - _region_to_grid = self._region_to_grid_coordinate + _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) @@ -95,7 +95,7 @@ class SpatialMap(Generic[ValueType]): results: list[ValueType] = self._fixed.copy() add_results = results.extend get_grid_values = self._map.get - for grid_coordinate in self._region_to_grid_coordinate(region): + 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) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index a8b0fb0d6..413ca4cad 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -36,7 +36,7 @@ from textual.geometry import Region def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) - assert list(spatial_map._region_to_grid_coordinate(region)) == grid + assert list(spatial_map._region_to_grid_coordinates(region)) == grid def test_get_values_in_region() -> None: From 6a665d088a8f12046311fa3308b452ec08fa216e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 09:58:44 +0000 Subject: [PATCH 30/31] Remove profile --- src/textual/screen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 0377f8034..58489c65a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,7 +9,6 @@ from rich.style import Style from . import errors, events, messages from ._callback import invoke from ._compositor import Compositor, MapGeometry -from ._profile import timer from ._types import CallbackType from .css.match import match from .css.parse import parse_selectors From 53e2ea77c33ad0140cb208310c04a192f08c3e97 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 10:12:04 +0000 Subject: [PATCH 31/31] docstrings --- src/textual/_compositor.py | 3 +++ src/textual/_layout.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e9965db8b..245eb3f34 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -308,6 +308,9 @@ class Compositor: 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 diff --git a/src/textual/_layout.py b/src/textual/_layout.py index f65541bae..f7ab16312 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -18,8 +18,11 @@ ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" @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