Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chaining click events (double/triple click etc) #5369

Merged
merged 28 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9d7c08a
Add comment about Click events
darrenburns Dec 9, 2024
65c463b
Remove unused `App._hover_effects_timer`
darrenburns Dec 9, 2024
2fe35e8
Add missing annotation
darrenburns Dec 9, 2024
e5ace10
Add missing type annotation
darrenburns Dec 9, 2024
f3605f7
Add `App._click_chain_timer`
darrenburns Dec 9, 2024
fd9aa98
Add support for click chaining (double click, triple click, etc.)
darrenburns Dec 9, 2024
77e6442
Create `App.CLICK_CHAIN_TIME_THRESHOLD` for controlling click chain t…
darrenburns Dec 9, 2024
92cf776
Some tests for chained clicks
darrenburns Dec 9, 2024
6d4da72
Test changes [no ci]
darrenburns Dec 9, 2024
c4a06c9
Have Pilot send only MouseUp and MouseDown, and let Textual generate …
darrenburns Dec 10, 2024
3925f2e
Fix DataTable click tet [no ci]
darrenburns Dec 10, 2024
c8621ce
Rename Click.count -> Click.chain
darrenburns Dec 10, 2024
4dd6376
Test fixes
darrenburns Dec 10, 2024
ebebba9
Enhance raw_click function documentation in test_app.py to clarify it…
darrenburns Dec 10, 2024
2c130b7
Refactor imports in events.py: remove Self from typing and import fro…
darrenburns Dec 10, 2024
599db2c
Merge branch 'main' of github.com:Textualize/textual into click-events
darrenburns Dec 10, 2024
214b3ea
Remove unnecessary pause in test_datatable_click_cell_cursor
darrenburns Dec 10, 2024
1cdbb13
Remove debug print statements and unnecessary pause in App class; add…
darrenburns Dec 10, 2024
274cd73
Remove debugging prints
darrenburns Dec 10, 2024
f7f7b1d
Add support for double and triple clicks in testing guide
darrenburns Dec 10, 2024
10bcd34
Add a note about double and triple clicks to the docs
darrenburns Dec 10, 2024
4e5923a
Turn off formatter for a section of code, and make it 3.8 compatible
darrenburns Dec 10, 2024
7ed775e
Update changelog [no ci]
darrenburns Dec 10, 2024
c8b2ecd
Simplify by removing an unecessary variable in `Pilot.click`
darrenburns Dec 10, 2024
bf0c724
Remove debugging code
darrenburns Dec 10, 2024
fd6faf6
Add target-version py38 to ruff config in pyproject.toml, and remove …
darrenburns Dec 10, 2024
9bb949f
Document timing of click chains
darrenburns Dec 11, 2024
a6bf352
Pilot.double_click and Pilot.triple_click
darrenburns Dec 11, 2024
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
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are calling times here and chain on the event?

I see why. It reads like "click button 2 times". But it feels inconsistent.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's a bit inconsistent but I think of the event being a lower level technical thing and as you said pilot interacting from the user POV.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a pilot.double_click and pilot.triple_click (in addition to the times parameter?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added these

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:
Copy link
Member Author

@darrenburns darrenburns Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've copied the docstring format from other classes in this module, assuming inheritance etc. rather than copy pasting a huge docstring several times.

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
Loading