Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 2 additions & 21 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 24 additions & 15 deletions tests/test_line_surface_style_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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()