diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5ba4841db4..73b7014b17 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -834,6 +834,7 @@ def __init__( refresh_parent: bool = False, display: bool = False, pointer: bool = False, + geometry: bool = True, ) -> None: self._valid_values = valid_values self._default = default @@ -842,6 +843,13 @@ def __init__( self._refresh_parent = refresh_parent self._display = display self._pointer = pointer + self._geometry = geometry + + def affects_geometry(self, previous: object | None, value: object | None) -> bool: + # Geometry inputs and scheduled layout are distinct: native wrapping, + # positioning and constraints can change geometry without _layout. + # Undeclared enum effects remain conservative. + return self._geometry def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 8ba4685a0f..e343945ec5 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -514,7 +514,7 @@ class StylesBase: """Padding added to left and right of lines.""" pointer: StringEnumProperty[PointerShape] = StringEnumProperty( - VALID_POINTER, "default", pointer=True + VALID_POINTER, "default", pointer=True, geometry=False ) """Set the pointer (cursor) shape when the mouse is over this widget. diff --git a/tests/css/test_programmatic_style_changes.py b/tests/css/test_programmatic_style_changes.py index 0bd44a477b..5e856eb99a 100644 --- a/tests/css/test_programmatic_style_changes.py +++ b/tests/css/test_programmatic_style_changes.py @@ -5,6 +5,43 @@ from textual.widgets import Label +async def test_pointer_shape_preserves_native_measurement_and_unknown_renderers(): + """Cursor publication has no native size input; custom renderers retain it.""" + from textual.content import Content + from textual.widgets import Static + + class PointerContent(Static): + def render(self): + return Content(self.styles.pointer) + + native, custom = Label("original row"), PointerContent() + app = App() + async with app.run_test(size=(40, 12)) as pilot: + await app.mount(native, custom) + await pilot.pause() + await pilot.hover(native) + before = native.region + measurement, geometry = native._layout_updates, native._geometry_revision + custom_measurement = custom._layout_updates + native.styles.pointer = "text" + assert app.screen._pointer_shape == "text" + assert native._layout_updates == measurement + assert native._geometry_revision == geometry + custom.styles.pointer = "crosshair" + assert custom._layout_updates > custom_measurement + await pilot.pause() + assert native.region == before + assert app.screen.get_widget_at(before.x, before.y)[0] is native + # Raw source writes and stylesheet replacement share the descriptor. + native._inline_styles.set_rule("pointer", "pointer") + native._css_styles.replace_rules(dict(native._css_styles.get_rules(), pointer="help")) + assert native._layout_updates == measurement + assert native._geometry_revision == geometry + # Not every unscheduled enum is paint-only: wrapping remains a size input. + native.styles.text_wrap = "nowrap" + assert native._layout_updates > measurement + + async def test_style_publication_preserves_source_and_content_lifetimes(): """A frame request must not retire measurements twice or hide new content.""" from textual.css.scalar import Scalar