From b366d1d49ce304f24383c13dcf27b14ab924207e Mon Sep 17 00:00:00 2001 From: darrenburns Date: Tue, 7 Mar 2023 15:19:23 +0000 Subject: [PATCH] Pilot - add hover and click methods (#1966) * Delete redundant test file * Sketch out pilot API improvements - signatures/docstrings * Pilot click and hover * Updating test to use new pilot hover method for DataTable * hover and click methods for Pilot * Update changelog * Add docstring --- CHANGELOG.md | 3 +- src/textual/pilot.py | 81 ++++++++++++++++++++++++++- src/textual/widgets/_data_table.py | 3 +- tests/test_auto_pilot.py | 20 ------- tests/test_data_table.py | 89 ++++++------------------------ 5 files changed, 102 insertions(+), 94 deletions(-) delete mode 100644 tests/test_auto_pilot.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 69653cba4..e9379b4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `list_view` attribute to `ListView` events https://github.com/Textualize/textual/pull/1940 - Added `radio_set` attribute to `RadioSet` events https://github.com/Textualize/textual/pull/1940 - Added `switch` attribute to `Switch` events https://github.com/Textualize/textual/pull/1940 +- Added `hover` and `click` methods to `Pilot` https://github.com/Textualize/textual/pull/1966 - Breaking change: Added `toggle_button` attribute to RadioButton and Checkbox events, replaces `input` https://github.com/Textualize/textual/pull/1940 - A percentage alpha can now be applied to a border https://github.com/Textualize/textual/issues/1863 - Added `Color.multiply_alpha`. ### Fixed -- Fixed bug that prevented app pilot to press some keys https://github.com/Textualize/textual/issues/1815 +- Fixed bug that prevented pilot from pressing some keys https://github.com/Textualize/textual/issues/1815 - DataTable race condition that caused crash https://github.com/Textualize/textual/pull/1962 ## [0.13.0] - 2023-03-02 diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 02362e5e1..c24d3edc2 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -1,12 +1,37 @@ from __future__ import annotations import asyncio -from typing import Generic +from typing import Any, Generic import rich.repr from ._wait import wait_for_idle from .app import App, ReturnType +from .css.query import QueryType +from .events import Click, MouseDown, MouseMove, MouseUp +from .geometry import Offset +from .widget import Widget + + +def _get_mouse_message_arguments( + target: Widget, offset: Offset = Offset(), button: int = 0 +) -> dict[str, Any]: + """Get the arguments to pass into mouse messages for the click and hover methods.""" + x, y = offset + click_x, click_y, _, _ = target.region.translate(offset) + message_arguments = { + "x": x, + "y": y, + "delta_x": 0, + "delta_y": 0, + "button": button, + "shift": False, + "meta": False, + "ctrl": False, + "screen_x": click_x, + "screen_y": click_y, + } + return message_arguments @rich.repr.auto(angular=True) @@ -34,6 +59,60 @@ class Pilot(Generic[ReturnType]): if keys: await self._app._press_keys(keys) + async def click( + self, selector: QueryType | None = None, offset: Offset = Offset() + ) -> None: + """Simulate clicking with the mouse. + + Args: + selector: The widget that should be clicked. If None, then the click + will occur relative to the screen. Note that this simply causes + a click to occur at the location of the widget. If the widget is + currently hidden or obscured by another widget, then the click may + not land on it. + offset: The offset to click within the selected widget. + """ + app = self.app + screen = app.screen + if selector is not None: + target_widget = screen.query_one(selector) + else: + target_widget = screen + + message_arguments = _get_mouse_message_arguments( + target_widget, offset, button=1 + ) + app.post_message(MouseDown(**message_arguments)) + app.post_message(MouseUp(**message_arguments)) + app.post_message(Click(**message_arguments)) + await self.pause() + + async def hover( + self, selector: QueryType | None = None, offset: Offset = Offset() + ) -> None: + """Simulate hovering with the mouse cursor. + + Args: + selector: The widget that should be hovered. If None, then the click + will occur relative to the screen. Note that this simply causes + a hover to occur at the location of the widget. If the widget is + currently hidden or obscured by another widget, then the hover may + not land on it. + offset: The offset to hover over within the selected widget. + """ + app = self.app + screen = app.screen + if selector is not None: + target_widget = screen.query_one(selector) + else: + target_widget = screen + + message_arguments = _get_mouse_message_arguments( + target_widget, offset, button=0 + ) + app.post_message(MouseMove(**message_arguments)) + await self.pause() + async def pause(self, delay: float | None = None) -> None: """Insert a pause. diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 957aa1aaa..15a51052c 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -1813,6 +1813,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): and column metadata from the segments present in the cells.""" self._set_hover_cursor(True) meta = event.style.meta + if meta and self.show_cursor and self.cursor_type != "none": try: self.hover_coordinate = Coordinate(meta["row"], meta["column"]) @@ -1899,7 +1900,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): def on_click(self, event: events.Click) -> None: self._set_hover_cursor(True) - meta = self.get_style_at(event.x, event.y).meta + meta = event.style.meta if not meta: return diff --git a/tests/test_auto_pilot.py b/tests/test_auto_pilot.py deleted file mode 100644 index a6d4c2d1a..000000000 --- a/tests/test_auto_pilot.py +++ /dev/null @@ -1,20 +0,0 @@ -from textual import events -from textual.app import App -from textual.pilot import Pilot - - -def test_auto_pilot() -> None: - keys_pressed: list[str] = [] - - class TestApp(App): - def on_key(self, event: events.Key) -> None: - keys_pressed.append(event.key) - - async def auto_pilot(pilot: Pilot) -> None: - await pilot.press("tab", *"foo") - await pilot.exit("bar") - - app = TestApp() - result = app.run(headless=True, auto_pilot=auto_pilot) - assert result == "bar" - assert keys_pressed == ["tab", "f", "o", "o"] diff --git a/tests/test_data_table.py b/tests/test_data_table.py index e9eb1c9fe..9c578ac3d 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -9,6 +9,7 @@ from textual.actions import SkipAction from textual.app import App from textual.coordinate import Coordinate from textual.events import Click, MouseMove +from textual.geometry import Offset from textual.message import Message from textual.widgets import DataTable from textual.widgets.data_table import ( @@ -555,32 +556,17 @@ async def test_coordinate_to_cell_key_invalid_coordinate(): table.coordinate_to_cell_key(Coordinate(9999, 9999)) -def make_click_event(): - return Click( - x=1, - y=2, - delta_x=0, - delta_y=0, - button=0, - shift=False, - meta=False, - ctrl=False, - ) - - -async def test_datatable_on_click_cell_cursor(): +async def test_datatable_click_cell_cursor(): """When the cell cursor is used, and we click, we emit a CellHighlighted *and* a CellSelected message for the cell that was clicked. Regression test for https://github.com/Textualize/textual/issues/1723""" app = DataTableApp() async with app.run_test() as pilot: table = app.query_one(DataTable) - click = make_click_event() column_key = table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") - table.on_click(event=click) - await pilot.pause() + 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). assert app.message_names == [ @@ -599,19 +585,17 @@ async def test_datatable_on_click_cell_cursor(): assert cell_selected_event.coordinate == Coordinate(1, 0) -async def test_on_click_row_cursor(): +async def test_click_row_cursor(): """When the row cursor is used, and we click, we emit a RowHighlighted *and* a RowSelected message for the row that was clicked.""" app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) table.cursor_type = "row" - click = make_click_event() table.add_column("ABC") table.add_row("123") row_key = table.add_row("456") - table.on_click(event=click) - await wait_for_idle(0) + await pilot.click(offset=Offset(1, 2)) assert app.message_names == ["RowHighlighted", "RowHighlighted", "RowSelected"] row_highlighted: DataTable.RowHighlighted = app.messages[1] @@ -624,19 +608,17 @@ async def test_on_click_row_cursor(): assert row_highlighted.cursor_row == 1 -async def test_on_click_column_cursor(): +async def test_click_column_cursor(): """When the column cursor is used, and we click, we emit a ColumnHighlighted *and* a ColumnSelected message for the column that was clicked.""" app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) table.cursor_type = "column" column_key = table.add_column("ABC") table.add_row("123") table.add_row("456") - click = make_click_event() - table.on_click(event=click) - await wait_for_idle(0) + await pilot.click(offset=Offset(1, 2)) assert app.message_names == [ "ColumnHighlighted", "ColumnHighlighted", @@ -654,27 +636,14 @@ async def test_on_click_column_cursor(): async def test_hover_coordinate(): """Ensure that the hover_coordinate reactive is updated as expected.""" app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) table.add_column("ABC") table.add_row("123") table.add_row("456") assert table.hover_coordinate == Coordinate(0, 0) - - mouse_move = MouseMove( - x=1, - y=2, - delta_x=0, - delta_y=0, - button=0, - shift=False, - meta=False, - ctrl=False, - style=Style(meta={"row": 1, "column": 2}), - ) - table.on_mouse_move(mouse_move) - await wait_for_idle(0) - assert table.hover_coordinate == Coordinate(1, 2) + await pilot.hover(DataTable, offset=Offset(2, 2)) + assert table.hover_coordinate == Coordinate(1, 0) async def test_header_selected(): @@ -685,19 +654,9 @@ async def test_header_selected(): table = app.query_one(DataTable) column_key = table.add_column("number") table.add_row(3) - click_event = Click( - x=3, - y=0, - delta_x=0, - delta_y=0, - button=1, - shift=False, - meta=False, - ctrl=False, - ) - await pilot.pause() - table.on_click(click_event) - await pilot.pause() + + click_location = Offset(3, 0) # Click the header + await pilot.click(DataTable, offset=click_location) message: DataTable.HeaderSelected = app.messages[-1] assert message.label == Text("number") assert message.column_index == 0 @@ -705,7 +664,7 @@ async def test_header_selected(): # Now hide the header and click in the exact same place - no additional message emitted. table.show_header = False - table.on_click(click_event) + await pilot.click(DataTable, offset=click_location) await pilot.pause() assert app.message_names.count("HeaderSelected") == 1 @@ -718,19 +677,8 @@ async def test_row_label_selected(): table = app.query_one(DataTable) table.add_column("number") row_key = table.add_row(3, label="A") - click_event = Click( - x=1, - y=1, - delta_x=0, - delta_y=0, - button=1, - shift=False, - meta=False, - ctrl=False, - ) - await pilot.pause() - table.on_click(click_event) await pilot.pause() + await pilot.click(DataTable, offset=Offset(1, 1)) message: DataTable.RowLabelSelected = app.messages[-1] assert message.label == Text("A") assert message.row_index == 0 @@ -738,8 +686,7 @@ async def test_row_label_selected(): # Now hide the row label and click in the same place - no additional message emitted. table.show_row_labels = False - table.on_click(click_event) - await pilot.pause() + await pilot.click(DataTable, offset=Offset(1, 1)) assert app.message_names.count("RowLabelSelected") == 1