From 6439972fed5eba5e64784439aa97dc916e57f3f2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Sun, 18 Oct 2026 19:13:19 -0400 Subject: [PATCH] fix(P553): declare fixed-shape paint on widget classes (#64) --- src/textual/scrollbar.py | 23 +----------- src/textual/widget.py | 9 ++++- tests/test_line_surface_style_measurement.py | 39 ++++++++++++-------- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 3b067a73e9..c587c516f7 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -223,6 +223,7 @@ def __rich_console__( @rich.repr.auto class ScrollBar(Widget): + _paint_only_render = True renderer: ClassVar[Type[ScrollBarRender]] = ScrollBarRender """The class used for rendering scrollbars. This can be overridden and set to a ScrollBarRender-derived class @@ -278,20 +279,6 @@ def validate_position(self, position: float) -> float: """Position has a granulatory of 1/8 of a cell.""" return int(position * 8) / 8 - def _render_styles_sensitive(self) -> bool: - """Native bar colors change paint, not the renderer's cell extent. - - Custom renderers and wrappers retain their arbitrary measurement - inputs. Select the live instance renderer, including runtime replacement. - """ - return ( - type(self) is not ScrollBar - or getattr(self.render, "__func__", None) is not ScrollBar.render - or getattr(self._render_bar, "__func__", None) is not ScrollBar._render_bar - or getattr(self._render, "__func__", None) is not Widget._render - or self.renderer is not ScrollBarRender - ) - def render(self) -> RenderableType: assert self.parent is not None styles = self.parent.styles @@ -418,13 +405,7 @@ class ScrollBarCorner(Widget): """Widget which fills the gap between horizontal and vertical scrollbars, should they both be present.""" - def _render_styles_sensitive(self) -> bool: - """Original Blank measures width and one row independently of color.""" - return ( - type(self) is not ScrollBarCorner - or getattr(self.render, "__func__", None) is not ScrollBarCorner.render - or getattr(self._render, "__func__", None) is not Widget._render - ) + _paint_only_render = True def render(self) -> Blank: assert self.parent is not None diff --git a/src/textual/widget.py b/src/textual/widget.py index 2bc008d91f..6e949e92f2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -311,10 +311,11 @@ def layout_invalidated_widgets(self): _container_selection_dependency: ClassVar[HeightDependency] = NATIVE_CONTAINER_SELECTION _native_box_measurement: ClassVar[bool] = True _native_measurement_layout_hooks: ClassVar[bool] = True + _paint_only_render: ClassVar[bool] = False def _render_styles_sensitive(self) -> bool: - """Unknown rendering can turn any style input into measured content.""" - return True + """Only a declared fixed-shape renderer can ignore paint-style changes.""" + return not type(self)._paint_only_render def _style_geometry_updated(self, geometry: bool) -> bool: geometry = super()._style_geometry_updated( @@ -3730,6 +3731,10 @@ def __init_subclass__( inherit_css=inherit_css, inherit_bindings=inherit_bindings, ) + if "_paint_only_render" not in cls.__dict__ and any( + method in cls.__dict__ for method in ("render", "_render", "_render_bar") + ): + cls._paint_only_render = False # Bind method contracts once per concrete class, never probe capabilities # on every measurement. Undeclared overrides select the safe full context. cls._content_height_dependency = getattr(cls.get_content_height, "_height_dependency", CONTEXT_HEIGHT) diff --git a/tests/test_line_surface_style_measurement.py b/tests/test_line_surface_style_measurement.py index 36295cc5a0..d7356e66ac 100644 --- a/tests/test_line_surface_style_measurement.py +++ b/tests/test_line_surface_style_measurement.py @@ -35,14 +35,14 @@ def compose(self): assert lines._layout_updates > originals[lines] assert lines.get_content_width(Size(10, 10), Size(60, 18)) == lines.virtual_size.width assert lines.get_content_height(Size(10, 10), Size(60, 18), 10) == lines.virtual_size.height - # A live replacement renderer restores the conservative child contract. + # A replacement bar renderer still paints within the reserved extent. class CustomBar(ScrollBarRender): pass bar = editor.vertical_scrollbar bar.renderer = CustomBar before = editor._layout_updates, bar._layout_updates group.styles.scrollbar_color = "blue" - assert editor._layout_updates > before[0] and bar._layout_updates > before[1] + assert (editor._layout_updates, bar._layout_updates) == before async def test_custom_extent_and_method_supply_stays_conservative(): @@ -67,22 +67,31 @@ def get_content_height(self, container, viewport, width): assert widget._layout_updates > before -def test_scrollbar_custom_declarations_and_live_method_replacement(): +def test_scrollbar_paint_contract_follows_class_declaration(): from textual.scrollbar import ScrollBar, ScrollBarCorner - class CustomBar(ScrollBar): + class InheritedBar(ScrollBar): pass - class CustomCorner(ScrollBarCorner): + class ReplacedBar(ScrollBar): + def render(self): + return "custom source" + + class ReplacedBarBody(ScrollBar): + def _render_bar(self, style): + return "custom source" + + class InheritedCorner(ScrollBarCorner): pass - assert CustomBar()._render_styles_sensitive() - assert CustomCorner()._render_styles_sensitive() - bar = ScrollBar() - assert not bar._render_styles_sensitive() - bar.render = lambda: "custom source" - assert bar._render_styles_sensitive() - corner = ScrollBarCorner() - assert not corner._render_styles_sensitive() - corner.render = lambda: "custom source" - assert corner._render_styles_sensitive() + class ReplacedCorner(ScrollBarCorner): + def render(self): + return "custom source" + + assert not ScrollBar()._render_styles_sensitive() + assert not InheritedBar()._render_styles_sensitive() + assert ReplacedBar()._render_styles_sensitive() + assert ReplacedBarBody()._render_styles_sensitive() + assert not ScrollBarCorner()._render_styles_sensitive() + assert not InheritedCorner()._render_styles_sensitive() + assert ReplacedCorner()._render_styles_sensitive()