From 78ddd7b836bcd1d794998809816cca1dde8bbecd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 16:43:32 +0000 Subject: [PATCH 1/8] tests --- pyproject.toml | 4 +- src/textual/_ansi_sequences.py | 17 ++++ src/textual/app.py | 19 +++++ src/textual/css/_style_properties.py | 4 + src/textual/css/_styles_builder.py | 19 +++++ src/textual/css/constants.py | 32 ++++++++ src/textual/css/styles.py | 15 ++++ src/textual/css/types.py | 33 ++++++++ src/textual/screen.py | 22 ++++++ src/textual/scrollbar.py | 2 + src/textual/widgets/_button.py | 4 +- src/textual/widgets/_collapsible.py | 1 + src/textual/widgets/_input.py | 1 + src/textual/widgets/_link.py | 1 + src/textual/widgets/_radio_set.py | 1 + src/textual/widgets/_select.py | 16 +++- src/textual/widgets/_switch.py | 3 + src/textual/widgets/_tabs.py | 3 + src/textual/widgets/_text_area.py | 1 + src/textual/widgets/_toggle_button.py | 1 + src/textual/widgets/_tree.py | 2 + tests/test_pointer_css.py | 107 ++++++++++++++++++++++++++ tests/test_unicode_data.py | 0 23 files changed, 302 insertions(+), 6 deletions(-) create mode 100644 tests/test_pointer_css.py create mode 100644 tests/test_unicode_data.py diff --git a/pyproject.toml b/pyproject.toml index 0450797920..76ebcfb38e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,8 @@ target-version = "py39" python = "^3.9" markdown-it-py = { extras = ["linkify"], version = ">=2.1.0" } mdit-py-plugins = "*" -rich = ">=14.2.0" -#rich = {path="../rich", develop=true} +#rich = ">=14.2.0" +rich = {path="../rich", develop=true} typing-extensions = "^4.4.0" platformdirs = ">=3.6.0,<5" diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index 04a58f4f6f..911419d487 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -435,3 +435,20 @@ class IgnoredSequence: # https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 SYNC_START = "\x1b[?2026h" SYNC_END = "\x1b[?2026l" + + +def set_pointer_shape(shape: str) -> str: + """Generate escape sequence to set pointer (cursor) shape using Kitty protocol. + + Args: + shape: The pointer shape name (e.g., "default", "pointer", "text", "crosshair", etc.) + + Returns: + The escape sequence to set the pointer shape. + + See: https://sw.kovidgoyal.net/kitty/pointer-shapes/ + """ + # Kitty pointer shape protocol: ESC ] 22 ; ST + # where ST is ESC \ or BEL (\x07) + # Using BEL as terminator for better compatibility + return f"\x1b]22;{shape}\x07" diff --git a/src/textual/app.py b/src/textual/app.py index fa16acc631..02d7ba4f20 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3105,6 +3105,7 @@ def capture_mouse(self, widget: Widget | None) -> None: self.mouse_captured = widget if widget is not None: widget.post_message(events.MouseCapture(self.mouse_position)) + self.screen.update_pointer_shape() def panic(self, *renderables: RenderableType) -> None: """Exits the app and display error message(s). @@ -3782,6 +3783,24 @@ def bell(self) -> None: if not self.is_headless and self._driver is not None: self._driver.write("\07") + def _set_pointer_shape(self, shape: str) -> None: + """Generate escape sequence to set pointer (cursor) shape using Kitty protocol. + + Args: + shape: The pointer shape name (e.g., "default", "pointer", "text", "crosshair", etc.) + + Returns: + The escape sequence to set the pointer shape. + + See: https://sw.kovidgoyal.net/kitty/pointer-shapes/ + """ + # Kitty pointer shape protocol: ESC ] 22 ; ST + # where ST is ESC \ or BEL (\x07) + # Using BEL as terminator for better compatibility + if self._driver is not None: + shape_sequence = f"\x1b]22;{shape}\x07" + self._driver.write(shape_sequence) + @property def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]: """Get a chain of nodes and bindings to consider. diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 40cb8a7370..5018f2b0dd 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -799,6 +799,7 @@ def __init__( refresh_children: bool = False, refresh_parent: bool = False, display: bool = False, + pointer: bool = False, ) -> None: self._valid_values = valid_values self._default = default @@ -806,6 +807,7 @@ def __init__( self._refresh_children = refresh_children self._refresh_parent = refresh_parent self._display = display + self._pointer = pointer def __set_name__(self, owner: StylesBase, name: str) -> None: self.name = name @@ -874,6 +876,8 @@ def __set__(self, obj: StylesBase, value: EnumType | None = None): children=self._refresh_children, parent=self._refresh_parent, ) + if self._pointer and obj.node is not None: + obj.node.screen.update_pointer_shape() class OverflowProperty(StringEnumProperty): diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 95c157e858..64e9777cc3 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -50,6 +50,7 @@ VALID_KEYLINE, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POINTER, VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_SCROLLBAR_VISIBILITY, @@ -1278,6 +1279,24 @@ def process_expand(self, name: str, tokens: list[Token]): self.error(name, tokens[0], expand_help_text(name)) self.styles._rules["expand"] = token.value + def process_pointer(self, name: str, tokens: list[Token]) -> None: + for token in tokens: + name, value, _, _, location, _ = token + if name == "token": + value = value.lower() + if value in VALID_POINTER: + self.styles._rules["pointer"] = value + else: + self.error( + name, + token, + string_enum_help_text( + "pointer", + valid_values=list(VALID_POINTER), + context="css", + ), + ) + def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None: """ Returns a valid CSS property "Python" name, or None if no close matches could be found. diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 46606d6709..8242ecd942 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -91,6 +91,38 @@ VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"} VALID_EXPAND: Final = {"greedy", "optimal"} VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"} +VALID_POINTER: Final = { + "alias", + "cell", + "copy", + "crosshair", + "default", + "e-resize", + "ew-resize", + "grab", + "grabbing", + "help", + "move", + "n-resize", + "ne-resize", + "nesw-resize", + "no-drop", + "not-allowed", + "ns-resize", + "nw-resize", + "nwse-resize", + "pointer", + "progress", + "s-resize", + "se-resize", + "sw-resize", + "text", + "vertical-text", + "w-resize", + "wait", + "zoom-in", + "zoom-out", +} HATCHES: Final = { "left": "╲", diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 406e2a3058..22eb4fae3c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -46,6 +46,7 @@ VALID_EXPAND, VALID_OVERFLOW, VALID_OVERLAY, + VALID_POINTER, VALID_POSITION, VALID_SCROLLBAR_GUTTER, VALID_SCROLLBAR_VISIBILITY, @@ -66,6 +67,7 @@ Expand, Overflow, Overlay, + PointerShape, ScrollbarGutter, Specificity3, Specificity6, @@ -209,6 +211,8 @@ class RulesMap(TypedDict, total=False): line_pad: int + pointer: PointerShape + RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) @@ -504,6 +508,17 @@ class StylesBase: line_pad = IntegerProperty(default=0, layout=True) """Padding added to left and right of lines.""" + pointer: StringEnumProperty[PointerShape] = StringEnumProperty( + VALID_POINTER, "default", pointer=True + ) + """Set the pointer (cursor) shape when the mouse is over this widget. + + Valid values include "default", "pointer", "text", "crosshair", "help", "wait", + "move", "grab", "grabbing", and various resize cursors. + + Requires terminal support for Kitty pointer shapes protocol. + """ + def __textual_animation__( self, attribute: str, diff --git a/src/textual/css/types.py b/src/textual/css/types.py index d2b2434808..29b8a0b7f6 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -41,6 +41,39 @@ Constrain = Literal["none", "inflect", "inside"] Overlay = Literal["none", "screen"] Position = Literal["relative", "absolute"] +PointerShape = Literal[ + "alias", + "cell", + "copy", + "crosshair", + "default", + "e-resize", + "ew-resize", + "grab", + "grabbing", + "help", + "move", + "n-resize", + "ne-resize", + "nesw-resize", + "no-drop", + "not-allowed", + "ns-resize", + "nw-resize", + "nwse-resize", + "pointer", + "progress", + "s-resize", + "se-resize", + "sw-resize", + "text", + "vertical-text", + "w-resize", + "wait", + "zoom-in", + "zoom-out", +] + TextWrap = Literal["wrap", "nowrap"] TextOverflow = Literal["clip", "fold", "ellipsis"] Expand = Literal["greedy", "expand"] diff --git a/src/textual/screen.py b/src/textual/screen.py index 64f61f863d..bbc61f3cdf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -48,6 +48,7 @@ from textual.css.match import match from textual.css.parse import parse_selectors from textual.css.query import NoMatches, QueryType +from textual.css.styles import PointerShape from textual.dom import DOMNode from textual.errors import NoWidget from textual.geometry import NULL_OFFSET, Offset, Region, Size @@ -261,6 +262,9 @@ class Screen(Generic[ScreenResultType], Widget): _mouse_down_offset: var[Offset | None] = var(None) """Last mouse down screen offset, or `None` if the mouse is up.""" + _pointer_shape: var[PointerShape] = var("default") + """The current mouse pointer shape.""" + BINDINGS = [ Binding("tab", "app.focus_next", "Focus Next", show=False), Binding("shift+tab", "app.focus_previous", "Focus Previous", show=False), @@ -569,6 +573,21 @@ def get_loading_widget(self) -> Widget: loading_widget = self.app.get_loading_widget() return loading_widget + def _watch__pointer_shape(self, pointer_shape: PointerShape) -> None: + self.app._set_pointer_shape(pointer_shape) + + def update_pointer_shape(self) -> None: + """Get the screen's current pointer shape.""" + if self._selecting: + self._pointer_shape = "text" + return + widget = self if self.app.mouse_over is None else self.app.mouse_over + pointer_shape = "default" + for node in widget.ancestors_with_self: + if (pointer_shape := node.styles.pointer) != "default": + break + self._pointer_shape = pointer_shape + def render(self) -> RenderableType: """Render method inherited from widget, used to render the screen's background. @@ -1600,6 +1619,7 @@ def _handle_mouse_move(self, event: events.MouseMove) -> None: pass else: self.app._set_mouse_over(widget, hover_widget) + self.update_pointer_shape() widget.hover_style = event.style if widget is self: self.post_message(event) @@ -1626,6 +1646,7 @@ def _handle_mouse_move(self, event: events.MouseMove) -> None: ) else: tooltip.display = False + self.screen.update_pointer_shape() @staticmethod def _translate_mouse_move_event( @@ -1752,6 +1773,7 @@ def _forward_event(self, event: events.Event) -> None: else: self.post_message(event) + self.update_pointer_shape() def _key_escape(self) -> None: self.clear_selection() diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 41c90d59cb..31785936e5 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -360,12 +360,14 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: event.stop() def _on_mouse_capture(self, event: events.MouseCapture) -> None: + self.styles.pointer = "grabbing" if isinstance(self._parent, Widget): self._parent.release_anchor() self.grabbed = event.mouse_position self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: + self.styles.pointer = "default" self.grabbed = None if self.vertical and isinstance(self.parent, Widget): self.parent._check_anchor() diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 0dbb1f2667..477ffb7cc0 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -54,7 +54,7 @@ class Button(Widget, can_focus=True): line-pad: 1; text-align: center; content-align: center middle; - + pointer: pointer; &.-style-flat { text-style: bold; @@ -75,6 +75,7 @@ class Button(Widget, can_focus=True): } &:disabled { color: auto 50%; + pointer: not-allowed; } &.-primary { @@ -133,6 +134,7 @@ class Button(Widget, can_focus=True): &:disabled { text-opacity: 0.6; + pointer: not-allowed; } &:focus { diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index 2185e96d70..fd825f45f8 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -27,6 +27,7 @@ class CollapsibleTitle(Static, can_focus=True): padding: 0 1; text-style: $block-cursor-blurred-text-style; color: $block-cursor-blurred-foreground; + pointer: pointer; &:hover { background: $block-hover-background; diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 4051a545f8..ef8be70c2d 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -183,6 +183,7 @@ class Input(ScrollView): width: 100%; height: 3; scrollbar-size-horizontal: 0; + pointer: text; &.-textual-compact { border: none !important; diff --git a/src/textual/widgets/_link.py b/src/textual/widgets/_link.py index c3d95c3b3d..d65ec320b6 100644 --- a/src/textual/widgets/_link.py +++ b/src/textual/widgets/_link.py @@ -17,6 +17,7 @@ class Link(Static, can_focus=True): text-style: underline; &:hover { color: $accent; } &:focus { text-style: bold reverse; } + pointer: pointer; } """ diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index ddb2adf335..af0a397b87 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -35,6 +35,7 @@ class RadioSet(VerticalScroll, can_focus=True, can_focus_children=False): padding: 0 1; height: auto; width: 1fr; + pointer: pointer; &.-textual-compact { border: none !important; diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 1926e5a530..07bc2adbd4 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -23,6 +23,10 @@ from textual.app import ComposeResult +class NonSelectableStatic(Static): + ALLOW_SELECT = False + + class NoSelection: """Used by the `Select` widget to flag the unselected state. See [`Select.BLANK`][textual.widgets.Select.BLANK].""" @@ -46,6 +50,8 @@ class SelectOverlay(OptionList): BINDINGS = [("escape", "dismiss", "Dismiss menu")] + ALLOW_SELECT = False + @dataclass class Dismiss(Message): """Inform ancestor the overlay should be dismissed.""" @@ -217,6 +223,8 @@ class SelectCurrent(Horizontal): } """ + ALLOW_SELECT = False + has_value: var[bool] = var(False) """True if there is a current value, or False if it is None.""" @@ -247,9 +255,9 @@ def update(self, label: RenderableType | NoSelection) -> None: def compose(self) -> ComposeResult: """Compose label and down arrow.""" - yield Static(self.placeholder, id="label") - yield Static("▼", classes="arrow down-arrow") - yield Static("▲", classes="arrow up-arrow") + yield NonSelectableStatic(self.placeholder, id="label") + yield NonSelectableStatic("▼", classes="arrow down-arrow") + yield NonSelectableStatic("▲", classes="arrow up-arrow") def _watch_has_value(self, has_value: bool) -> None: """Toggle the class.""" @@ -286,6 +294,8 @@ class Select(Generic[SelectType], Vertical, can_focus=True): | enter,down,space,up | Activate the overlay | """ + ALLOW_SELECT = False + DEFAULT_CSS = """ Select { height: auto; diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 72602b395a..0334b491e3 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -44,12 +44,15 @@ class Switch(Widget, can_focus=True): | `switch--slider` | Targets the slider of the switch. | """ + ALLOW_SELECT = False + DEFAULT_CSS = """ Switch { border: tall $border-blurred; background: $surface; height: auto; width: auto; + pointer: pointer; padding: 0 2; &.-on .switch--slider { diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ac856ebd65..42603e9a82 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -99,6 +99,7 @@ class Tab(Static): padding: 0 1; text-align: center; color: $foreground 50%; + pointer: pointer; &:hover { color: $foreground; @@ -116,6 +117,8 @@ class Tab(Static): } """ + ALLOW_SELECT = False + @dataclass class TabMessage(Message): """Tab-related messages. diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 3371c757f9..929bdddff7 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -118,6 +118,7 @@ class TextArea(ScrollView): padding: 0 1; color: $foreground; background: $surface; + pointer: text; &.-textual-compact { border: none !important; } diff --git a/src/textual/widgets/_toggle_button.py b/src/textual/widgets/_toggle_button.py index dc0a8f6c88..e242d54ae8 100644 --- a/src/textual/widgets/_toggle_button.py +++ b/src/textual/widgets/_toggle_button.py @@ -60,6 +60,7 @@ class ToggleButton(Static, can_focus=True): background: $surface; text-wrap: nowrap; text-overflow: ellipsis; + pointer: pointer; &.-textual-compact { border: none !important; diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 8f9119a556..a5fa719f17 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -558,6 +558,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): | down | Move the cursor down. | """ + ALLOW_SELECT = False + COMPONENT_CLASSES: ClassVar[set[str]] = { "tree--cursor", "tree--guides", diff --git a/tests/test_pointer_css.py b/tests/test_pointer_css.py new file mode 100644 index 0000000000..46dcc9709f --- /dev/null +++ b/tests/test_pointer_css.py @@ -0,0 +1,107 @@ +"""Unit tests for the pointer CSS property.""" + +import pytest + +from textual.css.errors import StyleValueError +from textual.css.styles import Styles +from textual.widget import Widget + + +def test_pointer_property_default(): + """Test that pointer property has correct default value.""" + styles = Styles() + assert styles.pointer == "default" + + +def test_pointer_property_set(): + """Test setting pointer property to valid values.""" + styles = Styles() + + # Test various valid pointer shapes + valid_pointers = [ + "default", + "pointer", + "text", + "crosshair", + "help", + "wait", + "move", + "grab", + "grabbing", + "n-resize", + "s-resize", + "e-resize", + "w-resize", + "ne-resize", + "nw-resize", + "se-resize", + "sw-resize", + "ew-resize", + "ns-resize", + "nesw-resize", + "nwse-resize", + "zoom-in", + "zoom-out", + "alias", + "copy", + "no-drop", + "not-allowed", + "cell", + "vertical-text", + "progress", + ] + + for pointer in valid_pointers: + styles.pointer = pointer + assert styles.pointer == pointer + + +def test_pointer_css_parsing(): + """Test parsing pointer from CSS.""" + css = "pointer: pointer;" + styles = Styles.parse(css, "test") + assert styles.pointer == "pointer" + + css = "pointer: text;" + styles = Styles.parse(css, "test") + assert styles.pointer == "text" + + css = "pointer: crosshair;" + styles = Styles.parse(css, "test") + assert styles.pointer == "crosshair" + + +def test_pointer_invalid_value(): + """Test that invalid pointer values raise errors during CSS parsing.""" + from textual.css.errors import StyleValueError + + # Try to parse CSS with an invalid pointer value + css = "pointer: invalid-cursor-shape;" + + # This should raise an error for invalid pointer shape + with pytest.raises((StyleValueError, Exception)): + styles = Styles.parse(css, "test") + + +def test_pointer_in_widget(): + """Test that pointer property works in widget context.""" + + class TestWidget(Widget): + DEFAULT_CSS = """ + TestWidget { + pointer: pointer; + } + """ + + widget = TestWidget() + # Note: styles won't be fully initialized without mounting + # This is just a basic smoke test + assert widget is not None + + +if __name__ == "__main__": + # Run basic tests + test_pointer_property_default() + test_pointer_property_set() + test_pointer_css_parsing() + print("All basic tests passed!") diff --git a/tests/test_unicode_data.py b/tests/test_unicode_data.py new file mode 100644 index 0000000000..e69de29bb2 From 1d6d66f0031928812571581dcc68101c92ef923f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:13:58 +0000 Subject: [PATCH 2/8] docs --- CHANGELOG.md | 6 ++++ docs/css_types/pointer.md | 59 ++++++++++++++++++++++++++++++++++ docs/styles/pointer.md | 66 +++++++++++++++++++++++++++++++++++++++ mkdocs-nav.yml | 2 ++ 4 files changed, 133 insertions(+) create mode 100644 docs/css_types/pointer.md create mode 100644 docs/styles/pointer.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 0103d65c05..6a835c2512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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/). +## [7.4.0] - 2026-01-25 + +### Added + +- Added `pointer` rule + ## [7.3.0] - 2026-01-15 ### Fixed diff --git a/docs/css_types/pointer.md b/docs/css_types/pointer.md new file mode 100644 index 0000000000..fc820de376 --- /dev/null +++ b/docs/css_types/pointer.md @@ -0,0 +1,59 @@ +# <pointer> + +The `` CSS type represents pointer (cursor) shapes that can be displayed when the mouse is over a widget. + +## Syntax + +The [``](./pointer.md) type can take any of the following values: + +| Value | Description | +|-----------------|--------------------------------------------------| +| `default` | Default pointer shape. | +| `pointer` | Pointing hand (typically used for links). | +| `text` | Text selection cursor (I-beam). | +| `crosshair` | Crosshair cursor. | +| `help` | Help cursor (often a question mark). | +| `wait` | Wait/busy cursor. | +| `progress` | Progress cursor (indicating background activity).| +| `move` | Move cursor (four-directional arrows). | +| `grab` | Open hand (grabbable). | +| `grabbing` | Closed hand (grabbing). | +| `cell` | Cell selection cursor. | +| `vertical-text` | Vertical text selection cursor. | +| `alias` | Alias/shortcut cursor. | +| `copy` | Copy cursor. | +| `no-drop` | No drop allowed cursor. | +| `not-allowed` | Not allowed/prohibited cursor. | +| `n-resize` | Resize cursor pointing north. | +| `s-resize` | Resize cursor pointing south. | +| `e-resize` | Resize cursor pointing east. | +| `w-resize` | Resize cursor pointing west. | +| `ne-resize` | Resize cursor pointing northeast. | +| `nw-resize` | Resize cursor pointing northwest. | +| `se-resize` | Resize cursor pointing southeast. | +| `sw-resize` | Resize cursor pointing southwest. | +| `ew-resize` | Resize cursor for horizontal resizing. | +| `ns-resize` | Resize cursor for vertical resizing. | +| `nesw-resize` | Resize cursor for diagonal (NE-SW) resizing. | +| `nwse-resize` | Resize cursor for diagonal (NW-SE) resizing. | +| `zoom-in` | Zoom in cursor (magnifying glass with +). | +| `zoom-out` | Zoom out cursor (magnifying glass with -). | + +!!! note + The `pointer` style requires terminal support for the Kitty pointer shapes protocol. Not all terminals support this feature. + +## Examples + +### CSS + +```css +#my-button { + pointer: pointer; /* Show a pointing hand cursor */ +} +``` + +### Python + +```py +widget.styles.pointer = "pointer" # Show a pointing hand cursor +``` diff --git a/docs/styles/pointer.md b/docs/styles/pointer.md new file mode 100644 index 0000000000..e15605446e --- /dev/null +++ b/docs/styles/pointer.md @@ -0,0 +1,66 @@ +# Pointer + +The `pointer` style sets the shape of the mouse pointer (cursor) when it is over a widget. + +!!! note + The `pointer` style requires terminal support for the Kitty pointer shapes protocol. Not all terminals support this feature. If your terminal does not support this protocol, the cursor shape will not change. + + +## Syntax + +--8<-- "docs/snippets/syntax_block_start.md" +pointer: <pointer>; +--8<-- "docs/snippets/syntax_block_end.md" + +The `pointer` style accepts a value of the type [``](../css_types/pointer.md) that defines the shape of the cursor when hovering over the widget. + +### Defaults + +The default value is `default`. + +## Example + +Many builtin widgets and scrollbars set the mouse pointer. + +Run the Textual demo to see the mouse pointer change (hover over buttons or click and drag a scrollbar): + +``` +python -m textual +``` + +## CSS + +```css +/* Show a pointing hand cursor */ +pointer: pointer; + +/* Show a text selection cursor */ +pointer: text; + +/* Show a grab cursor */ +pointer: grab; + +/* Show a crosshair cursor */ +pointer: crosshair; +``` + +## Python + +```python +# Show a pointing hand cursor +widget.styles.pointer = "pointer" + +# Show a text selection cursor +widget.styles.pointer = "text" + +# Show a grab cursor +widget.styles.pointer = "grab" + +# Show a crosshair cursor +widget.styles.pointer = "crosshair" +``` + + +## See also + + - [``](../css_types/pointer.md) data type for all available pointer shapes. diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index e6b7b66725..64c36db335 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -38,6 +38,7 @@ nav: - "css_types/name.md" - "css_types/number.md" - "css_types/overflow.md" + - "css_types/pointer.md" - "css_types/position.md" - "css_types/percentage.md" - "css_types/scalar.md" @@ -124,6 +125,7 @@ nav: - "styles/outline.md" - "styles/overflow.md" - "styles/padding.md" + - "styles/pointer.md" - "styles/position.md" - Scrollbar colors: - "styles/scrollbar_colors/index.md" From 73b39787784e5cb92f717d793b8117c7e23b415c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:18:13 +0000 Subject: [PATCH 3/8] test --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- src/textual/screen.py | 9 +++++++-- tests/test_app.py | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a835c2512..14e1bd2dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `pointer` rule +- Added `pointer` rule https://github.com/Textualize/textual/pull/6339 ## [7.3.0] - 2026-01-15 @@ -3328,6 +3328,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[7.4.0]: https://github.com/Textualize/textual/compare/v7.3.0...v7.4.0 [7.3.0]: https://github.com/Textualize/textual/compare/v7.2.0...v7.3.0 [7.2.0]: https://github.com/Textualize/textual/compare/v7.1.0...v7.2.0 [7.1.0]: https://github.com/Textualize/textual/compare/v7.0.3...v7.1.0 diff --git a/pyproject.toml b/pyproject.toml index 76ebcfb38e..20ad36d865 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "7.3.0" +version = "7.4.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" diff --git a/src/textual/screen.py b/src/textual/screen.py index bbc61f3cdf..0c9f1f8df5 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -584,8 +584,13 @@ def update_pointer_shape(self) -> None: widget = self if self.app.mouse_over is None else self.app.mouse_over pointer_shape = "default" for node in widget.ancestors_with_self: - if (pointer_shape := node.styles.pointer) != "default": - break + if isinstance(node, Widget): + if node.loading: + pointer_shape = "wait" + break + if (pointer_shape := node.styles.pointer) != "default": + break + self._pointer_shape = pointer_shape def render(self) -> RenderableType: diff --git a/tests/test_app.py b/tests/test_app.py index 124adf9d09..cfb149640a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -386,3 +386,19 @@ async def amain(): app = MyApp() result = app.run() assert result == 42 + + +async def test_pointer_shape() -> None: + """Test that the pointer shape updates.""" + + class PointerApp(App): + def compose(self) -> ComposeResult: + yield Static("Hello") + yield Button("Click me") + + app = PointerApp() + async with app.run_test() as pilot: + await pilot.hover(offset=(0, 0)) + assert app.screen._pointer_shape == "default" + await pilot.hover(offset=(1, 1)) + assert app.screen._pointer_shape == "pointer" From efd88fc859edf4acbed43c910bc4a96acda75e3c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:19:28 +0000 Subject: [PATCH 4/8] loading shape --- src/textual/widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8f577b2b2e..68c88b0f39 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1036,6 +1036,7 @@ def set_loading(self, loading: bool) -> None: self._cover(loading_indicator) else: self._uncover() + self.screen.update_pointer_shape() def _watch_loading(self, loading: bool) -> None: """Called when the 'loading' reactive is changed.""" From ae8d2ab049b9afc1ea5992169d6e3d58f7b2df84 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:20:50 +0000 Subject: [PATCH 5/8] fix rich --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 20ad36d865..02d2ee9c33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,8 +47,8 @@ target-version = "py39" python = "^3.9" markdown-it-py = { extras = ["linkify"], version = ">=2.1.0" } mdit-py-plugins = "*" -#rich = ">=14.2.0" -rich = {path="../rich", develop=true} +rich = ">=14.2.0" +#rich = {path="../rich", develop=true} typing-extensions = "^4.4.0" platformdirs = ">=3.6.0,<5" From a8cf1f7bd932bf1cfc447ee2033e2606ad683938 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:31:41 +0000 Subject: [PATCH 6/8] test fix --- src/textual/css/_style_properties.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5018f2b0dd..85b7b4be80 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -877,7 +877,12 @@ def __set__(self, obj: StylesBase, value: EnumType | None = None): parent=self._refresh_parent, ) if self._pointer and obj.node is not None: - obj.node.screen.update_pointer_shape() + from textual.dom import NoScreen + + try: + obj.node.screen.update_pointer_shape() + except NoScreen: + pass class OverflowProperty(StringEnumProperty): From a7dea5a40e32ca13032cc799c50ea270f2daa086 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:41:47 +0000 Subject: [PATCH 7/8] remove claudes test --- tests/test_pointer_css.py | 107 -------------------------------------- 1 file changed, 107 deletions(-) delete mode 100644 tests/test_pointer_css.py diff --git a/tests/test_pointer_css.py b/tests/test_pointer_css.py deleted file mode 100644 index 46dcc9709f..0000000000 --- a/tests/test_pointer_css.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Unit tests for the pointer CSS property.""" - -import pytest - -from textual.css.errors import StyleValueError -from textual.css.styles import Styles -from textual.widget import Widget - - -def test_pointer_property_default(): - """Test that pointer property has correct default value.""" - styles = Styles() - assert styles.pointer == "default" - - -def test_pointer_property_set(): - """Test setting pointer property to valid values.""" - styles = Styles() - - # Test various valid pointer shapes - valid_pointers = [ - "default", - "pointer", - "text", - "crosshair", - "help", - "wait", - "move", - "grab", - "grabbing", - "n-resize", - "s-resize", - "e-resize", - "w-resize", - "ne-resize", - "nw-resize", - "se-resize", - "sw-resize", - "ew-resize", - "ns-resize", - "nesw-resize", - "nwse-resize", - "zoom-in", - "zoom-out", - "alias", - "copy", - "no-drop", - "not-allowed", - "cell", - "vertical-text", - "progress", - ] - - for pointer in valid_pointers: - styles.pointer = pointer - assert styles.pointer == pointer - - -def test_pointer_css_parsing(): - """Test parsing pointer from CSS.""" - css = "pointer: pointer;" - styles = Styles.parse(css, "test") - assert styles.pointer == "pointer" - - css = "pointer: text;" - styles = Styles.parse(css, "test") - assert styles.pointer == "text" - - css = "pointer: crosshair;" - styles = Styles.parse(css, "test") - assert styles.pointer == "crosshair" - - -def test_pointer_invalid_value(): - """Test that invalid pointer values raise errors during CSS parsing.""" - from textual.css.errors import StyleValueError - - # Try to parse CSS with an invalid pointer value - css = "pointer: invalid-cursor-shape;" - - # This should raise an error for invalid pointer shape - with pytest.raises((StyleValueError, Exception)): - styles = Styles.parse(css, "test") - - -def test_pointer_in_widget(): - """Test that pointer property works in widget context.""" - - class TestWidget(Widget): - DEFAULT_CSS = """ - TestWidget { - pointer: pointer; - } - """ - - widget = TestWidget() - # Note: styles won't be fully initialized without mounting - # This is just a basic smoke test - assert widget is not None - - -if __name__ == "__main__": - # Run basic tests - test_pointer_property_default() - test_pointer_property_set() - test_pointer_css_parsing() - print("All basic tests passed!") From 79cf6851fd7f948c29a3f72f571392fb7ab90892 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 25 Jan 2026 17:46:23 +0000 Subject: [PATCH 8/8] pointer on select --- src/textual/widgets/_select.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 07bc2adbd4..221321b97d 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -190,6 +190,7 @@ class SelectCurrent(Horizontal): width: 1fr; height: auto; padding: 0 2; + pointer: pointer; &.-textual-compact { border: none !important;