Skip to content

Commit

Permalink
Merge branch 'main' of github.com:willmcgugan/textual into text-area-…
Browse files Browse the repository at this point in the history
…syntax-extra
  • Loading branch information
darrenburns committed Sep 25, 2023
2 parents c4fd72d + 8447192 commit 4f07275
Show file tree
Hide file tree
Showing 6 changed files with 290 additions and 23 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395

### Added

- `OutOfBounds` exception to be raised by `Pilot` https://github.com/Textualize/textual/pull/3360

### Changed

- `Pilot.click`/`Pilot.hover` now raises `OutOfBounds` when clicking outside visible screen https://github.com/Textualize/textual/pull/3360
- `Pilot.click`/`Pilot.hover` now return a Boolean indicating whether the click/hover landed on the widget that matches the selector https://github.com/Textualize/textual/pull/3360

## [0.38.1] - 2023-09-21

### Fixed
Expand Down
88 changes: 69 additions & 19 deletions src/textual/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ._wait import wait_for_idle
from .app import App, ReturnType
from .events import Click, MouseDown, MouseMove, MouseUp
from .geometry import Offset
from .widget import Widget


Expand Down Expand Up @@ -44,6 +45,10 @@ def _get_mouse_message_arguments(
return message_arguments


class OutOfBounds(Exception):
"""Raised when the pilot mouse target is outside of the (visible) screen."""


class WaitForScreenTimeout(Exception):
"""Exception raised if messages aren't being processed quickly enough.
Expand Down Expand Up @@ -83,19 +88,30 @@ async def click(
shift: bool = False,
meta: bool = False,
control: bool = False,
) -> None:
"""Simulate clicking with the mouse.
) -> 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.
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.
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
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 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 click landed on the selected
widget, False otherwise.
"""
app = self.app
screen = app.screen
Expand All @@ -107,27 +123,51 @@ async def click(
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."
)

app.post_message(MouseDown(**message_arguments))
await self.pause(0.1)
await self.pause()
app.post_message(MouseUp(**message_arguments))
await self.pause(0.1)
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(0.1)
await self.pause()

return selector is None or widget_at is target_widget

async def hover(
self,
selector: type[Widget] | str | None | None = None,
offset: tuple[int, int] = (0, 0),
) -> None:
"""Simulate hovering with the mouse cursor.
) -> bool:
"""Simulate hovering with the mouse cursor at a specified position.
The final position to be hovered is computed based on the selector provided and
the offset specified and it must be within the visible area of the screen.
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.
selector: A selector to specify a widget that should be used as the reference
for the hover offset. If this is not specified, the offset is interpreted
relative to the screen. You can use this parameter to try to hover a
specific widget. However, if the widget is currently hidden or obscured by
another widget, the hover may not land on the widget you specified.
offset: The offset to hover. The offset is relative to the selector provided
or to the screen, if no selector is provided.
Raises:
OutOfBounds: If the position to be hovered is outside of the (visible) screen.
Returns:
True if no selector was specified or if the hover landed on the selected
widget, False otherwise.
"""
app = self.app
screen = app.screen
Expand All @@ -139,10 +179,20 @@ async def hover(
message_arguments = _get_mouse_message_arguments(
target_widget, offset, button=0
)

hover_offset = Offset(message_arguments["x"], message_arguments["y"])
if hover_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, _ = 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:
"""Wait for the current screen and its children to have processed all pending events.
Expand Down
4 changes: 2 additions & 2 deletions tests/input/test_input_mouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def compose(self) -> ComposeResult:
(TEXT_SINGLE, 10, 10),
(TEXT_SINGLE, len(TEXT_SINGLE) - 1, len(TEXT_SINGLE) - 1),
(TEXT_SINGLE, len(TEXT_SINGLE), len(TEXT_SINGLE)),
(TEXT_SINGLE, len(TEXT_SINGLE) * 2, len(TEXT_SINGLE)),
(TEXT_SINGLE, len(TEXT_SINGLE) + 10, len(TEXT_SINGLE)),
# Double-width characters
(TEXT_DOUBLE, 0, 0),
(TEXT_DOUBLE, 1, 0),
Expand All @@ -55,7 +55,7 @@ def compose(self) -> ComposeResult:
(TEXT_MIXED, 13, 9),
(TEXT_MIXED, 14, 9),
(TEXT_MIXED, 15, 10),
(TEXT_MIXED, 1000, 10),
(TEXT_MIXED, 60, 10),
),
)
async def test_mouse_clicks_within(text, click_at, should_land):
Expand Down
1 change: 1 addition & 0 deletions tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ def test_blur_on_disabled(snap_compare):
def test_tooltips_in_compound_widgets(snap_compare):
# https://github.com/Textualize/textual/issues/2641
async def run_before(pilot) -> None:
await pilot.pause()
await pilot.hover("ProgressBar")
await pilot.pause(0.3)
await pilot.pause()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_data_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -815,7 +815,7 @@ async def test_hover_mouse_leave():
await pilot.hover(DataTable, offset=Offset(1, 1))
assert table._show_hover_cursor
# Move our cursor away from the DataTable, and the hover cursor is hidden
await pilot.hover(DataTable, offset=Offset(-1, -1))
await pilot.hover(DataTable, offset=Offset(20, 20))
assert not table._show_hover_cursor


Expand Down
Loading

0 comments on commit 4f07275

Please sign in to comment.