mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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
This commit is contained in:
@@ -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 `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 `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 `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
|
- 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
|
- A percentage alpha can now be applied to a border https://github.com/Textualize/textual/issues/1863
|
||||||
- Added `Color.multiply_alpha`.
|
- Added `Color.multiply_alpha`.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- DataTable race condition that caused crash https://github.com/Textualize/textual/pull/1962
|
||||||
|
|
||||||
## [0.13.0] - 2023-03-02
|
## [0.13.0] - 2023-03-02
|
||||||
|
|||||||
@@ -1,12 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Generic
|
from typing import Any, Generic
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
|
|
||||||
from ._wait import wait_for_idle
|
from ._wait import wait_for_idle
|
||||||
from .app import App, ReturnType
|
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)
|
@rich.repr.auto(angular=True)
|
||||||
@@ -34,6 +59,60 @@ class Pilot(Generic[ReturnType]):
|
|||||||
if keys:
|
if keys:
|
||||||
await self._app._press_keys(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:
|
async def pause(self, delay: float | None = None) -> None:
|
||||||
"""Insert a pause.
|
"""Insert a pause.
|
||||||
|
|
||||||
|
|||||||
@@ -1813,6 +1813,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
and column metadata from the segments present in the cells."""
|
and column metadata from the segments present in the cells."""
|
||||||
self._set_hover_cursor(True)
|
self._set_hover_cursor(True)
|
||||||
meta = event.style.meta
|
meta = event.style.meta
|
||||||
|
|
||||||
if meta and self.show_cursor and self.cursor_type != "none":
|
if meta and self.show_cursor and self.cursor_type != "none":
|
||||||
try:
|
try:
|
||||||
self.hover_coordinate = Coordinate(meta["row"], meta["column"])
|
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:
|
def on_click(self, event: events.Click) -> None:
|
||||||
self._set_hover_cursor(True)
|
self._set_hover_cursor(True)
|
||||||
meta = self.get_style_at(event.x, event.y).meta
|
meta = event.style.meta
|
||||||
if not meta:
|
if not meta:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -9,6 +9,7 @@ from textual.actions import SkipAction
|
|||||||
from textual.app import App
|
from textual.app import App
|
||||||
from textual.coordinate import Coordinate
|
from textual.coordinate import Coordinate
|
||||||
from textual.events import Click, MouseMove
|
from textual.events import Click, MouseMove
|
||||||
|
from textual.geometry import Offset
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.widgets import DataTable
|
from textual.widgets import DataTable
|
||||||
from textual.widgets.data_table import (
|
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))
|
table.coordinate_to_cell_key(Coordinate(9999, 9999))
|
||||||
|
|
||||||
|
|
||||||
def make_click_event():
|
async def test_datatable_click_cell_cursor():
|
||||||
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():
|
|
||||||
"""When the cell cursor is used, and we click, we emit a CellHighlighted
|
"""When the cell cursor is used, and we click, we emit a CellHighlighted
|
||||||
*and* a CellSelected message for the cell that was clicked.
|
*and* a CellSelected message for the cell that was clicked.
|
||||||
Regression test for https://github.com/Textualize/textual/issues/1723"""
|
Regression test for https://github.com/Textualize/textual/issues/1723"""
|
||||||
app = DataTableApp()
|
app = DataTableApp()
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
click = make_click_event()
|
|
||||||
column_key = table.add_column("ABC")
|
column_key = table.add_column("ABC")
|
||||||
table.add_row("123")
|
table.add_row("123")
|
||||||
row_key = table.add_row("456")
|
row_key = table.add_row("456")
|
||||||
table.on_click(event=click)
|
await pilot.click(offset=Offset(1, 2))
|
||||||
await pilot.pause()
|
|
||||||
# There's two CellHighlighted events since a cell is highlighted on initial load,
|
# There's two CellHighlighted events since a cell is highlighted on initial load,
|
||||||
# then when we click, another cell is highlighted (and selected).
|
# then when we click, another cell is highlighted (and selected).
|
||||||
assert app.message_names == [
|
assert app.message_names == [
|
||||||
@@ -599,19 +585,17 @@ async def test_datatable_on_click_cell_cursor():
|
|||||||
assert cell_selected_event.coordinate == Coordinate(1, 0)
|
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
|
"""When the row cursor is used, and we click, we emit a RowHighlighted
|
||||||
*and* a RowSelected message for the row that was clicked."""
|
*and* a RowSelected message for the row that was clicked."""
|
||||||
app = DataTableApp()
|
app = DataTableApp()
|
||||||
async with app.run_test():
|
async with app.run_test() as pilot:
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
click = make_click_event()
|
|
||||||
table.add_column("ABC")
|
table.add_column("ABC")
|
||||||
table.add_row("123")
|
table.add_row("123")
|
||||||
row_key = table.add_row("456")
|
row_key = table.add_row("456")
|
||||||
table.on_click(event=click)
|
await pilot.click(offset=Offset(1, 2))
|
||||||
await wait_for_idle(0)
|
|
||||||
assert app.message_names == ["RowHighlighted", "RowHighlighted", "RowSelected"]
|
assert app.message_names == ["RowHighlighted", "RowHighlighted", "RowSelected"]
|
||||||
|
|
||||||
row_highlighted: DataTable.RowHighlighted = app.messages[1]
|
row_highlighted: DataTable.RowHighlighted = app.messages[1]
|
||||||
@@ -624,19 +608,17 @@ async def test_on_click_row_cursor():
|
|||||||
assert row_highlighted.cursor_row == 1
|
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
|
"""When the column cursor is used, and we click, we emit a ColumnHighlighted
|
||||||
*and* a ColumnSelected message for the column that was clicked."""
|
*and* a ColumnSelected message for the column that was clicked."""
|
||||||
app = DataTableApp()
|
app = DataTableApp()
|
||||||
async with app.run_test():
|
async with app.run_test() as pilot:
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
table.cursor_type = "column"
|
table.cursor_type = "column"
|
||||||
column_key = table.add_column("ABC")
|
column_key = table.add_column("ABC")
|
||||||
table.add_row("123")
|
table.add_row("123")
|
||||||
table.add_row("456")
|
table.add_row("456")
|
||||||
click = make_click_event()
|
await pilot.click(offset=Offset(1, 2))
|
||||||
table.on_click(event=click)
|
|
||||||
await wait_for_idle(0)
|
|
||||||
assert app.message_names == [
|
assert app.message_names == [
|
||||||
"ColumnHighlighted",
|
"ColumnHighlighted",
|
||||||
"ColumnHighlighted",
|
"ColumnHighlighted",
|
||||||
@@ -654,27 +636,14 @@ async def test_on_click_column_cursor():
|
|||||||
async def test_hover_coordinate():
|
async def test_hover_coordinate():
|
||||||
"""Ensure that the hover_coordinate reactive is updated as expected."""
|
"""Ensure that the hover_coordinate reactive is updated as expected."""
|
||||||
app = DataTableApp()
|
app = DataTableApp()
|
||||||
async with app.run_test():
|
async with app.run_test() as pilot:
|
||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
table.add_column("ABC")
|
table.add_column("ABC")
|
||||||
table.add_row("123")
|
table.add_row("123")
|
||||||
table.add_row("456")
|
table.add_row("456")
|
||||||
assert table.hover_coordinate == Coordinate(0, 0)
|
assert table.hover_coordinate == Coordinate(0, 0)
|
||||||
|
await pilot.hover(DataTable, offset=Offset(2, 2))
|
||||||
mouse_move = MouseMove(
|
assert table.hover_coordinate == Coordinate(1, 0)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_header_selected():
|
async def test_header_selected():
|
||||||
@@ -685,19 +654,9 @@ async def test_header_selected():
|
|||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
column_key = table.add_column("number")
|
column_key = table.add_column("number")
|
||||||
table.add_row(3)
|
table.add_row(3)
|
||||||
click_event = Click(
|
|
||||||
x=3,
|
click_location = Offset(3, 0) # Click the header
|
||||||
y=0,
|
await pilot.click(DataTable, offset=click_location)
|
||||||
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()
|
|
||||||
message: DataTable.HeaderSelected = app.messages[-1]
|
message: DataTable.HeaderSelected = app.messages[-1]
|
||||||
assert message.label == Text("number")
|
assert message.label == Text("number")
|
||||||
assert message.column_index == 0
|
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.
|
# Now hide the header and click in the exact same place - no additional message emitted.
|
||||||
table.show_header = False
|
table.show_header = False
|
||||||
table.on_click(click_event)
|
await pilot.click(DataTable, offset=click_location)
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.message_names.count("HeaderSelected") == 1
|
assert app.message_names.count("HeaderSelected") == 1
|
||||||
|
|
||||||
@@ -718,19 +677,8 @@ async def test_row_label_selected():
|
|||||||
table = app.query_one(DataTable)
|
table = app.query_one(DataTable)
|
||||||
table.add_column("number")
|
table.add_column("number")
|
||||||
row_key = table.add_row(3, label="A")
|
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.pause()
|
||||||
|
await pilot.click(DataTable, offset=Offset(1, 1))
|
||||||
message: DataTable.RowLabelSelected = app.messages[-1]
|
message: DataTable.RowLabelSelected = app.messages[-1]
|
||||||
assert message.label == Text("A")
|
assert message.label == Text("A")
|
||||||
assert message.row_index == 0
|
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.
|
# Now hide the row label and click in the same place - no additional message emitted.
|
||||||
table.show_row_labels = False
|
table.show_row_labels = False
|
||||||
table.on_click(click_event)
|
await pilot.click(DataTable, offset=Offset(1, 1))
|
||||||
await pilot.pause()
|
|
||||||
assert app.message_names.count("RowLabelSelected") == 1
|
assert app.message_names.count("RowLabelSelected") == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user