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
This commit is contained in:
Darren Burns
2024-12-11 16:25:42 +00:00
committed by GitHub
parent 268971e27c
commit 3c120c0ab0
9 changed files with 380 additions and 34 deletions

View File

@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `App.clipboard` https://github.com/Textualize/textual/pull/5352
- Added standard cut/copy/paste (ctrl+x, ctrl+c, ctrl+v) bindings to Input / TextArea https://github.com/Textualize/textual/pull/5352
- Added `system` boolean to Binding, which hides the binding from the help panel https://github.com/Textualize/textual/pull/5352
- Added support for double/triple/etc clicks via `chain` attribute on `Click` events https://github.com/Textualize/textual/pull/5369
- Added `times` parameter to `Pilot.click` method, for simulating rapid clicks https://github.com/Textualize/textual/pull/5369
### Changed

View File

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

View File

@@ -138,6 +138,15 @@ Here's how you would click the line *above* a button.
await pilot.click(Button, offset=(0, -1))
```
### Double & triple clicks
You can simulate double and triple clicks by setting the `times` parameter.
```python
await pilot.click(Button, times=2) # Double click
await pilot.click(Button, times=3) # Triple click
```
### Modifier keys
You can simulate clicks in combination with modifier keys, by setting the `shift`, `meta`, or `control` parameters.

View File

@@ -40,6 +40,9 @@ include = [
[tool.poetry.urls]
"Bug Tracker" = "https://github.com/Textualize/textual/issues"
[tool.ruff]
target-version = "py38"
[tool.poetry.dependencies]
python = "^3.8.1"
markdown-it-py = { extras = ["plugins", "linkify"], version = ">=2.1.0" }

View File

@@ -437,6 +437,10 @@ class App(Generic[ReturnType], DOMNode):
ALLOW_IN_MAXIMIZED_VIEW: ClassVar[str] = "Footer"
"""The default value of [Screen.ALLOW_IN_MAXIMIZED_VIEW][textual.screen.Screen.ALLOW_IN_MAXIMIZED_VIEW]."""
CLICK_CHAIN_TIME_THRESHOLD: ClassVar[float] = 0.5
"""The maximum number of seconds between clicks to upgrade a single click to a double click,
a double click to a triple click, etc."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding(
"ctrl+q",
@@ -590,6 +594,15 @@ class App(Generic[ReturnType], DOMNode):
self._mouse_down_widget: Widget | None = None
"""The widget that was most recently mouse downed (used to create click events)."""
self._click_chain_last_offset: Offset | None = None
"""The last offset at which a Click occurred, in screen-space."""
self._click_chain_last_time: float | None = None
"""The last time at which a Click occurred."""
self._chained_clicks: int = 1
"""Counter which tracks the number of clicks received in a row."""
self._previous_cursor_position = Offset(0, 0)
"""The previous cursor position"""
@@ -767,8 +780,6 @@ class App(Generic[ReturnType], DOMNode):
self._previous_inline_height: int | None = None
"""Size of previous inline update."""
self._hover_effects_timer: Timer | None = None
self._resize_event: events.Resize | None = None
"""A pending resize event, sent on idle."""
@@ -1912,7 +1923,7 @@ class App(Generic[ReturnType], DOMNode):
"""Called when app is ready to process events."""
app_ready_event.set()
async def run_app(app: App) -> None:
async def run_app(app: App[ReturnType]) -> None:
"""Run the apps message loop.
Args:
@@ -1986,7 +1997,7 @@ class App(Generic[ReturnType], DOMNode):
if auto_pilot is None and constants.PRESS:
keys = constants.PRESS.split(",")
async def press_keys(pilot: Pilot) -> None:
async def press_keys(pilot: Pilot[ReturnType]) -> None:
"""Auto press keys."""
await pilot.press(*keys)
@@ -3691,14 +3702,12 @@ class App(Generic[ReturnType], DOMNode):
if isinstance(event, events.Compose):
await self._init_mode(self._current_mode)
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
if not self.app_focus and isinstance(event, (events.Key, events.MouseDown)):
self.app_focus = True
if isinstance(event, events.MouseEvent):
# Record current mouse position on App
self.mouse_position = Offset(event.x, event.y)
if isinstance(event, events.MouseDown):
try:
self._mouse_down_widget, _ = self.get_widget_at(
@@ -3710,18 +3719,39 @@ class App(Generic[ReturnType], DOMNode):
self.screen._forward_event(event)
# If a MouseUp occurs at the same widget as a MouseDown, then we should
# consider it a click, and produce a Click event.
if (
isinstance(event, events.MouseUp)
and self._mouse_down_widget is not None
):
try:
if (
self.get_widget_at(event.x, event.y)[0]
is self._mouse_down_widget
):
click_event = events.Click.from_event(
self._mouse_down_widget, event
screen_offset = event.screen_offset
mouse_down_widget = self._mouse_down_widget
mouse_up_widget, _ = self.get_widget_at(*screen_offset)
if mouse_up_widget is mouse_down_widget:
same_offset = (
self._click_chain_last_offset is not None
and self._click_chain_last_offset == screen_offset
)
within_time_threshold = (
self._click_chain_last_time is not None
and event.time - self._click_chain_last_time
<= self.CLICK_CHAIN_TIME_THRESHOLD
)
if same_offset and within_time_threshold:
self._chained_clicks += 1
else:
self._chained_clicks = 1
click_event = events.Click.from_event(
mouse_down_widget, event, chain=self._chained_clicks
)
self._click_chain_last_time = event.time
self._click_chain_last_offset = screen_offset
self.screen._forward_event(click_event)
except NoWidget:
pass

View File

@@ -16,6 +16,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Type, TypeVar
from typing_extensions import Self
import rich.repr
from rich.style import Style
@@ -556,8 +557,88 @@ class Click(MouseEvent, bubble=True):
- [X] Bubbles
- [ ] Verbose
Args:
chain: The number of clicks in the chain. 2 is a double click, 3 is a triple click, etc.
"""
def __init__(
self,
widget: Widget | None,
x: int,
y: int,
delta_x: int,
delta_y: int,
button: int,
shift: bool,
meta: bool,
ctrl: bool,
screen_x: int | None = None,
screen_y: int | None = None,
style: Style | None = None,
chain: int = 1,
) -> None:
super().__init__(
widget,
x,
y,
delta_x,
delta_y,
button,
shift,
meta,
ctrl,
screen_x,
screen_y,
style,
)
self.chain = chain
@classmethod
def from_event(
cls: Type[Self],
widget: Widget,
event: MouseEvent,
chain: int = 1,
) -> Self:
new_event = cls(
widget,
event.x,
event.y,
event.delta_x,
event.delta_y,
event.button,
event.shift,
event.meta,
event.ctrl,
event.screen_x,
event.screen_y,
event._style,
chain=chain,
)
return new_event
def _apply_offset(self, x: int, y: int) -> Self:
return self.__class__(
self.widget,
x=self.x + x,
y=self.y + y,
delta_x=self.delta_x,
delta_y=self.delta_y,
button=self.button,
shift=self.shift,
meta=self.meta,
ctrl=self.ctrl,
screen_x=self.screen_x,
screen_y=self.screen_y,
style=self.style,
chain=self.chain,
)
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "chain", self.chain
@rich.repr.auto
class Timer(Event, bubble=False, verbose=True):

View File

@@ -810,7 +810,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
message: A message (including Event).
Returns:
`True` if the messages was processed, `False` if it wasn't.
`True` if the message was queued for processing, otherwise `False`.
"""
_rich_traceback_omit = True
if not hasattr(message, "_prevent"):

View File

@@ -194,12 +194,15 @@ class Pilot(Generic[ReturnType]):
shift: bool = False,
meta: bool = False,
control: bool = False,
times: int = 1,
) -> bool:
"""Simulate clicking with the mouse at a specified position.
The final position to be clicked is computed based on the selector provided and
the offset specified and it must be within the visible area of the screen.
Implementation note: This method bypasses the normal event processing in `App.on_event`.
Example:
The code below runs an app and clicks its only button right in the middle:
```py
@@ -218,6 +221,7 @@ class Pilot(Generic[ReturnType]):
shift: Click with the shift key held down.
meta: Click with the meta key held down.
control: Click with the control key held down.
times: The number of times to click. 2 will double-click, 3 will triple-click, etc.
Raises:
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
@@ -235,10 +239,101 @@ class Pilot(Generic[ReturnType]):
shift=shift,
meta=meta,
control=control,
times=times,
)
except OutOfBounds as error:
raise error from None
async def double_click(
self,
widget: Widget | type[Widget] | str | None = None,
offset: tuple[int, int] = (0, 0),
shift: bool = False,
meta: bool = False,
control: bool = False,
) -> bool:
"""Simulate double clicking with the mouse at a specified position.
Alias for `pilot.click(..., times=2)`.
The final position to be clicked is computed based on the selector provided and
the offset specified and it must be within the visible area of the screen.
Implementation note: This method bypasses the normal event processing in `App.on_event`.
Example:
The code below runs an app and double-clicks its only button right in the middle:
```py
async with SingleButtonApp().run_test() as pilot:
await pilot.double_click(Button, offset=(8, 1))
```
Args:
widget: A widget or selector used as an origin
for the click offset. If this is not specified, the offset is interpreted
relative to the screen. You can use this parameter to try to click on a
specific widget. However, if the widget is currently hidden or obscured by
another widget, the click may not land on the widget you specified.
offset: The offset to click. The offset is relative to the widget / selector provided
or to the screen, if no selector is provided.
shift: Click with the shift key held down.
meta: Click with the meta key held down.
control: Click with the control key held down.
Raises:
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
Returns:
True if no selector was specified or if the clicks landed on the selected
widget, False otherwise.
"""
await self.click(widget, offset, shift, meta, control, times=2)
async def triple_click(
self,
widget: Widget | type[Widget] | str | None = None,
offset: tuple[int, int] = (0, 0),
shift: bool = False,
meta: bool = False,
control: bool = False,
) -> bool:
"""Simulate triple clicking with the mouse at a specified position.
Alias for `pilot.click(..., times=3)`.
The final position to be clicked is computed based on the selector provided and
the offset specified and it must be within the visible area of the screen.
Implementation note: This method bypasses the normal event processing in `App.on_event`.
Example:
The code below runs an app and triple-clicks its only button right in the middle:
```py
async with SingleButtonApp().run_test() as pilot:
await pilot.triple_click(Button, offset=(8, 1))
```
Args:
widget: A widget or selector used as an origin
for the click offset. If this is not specified, the offset is interpreted
relative to the screen. You can use this parameter to try to click on a
specific widget. However, if the widget is currently hidden or obscured by
another widget, the click may not land on the widget you specified.
offset: The offset to click. The offset is relative to the widget / selector provided
or to the screen, if no selector is provided.
shift: Click with the shift key held down.
meta: Click with the meta key held down.
control: Click with the control key held down.
Raises:
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
Returns:
True if no selector was specified or if the clicks landed on the selected
widget, False otherwise.
"""
await self.click(widget, offset, shift, meta, control, times=3)
async def hover(
self,
widget: Widget | type[Widget] | str | None | None = None,
@@ -282,6 +377,7 @@ class Pilot(Generic[ReturnType]):
shift: bool = False,
meta: bool = False,
control: bool = False,
times: int = 1,
) -> bool:
"""Simulate a series of mouse events to be fired at a given position.
@@ -302,7 +398,7 @@ class Pilot(Generic[ReturnType]):
shift: Simulate the events with the shift key held down.
meta: Simulate the events with the meta key held down.
control: Simulate the events with the control key held down.
times: The number of times to click. 2 will double-click, 3 will triple-click, etc.
Raises:
OutOfBounds: If the position for the events is outside of the (visible) screen.
@@ -336,22 +432,26 @@ class Pilot(Generic[ReturnType]):
)
widget_at = None
for mouse_event_cls in events:
# Get the widget under the mouse before the event because the app might
# react to the event and move things around. We override on each iteration
# because we assume the final event in `events` is the actual event we care
# about and that all the preceding events are just setup.
# E.g., the click event is preceded by MouseDown/MouseUp to emulate how
# the driver works and emits a click event.
widget_at, _ = app.get_widget_at(*offset)
event = mouse_event_cls(**message_arguments)
# Bypass event processing in App.on_event. Because App.on_event
# is responsible for updating App.mouse_position, and because
# that's useful to other things (tooltip handling, for example),
# we patch the offset in there as well.
app.mouse_position = offset
app.screen._forward_event(event)
await self.pause()
for chain in range(1, times + 1):
for mouse_event_cls in events:
# Get the widget under the mouse before the event because the app might
# react to the event and move things around. We override on each iteration
# because we assume the final event in `events` is the actual event we care
# about and that all the preceding events are just setup.
# E.g., the click event is preceded by MouseDown/MouseUp to emulate how
# the driver works and emits a click event.
kwargs = message_arguments
if mouse_event_cls is Click:
kwargs["chain"] = chain
widget_at, _ = app.get_widget_at(*offset)
event = mouse_event_cls(**kwargs)
# Bypass event processing in App.on_event. Because App.on_event
# is responsible for updating App.mouse_position, and because
# that's useful to other things (tooltip handling, for example),
# we patch the offset in there as well.
app.mouse_position = offset
screen._forward_event(event)
await self.pause()
return widget is None or widget_at is target_widget

View File

@@ -1,10 +1,13 @@
import contextlib
import pytest
from rich.terminal_theme import DIMMED_MONOKAI, MONOKAI, NIGHT_OWLISH
from textual import events
from textual.app import App, ComposeResult
from textual.command import SimpleCommand
from textual.widgets import Button, Input, Static
from textual.pilot import Pilot, _get_mouse_message_arguments
from textual.widgets import Button, Input, Label, Static
def test_batch_update():
@@ -224,6 +227,116 @@ async def test_search_with_tuples():
async def test_search_with_empty_list():
"""Test search with an empty command list doesn't crash."""
app = App[None]()
async with app.run_test() as pilot:
async with app.run_test():
await app.search_commands([])
await pilot.press("escape")
async def raw_click(pilot: Pilot, selector: str, times: int = 1):
"""A lower level click function that doesn't use the Pilot,
and so doesn't bypass the click chain logic in App.on_event."""
app = pilot.app
kwargs = _get_mouse_message_arguments(app.query_one(selector))
for _ in range(times):
app.post_message(events.MouseDown(**kwargs))
app.post_message(events.MouseUp(**kwargs))
await pilot.pause()
@pytest.mark.parametrize("number_of_clicks,final_count", [(1, 1), (2, 3), (3, 6)])
async def test_click_chain_initial_repeated_clicks(
number_of_clicks: int, final_count: int
):
click_count = 0
class MyApp(App[None]):
# Ensure clicks are always within the time threshold
CLICK_CHAIN_TIME_THRESHOLD = 1000.0
def compose(self) -> ComposeResult:
yield Label("Click me!", id="one")
def on_click(self, event: events.Click) -> None:
nonlocal click_count
print(f"event: {event}")
click_count += event.chain
async with MyApp().run_test() as pilot:
# Clicking the same Label at the same offset creates a double and triple click.
for _ in range(number_of_clicks):
await raw_click(pilot, "#one")
assert click_count == final_count
async def test_click_chain_different_offset():
click_count = 0
class MyApp(App[None]):
# Ensure clicks are always within the time threshold
CLICK_CHAIN_TIME_THRESHOLD = 1000.0
def compose(self) -> ComposeResult:
yield Label("One!", id="one")
yield Label("Two!", id="two")
yield Label("Three!", id="three")
def on_click(self, event: events.Click) -> None:
nonlocal click_count
click_count += event.chain
async with MyApp().run_test() as pilot:
# Clicking on different offsets in quick-succession doesn't qualify as a double or triple click.
await raw_click(pilot, "#one")
assert click_count == 1
await raw_click(pilot, "#two")
assert click_count == 2
await raw_click(pilot, "#three")
assert click_count == 3
async def test_click_chain_offset_changes_mid_chain():
"""If we're in the middle of a click chain (e.g. we've double clicked), and the third click
comes in at a different offset, that third click should be considered a single click.
"""
click_count = 0
class MyApp(App[None]):
# Ensure clicks are always within the time threshold
CLICK_CHAIN_TIME_THRESHOLD = 1000.0
def compose(self) -> ComposeResult:
yield Label("Click me!", id="one")
yield Label("Another button!", id="two")
def on_click(self, event: events.Click) -> None:
nonlocal click_count
click_count = event.chain
async with MyApp().run_test() as pilot:
await raw_click(pilot, "#one", times=2) # Double click
assert click_count == 2
await raw_click(pilot, "#two") # Single click (because different widget)
assert click_count == 1
async def test_click_chain_time_outwith_threshold():
click_count = 0
class MyApp(App[None]):
# Intentionally set the threshold to 0.0 to ensure we always exceed it
# and can confirm that a click chain is never created
CLICK_CHAIN_TIME_THRESHOLD = 0.0
def compose(self) -> ComposeResult:
yield Label("Click me!", id="one")
def on_click(self, event: events.Click) -> None:
nonlocal click_count
click_count += event.chain
async with MyApp().run_test() as pilot:
for i in range(1, 4):
# Each click is outwith the time threshold, so a click chain is never created.
await raw_click(pilot, "#one")
assert click_count == i