Skip to content

Commit 47ecedf

Browse files
authored
Merge pull request #6339 from Textualize/pointer
Pointer rule
2 parents 813aeea + 79cf685 commit 47ecedf

28 files changed

+356
-5
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [7.4.0] - 2026-01-25
9+
10+
### Added
11+
12+
- Added `pointer` rule https://github.com/Textualize/textual/pull/6339
13+
814
## [7.3.0] - 2026-01-15
915

1016
### Fixed
@@ -3322,6 +3328,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
33223328
- New handler system for messages that doesn't require inheritance
33233329
- Improved traceback handling
33243330

3331+
[7.4.0]: https://github.com/Textualize/textual/compare/v7.3.0...v7.4.0
33253332
[7.3.0]: https://github.com/Textualize/textual/compare/v7.2.0...v7.3.0
33263333
[7.2.0]: https://github.com/Textualize/textual/compare/v7.1.0...v7.2.0
33273334
[7.1.0]: https://github.com/Textualize/textual/compare/v7.0.3...v7.1.0

docs/css_types/pointer.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# <pointer>
2+
3+
The `<pointer>` CSS type represents pointer (cursor) shapes that can be displayed when the mouse is over a widget.
4+
5+
## Syntax
6+
7+
The [`<pointer>`](./pointer.md) type can take any of the following values:
8+
9+
| Value | Description |
10+
|-----------------|--------------------------------------------------|
11+
| `default` | Default pointer shape. |
12+
| `pointer` | Pointing hand (typically used for links). |
13+
| `text` | Text selection cursor (I-beam). |
14+
| `crosshair` | Crosshair cursor. |
15+
| `help` | Help cursor (often a question mark). |
16+
| `wait` | Wait/busy cursor. |
17+
| `progress` | Progress cursor (indicating background activity).|
18+
| `move` | Move cursor (four-directional arrows). |
19+
| `grab` | Open hand (grabbable). |
20+
| `grabbing` | Closed hand (grabbing). |
21+
| `cell` | Cell selection cursor. |
22+
| `vertical-text` | Vertical text selection cursor. |
23+
| `alias` | Alias/shortcut cursor. |
24+
| `copy` | Copy cursor. |
25+
| `no-drop` | No drop allowed cursor. |
26+
| `not-allowed` | Not allowed/prohibited cursor. |
27+
| `n-resize` | Resize cursor pointing north. |
28+
| `s-resize` | Resize cursor pointing south. |
29+
| `e-resize` | Resize cursor pointing east. |
30+
| `w-resize` | Resize cursor pointing west. |
31+
| `ne-resize` | Resize cursor pointing northeast. |
32+
| `nw-resize` | Resize cursor pointing northwest. |
33+
| `se-resize` | Resize cursor pointing southeast. |
34+
| `sw-resize` | Resize cursor pointing southwest. |
35+
| `ew-resize` | Resize cursor for horizontal resizing. |
36+
| `ns-resize` | Resize cursor for vertical resizing. |
37+
| `nesw-resize` | Resize cursor for diagonal (NE-SW) resizing. |
38+
| `nwse-resize` | Resize cursor for diagonal (NW-SE) resizing. |
39+
| `zoom-in` | Zoom in cursor (magnifying glass with +). |
40+
| `zoom-out` | Zoom out cursor (magnifying glass with -). |
41+
42+
!!! note
43+
The `pointer` style requires terminal support for the Kitty pointer shapes protocol. Not all terminals support this feature.
44+
45+
## Examples
46+
47+
### CSS
48+
49+
```css
50+
#my-button {
51+
pointer: pointer; /* Show a pointing hand cursor */
52+
}
53+
```
54+
55+
### Python
56+
57+
```py
58+
widget.styles.pointer = "pointer" # Show a pointing hand cursor
59+
```

docs/styles/pointer.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Pointer
2+
3+
The `pointer` style sets the shape of the mouse pointer (cursor) when it is over a widget.
4+
5+
!!! note
6+
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.
7+
8+
9+
## Syntax
10+
11+
--8<-- "docs/snippets/syntax_block_start.md"
12+
pointer: <a href="../../css_types/pointer">&lt;pointer&gt;</a>;
13+
--8<-- "docs/snippets/syntax_block_end.md"
14+
15+
The `pointer` style accepts a value of the type [`<pointer>`](../css_types/pointer.md) that defines the shape of the cursor when hovering over the widget.
16+
17+
### Defaults
18+
19+
The default value is `default`.
20+
21+
## Example
22+
23+
Many builtin widgets and scrollbars set the mouse pointer.
24+
25+
Run the Textual demo to see the mouse pointer change (hover over buttons or click and drag a scrollbar):
26+
27+
```
28+
python -m textual
29+
```
30+
31+
## CSS
32+
33+
```css
34+
/* Show a pointing hand cursor */
35+
pointer: pointer;
36+
37+
/* Show a text selection cursor */
38+
pointer: text;
39+
40+
/* Show a grab cursor */
41+
pointer: grab;
42+
43+
/* Show a crosshair cursor */
44+
pointer: crosshair;
45+
```
46+
47+
## Python
48+
49+
```python
50+
# Show a pointing hand cursor
51+
widget.styles.pointer = "pointer"
52+
53+
# Show a text selection cursor
54+
widget.styles.pointer = "text"
55+
56+
# Show a grab cursor
57+
widget.styles.pointer = "grab"
58+
59+
# Show a crosshair cursor
60+
widget.styles.pointer = "crosshair"
61+
```
62+
63+
64+
## See also
65+
66+
- [`<pointer>`](../css_types/pointer.md) data type for all available pointer shapes.

mkdocs-nav.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ nav:
3838
- "css_types/name.md"
3939
- "css_types/number.md"
4040
- "css_types/overflow.md"
41+
- "css_types/pointer.md"
4142
- "css_types/position.md"
4243
- "css_types/percentage.md"
4344
- "css_types/scalar.md"
@@ -124,6 +125,7 @@ nav:
124125
- "styles/outline.md"
125126
- "styles/overflow.md"
126127
- "styles/padding.md"
128+
- "styles/pointer.md"
127129
- "styles/position.md"
128130
- Scrollbar colors:
129131
- "styles/scrollbar_colors/index.md"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "7.3.0"
3+
version = "7.4.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/_ansi_sequences.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,3 +435,20 @@ class IgnoredSequence:
435435
# https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
436436
SYNC_START = "\x1b[?2026h"
437437
SYNC_END = "\x1b[?2026l"
438+
439+
440+
def set_pointer_shape(shape: str) -> str:
441+
"""Generate escape sequence to set pointer (cursor) shape using Kitty protocol.
442+
443+
Args:
444+
shape: The pointer shape name (e.g., "default", "pointer", "text", "crosshair", etc.)
445+
446+
Returns:
447+
The escape sequence to set the pointer shape.
448+
449+
See: https://sw.kovidgoyal.net/kitty/pointer-shapes/
450+
"""
451+
# Kitty pointer shape protocol: ESC ] 22 ; <shape> ST
452+
# where ST is ESC \ or BEL (\x07)
453+
# Using BEL as terminator for better compatibility
454+
return f"\x1b]22;{shape}\x07"

src/textual/app.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3105,6 +3105,7 @@ def capture_mouse(self, widget: Widget | None) -> None:
31053105
self.mouse_captured = widget
31063106
if widget is not None:
31073107
widget.post_message(events.MouseCapture(self.mouse_position))
3108+
self.screen.update_pointer_shape()
31083109

31093110
def panic(self, *renderables: RenderableType) -> None:
31103111
"""Exits the app and display error message(s).
@@ -3782,6 +3783,24 @@ def bell(self) -> None:
37823783
if not self.is_headless and self._driver is not None:
37833784
self._driver.write("\07")
37843785

3786+
def _set_pointer_shape(self, shape: str) -> None:
3787+
"""Generate escape sequence to set pointer (cursor) shape using Kitty protocol.
3788+
3789+
Args:
3790+
shape: The pointer shape name (e.g., "default", "pointer", "text", "crosshair", etc.)
3791+
3792+
Returns:
3793+
The escape sequence to set the pointer shape.
3794+
3795+
See: https://sw.kovidgoyal.net/kitty/pointer-shapes/
3796+
"""
3797+
# Kitty pointer shape protocol: ESC ] 22 ; <shape> ST
3798+
# where ST is ESC \ or BEL (\x07)
3799+
# Using BEL as terminator for better compatibility
3800+
if self._driver is not None:
3801+
shape_sequence = f"\x1b]22;{shape}\x07"
3802+
self._driver.write(shape_sequence)
3803+
37853804
@property
37863805
def _binding_chain(self) -> list[tuple[DOMNode, BindingsMap]]:
37873806
"""Get a chain of nodes and bindings to consider.

src/textual/css/_style_properties.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,13 +799,15 @@ def __init__(
799799
refresh_children: bool = False,
800800
refresh_parent: bool = False,
801801
display: bool = False,
802+
pointer: bool = False,
802803
) -> None:
803804
self._valid_values = valid_values
804805
self._default = default
805806
self._layout = layout
806807
self._refresh_children = refresh_children
807808
self._refresh_parent = refresh_parent
808809
self._display = display
810+
self._pointer = pointer
809811

810812
def __set_name__(self, owner: StylesBase, name: str) -> None:
811813
self.name = name
@@ -874,6 +876,13 @@ def __set__(self, obj: StylesBase, value: EnumType | None = None):
874876
children=self._refresh_children,
875877
parent=self._refresh_parent,
876878
)
879+
if self._pointer and obj.node is not None:
880+
from textual.dom import NoScreen
881+
882+
try:
883+
obj.node.screen.update_pointer_shape()
884+
except NoScreen:
885+
pass
877886

878887

879888
class OverflowProperty(StringEnumProperty):

src/textual/css/_styles_builder.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
VALID_KEYLINE,
5151
VALID_OVERFLOW,
5252
VALID_OVERLAY,
53+
VALID_POINTER,
5354
VALID_POSITION,
5455
VALID_SCROLLBAR_GUTTER,
5556
VALID_SCROLLBAR_VISIBILITY,
@@ -1278,6 +1279,24 @@ def process_expand(self, name: str, tokens: list[Token]):
12781279
self.error(name, tokens[0], expand_help_text(name))
12791280
self.styles._rules["expand"] = token.value
12801281

1282+
def process_pointer(self, name: str, tokens: list[Token]) -> None:
1283+
for token in tokens:
1284+
name, value, _, _, location, _ = token
1285+
if name == "token":
1286+
value = value.lower()
1287+
if value in VALID_POINTER:
1288+
self.styles._rules["pointer"] = value
1289+
else:
1290+
self.error(
1291+
name,
1292+
token,
1293+
string_enum_help_text(
1294+
"pointer",
1295+
valid_values=list(VALID_POINTER),
1296+
context="css",
1297+
),
1298+
)
1299+
12811300
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
12821301
"""
12831302
Returns a valid CSS property "Python" name, or None if no close matches could be found.

src/textual/css/constants.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,38 @@
9191
VALID_TEXT_OVERFLOW: Final = {"clip", "fold", "ellipsis"}
9292
VALID_EXPAND: Final = {"greedy", "optimal"}
9393
VALID_SCROLLBAR_VISIBILITY: Final = {"visible", "hidden"}
94+
VALID_POINTER: Final = {
95+
"alias",
96+
"cell",
97+
"copy",
98+
"crosshair",
99+
"default",
100+
"e-resize",
101+
"ew-resize",
102+
"grab",
103+
"grabbing",
104+
"help",
105+
"move",
106+
"n-resize",
107+
"ne-resize",
108+
"nesw-resize",
109+
"no-drop",
110+
"not-allowed",
111+
"ns-resize",
112+
"nw-resize",
113+
"nwse-resize",
114+
"pointer",
115+
"progress",
116+
"s-resize",
117+
"se-resize",
118+
"sw-resize",
119+
"text",
120+
"vertical-text",
121+
"w-resize",
122+
"wait",
123+
"zoom-in",
124+
"zoom-out",
125+
}
94126

95127
HATCHES: Final = {
96128
"left": "╲",

0 commit comments

Comments
 (0)