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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions docs/css_types/pointer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# <pointer>

The `<pointer>` CSS type represents pointer (cursor) shapes that can be displayed when the mouse is over a widget.

## Syntax

The [`<pointer>`](./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
```
66 changes: 66 additions & 0 deletions docs/styles/pointer.md
Original file line number Diff line number Diff line change
@@ -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: <a href="../../css_types/pointer">&lt;pointer&gt;</a>;
--8<-- "docs/snippets/syntax_block_end.md"

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.

### 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

- [`<pointer>`](../css_types/pointer.md) data type for all available pointer shapes.
2 changes: 2 additions & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
17 changes: 17 additions & 0 deletions src/textual/_ansi_sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ; <shape> ST
# where ST is ESC \ or BEL (\x07)
# Using BEL as terminator for better compatibility
return f"\x1b]22;{shape}\x07"
19 changes: 19 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 ; <shape> 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.
Expand Down
9 changes: 9 additions & 0 deletions src/textual/css/_style_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -799,13 +799,15 @@ 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
self._layout = layout
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
Expand Down Expand Up @@ -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):
Expand Down
19 changes: 19 additions & 0 deletions src/textual/css/_styles_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
VALID_KEYLINE,
VALID_OVERFLOW,
VALID_OVERLAY,
VALID_POINTER,
VALID_POSITION,
VALID_SCROLLBAR_GUTTER,
VALID_SCROLLBAR_VISIBILITY,
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/textual/css/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "╲",
Expand Down
15 changes: 15 additions & 0 deletions src/textual/css/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
VALID_EXPAND,
VALID_OVERFLOW,
VALID_OVERLAY,
VALID_POINTER,
VALID_POSITION,
VALID_SCROLLBAR_GUTTER,
VALID_SCROLLBAR_VISIBILITY,
Expand All @@ -66,6 +67,7 @@
Expand,
Overflow,
Overlay,
PointerShape,
ScrollbarGutter,
Specificity3,
Specificity6,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading