From 9d7c08a2f8f8db406212e645980f0c4d1fcc473a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 10:38:52 +0000 Subject: [PATCH 01/27] Add comment about Click events --- src/textual/app.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 3a8c4ea207..c3d327716e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3710,6 +3710,7 @@ 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 @@ -4325,9 +4326,11 @@ def suspend(self) -> Iterator[None]: # app, and we don't want to have the driver auto-restart # application mode when the application comes back to the # foreground, in this context. - with self._driver.no_automatic_restart(), redirect_stdout( - sys.__stdout__ - ), redirect_stderr(sys.__stderr__): + with ( + self._driver.no_automatic_restart(), + redirect_stdout(sys.__stdout__), + redirect_stderr(sys.__stderr__), + ): yield # We're done with the dev's code so resume application mode. self._driver.resume_application_mode() From 65c463be511940b16d08710cd566431ba9a48186 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 10:44:52 +0000 Subject: [PATCH 02/27] Remove unused `App._hover_effects_timer` --- src/textual/app.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c3d327716e..d7f85995ed 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -767,8 +767,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.""" From 2fe35e8c8a121cced4d47a9790b859e1b36ebc99 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 10:45:45 +0000 Subject: [PATCH 03/27] Add missing annotation --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index d7f85995ed..7d996c6bad 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1910,7 +1910,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: From e5ace1099b2a57445c4d2333ec70015f568ebad9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 10:46:23 +0000 Subject: [PATCH 04/27] Add missing type annotation --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7d996c6bad..491d08846a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1984,7 +1984,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) From f3605f71aba59abcc3e30d44233e878cc69ffc63 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 10:48:06 +0000 Subject: [PATCH 05/27] Add `App._click_chain_timer` --- src/textual/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 491d08846a..c17764d986 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -590,6 +590,9 @@ 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_timer: Timer | None = None + """A timer which is used to measure the duration between mouse down and mouse up events, in order for Textual to generate the corresponding `SingleClick`, `DoubleClick`, or `TripleClick` events.""" + self._previous_cursor_position = Offset(0, 0) """The previous cursor position""" From fd9aa98dbcdc2a20aa1cf34fb1b55a62c3142a9c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 16:45:44 +0000 Subject: [PATCH 06/27] Add support for click chaining (double click, triple click, etc.) --- src/textual/app.py | 39 +++++++++++++++++---- src/textual/events.py | 79 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index c17764d986..3a6d67d64d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -590,8 +590,14 @@ 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_timer: Timer | None = None - """A timer which is used to measure the duration between mouse down and mouse up events, in order for Textual to generate the corresponding `SingleClick`, `DoubleClick`, or `TripleClick` 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""" @@ -3717,13 +3723,32 @@ async def on_event(self, event: events.Event) -> None: 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 < 0.5 + ) + + 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, count=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..72edec95bd 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Type, TypeVar +from typing import TYPE_CHECKING, Self, Type, TypeVar import rich.repr from rich.style import Style @@ -558,6 +558,83 @@ class Click(MouseEvent, bubble=True): - [ ] Verbose """ + 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, + count: int = 1, + ) -> None: + super().__init__( + widget, + x, + y, + delta_x, + delta_y, + button, + shift, + meta, + ctrl, + screen_x, + screen_y, + style, + ) + self.count = count + + @classmethod + def from_event( + cls: Type[Self], + widget: Widget, + event: MouseEvent, + count: 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, + count=count, + ) + 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, + count=self.count, + ) + + def __rich_repr__(self) -> rich.repr.Result: + yield from super().__rich_repr__() + yield "count", self.count + @rich.repr.auto class Timer(Event, bubble=False, verbose=True): From 77e64424ffa9f54e0cf51f5ffe9e53a188c2ae33 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 16:51:59 +0000 Subject: [PATCH 07/27] Create `App.CLICK_CHAIN_TIME_THRESHOLD` for controlling click chain timing --- src/textual/app.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 3a6d67d64d..5a72083939 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", @@ -3734,7 +3738,8 @@ async def on_event(self, event: events.Event) -> None: ) within_time_threshold = ( self._click_chain_last_time is not None - and event.time - self._click_chain_last_time < 0.5 + and event.time - self._click_chain_last_time + < self.CLICK_CHAIN_TIME_THRESHOLD ) if same_offset and within_time_threshold: From 92cf776a69b0757a7dcf6441e596b5fedbd68070 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 17:11:50 +0000 Subject: [PATCH 08/27] Some tests for chained clicks --- tests/test_app.py | 53 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index f610428993..337052d735 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,10 +1,12 @@ 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.widgets import Button, Input, Label, Static def test_batch_update(): @@ -224,6 +226,51 @@ 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") + + +@pytest.mark.parametrize("click_count", [1, 2, 3]) +async def test_click_chain_initial_repeated_clicks(click_count: int): + click_count = 0 + + class MyApp(App[None]): + CLICK_CHAIN_TIME_THRESHOLD = 10.0 + + def compose(self) -> ComposeResult: + yield Label("Click me!") + + def on_click(self, event: events.Click) -> None: + nonlocal click_count + click_count += event.count + + async with MyApp().run_test() as pilot: + # Clicking the same Label at the same offset creates a double and triple click. + for _ in range(click_count): + await pilot.click(Label) + assert click_count == click_count + + +async def test_click_chain_different_offset(): + click_count = 0 + + class MyApp(App[None]): + CLICK_CHAIN_TIME_THRESHOLD = 10.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.count + + async with MyApp().run_test() as pilot: + # Clicking on different offsets in quick-succession doesn't qualify as a double or triple click. + await pilot.click("#one") + assert click_count == 1 + await pilot.click("#two") + assert click_count == 2 + await pilot.click("#three") + assert click_count == 3 From 6d4da72aea7bad52c0378f8f480bc23a3aa3b205 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 9 Dec 2024 17:50:46 +0000 Subject: [PATCH 09/27] Test changes [no ci] --- src/textual/app.py | 9 +++-- src/textual/pilot.py | 4 +-- tests/test_app.py | 81 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 5a72083939..e6b3e45ec1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3721,7 +3721,8 @@ 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 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 @@ -3731,6 +3732,8 @@ async def on_event(self, event: events.Event) -> None: mouse_down_widget = self._mouse_down_widget mouse_up_widget, _ = self.get_widget_at(*screen_offset) + print(screen_offset, mouse_down_widget, mouse_up_widget) + if mouse_up_widget is mouse_down_widget: same_offset = ( self._click_chain_last_offset is not None @@ -3739,9 +3742,11 @@ async def on_event(self, event: events.Event) -> None: within_time_threshold = ( self._click_chain_last_time is not None and event.time - self._click_chain_last_time - < self.CLICK_CHAIN_TIME_THRESHOLD + <= self.CLICK_CHAIN_TIME_THRESHOLD ) + print(same_offset, within_time_threshold) + if same_offset and within_time_threshold: self._chained_clicks += 1 else: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index e7362ea4e6..41c1070502 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -16,7 +16,7 @@ from textual._wait import wait_for_idle from textual.app import App, ReturnType from textual.drivers.headless_driver import HeadlessDriver -from textual.events import Click, MouseDown, MouseEvent, MouseMove, MouseUp, Resize +from textual.events import MouseDown, MouseEvent, MouseMove, MouseUp, Resize from textual.geometry import Offset, Size from textual.widget import Widget @@ -228,7 +228,7 @@ async def click( """ try: return await self._post_mouse_events( - [MouseDown, MouseUp, Click], + [MouseDown, MouseUp], widget=widget, offset=offset, button=1, diff --git a/tests/test_app.py b/tests/test_app.py index 337052d735..6d6ebbd880 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -230,32 +230,46 @@ async def test_search_with_empty_list(): await app.search_commands([]) -@pytest.mark.parametrize("click_count", [1, 2, 3]) -async def test_click_chain_initial_repeated_clicks(click_count: int): +@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]): - CLICK_CHAIN_TIME_THRESHOLD = 10.0 + # Ensure clicks are always within the time threshold + CLICK_CHAIN_TIME_THRESHOLD = 1000.0 def compose(self) -> ComposeResult: - yield Label("Click me!") + yield Label("Click me!", id="one") def on_click(self, event: events.Click) -> None: nonlocal click_count click_count += event.count + print("event.count", event.count) + print("click_count", click_count) async with MyApp().run_test() as pilot: # Clicking the same Label at the same offset creates a double and triple click. - for _ in range(click_count): - await pilot.click(Label) - assert click_count == click_count + for _ in range(number_of_clicks): + # TODO - we'll have to dispatch messages to the app directly here + # in order to test this properly, or rewrite pilot.click to dispatch + # only a mouseup and mousedown event to the *APP*. Right now it sends + # a fake message directly to a target widget. + # If we send the mouseup and mousedown events to the app, we'd be able + # to test the click chaining logic. + await pilot.click("#one") + await pilot.pause() + + assert click_count == final_count async def test_click_chain_different_offset(): click_count = 0 class MyApp(App[None]): - CLICK_CHAIN_TIME_THRESHOLD = 10.0 + # Ensure clicks are always within the time threshold + CLICK_CHAIN_TIME_THRESHOLD = 1000.0 def compose(self) -> ComposeResult: yield Label("One!", id="one") @@ -274,3 +288,54 @@ def on_click(self, event: events.Click) -> None: assert click_count == 2 await pilot.click("#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.count + print(event.count) + + async with MyApp().run_test() as pilot: + await pilot.click("#one") # Single click + assert click_count == 1 + await pilot.click("#one") # Double click + assert click_count == 2 + await pilot.click("#two") # Single click (because different offset) + 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!") + + def on_click(self, event: events.Click) -> None: + nonlocal click_count + click_count += event.count + + 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 pilot.click(Label) + assert click_count == i From c4a06c909f0c767b87669cdc9ae8a19a06bf1381 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 09:34:35 +0000 Subject: [PATCH 10/27] Have Pilot send only MouseUp and MouseDown, and let Textual generate clicks itself [no ci] --- src/textual/app.py | 6 ------ src/textual/pilot.py | 5 +++-- tests/test_app.py | 9 --------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index e6b3e45ec1..872f2574ab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3702,7 +3702,6 @@ 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 @@ -3731,9 +3730,6 @@ async def on_event(self, event: events.Event) -> None: screen_offset = event.screen_offset mouse_down_widget = self._mouse_down_widget mouse_up_widget, _ = self.get_widget_at(*screen_offset) - - print(screen_offset, mouse_down_widget, mouse_up_widget) - if mouse_up_widget is mouse_down_widget: same_offset = ( self._click_chain_last_offset is not None @@ -3745,8 +3741,6 @@ async def on_event(self, event: events.Event) -> None: <= self.CLICK_CHAIN_TIME_THRESHOLD ) - print(same_offset, within_time_threshold) - if same_offset and within_time_threshold: self._chained_clicks += 1 else: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 41c1070502..1ff465487f 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -227,7 +227,7 @@ async def click( widget, False otherwise. """ try: - return await self._post_mouse_events( + done = await self._post_mouse_events( [MouseDown, MouseUp], widget=widget, offset=offset, @@ -236,6 +236,7 @@ async def click( meta=meta, control=control, ) + return done except OutOfBounds as error: raise error from None @@ -350,7 +351,7 @@ async def _post_mouse_events( # 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) + app.post_message(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 6d6ebbd880..b3f07253cf 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -246,18 +246,10 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count click_count += event.count - print("event.count", event.count) - print("click_count", click_count) 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): - # TODO - we'll have to dispatch messages to the app directly here - # in order to test this properly, or rewrite pilot.click to dispatch - # only a mouseup and mousedown event to the *APP*. Right now it sends - # a fake message directly to a target widget. - # If we send the mouseup and mousedown events to the app, we'd be able - # to test the click chaining logic. await pilot.click("#one") await pilot.pause() @@ -308,7 +300,6 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count click_count = event.count - print(event.count) async with MyApp().run_test() as pilot: await pilot.click("#one") # Single click From 3925f2e388d94681e72dca10940f27bd0c7223ac Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 09:43:18 +0000 Subject: [PATCH 11/27] Fix DataTable click tet [no ci] --- src/textual/app.py | 7 ++++++- tests/test_data_table.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 872f2574ab..11ddf57241 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1955,6 +1955,7 @@ async def run_app(app: App[ReturnType]) -> None: try: pilot = Pilot(app) await pilot._wait_for_screen() + await pilot.pause() yield pilot finally: # Shutdown the app cleanly @@ -3708,12 +3709,13 @@ async def on_event(self, event: events.Event) -> None: if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) - + print("mouse event (on_event)", event) if isinstance(event, events.MouseDown): try: self._mouse_down_widget, _ = self.get_widget_at( event.x, event.y ) + print("mouse down widget (on_event)", self._mouse_down_widget) except NoWidget: # Shouldn't occur, since at the very least this will find the Screen self._mouse_down_widget = None @@ -3726,10 +3728,12 @@ async def on_event(self, event: events.Event) -> None: isinstance(event, events.MouseUp) and self._mouse_down_widget is not None ): + print("mouse up (on_event)", event) try: screen_offset = event.screen_offset mouse_down_widget = self._mouse_down_widget mouse_up_widget, _ = self.get_widget_at(*screen_offset) + print("mouse up widget (on_event)", mouse_up_widget) if mouse_up_widget is mouse_down_widget: same_offset = ( self._click_chain_last_offset is not None @@ -3749,6 +3753,7 @@ async def on_event(self, event: events.Event) -> None: click_event = events.Click.from_event( mouse_down_widget, event, count=self._chained_clicks ) + print("generated click event (on_event)", click_event) self._click_chain_last_time = event.time self._click_chain_last_offset = screen_offset diff --git a/tests/test_data_table.py b/tests/test_data_table.py index d78d49e2fa..cb6bab71d4 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -777,6 +777,7 @@ async def test_datatable_click_cell_cursor(): column_key = table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") + await pilot.pause() # add_row happens on_idle await pilot.click(offset=Offset(1, 2)) # There's two CellHighlighted events since a cell is highlighted on initial load, # then when we click, another cell is highlighted (and selected). From c8621ce9e94f9fd32e9ed9398654bf19c5b5d2c2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 15:06:11 +0000 Subject: [PATCH 12/27] Rename Click.count -> Click.chain --- src/textual/app.py | 2 +- src/textual/events.py | 12 ++++++------ src/textual/pilot.py | 14 ++++++++++---- tests/test_app.py | 8 ++++---- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 11ddf57241..879e98d653 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3751,7 +3751,7 @@ async def on_event(self, event: events.Event) -> None: self._chained_clicks = 1 click_event = events.Click.from_event( - mouse_down_widget, event, count=self._chained_clicks + mouse_down_widget, event, chain=self._chained_clicks ) print("generated click event (on_event)", click_event) diff --git a/src/textual/events.py b/src/textual/events.py index 72edec95bd..f1b6a37084 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -572,7 +572,7 @@ def __init__( screen_x: int | None = None, screen_y: int | None = None, style: Style | None = None, - count: int = 1, + chain: int = 1, ) -> None: super().__init__( widget, @@ -588,14 +588,14 @@ def __init__( screen_y, style, ) - self.count = count + self.chain = chain @classmethod def from_event( cls: Type[Self], widget: Widget, event: MouseEvent, - count: int = 1, + chain: int = 1, ) -> Self: new_event = cls( widget, @@ -610,7 +610,7 @@ def from_event( event.screen_x, event.screen_y, event._style, - count=count, + chain=chain, ) return new_event @@ -628,12 +628,12 @@ def _apply_offset(self, x: int, y: int) -> Self: screen_x=self.screen_x, screen_y=self.screen_y, style=self.style, - count=self.count, + chain=self.chain, ) def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() - yield "count", self.count + yield "chain", self.chain @rich.repr.auto diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 1ff465487f..1d057a5959 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -16,7 +16,7 @@ from textual._wait import wait_for_idle from textual.app import App, ReturnType from textual.drivers.headless_driver import HeadlessDriver -from textual.events import MouseDown, MouseEvent, MouseMove, MouseUp, Resize +from textual.events import Click, MouseDown, MouseEvent, MouseMove, MouseUp, Resize from textual.geometry import Offset, Size from textual.widget import Widget @@ -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. @@ -228,13 +232,14 @@ async def click( """ try: done = await self._post_mouse_events( - [MouseDown, MouseUp], + [MouseDown, MouseUp, Click], widget=widget, offset=offset, button=1, shift=shift, meta=meta, control=control, + times=times, ) return done except OutOfBounds as error: @@ -283,6 +288,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. @@ -303,7 +309,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. @@ -351,7 +357,7 @@ async def _post_mouse_events( # that's useful to other things (tooltip handling, for example), # we patch the offset in there as well. app.mouse_position = offset - app.post_message(event) + 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 b3f07253cf..fc9d8d3b75 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -245,7 +245,7 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count - click_count += event.count + 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. @@ -270,7 +270,7 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count - click_count += event.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. @@ -299,7 +299,7 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count - click_count = event.count + click_count = event.chain async with MyApp().run_test() as pilot: await pilot.click("#one") # Single click @@ -323,7 +323,7 @@ def compose(self) -> ComposeResult: def on_click(self, event: events.Click) -> None: nonlocal click_count - click_count += event.count + click_count += event.chain async with MyApp().run_test() as pilot: for i in range(1, 4): From 4dd6376dac3319f4211bf9bc5ce106e6fd47b232 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:15:58 +0000 Subject: [PATCH 13/27] Test fixes --- src/textual/events.py | 3 +++ src/textual/message_pump.py | 2 +- src/textual/pilot.py | 36 ++++++++++++++++++++---------------- tests/test_app.py | 30 +++++++++++++++++++----------- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index f1b6a37084..3eb3f4fa01 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -556,6 +556,9 @@ 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__( 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 1d057a5959..c2c45382bd 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -343,22 +343,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 - 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 fc9d8d3b75..71e3735131 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -6,6 +6,7 @@ from textual import events from textual.app import App, ComposeResult from textual.command import SimpleCommand +from textual.pilot import Pilot, _get_mouse_message_arguments from textual.widgets import Button, Input, Label, Static @@ -230,6 +231,15 @@ async def test_search_with_empty_list(): await app.search_commands([]) +async def raw_click(pilot: Pilot, selector: str, times: int = 1): + 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 @@ -245,13 +255,13 @@ def compose(self) -> ComposeResult: 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 pilot.click("#one") - await pilot.pause() + await raw_click(pilot, "#one") assert click_count == final_count @@ -274,11 +284,11 @@ def on_click(self, event: events.Click) -> None: async with MyApp().run_test() as pilot: # Clicking on different offsets in quick-succession doesn't qualify as a double or triple click. - await pilot.click("#one") + await raw_click(pilot, "#one") assert click_count == 1 - await pilot.click("#two") + await raw_click(pilot, "#two") assert click_count == 2 - await pilot.click("#three") + await raw_click(pilot, "#three") assert click_count == 3 @@ -302,11 +312,9 @@ def on_click(self, event: events.Click) -> None: click_count = event.chain async with MyApp().run_test() as pilot: - await pilot.click("#one") # Single click - assert click_count == 1 - await pilot.click("#one") # Double click + await raw_click(pilot, "#one", times=2) # Double click assert click_count == 2 - await pilot.click("#two") # Single click (because different offset) + await raw_click(pilot, "#two") # Single click (because different widget) assert click_count == 1 @@ -319,7 +327,7 @@ class MyApp(App[None]): CLICK_CHAIN_TIME_THRESHOLD = 0.0 def compose(self) -> ComposeResult: - yield Label("Click me!") + yield Label("Click me!", id="one") def on_click(self, event: events.Click) -> None: nonlocal click_count @@ -328,5 +336,5 @@ def on_click(self, event: events.Click) -> None: 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 pilot.click(Label) + await raw_click(pilot, "#one") assert click_count == i From ebebba935c64c9f5d1553a47a412241171c3a031 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:17:11 +0000 Subject: [PATCH 14/27] Enhance raw_click function documentation in test_app.py to clarify its purpose and behavior --- tests/test_app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index 71e3735131..a986f2284a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -232,6 +232,8 @@ async def test_search_with_empty_list(): 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): From 2c130b7d79166dd2992dad485d7123042f8a3a97 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:18:49 +0000 Subject: [PATCH 15/27] Refactor imports in events.py: remove Self from typing and import from typing_extensions --- src/textual/events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/events.py b/src/textual/events.py index 3eb3f4fa01..b977c1edf4 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -15,7 +15,8 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Self, Type, TypeVar +from typing import TYPE_CHECKING, Type, TypeVar +from typing_extensions import Self import rich.repr from rich.style import Style From 214b3ea52b1984b65f9aaf4e089b1f5d05bdfcde Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:35:22 +0000 Subject: [PATCH 16/27] Remove unnecessary pause in test_datatable_click_cell_cursor --- tests/test_data_table.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_data_table.py b/tests/test_data_table.py index cb6bab71d4..d78d49e2fa 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -777,7 +777,6 @@ async def test_datatable_click_cell_cursor(): column_key = table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") - await pilot.pause() # add_row happens on_idle await pilot.click(offset=Offset(1, 2)) # There's two CellHighlighted events since a cell is highlighted on initial load, # then when we click, another cell is highlighted (and selected). From 1cdbb139310fec0e2312705a2533cd92ff468202 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:55:38 +0000 Subject: [PATCH 17/27] Remove debug print statements and unnecessary pause in App class; add on_mount method to LazyApp for better lifecycle management in tests --- src/textual/app.py | 6 ------ tests/test_lazy.py | 7 +++++++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 879e98d653..ed8c140dd3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1955,7 +1955,6 @@ async def run_app(app: App[ReturnType]) -> None: try: pilot = Pilot(app) await pilot._wait_for_screen() - await pilot.pause() yield pilot finally: # Shutdown the app cleanly @@ -3709,13 +3708,11 @@ async def on_event(self, event: events.Event) -> None: if isinstance(event, events.MouseEvent): # Record current mouse position on App self.mouse_position = Offset(event.x, event.y) - print("mouse event (on_event)", event) if isinstance(event, events.MouseDown): try: self._mouse_down_widget, _ = self.get_widget_at( event.x, event.y ) - print("mouse down widget (on_event)", self._mouse_down_widget) except NoWidget: # Shouldn't occur, since at the very least this will find the Screen self._mouse_down_widget = None @@ -3728,12 +3725,10 @@ async def on_event(self, event: events.Event) -> None: isinstance(event, events.MouseUp) and self._mouse_down_widget is not None ): - print("mouse up (on_event)", event) try: screen_offset = event.screen_offset mouse_down_widget = self._mouse_down_widget mouse_up_widget, _ = self.get_widget_at(*screen_offset) - print("mouse up widget (on_event)", mouse_up_widget) if mouse_up_widget is mouse_down_widget: same_offset = ( self._click_chain_last_offset is not None @@ -3753,7 +3748,6 @@ async def on_event(self, event: events.Event) -> None: click_event = events.Click.from_event( mouse_down_widget, event, chain=self._chained_clicks ) - print("generated click event (on_event)", click_event) self._click_chain_last_time = event.time self._click_chain_last_offset = screen_offset diff --git a/tests/test_lazy.py b/tests/test_lazy.py index e862d0ce1f..9fe5fc00d3 100644 --- a/tests/test_lazy.py +++ b/tests/test_lazy.py @@ -12,13 +12,20 @@ def compose(self) -> ComposeResult: with Horizontal(): yield Label(id="bar") + def on_mount(self) -> None: + print("on_mount") + async def test_lazy(): app = LazyApp() async with app.run_test() as pilot: # No #foo on initial mount + print("before query #foo") assert len(app.query("#foo")) == 0 + print("after query #foo") + print("before query #bar") assert len(app.query("#bar")) == 1 + print("after query #bar") await pilot.pause() await pilot.pause() # #bar mounted after refresh From 274cd73fbb1437a63323d3db43421f752270c5ff Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 16:57:25 +0000 Subject: [PATCH 18/27] Remove debugging prints --- tests/test_lazy.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_lazy.py b/tests/test_lazy.py index 9fe5fc00d3..9a23adacbc 100644 --- a/tests/test_lazy.py +++ b/tests/test_lazy.py @@ -20,12 +20,8 @@ async def test_lazy(): app = LazyApp() async with app.run_test() as pilot: # No #foo on initial mount - print("before query #foo") assert len(app.query("#foo")) == 0 - print("after query #foo") - print("before query #bar") assert len(app.query("#bar")) == 1 - print("after query #bar") await pilot.pause() await pilot.pause() # #bar mounted after refresh From f7f7b1d17ad8e1c6920f575370fef30638910a17 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 17:05:25 +0000 Subject: [PATCH 19/27] Add support for double and triple clicks in testing guide --- docs/guide/testing.md | 9 +++++++++ 1 file changed, 9 insertions(+) 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. From 10bcd343b486b0623cd5dc2c72628a0ad72dee7d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 17:30:48 +0000 Subject: [PATCH 20/27] Add a note about double and triple clicks to the docs --- docs/events/click.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/events/click.md b/docs/events/click.md index cc5b83e73e..e87da177e2 100644 --- a/docs/events/click.md +++ b/docs/events/click.md @@ -2,7 +2,11 @@ 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. + +See [MouseEvent][textual.events.MouseEvent] for the list of properties and methods on the parent class. ## See also From 4e5923a8a7e9450bbff3be829142787be386c35a Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 17:53:50 +0000 Subject: [PATCH 21/27] Turn off formatter for a section of code, and make it 3.8 compatible --- src/textual/app.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index ed8c140dd3..a259a63585 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4355,12 +4355,12 @@ def suspend(self) -> Iterator[None]: # app, and we don't want to have the driver auto-restart # application mode when the application comes back to the # foreground, in this context. - with ( - self._driver.no_automatic_restart(), - redirect_stdout(sys.__stdout__), - redirect_stderr(sys.__stderr__), - ): + # fmt: off + with self._driver.no_automatic_restart(), redirect_stdout( + sys.__stdout__ + ), redirect_stderr(sys.__stderr__): yield + # fmt: on # We're done with the dev's code so resume application mode. self._driver.resume_application_mode() # ...and publish a resume signal. From 7ed775e6a9487451c5c4e4cdfc6f3d67b232859d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 18:09:56 +0000 Subject: [PATCH 22/27] Update changelog [no ci] --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a6cb0c2b..307fe311ad 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 From c8b2ecd4f1f96100faaa380ec9b65aa1274d7e29 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 18:14:18 +0000 Subject: [PATCH 23/27] Simplify by removing an unecessary variable in `Pilot.click` --- src/textual/pilot.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index c2c45382bd..424a1562ef 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -231,7 +231,7 @@ async def click( widget, False otherwise. """ try: - done = await self._post_mouse_events( + return await self._post_mouse_events( [MouseDown, MouseUp, Click], widget=widget, offset=offset, @@ -241,7 +241,6 @@ async def click( control=control, times=times, ) - return done except OutOfBounds as error: raise error from None From bf0c7249e23496eaf7b90a00116c4e37f51c2018 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 18:15:06 +0000 Subject: [PATCH 24/27] Remove debugging code --- tests/test_lazy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_lazy.py b/tests/test_lazy.py index 9a23adacbc..e862d0ce1f 100644 --- a/tests/test_lazy.py +++ b/tests/test_lazy.py @@ -12,9 +12,6 @@ def compose(self) -> ComposeResult: with Horizontal(): yield Label(id="bar") - def on_mount(self) -> None: - print("on_mount") - async def test_lazy(): app = LazyApp() From fd6faf6278a1d27bcf14bb79b36a12f8c79248d4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 10 Dec 2024 18:26:10 +0000 Subject: [PATCH 25/27] Add target-version py38 to ruff config in pyproject.toml, and remove formatter comments --- pyproject.toml | 3 +++ src/textual/app.py | 2 -- 2 files changed, 3 insertions(+), 2 deletions(-) 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 a259a63585..6c0c10e8d2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -4355,12 +4355,10 @@ def suspend(self) -> Iterator[None]: # app, and we don't want to have the driver auto-restart # application mode when the application comes back to the # foreground, in this context. - # fmt: off with self._driver.no_automatic_restart(), redirect_stdout( sys.__stdout__ ), redirect_stderr(sys.__stderr__): yield - # fmt: on # We're done with the dev's code so resume application mode. self._driver.resume_application_mode() # ...and publish a resume signal. From 9bb949f8e15c3ba96e9948741c0ecc3b9ed3eba4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 11 Dec 2024 14:20:12 +0000 Subject: [PATCH 26/27] Document timing of click chains --- docs/events/click.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/events/click.md b/docs/events/click.md index e87da177e2..e93dd8d33b 100644 --- a/docs/events/click.md +++ b/docs/events/click.md @@ -4,7 +4,11 @@ ## 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. +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. From a6bf352e0dd888d71c659f740ae6afe697b56d65 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 11 Dec 2024 16:00:01 +0000 Subject: [PATCH 27/27] Pilot.double_click and Pilot.triple_click --- src/textual/pilot.py | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 424a1562ef..473341b7e9 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -244,6 +244,96 @@ async def click( 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,