diff --git a/CHANGELOG.md b/CHANGELOG.md index c37d8f9f5f..074af22357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `is_blank` - Constant `Select.BLANK` to flag an empty selection https://github.com/Textualize/textual/pull/3614 - Added `restrict`, `type`, `max_length`, and `valid_empty` to Input https://github.com/Textualize/textual/pull/3657 +- Added `Pilot.mouse_down` to simulate `MouseDown` events https://github.com/Textualize/textual/pull/3495 +- Added `Pilot.mouse_up` to simulate `MouseUp` events https://github.com/Textualize/textual/pull/3495 ### Changed @@ -39,6 +41,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Setting `Select.value` to `None` no longer clears the selection (See `Select.BLANK` and `Select.clear`) https://github.com/Textualize/textual/pull/3614 - Breaking change: `Button` no longer inherits from `Static`, now it inherits directly from `Widget` https://github.com/Textualize/textual/issues/3603 - Rich markup in markdown headings is now escaped when building the TOC https://github.com/Textualize/textual/issues/3689 +- Mechanics behind mouse clicks. See [this](https://github.com/Textualize/textual/pull/3495#issue-1934915047) for more details. https://github.com/Textualize/textual/pull/3495 ## [0.41.0] - 2023-10-31 diff --git a/src/textual/driver.py b/src/textual/driver.py index 2676663902..5e472f5bc6 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -4,11 +4,12 @@ from abc import ABC, abstractmethod from typing import TYPE_CHECKING -from . import _time, events +from . import events from .events import MouseUp if TYPE_CHECKING: from .app import App + from .widget import Widget class Driver(ABC): @@ -32,7 +33,7 @@ def __init__( self._debug = debug self._size = size self._loop = asyncio.get_running_loop() - self._mouse_down_time = _time.get_time() + self._mouse_down_widget: Widget | None = None self._down_buttons: list[int] = [] self._last_move_event: events.MouseMove | None = None @@ -59,7 +60,7 @@ def process_event(self, event: events.Event) -> None: """ event._set_sender(self._app) if isinstance(event, events.MouseDown): - self._mouse_down_time = event.time + self._mouse_down_widget = self._app.get_widget_at(event.x, event.y)[0] if event.button: self._down_buttons.append(event.button) elif isinstance(event, events.MouseUp): @@ -100,7 +101,7 @@ def process_event(self, event: events.Event) -> None: if ( isinstance(event, events.MouseUp) - and event.time - self._mouse_down_time <= 0.5 + and self._app.get_widget_at(event.x, event.y)[0] is self._mouse_down_widget ): click_event = events.Click.from_event(event) self.send_event(click_event) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 9069f61a31..c3bcbe2978 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -15,7 +15,7 @@ from ._wait import wait_for_idle from .app import App, ReturnType -from .events import Click, MouseDown, MouseMove, MouseUp +from .events import Click, MouseDown, MouseEvent, MouseMove, MouseUp from .geometry import Offset from .widget import Widget @@ -81,6 +81,96 @@ async def press(self, *keys: str) -> None: await self._app._press_keys(keys) await self._wait_for_screen() + async def mouse_down( + self, + selector: type[Widget] | str | None = None, + offset: tuple[int, int] = (0, 0), + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a [`MouseDown`][textual.events.MouseDown] event at a specified position. + + The final position for the event is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the event offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the event may not land on the widget you specified. + offset: The offset for the event. The offset is relative to the selector + provided or to the screen, if no selector is provided. + shift: Simulate the event with the shift key held down. + meta: Simulate the event with the meta key held down. + control: Simulate the event with the control key held down. + + Raises: + OutOfBounds: If the position for the event is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the event landed on the selected + widget, False otherwise. + """ + try: + return await self._post_mouse_events( + [MouseDown], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, + ) + except OutOfBounds as error: + raise error from None + + async def mouse_up( + self, + selector: type[Widget] | str | None = None, + offset: tuple[int, int] = (0, 0), + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a [`MouseUp`][textual.events.MouseUp] event at a specified position. + + The final position for the event is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the event offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the event may not land on the widget you specified. + offset: The offset for the event. The offset is relative to the selector + provided or to the screen, if no selector is provided. + shift: Simulate the event with the shift key held down. + meta: Simulate the event with the meta key held down. + control: Simulate the event with the control key held down. + + Raises: + OutOfBounds: If the position for the event is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the event landed on the selected + widget, False otherwise. + """ + try: + return await self._post_mouse_events( + [MouseUp], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, + ) + except OutOfBounds as error: + raise error from None + async def click( self, selector: type[Widget] | str | None = None, @@ -94,6 +184,13 @@ async def click( 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. + Example: + The code below runs an app and clicks its only button right in the middle: + ```py + async with SingleButtonApp().run_test() as pilot: + await pilot.click(Button, offset=(8, 1)) + ``` + Args: selector: A selector to specify a widget that should be used as the reference for the click offset. If this is not specified, the offset is interpreted @@ -113,35 +210,18 @@ async def click( True if no selector was specified or if the click landed on the selected widget, False otherwise. """ - app = self.app - screen = app.screen - if selector is not None: - target_widget = app.query_one(selector) - else: - target_widget = screen - - message_arguments = _get_mouse_message_arguments( - target_widget, offset, button=1, shift=shift, meta=meta, control=control - ) - - click_offset = Offset(message_arguments["x"], message_arguments["y"]) - if click_offset not in screen.region: - raise OutOfBounds( - "Target offset is outside of currently-visible screen region." + try: + return await self._post_mouse_events( + [MouseDown, MouseUp, Click], + selector=selector, + offset=offset, + button=1, + shift=shift, + meta=meta, + control=control, ) - - app.post_message(MouseDown(**message_arguments)) - await self.pause() - app.post_message(MouseUp(**message_arguments)) - await self.pause() - - # Figure out the widget under the click before we click because the app - # might react to the click and move things. - widget_at, _ = app.get_widget_at(*click_offset) - app.post_message(Click(**message_arguments)) - await self.pause() - - return selector is None or widget_at is target_widget + except OutOfBounds as error: + raise error from None async def hover( self, @@ -169,6 +249,53 @@ async def hover( True if no selector was specified or if the hover landed on the selected widget, False otherwise. """ + # This is usually what the user wants because it gives time for the mouse to + # "settle" before moving it to the new hover position. + await self.pause() + try: + return await self._post_mouse_events( + [MouseMove], selector, offset, button=0 + ) + except OutOfBounds as error: + raise error from None + + async def _post_mouse_events( + self, + events: list[type[MouseEvent]], + selector: type[Widget] | str | None | None = None, + offset: tuple[int, int] = (0, 0), + button: int = 0, + shift: bool = False, + meta: bool = False, + control: bool = False, + ) -> bool: + """Simulate a series of mouse events to be fired at a given position. + + The final position for the events is computed based on the selector provided and + the offset specified and it must be within the visible area of the screen. + + This function abstracts away the commonalities of the other mouse event-related + functions that the pilot exposes. + + Args: + selector: A selector to specify a widget that should be used as the reference + for the events offset. If this is not specified, the offset is interpreted + relative to the screen. You can use this parameter to try to target a + specific widget. However, if the widget is currently hidden or obscured by + another widget, the events may not land on the widget you specified. + offset: The offset for the events. The offset is relative to the selector + provided or to the screen, if no selector is provided. + 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. + + Raises: + OutOfBounds: If the position for the events is outside of the (visible) screen. + + Returns: + True if no selector was specified or if the *final* event landed on the + selected widget, False otherwise. + """ app = self.app screen = app.screen if selector is not None: @@ -177,20 +304,33 @@ async def hover( target_widget = screen message_arguments = _get_mouse_message_arguments( - target_widget, offset, button=0 + target_widget, + offset, + button=button, + shift=shift, + meta=meta, + control=control, ) - hover_offset = Offset(message_arguments["x"], message_arguments["y"]) - if hover_offset not in screen.region: + offset = Offset(message_arguments["x"], message_arguments["y"]) + if offset not in screen.region: raise OutOfBounds( "Target offset is outside of currently-visible screen region." ) - await self.pause() - app.post_message(MouseMove(**message_arguments)) - await self.pause() + 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 preceeding events are just setup. + # E.g., the click event is preceeded 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) + app.post_message(event) + await self.pause() - widget_at, _ = app.get_widget_at(*hover_offset) return selector is None or widget_at is target_widget async def _wait_for_screen(self, timeout: float = 30.0) -> bool: diff --git a/src/textual/screen.py b/src/textual/screen.py index 807edcc110..070e0016b2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -963,12 +963,7 @@ def _forward_event(self, event: events.Event) -> None: self.set_focus(None) else: if isinstance(event, events.MouseDown) and widget.focusable: - self.set_focus(widget) - elif isinstance(event, events.MouseUp) and widget.focusable: - if self.focused is not widget: - self.set_focus(widget) - event.stop() - return + self.set_focus(widget, scroll_visible=False) event.style = self.get_style_at(event.screen_x, event.screen_y) if widget is self: event._set_forwarded() diff --git a/tests/test_driver.py b/tests/test_driver.py new file mode 100644 index 0000000000..e3b5feba81 --- /dev/null +++ b/tests/test_driver.py @@ -0,0 +1,125 @@ +from textual import on +from textual.app import App +from textual.events import Click, MouseDown, MouseUp +from textual.widgets import Button + + +async def test_driver_mouse_down_up_click(): + """Mouse down and up should issue a click.""" + + class MyApp(App): + messages = [] + + @on(Click) + @on(MouseDown) + @on(MouseUp) + def handle(self, event): + self.messages.append(event) + + app = MyApp() + async with app.run_test() as pilot: + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False)) + await pilot.pause() + assert len(app.messages) == 3 + assert isinstance(app.messages[0], MouseDown) + assert isinstance(app.messages[1], MouseUp) + assert isinstance(app.messages[2], Click) + + +async def test_driver_mouse_down_up_click_widget(): + """Mouse down and up should issue a click when they're on a widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + async with app.run_test() as pilot: + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False)) + await pilot.pause() + assert len(app.messages) == 1 + + +async def test_driver_mouse_down_drag_inside_widget_up_click(): + """Mouse down and up should issue a click, even if the mouse moves but remains + inside the same widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + button_width = 16 + button_height = 3 + async with app.run_test() as pilot: + # Sanity check + width, height = app.query_one(Button).region.size + assert (width, height) == (button_width, button_height) + + # Mouse down on the button, then move the mouse inside the button, then mouse up. + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event( + MouseUp( + button_width - 1, + button_height - 1, + button_width - 1, + button_height - 1, + 1, + False, + False, + False, + ) + ) + await pilot.pause() + # A click should still be triggered. + assert len(app.messages) == 1 + + +async def test_driver_mouse_down_drag_outside_widget_up_click(): + """Mouse down and up don't issue a click if the mouse moves outside of the initial widget.""" + + class MyApp(App): + messages = [] + + def compose(self): + yield Button() + + def on_button_pressed(self, event): + self.messages.append(event) + + app = MyApp() + button_width = 16 + button_height = 3 + async with app.run_test() as pilot: + # Sanity check + width, height = app.query_one(Button).region.size + assert (width, height) == (button_width, button_height) + + # Mouse down on the button, then move the mouse outside the button, then mouse up. + app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False)) + app._driver.process_event( + MouseUp( + button_width + 1, + button_height + 1, + button_width + 1, + button_height + 1, + 1, + False, + False, + False, + ) + ) + await pilot.pause() + assert len(app.messages) == 0 diff --git a/tests/test_focus.py b/tests/test_focus.py index 486356bfe0..90f3db220c 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -311,6 +311,38 @@ def compose(self): ] +async def test_mouse_down_gives_focus(): + class MyApp(App): + AUTO_FOCUS = None + + def compose(self): + yield Button() + + app = MyApp() + async with app.run_test() as pilot: + # Sanity check. + assert app.focused is None + + await pilot.mouse_down(Button) + assert isinstance(app.focused, Button) + + +async def test_mouse_up_does_not_give_focus(): + class MyApp(App): + AUTO_FOCUS = None + + def compose(self): + yield Button() + + app = MyApp() + async with app.run_test() as pilot: + # Sanity check. + assert app.focused is None + + await pilot.mouse_up(Button) + assert app.focused is None + + async def test_focus_pseudo_class(): """Test focus and blue pseudo classes""" diff --git a/tests/test_pilot.py b/tests/test_pilot.py index 43789bb203..0650c258a9 100644 --- a/tests/test_pilot.py +++ b/tests/test_pilot.py @@ -6,6 +6,7 @@ from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Middle +from textual.events import MouseDown, MouseUp from textual.pilot import OutOfBounds from textual.widgets import Button, Label @@ -142,6 +143,42 @@ async def test_pilot_hover_screen(): ("hover", (5, 5), (-1, -1)), # Top-left of screen. ("hover", (5, 5), (3, -1)), # Above screen. ("hover", (5, 5), (7, -1)), # Top-right of screen. + # + ("mouse_down", (80, 24), (100, 12)), # Right of screen. + ("mouse_down", (80, 24), (100, 36)), # Bottom-right of screen. + ("mouse_down", (80, 24), (50, 36)), # Under screen. + ("mouse_down", (80, 24), (-10, 36)), # Bottom-left of screen. + ("mouse_down", (80, 24), (-10, 12)), # Left of screen. + ("mouse_down", (80, 24), (-10, -2)), # Top-left of screen. + ("mouse_down", (80, 24), (50, -2)), # Above screen. + ("mouse_down", (80, 24), (100, -2)), # Top-right of screen. + # + ("mouse_down", (5, 5), (7, 3)), # Right of screen. + ("mouse_down", (5, 5), (7, 7)), # Bottom-right of screen. + ("mouse_down", (5, 5), (3, 7)), # Under screen. + ("mouse_down", (5, 5), (-1, 7)), # Bottom-left of screen. + ("mouse_down", (5, 5), (-1, 3)), # Left of screen. + ("mouse_down", (5, 5), (-1, -1)), # Top-left of screen. + ("mouse_down", (5, 5), (3, -1)), # Above screen. + ("mouse_down", (5, 5), (7, -1)), # Top-right of screen. + # + ("mouse_up", (80, 24), (100, 12)), # Right of screen. + ("mouse_up", (80, 24), (100, 36)), # Bottom-right of screen. + ("mouse_up", (80, 24), (50, 36)), # Under screen. + ("mouse_up", (80, 24), (-10, 36)), # Bottom-left of screen. + ("mouse_up", (80, 24), (-10, 12)), # Left of screen. + ("mouse_up", (80, 24), (-10, -2)), # Top-left of screen. + ("mouse_up", (80, 24), (50, -2)), # Above screen. + ("mouse_up", (80, 24), (100, -2)), # Top-right of screen. + # + ("mouse_up", (5, 5), (7, 3)), # Right of screen. + ("mouse_up", (5, 5), (7, 7)), # Bottom-right of screen. + ("mouse_up", (5, 5), (3, 7)), # Under screen. + ("mouse_up", (5, 5), (-1, 7)), # Bottom-left of screen. + ("mouse_up", (5, 5), (-1, 3)), # Left of screen. + ("mouse_up", (5, 5), (-1, -1)), # Top-left of screen. + ("mouse_up", (5, 5), (3, -1)), # Above screen. + ("mouse_up", (5, 5), (7, -1)), # Top-right of screen. ], ) async def test_pilot_target_outside_screen_errors(method, screen_size, offset): @@ -175,6 +212,26 @@ async def test_pilot_target_outside_screen_errors(method, screen_size, offset): ("hover", (40, 23)), # Bottom-left corner. ("hover", (0, 12)), # Left edge. ("hover", (40, 12)), # Right in the middle. + # + ("mouse_down", (0, 0)), # Top-left corner. + ("mouse_down", (40, 0)), # Top edge. + ("mouse_down", (79, 0)), # Top-right corner. + ("mouse_down", (79, 12)), # Right edge. + ("mouse_down", (79, 23)), # Bottom-right corner. + ("mouse_down", (40, 23)), # Bottom edge. + ("mouse_down", (40, 23)), # Bottom-left corner. + ("mouse_down", (0, 12)), # Left edge. + ("mouse_down", (40, 12)), # Right in the middle. + # + ("mouse_up", (0, 0)), # Top-left corner. + ("mouse_up", (40, 0)), # Top edge. + ("mouse_up", (79, 0)), # Top-right corner. + ("mouse_up", (79, 12)), # Right edge. + ("mouse_up", (79, 23)), # Bottom-right corner. + ("mouse_up", (40, 23)), # Bottom edge. + ("mouse_up", (40, 23)), # Bottom-left corner. + ("mouse_up", (0, 12)), # Left edge. + ("mouse_up", (40, 12)), # Right in the middle. ], ) async def test_pilot_target_inside_screen_is_fine_with_correct_coordinate_system( @@ -203,6 +260,14 @@ async def test_pilot_target_inside_screen_is_fine_with_correct_coordinate_system ("hover", "#label0"), ("hover", "#label90"), ("hover", Button), + # + ("mouse_down", "#label0"), + ("mouse_down", "#label90"), + ("mouse_down", Button), + # + ("mouse_up", "#label0"), + ("mouse_up", "#label90"), + ("mouse_up", Button), ], ) async def test_pilot_target_on_widget_that_is_not_visible_errors(method, target): @@ -217,7 +282,7 @@ async def test_pilot_target_on_widget_that_is_not_visible_errors(method, target) await pilot_method(target) -@pytest.mark.parametrize("method", ["click", "hover"]) +@pytest.mark.parametrize("method", ["click", "hover", "mouse_down", "mouse_up"]) async def test_pilot_target_widget_under_another_widget(method): """The targeting method should return False when the targeted widget is covered.""" @@ -243,7 +308,7 @@ def on_mount(self): assert (await pilot_method(Button)) is False -@pytest.mark.parametrize("method", ["click", "hover"]) +@pytest.mark.parametrize("method", ["click", "hover", "mouse_down", "mouse_up"]) async def test_pilot_target_visible_widget(method): """The targeting method should return True when the targeted widget is hit.""" @@ -270,6 +335,16 @@ def compose(self): ("hover", (2, 0)), ("hover", (10, 23)), ("hover", (70, 0)), + # + ("mouse_down", (0, 0)), + ("mouse_down", (2, 0)), + ("mouse_down", (10, 23)), + ("mouse_down", (70, 0)), + # + ("mouse_up", (0, 0)), + ("mouse_up", (2, 0)), + ("mouse_up", (10, 23)), + ("mouse_up", (70, 0)), ], ) async def test_pilot_target_screen_always_true(method, offset): diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index afb3bfaef0..f8d0c02942 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -186,10 +186,12 @@ def test_double_escape(parser): ("\x1b[<0;50;25M", MouseDown, False, False), ("\x1b[<4;50;25M", MouseDown, True, False), ("\x1b[<8;50;25M", MouseDown, False, True), + ("\x1b[<12;50;25M", MouseDown, True, True), # Mouse up, with and without modifiers ("\x1b[<0;50;25m", MouseUp, False, False), ("\x1b[<4;50;25m", MouseUp, True, False), ("\x1b[<8;50;25m", MouseUp, False, True), + ("\x1b[<12;50;25m", MouseUp, True, True), ], ) def test_mouse_click(parser, sequence, event_type, shift, meta):