diff --git a/CHANGELOG.md b/CHANGELOG.md index 299c3db889..2531e0c14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,12 @@ 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` 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 diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index ddab9b185c..6cdca4af3d 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,17 @@ 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 = 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 +78,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: @@ -94,8 +94,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/_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/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 c1720173d6..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.app.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 ffae3b58ce..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.app.size + viewport = parent.app.viewport_size _Region = Region _WidgetPlacement = WidgetPlacement @@ -81,3 +81,37 @@ def arrange( y += height return placements + + 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 + + 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..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.app.size + viewport = parent.app.viewport_size child_styles = [child.styles for child in children] box_margins: list[Spacing] = [ diff --git a/src/textual/screen.py b/src/textual/screen.py index 14e11046da..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.screen.size, + self.size, False, ) diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ab9664c1..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 @@ -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() @@ -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: @@ -717,10 +717,26 @@ 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 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]."""