Skip to content

Commit

Permalink
Chaining click events (double/triple click etc) (#5369)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
darrenburns authored Dec 11, 2024
1 parent 268971e commit 3c120c0
Show file tree
Hide file tree
Showing 9 changed files with 379 additions and 33 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion docs/events/click.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
52 changes: 41 additions & 11 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,10 @@ class MyApp(App[None]):
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",
Expand Down Expand Up @@ -590,6 +594,15 @@ def __init__(
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"""

Expand Down Expand Up @@ -767,8 +780,6 @@ def __init__(
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."""

Expand Down Expand Up @@ -1912,7 +1923,7 @@ def on_app_ready() -> None:
"""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:
Expand Down Expand Up @@ -1986,7 +1997,7 @@ async def run_async(
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)

Expand Down Expand Up @@ -3691,14 +3702,12 @@ async def on_event(self, event: events.Event) -> None:
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(
Expand All @@ -3710,18 +3719,39 @@ async def on_event(self, event: events.Event) -> None:

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
):
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(
self._mouse_down_widget, 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
Expand Down
81 changes: 81 additions & 0 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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
Expand Down Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -810,7 +810,7 @@ def post_message(self, message: Message) -> bool:
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"):
Expand Down
Loading

0 comments on commit 3c120c0

Please sign in to comment.