From 3c120c0ab012cd7c4944bcf7864585cda523a77e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 11 Dec 2024 16:25:42 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + docs/events/click.md | 10 ++- docs/guide/testing.md | 9 +++ pyproject.toml | 3 + src/textual/app.py | 52 +++++++++++--- src/textual/events.py | 81 ++++++++++++++++++++++ src/textual/message_pump.py | 2 +- src/textual/pilot.py | 134 +++++++++++++++++++++++++++++++----- tests/test_app.py | 119 +++++++++++++++++++++++++++++++- 9 files changed, 379 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1666869d1..4ca3940be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/events/click.md b/docs/events/click.md index cc5b83e73e..e93dd8d33b 100644 --- a/docs/events/click.md +++ b/docs/events/click.md @@ -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 diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 32a4d33dc7..9979ee86af 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 869a5784c1..9f3ac5ffc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/textual/app.py b/src/textual/app.py index 3a8c4ea207..6c0c10e8d2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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", @@ -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""" @@ -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.""" @@ -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: @@ -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) @@ -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( @@ -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 diff --git a/src/textual/events.py b/src/textual/events.py index fd4dea3edd..b977c1edf4 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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 @@ -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): diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d47c51cf1c..9ec49f9047 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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"): diff --git a/src/textual/pilot.py b/src/textual/pilot.py index e7362ea4e6..473341b7e9 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -194,12 +194,15 @@ async def click( 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 @@ async def click( 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 @@ async def click( 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 @@ async def _post_mouse_events( 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 @@ async def _post_mouse_events( 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 @@ async def _post_mouse_events( ) 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 diff --git a/tests/test_app.py b/tests/test_app.py index f610428993..a986f2284a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 @@ def callback(): 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