From 0a396998f7803e8857f935b28d9f3dd4a38af2be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 10:20:20 +0100 Subject: [PATCH 1/9] optimize layout --- src/textual/_profile.py | 15 ++++++++---- src/textual/layout.py | 2 ++ src/textual/layouts/stream.py | 41 +++++++++++++++++++++++++++++++++ src/textual/layouts/vertical.py | 3 +++ src/textual/widget.py | 2 +- 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/src/textual/_profile.py b/src/textual/_profile.py index 3e880cf15c..8324db9dee 100644 --- a/src/textual/_profile.py +++ b/src/textual/_profile.py @@ -10,10 +10,17 @@ @contextlib.contextmanager -def timer(subject: str = "time") -> Generator[None, None, None]: - """print the elapsed time. (only used in debugging)""" +def timer(subject: str = "time", threshold: float = 0) -> Generator[None, None, None]: + """print the elapsed time. (only used in debugging). + + Args: + subject: Text shown in log. + threshold: Time in second after which the log is written. + + """ start = perf_counter() yield elapsed = perf_counter() - start - elapsed_ms = elapsed * 1000 - log(f"{subject} elapsed {elapsed_ms:.4f}ms") + if elapsed >= threshold: + elapsed_ms = elapsed * 1000 + log(f"{subject} elapsed {elapsed_ms:.4f}ms") diff --git a/src/textual/layout.py b/src/textual/layout.py index fe7ca4335b..fc16b7290e 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -226,6 +226,7 @@ def arrange( An iterable of widget location """ + # @timer("get_content_width") def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: """Get the optimal content width by arranging children. @@ -247,6 +248,7 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> width = arrangement.total_region.right return width + # @timer("get_content_height") def get_content_height( self, widget: Widget, container: Size, viewport: Size, width: int ) -> int: diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index ffae3b58ce..8ef4c9a16e 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -10,6 +10,9 @@ from textual.widget import Widget +from textual._profile import timer + + class StreamLayout(Layout): """A cut down version of the vertical layout. @@ -31,9 +34,11 @@ class StreamLayout(Layout): name = "stream" + @timer("arrange STREAM") def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: + print(parent) parent.pre_layout(self) if not children: return [] @@ -81,3 +86,39 @@ def arrange( y += height return placements + + @timer("STREAM.get_content_width") + def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: + """Get the optimal content width by arranging children. + + Args: + widget: The container widget. + container: The container size. + viewport: The viewport size. + + Returns: + Width of the content. + """ + return widget.scrollable_content_region.width + + @timer("STREAM.get_content_height") + def get_content_height( + self, widget: Widget, container: Size, viewport: Size, width: int + ) -> int: + """Get the content height. + + Args: + widget: The container widget. + container: The container size. + viewport: The viewport. + width: The content width. + + Returns: + Content height (in lines). + """ + if widget._nodes: + arrangement = widget._arrange(Size(width, 0)) + height = arrangement.total_region.height + else: + height = 0 + return height diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index b229fa6622..ab7261a9ff 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -11,12 +11,15 @@ 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("arrange VERTICAL") def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ab9664c1..1d272f8c2f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -717,10 +717,10 @@ def _cover(self, widget: Widget) -> None: self._uncover() self._cover_widget = widget widget._parent = self + widget._start_messages() widget._post_register(self.app) self.app.stylesheet.apply(widget) self.refresh(layout=True) - widget._start_messages() def _uncover(self) -> None: """Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover].""" From fdfac1e878fbcdbce7162df808e845f72051af1a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 13:08:26 +0100 Subject: [PATCH 2/9] use app.screen --- src/textual/layouts/grid.py | 2 +- src/textual/screen.py | 2 +- src/textual/widget.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 5aa419b057..432f337148 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -60,7 +60,7 @@ def arrange( table_size_columns -= 1 table_size_rows = styles.grid_size_rows - viewport = parent.screen.size + viewport = parent.app.size keyline_style, _keyline_color = styles.keyline offset = (0, 0) gutter_spacing: Spacing | None diff --git a/src/textual/screen.py b/src/textual/screen.py index 14e11046da..3bec40bbb5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -512,7 +512,7 @@ def get_maximize_widgets(maximized: Widget) -> list[Widget]: else self._nodes ), size, - self.screen.size, + self.app.size, False, ) diff --git a/src/textual/widget.py b/src/textual/widget.py index 1d272f8c2f..c1ed53a8f3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1263,7 +1263,7 @@ def _arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: return cached_result arrangement = self._arrangement_cache[cache_key] = arrange( - self, self._nodes, size, self.screen.size, optimal=optimal + self, self._nodes, size, self.app.size, optimal=optimal ) return arrangement From 13c993ecb5a0a1e2b250e91dfd90cfdd155a23b0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 13:35:58 +0100 Subject: [PATCH 3/9] optimize screen size --- src/textual/screen.py | 7 ++++++- src/textual/widget.py | 4 ++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 3bec40bbb5..f721a3913f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -341,6 +341,11 @@ def layers(self) -> tuple[str, ...]: extras.append("_tooltips") return (*super().layers, *extras) + @property + def size(self) -> Size: + """The size of the screen.""" + return self.app.size - self.styles.gutter.totals + def _watch_focused(self): self.refresh_bindings() @@ -512,7 +517,7 @@ def get_maximize_widgets(maximized: Widget) -> list[Widget]: else self._nodes ), size, - self.app.size, + self.size, False, ) diff --git a/src/textual/widget.py b/src/textual/widget.py index c1ed53a8f3..d064eb8b70 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -605,7 +605,7 @@ def offset(self) -> Offset: Returns: Relative offset. """ - return self.styles.offset.resolve(self.size, self.app.size) + return self.styles.offset.resolve(self.size, self.screen.size) @offset.setter def offset(self, offset: tuple[int, int]) -> None: @@ -1263,7 +1263,7 @@ def _arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: return cached_result arrangement = self._arrangement_cache[cache_key] = arrange( - self, self._nodes, size, self.app.size, optimal=optimal + self, self._nodes, size, self.screen.size, optimal=optimal ) return arrangement From 98899a0cfb3d85e27ce595ca182a6f4cca7e96ac Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 18:03:05 +0100 Subject: [PATCH 4/9] optimization --- src/textual/_arrange.py | 19 ++++++++++--------- src/textual/widget.py | 2 +- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index ddab9b185c..bb6bec570e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -31,6 +31,11 @@ def _build_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]: return layers +_get_dock = attrgetter("styles.is_docked") +_get_split = attrgetter("styles.is_split") +_get_display = attrgetter("display") + + def arrange( widget: Widget, children: Sequence[Widget], @@ -50,22 +55,18 @@ def arrange( """ placements: list[WidgetPlacement] = [] - scroll_spacing = Spacing() - - get_dock = attrgetter("styles.is_docked") - get_split = attrgetter("styles.is_split") - get_display = attrgetter("styles.display") - + scroll_spacing = NULL_SPACING styles = widget.styles # Widgets which will be displayed - display_widgets = [child for child in children if get_display(child) != "none"] + # display_widgets = [child for child in children if get_display(child) != "none"] + display_widgets = list(filter(_get_display, children)) # Widgets organized into layers layers = _build_layers(display_widgets) for widgets in layers.values(): # Partition widgets into split widgets and non-split widgets - non_split_widgets, split_widgets = partition(get_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 @@ -78,7 +79,7 @@ def arrange( # Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the # document), and "dock" widgets which are positioned relative to an edge - layout_widgets, dock_widgets = partition(get_dock, non_split_widgets) + layout_widgets, dock_widgets = partition(_get_dock, non_split_widgets) # Arrange docked widgets if dock_widgets: diff --git a/src/textual/widget.py b/src/textual/widget.py index d064eb8b70..403984ec0b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -460,7 +460,7 @@ def __init__( self._content_height_cache: tuple[object, int] = (None, 0) self._arrangement_cache: FIFOCache[ - tuple[Size, int, Widget], DockArrangeResult + tuple[Size, int, Widget | None], DockArrangeResult ] = FIFOCache(4) self._styles_cache = StylesCache() From 9d427840349d9a5e9e1f2a25db44af65f72656b4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 18:23:08 +0100 Subject: [PATCH 5/9] process layout hook --- src/textual/_arrange.py | 6 ++++-- src/textual/widget.py | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index bb6bec570e..9b65c9d127 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -95,8 +95,10 @@ def arrange( if layout_widgets: # Arrange layout widgets (i.e. not docked) - layout_placements = widget.layout.arrange( - widget, layout_widgets, dock_region.size, greedy=not optimal + layout_placements = widget.process_layout( + widget.layout.arrange( + widget, layout_widgets, dock_region.size, greedy=not optimal + ) ) scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) placement_offset = dock_region.offset diff --git a/src/textual/widget.py b/src/textual/widget.py index 403984ec0b..ae298fe121 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -79,7 +79,7 @@ Spacing, clamp, ) -from textual.layout import Layout +from textual.layout import Layout, WidgetPlacement from textual.layouts.vertical import VerticalLayout from textual.message import Message from textual.messages import CallbackType, Prune @@ -722,6 +722,22 @@ def _cover(self, widget: Widget) -> None: self.app.stylesheet.apply(widget) self.refresh(layout=True) + def process_layout( + self, placements: list[WidgetPlacement] + ) -> list[WidgetPlacement]: + """A hook to allow for the manipulation of widget placements before rendering. + + You could use this as a way to modify the positions / margins of widgets if your requirement is + not supported in TCSS. In practice, this method is rarely needed! + + Args: + placements: A list of [`WidgetPlacement`][textual.layout.WidgetPlacement] objects. + + Returns: + A new list of placements. + """ + return placements + def _uncover(self) -> None: """Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover].""" if self._cover_widget is not None: From 87e918db4ce6fabf4681cc71fea4a466c5211a34 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 18:23:41 +0100 Subject: [PATCH 6/9] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 299c3db889..f23df7a8b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Eager tasks are now enabled On Python3.12 and above https://github.com/Textualize/textual/pull/6102 - ### Added - Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102 +- Added `Widget.process_layout` ## [6.1.0] - 2025-08-01 From 6fd179790ed41fb42a1f364faa737af939c014d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 18:31:08 +0100 Subject: [PATCH 7/9] remove timers --- CHANGELOG.md | 2 +- src/textual/_arrange.py | 1 - src/textual/layout.py | 2 -- src/textual/layouts/grid.py | 2 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/stream.py | 9 +-------- src/textual/layouts/vertical.py | 5 +---- 7 files changed, 5 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f23df7a8b6..4977fcd24d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102 -- Added `Widget.process_layout` +- Added `Widget.process_layout` https://github.com/Textualize/textual/pull/6105 ## [6.1.0] - 2025-08-01 diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 9b65c9d127..6cdca4af3d 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -59,7 +59,6 @@ def arrange( styles = widget.styles # Widgets which will be displayed - # display_widgets = [child for child in children if get_display(child) != "none"] display_widgets = list(filter(_get_display, children)) # Widgets organized into layers layers = _build_layers(display_widgets) diff --git a/src/textual/layout.py b/src/textual/layout.py index fc16b7290e..fe7ca4335b 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -226,7 +226,6 @@ def arrange( An iterable of widget location """ - # @timer("get_content_width") def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: """Get the optimal content width by arranging children. @@ -248,7 +247,6 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> width = arrangement.total_region.right return width - # @timer("get_content_height") def get_content_height( self, widget: Widget, container: Size, viewport: Size, width: int ) -> int: diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 432f337148..5aa419b057 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -60,7 +60,7 @@ def arrange( table_size_columns -= 1 table_size_rows = styles.grid_size_rows - viewport = parent.app.size + viewport = parent.screen.size keyline_style, _keyline_color = styles.keyline offset = (0, 0) gutter_spacing: Spacing | None diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index c1720173d6..735759bc75 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -25,7 +25,7 @@ def arrange( parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.size + viewport = parent.screen.size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index 8ef4c9a16e..014198f663 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -10,9 +10,6 @@ from textual.widget import Widget -from textual._profile import timer - - class StreamLayout(Layout): """A cut down version of the vertical layout. @@ -34,15 +31,13 @@ class StreamLayout(Layout): name = "stream" - @timer("arrange STREAM") def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: - print(parent) parent.pre_layout(self) if not children: return [] - viewport = parent.app.size + viewport = parent.screen.size _Region = Region _WidgetPlacement = WidgetPlacement @@ -87,7 +82,6 @@ def arrange( return placements - @timer("STREAM.get_content_width") def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: """Get the optimal content width by arranging children. @@ -101,7 +95,6 @@ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> """ return widget.scrollable_content_region.width - @timer("STREAM.get_content_height") def get_content_height( self, widget: Widget, container: Size, viewport: Size, width: int ) -> int: diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index ab7261a9ff..d04e04b4d1 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -11,22 +11,19 @@ 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("arrange VERTICAL") def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.app.size + viewport = parent.screen.size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ From 2e2d5d588d1b57d159b052e5bcda44ee48810383 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 6 Sep 2025 18:38:27 +0100 Subject: [PATCH 8/9] fix stack error --- src/textual/app.py | 8 ++++++++ src/textual/layouts/grid.py | 2 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/stream.py | 2 +- src/textual/layouts/vertical.py | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 26c3f818f4..92748d0e7d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1565,6 +1565,14 @@ def size(self) -> Size: width, height = self.console.size return Size(width, height) + @property + def viewport_size(self) -> Size: + """Get the viewport size (size of the screen).""" + try: + return self.screen.size + except (ScreenStackError, NoScreen): + return self.size + def _get_inline_height(self) -> int: """Get the inline height (height when in inline mode). diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 5aa419b057..c6f518360b 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -60,7 +60,7 @@ def arrange( table_size_columns -= 1 table_size_rows = styles.grid_size_rows - viewport = parent.screen.size + viewport = parent.app.viewport_size keyline_style, _keyline_color = styles.keyline offset = (0, 0) gutter_spacing: Spacing | None diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 735759bc75..fdead10c1f 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -25,7 +25,7 @@ def arrange( parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.screen.size + viewport = parent.app.viewport_size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index 014198f663..9665f408f6 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -37,7 +37,7 @@ def arrange( parent.pre_layout(self) if not children: return [] - viewport = parent.screen.size + viewport = parent.app.viewport_size _Region = Region _WidgetPlacement = WidgetPlacement diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index d04e04b4d1..4f68449417 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -23,7 +23,7 @@ def arrange( parent.pre_layout(self) placements: list[WidgetPlacement] = [] add_placement = placements.append - viewport = parent.screen.size + viewport = parent.app.viewport_size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ From 037790a7b9c5918241e6d0e435a7c5b3b19c2325 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Sep 2025 08:27:02 +0100 Subject: [PATCH 9/9] changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4977fcd24d..2531e0c14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DOMNode.displayed_and_visible_children` https://github.com/Textualize/textual/pull/6102 - Added `Widget.process_layout` https://github.com/Textualize/textual/pull/6105 +- Added `App.viewport_size` https://github.com/Textualize/textual/pull/6105 +- Added `Screen.size` https://github.com/Textualize/textual/pull/6105 ## [6.1.0] - 2025-08-01