Skip to content

Commit 3c120c0

Browse files
authored
Chaining click events (double/triple click etc) (#5369)
* Add comment about Click events * Remove unused `App._hover_effects_timer` * Add missing annotation * Add missing type annotation * Add `App._click_chain_timer` * Add support for click chaining (double click, triple click, etc.) * Create `App.CLICK_CHAIN_TIME_THRESHOLD` for controlling click chain timing * Some tests for chained clicks * Test changes [no ci] * Have Pilot send only MouseUp and MouseDown, and let Textual generate clicks itself [no ci] * Fix DataTable click tet [no ci] * Rename Click.count -> Click.chain * Test fixes * Enhance raw_click function documentation in test_app.py to clarify its purpose and behavior * Refactor imports in events.py: remove Self from typing and import from typing_extensions * Remove unnecessary pause in test_datatable_click_cell_cursor * Remove debug print statements and unnecessary pause in App class; add on_mount method to LazyApp for better lifecycle management in tests * Remove debugging prints * Add support for double and triple clicks in testing guide * Add a note about double and triple clicks to the docs * Turn off formatter for a section of code, and make it 3.8 compatible * Update changelog [no ci] * Simplify by removing an unecessary variable in `Pilot.click` * Remove debugging code * Add target-version py38 to ruff config in pyproject.toml, and remove formatter comments * Document timing of click chains * Pilot.double_click and Pilot.triple_click
1 parent 268971e commit 3c120c0

File tree

9 files changed

+379
-33
lines changed

9 files changed

+379
-33
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212
- Added `App.clipboard` https://github.com/Textualize/textual/pull/5352
1313
- Added standard cut/copy/paste (ctrl+x, ctrl+c, ctrl+v) bindings to Input / TextArea https://github.com/Textualize/textual/pull/5352
1414
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352
15+
- Added support for double/triple/etc clicks via `chain` attribute on `Click` events https://github.com/Textualize/textual/pull/5369
16+
- Added `times` parameter to `Pilot.click` method, for simulating rapid clicks https://github.com/Textualize/textual/pull/5369
1517

1618
### Changed
1719

docs/events/click.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22
options:
33
heading_level: 1
44

5-
See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods.
5+
## Double & triple clicks
6+
7+
The `chain` attribute on the `Click` event can be used to determine the number of clicks that occurred in quick succession.
8+
A value of `1` indicates a single click, `2` indicates a double click, and so on.
9+
10+
By default, clicks must occur within 500ms of each other for them to be considered a chain.
11+
You can change this value by setting the `CLICK_CHAIN_TIME_THRESHOLD` class variable on your `App` subclass.
12+
13+
See [MouseEvent][textual.events.MouseEvent] for the list of properties and methods on the parent class.
614

715
## See also
816

docs/guide/testing.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ Here's how you would click the line *above* a button.
138138
await pilot.click(Button, offset=(0, -1))
139139
```
140140

141+
### Double & triple clicks
142+
143+
You can simulate double and triple clicks by setting the `times` parameter.
144+
145+
```python
146+
await pilot.click(Button, times=2) # Double click
147+
await pilot.click(Button, times=3) # Triple click
148+
```
149+
141150
### Modifier keys
142151

143152
You can simulate clicks in combination with modifier keys, by setting the `shift`, `meta`, or `control` parameters.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ include = [
4040
[tool.poetry.urls]
4141
"Bug Tracker" = "https://github.com/Textualize/textual/issues"
4242

43+
[tool.ruff]
44+
target-version = "py38"
45+
4346
[tool.poetry.dependencies]
4447
python = "^3.8.1"
4548
markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" }

src/textual/app.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,10 @@ class MyApp(App[None]):
437437
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = "Footer"
438438
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""
439439

440+
CLICK_CHAIN_TIME_THRESHOLD: ClassVar[float] = 0.5
441+
"""The maximum number of seconds between clicks to upgrade a single click to a double click,
442+
a double click to a triple click, etc."""
443+
440444
BINDINGS: ClassVar[list[BindingType]] = [
441445
Binding(
442446
"ctrl+q",
@@ -590,6 +594,15 @@ def __init__(
590594
self._mouse_down_widget: Widget | None = None
591595
"""The widget that was most recently mouse downed (used to create click events)."""
592596

597+
self._click_chain_last_offset: Offset | None = None
598+
"""The last offset at which a Click occurred, in screen-space."""
599+
600+
self._click_chain_last_time: float | None = None
601+
"""The last time at which a Click occurred."""
602+
603+
self._chained_clicks: int = 1
604+
"""Counter which tracks the number of clicks received in a row."""
605+
593606
self._previous_cursor_position = Offset(0, 0)
594607
"""The previous cursor position"""
595608

@@ -767,8 +780,6 @@ def __init__(
767780
self._previous_inline_height: int | None = None
768781
"""Size of previous inline update."""
769782

770-
self._hover_effects_timer: Timer | None = None
771-
772783
self._resize_event: events.Resize | None = None
773784
"""A pending resize event, sent on idle."""
774785

@@ -1912,7 +1923,7 @@ def on_app_ready() -> None:
19121923
"""Called when app is ready to process events."""
19131924
app_ready_event.set()
19141925

1915-
async def run_app(app: App) -> None:
1926+
async def run_app(app: App[ReturnType]) -> None:
19161927
"""Run the apps message loop.
19171928
19181929
Args:
@@ -1986,7 +1997,7 @@ async def run_async(
19861997
if auto_pilot is None and constants.PRESS:
19871998
keys = constants.PRESS.split(",")
19881999

1989-
async def press_keys(pilot: Pilot) -> None:
2000+
async def press_keys(pilot: Pilot[ReturnType]) -> None:
19902001
"""Auto press keys."""
19912002
await pilot.press(*keys)
19922003

@@ -3691,14 +3702,12 @@ async def on_event(self, event: events.Event) -> None:
36913702
if isinstance(event, events.Compose):
36923703
await self._init_mode(self._current_mode)
36933704
await super().on_event(event)
3694-
36953705
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
36963706
if not self.app_focus and isinstance(event, (events.Key, events.MouseDown)):
36973707
self.app_focus = True
36983708
if isinstance(event, events.MouseEvent):
36993709
# Record current mouse position on App
37003710
self.mouse_position = Offset(event.x, event.y)
3701-
37023711
if isinstance(event, events.MouseDown):
37033712
try:
37043713
self._mouse_down_widget, _ = self.get_widget_at(
@@ -3710,18 +3719,39 @@ async def on_event(self, event: events.Event) -> None:
37103719

37113720
self.screen._forward_event(event)
37123721

3722+
# If a MouseUp occurs at the same widget as a MouseDown, then we should
3723+
# consider it a click, and produce a Click event.
37133724
if (
37143725
isinstance(event, events.MouseUp)
37153726
and self._mouse_down_widget is not None
37163727
):
37173728
try:
3718-
if (
3719-
self.get_widget_at(event.x, event.y)[0]
3720-
is self._mouse_down_widget
3721-
):
3729+
screen_offset = event.screen_offset
3730+
mouse_down_widget = self._mouse_down_widget
3731+
mouse_up_widget, _ = self.get_widget_at(*screen_offset)
3732+
if mouse_up_widget is mouse_down_widget:
3733+
same_offset = (
3734+
self._click_chain_last_offset is not None
3735+
and self._click_chain_last_offset == screen_offset
3736+
)
3737+
within_time_threshold = (
3738+
self._click_chain_last_time is not None
3739+
and event.time - self._click_chain_last_time
3740+
<= self.CLICK_CHAIN_TIME_THRESHOLD
3741+
)
3742+
3743+
if same_offset and within_time_threshold:
3744+
self._chained_clicks += 1
3745+
else:
3746+
self._chained_clicks = 1
3747+
37223748
click_event = events.Click.from_event(
3723-
self._mouse_down_widget, event
3749+
mouse_down_widget, event, chain=self._chained_clicks
37243750
)
3751+
3752+
self._click_chain_last_time = event.time
3753+
self._click_chain_last_offset = screen_offset
3754+
37253755
self.screen._forward_event(click_event)
37263756
except NoWidget:
37273757
pass

src/textual/events.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from dataclasses import dataclass
1717
from pathlib import Path
1818
from typing import TYPE_CHECKING, Type, TypeVar
19+
from typing_extensions import Self
1920

2021
import rich.repr
2122
from rich.style import Style
@@ -556,8 +557,88 @@ class Click(MouseEvent, bubble=True):
556557
557558
- [X] Bubbles
558559
- [ ] Verbose
560+
561+
Args:
562+
chain: The number of clicks in the chain. 2 is a double click, 3 is a triple click, etc.
559563
"""
560564

565+
def __init__(
566+
self,
567+
widget: Widget | None,
568+
x: int,
569+
y: int,
570+
delta_x: int,
571+
delta_y: int,
572+
button: int,
573+
shift: bool,
574+
meta: bool,
575+
ctrl: bool,
576+
screen_x: int | None = None,
577+
screen_y: int | None = None,
578+
style: Style | None = None,
579+
chain: int = 1,
580+
) -> None:
581+
super().__init__(
582+
widget,
583+
x,
584+
y,
585+
delta_x,
586+
delta_y,
587+
button,
588+
shift,
589+
meta,
590+
ctrl,
591+
screen_x,
592+
screen_y,
593+
style,
594+
)
595+
self.chain = chain
596+
597+
@classmethod
598+
def from_event(
599+
cls: Type[Self],
600+
widget: Widget,
601+
event: MouseEvent,
602+
chain: int = 1,
603+
) -> Self:
604+
new_event = cls(
605+
widget,
606+
event.x,
607+
event.y,
608+
event.delta_x,
609+
event.delta_y,
610+
event.button,
611+
event.shift,
612+
event.meta,
613+
event.ctrl,
614+
event.screen_x,
615+
event.screen_y,
616+
event._style,
617+
chain=chain,
618+
)
619+
return new_event
620+
621+
def _apply_offset(self, x: int, y: int) -> Self:
622+
return self.__class__(
623+
self.widget,
624+
x=self.x + x,
625+
y=self.y + y,
626+
delta_x=self.delta_x,
627+
delta_y=self.delta_y,
628+
button=self.button,
629+
shift=self.shift,
630+
meta=self.meta,
631+
ctrl=self.ctrl,
632+
screen_x=self.screen_x,
633+
screen_y=self.screen_y,
634+
style=self.style,
635+
chain=self.chain,
636+
)
637+
638+
def __rich_repr__(self) -> rich.repr.Result:
639+
yield from super().__rich_repr__()
640+
yield "chain", self.chain
641+
561642

562643
@rich.repr.auto
563644
class Timer(Event, bubble=False, verbose=True):

src/textual/message_pump.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -810,7 +810,7 @@ def post_message(self, message: Message) -> bool:
810810
message: A message (including Event).
811811
812812
Returns:
813-
`True` if the messages was processed, `False` if it wasn't.
813+
`True` if the message was queued for processing, otherwise `False`.
814814
"""
815815
_rich_traceback_omit = True
816816
if not hasattr(message, "_prevent"):

0 commit comments

Comments
 (0)