From 51dbf16d4528eda6aa8f938ce4fca8f81eb61087 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 6 Oct 2025 09:21:49 +0100 Subject: [PATCH] faster layout --- src/textual/_arrange.py | 3 +- src/textual/_compositor.py | 3 +- src/textual/layouts/vertical.py | 23 +++++++++------ src/textual/messages.py | 4 +++ src/textual/reactive.py | 2 ++ src/textual/screen.py | 15 +++++++--- src/textual/widget.py | 51 ++++++++++++++++++++++++--------- src/textual/widgets/_button.py | 9 +++--- tests/test_box_model.py | 1 - 9 files changed, 77 insertions(+), 34 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 6cdca4af3..edfc8335e 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 @@ -65,8 +64,10 @@ def arrange( for widgets in layers.values(): # Partition widgets into split widgets and non-split widgets + non_split_widgets, split_widgets = partition(_get_split, widgets) if split_widgets: + _split_placements, dock_region = _arrange_split_widgets( split_widgets, size, viewport ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f60d4c9b8..32192471f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1248,8 +1248,7 @@ class Compositor: 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/layouts/vertical.py b/src/textual/layouts/vertical.py index da3462267..8b795d469 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -11,15 +11,19 @@ if TYPE_CHECKING: from textual.geometry import Spacing from textual.widget import Widget +from textual._profile import timer + class VerticalLayout(Layout): """Used to layout Widgets vertically on screen, from top to bottom.""" name = "vertical" + @timer("Vertical.arrange") def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: + print(parent, size, greedy, len(children)) parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append @@ -50,15 +54,16 @@ class VerticalLayout(Layout): else: resolve_margin = Size(0, 0) - box_models = resolve_box_models( - [styles.height for styles in child_styles], - children, - size, - parent.app.size, - resolve_margin, - resolve_dimension="height", - greedy=greedy, - ) + with timer("resolve_box_models"): + box_models = resolve_box_models( + [styles.height for styles in child_styles], + children, + size, + parent.app.size, + resolve_margin, + resolve_dimension="height", + greedy=greedy, + ) margins = [ ( diff --git a/src/textual/messages.py b/src/textual/messages.py index dc0f6544a..90d0c9fd8 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -52,6 +52,10 @@ class Update(Message, verbose=True): 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/reactive.py b/src/textual/reactive.py index 57c0bf3ea..f65a9f1a0 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -362,6 +362,8 @@ class Reactive(Generic[ReactiveType]): # Refresh according to descriptor flags if self._layout or self._repaint or self._recompose: + if self._layout: + print(self, "layout", obj) obj.refresh( repaint=self._repaint, layout=self._layout, diff --git a/src/textual/screen.py b/src/textual/screen.py index d96dcbd90..05e18656e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -321,6 +321,9 @@ class Screen(Generic[ScreenResultType], Widget): self._css_update_count = -1 """Track updates to CSS.""" + self._layout_widgets: set[Widget] = set() + """Widgets whose layout may have changed.""" + @property def is_modal(self) -> bool: """Is the screen modal?""" @@ -486,11 +489,12 @@ class Screen(Generic[ScreenResultType], Widget): 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 @@ class Screen(Generic[ScreenResultType], Widget): 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 @@ class Screen(Generic[ScreenResultType], Widget): else: hidden, shown, resized = self._compositor.reflow(self, size) + self._layout_widgets.clear() Hide = events.Hide Show = events.Show @@ -1348,8 +1353,10 @@ class Screen(Generic[ScreenResultType], Widget): async def _on_layout(self, message: messages.Layout) -> None: message.stop() message.prevent_default() - self._layout_required = True - self.check_idle() + if message.widget not in self._layout_widgets: + self._layout_widgets.add(message.widget) + 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 cf56e9536..8ecfefdb3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -60,7 +60,7 @@ from textual._types import AnimationLevel 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 @@ class Widget(DOMNode): 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 @@ class Widget(DOMNode): # 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 @@ class Widget(DOMNode): Returns: The size and margin for this widget. """ + cache_key = ( + container, + viewport, + width_fraction, + height_fraction, + greedy, + constrain_width, + 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 @@ class Widget(DOMNode): 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: @@ -3999,10 +4016,10 @@ class Widget(DOMNode): if self._size != size: self._set_dirty() self._size = size - if layout: - self.virtual_size = virtual_size - else: - self.set_reactive(Widget.virtual_size, virtual_size) + # if layout: + # self.virtual_size = virtual_size + # else: + self.set_reactive(Widget.virtual_size, virtual_size) self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) @@ -4200,14 +4217,15 @@ class Widget(DOMNode): 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 + # for ancestor in self.ancestors: + # if not isinstance(ancestor, Widget): + # break + # ancestor._clear_arrangement_cache() + # if not ancestor.styles.auto_dimensions: + # break if recompose: self._recompose_required = True @@ -4422,7 +4440,14 @@ class Widget(DOMNode): 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 9d14a0557..0dbb1f266 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 @@ class Button(Widget, can_focus=True): 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/test_box_model.py b/tests/test_box_model.py index d775dc9be..5a4b1790a 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -133,7 +133,6 @@ def test_height(): 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