diff --git a/CHANGELOG.md b/CHANGELOG.md index 0103d65c05..14e1bd2dd9 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 https://github.com/Textualize/textual/pull/6339 + ## [7.3.0] - 2026-01-15 ### Fixed @@ -3322,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/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" diff --git a/pyproject.toml b/pyproject.toml index 0450797920..02d2ee9c33 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/_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..85b7b4be80 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,13 @@ 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: + from textual.dom import NoScreen + + try: + obj.node.screen.update_pointer_shape() + except NoScreen: + pass 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..0c9f1f8df5 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,26 @@ 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 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: """Render method inherited from widget, used to render the screen's background. @@ -1600,6 +1624,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 +1651,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 +1778,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/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.""" 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..221321b97d 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.""" @@ -184,6 +190,7 @@ class SelectCurrent(Horizontal): width: 1fr; height: auto; padding: 0 2; + pointer: pointer; &.-textual-compact { border: none !important; @@ -217,6 +224,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 +256,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 +295,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_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" diff --git a/tests/test_unicode_data.py b/tests/test_unicode_data.py new file mode 100644 index 0000000000..e69de29bb2