From 689cfd837f287e86b39c8147d379d949cd62b854 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 13 Nov 2025 11:40:27 +0000 Subject: [PATCH 1/3] loading widget --- src/textual/_compositor.py | 11 ++++++++- src/textual/containers.py | 4 ++++ src/textual/css/_style_properties.py | 35 ++++++++++++++++------------ src/textual/layouts/grid.py | 18 +++++++++++--- src/textual/screen.py | 11 +++++++++ src/textual/scroll_view.py | 4 ++++ src/textual/widget.py | 2 +- 7 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 945d0841d4..47ad49719e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -689,6 +689,15 @@ def add_widget( arrange_result.scroll_spacing, ) layer_order -= 1 + else: + if widget._anchored and not widget._anchor_released: + new_scroll_y = widget.virtual_size.height - ( + widget.container_size.height + - widget.scrollbar_size_horizontal + ) + widget.set_reactive(Widget.scroll_y, new_scroll_y) + widget.set_reactive(Widget.scroll_target_y, new_scroll_y) + widget.vertical_scrollbar._reactive_position = new_scroll_y if visible: # Add any scrollbars @@ -709,7 +718,7 @@ def add_widget( dock_gutter, ) - map[widget] = _MapGeometry( + map[widget._render_widget] = _MapGeometry( region, order, clip, diff --git a/src/textual/containers.py b/src/textual/containers.py index 4478901d35..f60929b660 100644 --- a/src/textual/containers.py +++ b/src/textual/containers.py @@ -267,6 +267,7 @@ class ItemGrid(Widget): stretch_height: reactive[bool] = reactive(True) min_column_width: reactive[int | None] = reactive(None, layout=True) + max_column_width: reactive[int | None] = reactive(None, layout=True) regular: reactive[bool] = reactive(False) def __init__( @@ -277,6 +278,7 @@ def __init__( classes: str | None = None, disabled: bool = False, min_column_width: int | None = None, + max_column_width: int | None = None, stretch_height: bool = True, regular: bool = False, ) -> None: @@ -298,10 +300,12 @@ def __init__( ) self.set_reactive(ItemGrid.stretch_height, stretch_height) self.set_reactive(ItemGrid.min_column_width, min_column_width) + self.set_reactive(ItemGrid.max_column_width, max_column_width) self.set_reactive(ItemGrid.regular, regular) def pre_layout(self, layout: Layout) -> None: if isinstance(layout, GridLayout): layout.stretch_height = self.stretch_height layout.min_column_width = self.min_column_width + layout.max_column_width = self.max_column_width layout.regular = self.regular diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 2b3ebfe77c..7f63cf152b 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -667,8 +667,9 @@ def __get__( Args: obj: The Styles object. objtype: The Styles class. + Returns: - The ``Layout`` object. + The `Layout` object. """ return obj.get_rule(self.name) # type: ignore[return-value] @@ -677,7 +678,7 @@ def __set__(self, obj: StylesBase, layout: str | Layout | None): Args: obj: The Styles object. layout: The layout to use. You can supply the name of the layout - or a ``Layout`` object. + or a `Layout` object. """ from textual.layouts.factory import Layout # Prevents circular import @@ -687,19 +688,23 @@ def __set__(self, obj: StylesBase, layout: str | Layout | None): if layout is None: if obj.clear_rule("layout"): obj.refresh(layout=True, children=True) - elif isinstance(layout, Layout): - if obj.set_rule("layout", layout): - obj.refresh(layout=True, children=True) - else: - try: - layout_object = get_layout(layout) - except MissingLayout as error: - raise StyleValueError( - str(error), - help_text=layout_property_help_text(self.name, context="inline"), - ) - if obj.set_rule("layout", layout_object): - obj.refresh(layout=True, children=True) + return + + if isinstance(layout, Layout): + layout = layout.name + + if obj.layout is not None and obj.layout.name == layout: + return + + try: + layout_object = get_layout(layout) + except MissingLayout as error: + raise StyleValueError( + str(error), + help_text=layout_property_help_text(self.name, context="inline"), + ) + if obj.set_rule("layout", layout_object): + obj.refresh(layout=True, children=True) class OffsetProperty: diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index d8f389f38c..9f91df41a1 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -20,9 +20,13 @@ class GridLayout(Layout): def __init__(self) -> None: self.min_column_width: int | None = None + """Maintain a minimum column width, or `None` for no minimum.""" + self.max_column_width: int | None = None + """Maintain a maximum column width, or `None` for no maximum.""" self.stretch_height: bool = False """Stretch the height of cells to be equal in each row.""" self.regular: bool = False + """Grid should be regular (no remainder in last row).""" self.expand: bool = False """Expand the grid to fit the container if it is smaller.""" self.shrink: bool = False @@ -57,14 +61,23 @@ def arrange( table_size_columns = max(1, styles.grid_size_columns) min_column_width = self.min_column_width + max_column_width = self.max_column_width + + container_width = size.width + if max_column_width is not None: + container_width = ( + max(1, min(len(children), (container_width // max_column_width))) + * max_column_width + ) + size = Size(container_width, size.height) if min_column_width is not None: - container_width = size.width table_size_columns = max( 1, (container_width + gutter_horizontal) // (min_column_width + gutter_horizontal), ) + table_size_columns = min(table_size_columns, len(children)) if self.regular: while len(children) % table_size_columns and table_size_columns > 1: @@ -139,8 +152,7 @@ def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]: cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {} cell_size_map: dict[Widget, tuple[int, int, int, int]] = {} - column_count = table_size_columns - next_coord = iter(cell_coords(column_count)).__next__ + next_coord = iter(cell_coords(table_size_columns)).__next__ cell_coord = (0, 0) column = row = 0 diff --git a/src/textual/screen.py b/src/textual/screen.py index bc6f14207f..b398f31b55 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -558,6 +558,17 @@ def allow_select(self) -> bool: """Check if this widget permits text selection.""" return self.ALLOW_SELECT + def get_loading_widget(self) -> Widget: + """Get a widget to display a loading indicator. + + The default implementation will defer to App.get_loading_widget. + + Returns: + A widget in place of this widget to indicate a loading. + """ + loading_widget = self.app.get_loading_widget() + return loading_widget + def render(self) -> RenderableType: """Render method inherited from widget, used to render the screen's background. diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 7609d38cc7..89fb3bf684 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -32,6 +32,10 @@ def is_scrollable(self) -> bool: """Always scrollable.""" return True + @property + def is_container(self) -> bool: + return False + def watch_scroll_x(self, old_value: float, new_value: float) -> None: if self.show_horizontal_scrollbar: self.horizontal_scrollbar.position = new_value diff --git a/src/textual/widget.py b/src/textual/widget.py index ad526e606e..c6aae7a478 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1014,7 +1014,7 @@ def get_loading_widget(self) -> Widget: Returns: A widget in place of this widget to indicate a loading. """ - loading_widget = self.app.get_loading_widget() + loading_widget = self.screen.get_loading_widget() return loading_widget def set_loading(self, loading: bool) -> None: From 4e3bf85942ad2349f17f40a745dc5965c7a98d77 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 13 Nov 2025 11:48:09 +0000 Subject: [PATCH 2/3] docstrings --- src/textual/scroll_view.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 89fb3bf684..0365ae7d7f 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -16,6 +16,11 @@ class ScrollView(ScrollableContainer): """ A base class for a Widget that handles its own scrolling (i.e. doesn't rely on the compositor to render children). + + !!! note + + This is the typically wrong class for making something scrollable. If you want to make something scroll, set it's + `overflow` style to auto or scroll. Or use one of the pre-defined scrolling containers such as [VerticalScroll][textual.containers.VerticalScroll]. """ ALLOW_MAXIMIZE = True @@ -34,6 +39,8 @@ def is_scrollable(self) -> bool: @property def is_container(self) -> bool: + """Since a ScrollView should be a line-api widget, it won't have children, + and therefore isn't a container.""" return False def watch_scroll_x(self, old_value: float, new_value: float) -> None: From 3da69f40eea6ceab7947e8a7f568e458629ee6cb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 13 Nov 2025 11:53:28 +0000 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 14 +++++ .../test_snapshots/test_loading_indicator.svg | 56 +++++++++---------- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fde32b7c1..6d972c4a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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/). +## Unreleased + +### Added + +- Added `GridLayout.max_column_width` https://github.com/Textualize/textual/pull/6228 + +### Changed + +- Added `Screen.get_loading_widget` which deferes to `App.get_loading_widget` https://github.com/Textualize/textual/pull/6228 + +### Fixed + +- Fixed `anchor` with `ScrollView` widgets https://github.com/Textualize/textual/pull/6228 + ## [6.6.0] - 2025-11-10 ### Fixed diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_loading_indicator.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_loading_indicator.svg index 6849ad7ac3..cfed408157 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_loading_indicator.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_loading_indicator.svg @@ -32,11 +32,11 @@ font-family: arial; } - .terminal-r1 { fill: #121212 } -.terminal-r2 { fill: #0178d4 } + .terminal-r1 { fill: #004578 } +.terminal-r2 { fill: #121212 } .terminal-r3 { fill: #191919 } .terminal-r4 { fill: #c5c8c6 } -.terminal-r5 { fill: #004578 } +.terminal-r5 { fill: #272727 } .terminal-r6 { fill: #e0e0e0 } .terminal-r7 { fill: #1e1e1e } .terminal-r8 { fill: #000000 } @@ -125,32 +125,32 @@ - + - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo▄▄ -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -Loading!foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -foo barfoo barfoo barfoo barfoo -bar -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +foo barfoo barfoo barfoo barfoo +bar +▄▄foo barfoo barfoo barfoo barfoo▄▄ +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +Loading!foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +foo barfoo barfoo barfoo barfoo +bar +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁