diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 6cdca4af3d..4cfd2448fb 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -53,7 +53,6 @@ def arrange( Returns: Widget arrangement information. """ - placements: list[WidgetPlacement] = [] scroll_spacing = NULL_SPACING styles = widget.styles diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f60d4c9b83..32192471ff 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1248,8 +1248,7 @@ def update_widgets(self, widgets: set[Widget]) -> None: offset = region.offset intersection = clip.intersection for dirty_region in widget._exchange_repaint_regions(): - update_region = intersection(dirty_region.translate(offset)) - if update_region: + if update_region := intersection(dirty_region.translate(offset)): add_region(update_region) self._dirty_regions.update(regions) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 646b56321a..e11acc0bbd 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -104,7 +104,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: Returns: Rendered lines. """ - border_title = widget._border_title border_subtitle = widget._border_subtitle diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index 4bf92d558b..782beb3e3f 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -1,5 +1,6 @@ from __future__ import annotations +from itertools import zip_longest from typing import TYPE_CHECKING from textual.geometry import NULL_OFFSET, Region, Size @@ -30,6 +31,11 @@ class StreamLayout(Layout): name = "stream" + def __init__(self) -> None: + self._cached_placements: list[WidgetPlacement] | None = None + self._cached_width = 0 + super().__init__() + def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: @@ -38,6 +44,12 @@ def arrange( return [] viewport = parent.app.viewport_size + if size.width != self._cached_width: + self._cached_placements = None + previous_results = self._cached_placements or [] + + layout_widgets = parent.screen._layout_widgets.get(parent, []) + _Region = Region _WidgetPlacement = WidgetPlacement @@ -48,7 +60,20 @@ def arrange( previous_margin = first_child_styles.margin.top null_offset = NULL_OFFSET - for widget in children: + pre_populate = bool(previous_results and layout_widgets) + for widget, placement in zip_longest(children, previous_results): + if pre_populate and placement is not None and widget is placement.widget: + if widget in layout_widgets: + pre_populate = False + else: + placements.append(placement) + y = placement.region.bottom + styles = widget.styles._base_styles + previous_margin = styles.margin.bottom + continue + if widget is None: + break + styles = widget.styles._base_styles margin = styles.margin gutter_width, gutter_height = styles.gutter.totals @@ -85,6 +110,8 @@ def arrange( ) y += height + self._cached_width = size.width + self._cached_placements = placements return placements def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: diff --git a/src/textual/messages.py b/src/textual/messages.py index dc0f6544a8..90d0c9fd82 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -52,6 +52,10 @@ def can_replace(self, message: Message) -> bool: class Layout(Message, verbose=True): """Sent by Textual when a layout is required.""" + def __init__(self, widget: Widget) -> None: + super().__init__() + self.widget = widget + def can_replace(self, message: Message) -> bool: return isinstance(message, Layout) diff --git a/src/textual/screen.py b/src/textual/screen.py index d96dcbd909..c130af0a1a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -321,6 +321,9 @@ def __init__( self._css_update_count = -1 """Track updates to CSS.""" + self._layout_widgets: dict[DOMNode, set[Widget]] = {} + """Widgets whose layout may have changed.""" + @property def is_modal(self) -> bool: """Is the screen modal?""" @@ -486,11 +489,12 @@ def active_bindings(self) -> dict[str, ActiveBinding]: return bindings_map - def arrange(self, size: Size) -> DockArrangeResult: + def arrange(self, size: Size, _optimal: bool = False) -> DockArrangeResult: """Arrange children. Args: size: Size of container. + optimal: Ignored on screen. Returns: Widget locations. @@ -1270,7 +1274,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non ResizeEvent = events.Resize try: - if scroll: + if scroll and not self._layout_widgets: exposed_widgets = self._compositor.reflow_visible(self, size) if exposed_widgets: layers = self._compositor.layers @@ -1295,6 +1299,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non else: hidden, shown, resized = self._compositor.reflow(self, size) + self._layout_widgets.clear() Hide = events.Hide Show = events.Show @@ -1348,8 +1353,24 @@ async def _on_update(self, message: messages.Update) -> None: async def _on_layout(self, message: messages.Layout) -> None: message.stop() message.prevent_default() - self._layout_required = True - self.check_idle() + + layout_required = False + widget: DOMNode = message.widget + for ancestor in message.widget.ancestors: + if not isinstance(ancestor, Widget): + break + if ancestor not in self._layout_widgets: + self._layout_widgets[ancestor] = set() + if widget not in self._layout_widgets: + self._layout_widgets[ancestor].add(widget) + layout_required = True + if not ancestor.styles.auto_dimensions: + break + widget = ancestor + + if layout_required and not self._layout_required: + self._layout_required = True + self.check_idle() async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: message.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index cf56e9536d..9382a25dc5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -60,7 +60,7 @@ from textual.actions import SkipAction from textual.await_remove import AwaitRemove from textual.box_model import BoxModel -from textual.cache import FIFOCache +from textual.cache import FIFOCache, LRUCache from textual.color import Color from textual.compose import compose from textual.content import Content, ContentType @@ -427,6 +427,7 @@ def __init__( self._size = _null_size self._container_size = _null_size self._layout_required = False + self._layout_updates = 0 self._repaint_required = False self._scroll_required = False self._recompose_required = False @@ -455,6 +456,8 @@ def __init__( # Regions which need to be transferred from cache to screen self._repaint_regions: set[Region] = set() + self._box_model_cache: LRUCache[object, BoxModel] = LRUCache(16) + # Cache the auto content dimensions self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) @@ -1642,6 +1645,19 @@ def _get_box_model( Returns: The size and margin for this widget. """ + cache_key = ( + container, + viewport, + width_fraction, + height_fraction, + constrain_width, + greedy, + self._layout_updates, + self.styles._cache_key, + ) + if cached_box_model := self._box_model_cache.get(cache_key): + return cached_box_model + styles = self.styles is_border_box = styles.box_sizing == "border-box" gutter = styles.gutter # Padding plus border @@ -1750,6 +1766,7 @@ def _get_box_model( model = BoxModel( content_width + gutter.width, content_height + gutter.height, margin ) + self._box_model_cache[cache_key] = model return model def get_content_width(self, container: Size, viewport: Size) -> int: @@ -3610,7 +3627,7 @@ def scroll_visible( """ parent = self.parent if isinstance(parent, Widget): - if self.region: + if self._size: self.screen.scroll_to_widget( self, animate=animate, @@ -4200,14 +4217,9 @@ def refresh( Returns: The `Widget` instance. """ - if layout: + if layout and not self._layout_required: self._layout_required = True - for ancestor in self.ancestors: - if not isinstance(ancestor, Widget): - break - ancestor._clear_arrangement_cache() - if not ancestor.styles.auto_dimensions: - break + self._layout_updates += 1 if recompose: self._recompose_required = True @@ -4422,7 +4434,14 @@ def _check_refresh(self) -> None: screen.post_message(messages.Update(self)) if self._layout_required: self._layout_required = False - screen.post_message(messages.Layout()) + for ancestor in self.ancestors: + if not isinstance(ancestor, Widget): + break + ancestor._clear_arrangement_cache() + ancestor._layout_updates += 1 + if not ancestor.styles.auto_dimensions: + break + screen.post_message(messages.Layout(self)) def focus(self, scroll_visible: bool = True) -> Self: """Give focus to this widget. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 9d14a0557d..0dbb1f2667 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -228,7 +228,7 @@ class Button(Widget, can_focus=True): BINDINGS = [Binding("enter", "press", "Press button", show=False)] - label: reactive[ContentText] = reactive[ContentText](Content.empty) + label: reactive[ContentText] = reactive[ContentText](Content.empty()) """The text label that appears within the button.""" variant = reactive("default", init=False) @@ -293,11 +293,12 @@ def __init__( if label is None: label = self.css_identifier_styled - self.label = Content.from_text(label) self.variant = variant - self.action = action - self.compact = compact self.flat = flat + self.compact = compact + self.set_reactive(Button.label, Content.from_text(label)) + + self.action = action self.active_effect_duration = 0.2 """Amount of time in seconds the button 'press' animation lasts.""" diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 86cf17914a..1ace31a82c 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -807,7 +807,9 @@ def test_remove_with_auto_height(snap_compare): def test_auto_table(snap_compare): - assert snap_compare(SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40)) + assert snap_compare( + SNAPSHOT_APPS_DIR / "auto-table.py", terminal_size=(120, 40), press=["wait:100"] + ) def test_table_markup(snap_compare): diff --git a/tests/test_box_model.py b/tests/test_box_model.py index d775dc9be9..5a4b1790ae 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -133,7 +133,6 @@ def get_content_height(self, container: Size, parent: Size, width: int) -> int: styles.margin = 2 box_model = widget._get_box_model(Size(60, 20), Size(80, 24), one, one) - print(box_model) assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2)) styles.margin = 1, 2, 3, 4