Skip to content

Commit

Permalink
Merge branch 'Textualize:main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
davep authored Nov 20, 2023
2 parents 01cfdf8 + 63111fe commit 43de6d7
Show file tree
Hide file tree
Showing 11 changed files with 521 additions and 121 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,17 @@ 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

- CSS error reporting will no longer provide links to the files in question https://github.com/Textualize/textual/pull/3582
- inline CSS error reporting will report widget/class variable where the CSS was read from https://github.com/Textualize/textual/pull/3582
- 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
Expand Down
9 changes: 5 additions & 4 deletions src/textual/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
212 changes: 176 additions & 36 deletions src/textual/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 1 addition & 6 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion src/textual/widgets/_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,7 +937,8 @@ def set_table_of_contents(self, table_of_contents: TableOfContentsType) -> None:
node.allow_expand = True
else:
node = node.add(NUMERALS[level], expand=True)
node.add_leaf(f"[dim]{NUMERALS[level]}[/] {name}", {"block_id": block_id})
node_label = Text.assemble((f"{NUMERALS[level]} ", "dim"), name)
node.add_leaf(node_label, {"block_id": block_id})

async def _on_tree_node_selected(self, message: Tree.NodeSelected) -> None:
node_data = message.node.data
Expand Down
Loading

0 comments on commit 43de6d7

Please sign in to comment.