From 27f26c06d203ad26a32024c45c101c250ad5f836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 10 Oct 2023 10:22:13 +0100 Subject: [PATCH 1/6] Change when click events are emitted. --- src/textual/driver.py | 9 +++++---- src/textual/screen.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) 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/screen.py b/src/textual/screen.py index be09b66e7c..2707b74028 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -963,7 +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) + self.set_focus(widget, scroll_visible=False) elif isinstance(event, events.MouseUp) and widget.focusable: if self.focused is not widget: self.set_focus(widget) From 60472ef3cf8de034b9ba4965f47c98dc32c68633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 10 Oct 2023 10:35:40 +0100 Subject: [PATCH 2/6] Don't focus on MouseUp. --- src/textual/screen.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 2707b74028..270b330c2c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -964,11 +964,6 @@ def _forward_event(self, event: events.Event) -> None: else: if isinstance(event, events.MouseDown) and widget.focusable: self.set_focus(widget, scroll_visible=False) - elif isinstance(event, events.MouseUp) and widget.focusable: - if self.focused is not widget: - self.set_focus(widget) - event.stop() - return event.style = self.get_style_at(event.screen_x, event.screen_y) if widget is self: event._set_forwarded() From 23b882cb252ea5c23226a211ccd9a5a6efa3db50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:21:41 +0100 Subject: [PATCH 3/6] Add two tests for completeness of combinations. --- tests/test_xterm_parser.py | 2 ++ 1 file changed, 2 insertions(+) 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): From 69d816a96314cb5c49a2f1c6dd352f986acca033 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:22:53 +0100 Subject: [PATCH 4/6] Add Pilot.mouse_down/mouse_up. --- CHANGELOG.md | 2 + src/textual/pilot.py | 212 +++++++++++++++++++++++++++++++++++-------- tests/test_pilot.py | 79 +++++++++++++++- 3 files changed, 255 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e42631a097..a843c6a255 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Input.clear` method https://github.com/Textualize/textual/pull/3430 - Added `TextArea.SelectionChanged` and `TextArea.Changed` messages https://github.com/Textualize/textual/pull/3442 - Added `wait_for_dismiss` parameter to `App.push_screen` https://github.com/Textualize/textual/pull/3477 +- 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 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/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): From 5e1cbf76d11fb9f2956e078afcdc67d4915d12b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:23:11 +0100 Subject: [PATCH 5/6] Test new click/focus behaviour. --- tests/test_driver.py | 125 +++++++++++++++++++++++++++++++++++++++++++ tests/test_focus.py | 32 +++++++++++ 2 files changed, 157 insertions(+) create mode 100644 tests/test_driver.py 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 489d808c26..eac45a19ca 100644 --- a/tests/test_focus.py +++ b/tests/test_focus.py @@ -309,3 +309,35 @@ def compose(self): w11, w12, ] + + +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 From ef1aebd4c3fc5757d0fb50f0576a02d9f22dd985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 20 Nov 2023 10:58:59 +0000 Subject: [PATCH 6/6] Escape markup in markdown headings. (#3697) * Escape markup in markdown headings. The markup would already be 'escaped' (ignored, really) in the markdown document itself, but it would be processed when building the table of contents because of the way the widget 'Tree' internally processes labels. This was changed, so that we create our own 'Text' instances for the labels, which means we get to avoid markup processing. Related issue: #3689. * Update CHANGELOG.md Co-authored-by: Dave Pearson * Optimisation. Related review comment: https://github.com/Textualize/textual/pull/3697/#discussion_r1397241681 * Optimisation. Related review comment: https://github.com/Textualize/textual/pull/3697#discussion_r1398983336 * Update snapshot. --------- Co-authored-by: Dave Pearson --- CHANGELOG.md | 1 + src/textual/widgets/_markdown.py | 3 +- .../__snapshots__/test_snapshots.ambr | 138 +++++++++--------- tests/test_markdownviewer.py | 31 +++- 4 files changed, 100 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d27c6f7dd..c37d8f9f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 ## [0.41.0] - 2023-10-31 diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index ac6de4f4bc..de8e75059a 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -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 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5a1667c547..f9abc1ed64 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21069,145 +21069,145 @@ font-weight: 700; } - .terminal-1944007215-matrix { + .terminal-3017552431-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1944007215-title { + .terminal-3017552431-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1944007215-r1 { fill: #c5c8c6 } - .terminal-1944007215-r2 { fill: #24292f } - .terminal-1944007215-r3 { fill: #121212 } - .terminal-1944007215-r4 { fill: #e1e1e1 } - .terminal-1944007215-r5 { fill: #e2e3e3 } - .terminal-1944007215-r6 { fill: #96989b } - .terminal-1944007215-r7 { fill: #0053aa } - .terminal-1944007215-r8 { fill: #008139 } - .terminal-1944007215-r9 { fill: #dde8f3;font-weight: bold } - .terminal-1944007215-r10 { fill: #939393;font-weight: bold } - .terminal-1944007215-r11 { fill: #14191f } - .terminal-1944007215-r12 { fill: #e2e3e3;font-weight: bold } - .terminal-1944007215-r13 { fill: #4ebf71;font-weight: bold } - .terminal-1944007215-r14 { fill: #e1e1e1;font-style: italic; } - .terminal-1944007215-r15 { fill: #e1e1e1;font-weight: bold } + .terminal-3017552431-r1 { fill: #c5c8c6 } + .terminal-3017552431-r2 { fill: #24292f } + .terminal-3017552431-r3 { fill: #121212 } + .terminal-3017552431-r4 { fill: #e1e1e1 } + .terminal-3017552431-r5 { fill: #e2e3e3 } + .terminal-3017552431-r6 { fill: #96989b } + .terminal-3017552431-r7 { fill: #0053aa } + .terminal-3017552431-r8 { fill: #008139 } + .terminal-3017552431-r9 { fill: #dde8f3;font-weight: bold } + .terminal-3017552431-r10 { fill: #939393;font-weight: bold } + .terminal-3017552431-r11 { fill: #14191f } + .terminal-3017552431-r12 { fill: #e2e3e3;font-weight: bold } + .terminal-3017552431-r13 { fill: #4ebf71;font-weight: bold } + .terminal-3017552431-r14 { fill: #e1e1e1;font-style: italic; } + .terminal-3017552431-r15 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼  Markdown Viewer - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅ - -                  Features                  - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code - etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼ Ⅰ Markdown Viewer + ├── Ⅱ FeaturesMarkdown Viewer + ├── Ⅱ Tables + └── Ⅱ Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▅▅ + +                  Features                  + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code + etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index 27ccf0da99..8d94d4b946 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -1,10 +1,12 @@ from pathlib import Path import pytest +from rich.text import Text +import textual.widgets._markdown as MD from textual.app import App, ComposeResult from textual.geometry import Offset -from textual.widgets import Markdown, MarkdownViewer +from textual.widgets import Markdown, MarkdownViewer, Tree TEST_MARKDOWN = """\ * [First]({{file}}#first) @@ -45,8 +47,12 @@ async def test_markdown_file_viewer_anchor_link(tmp_path, link: int) -> None: class MarkdownStringViewerApp(App[None]): + def __init__(self, markdown_string: str) -> None: + self.markdown_string = markdown_string + super().__init__() + def compose(self) -> ComposeResult: - yield MarkdownViewer(TEST_MARKDOWN.replace("{{file}}", "")) + yield MarkdownViewer(self.markdown_string) async def on_mount(self) -> None: self.query_one(MarkdownViewer).show_table_of_contents = False @@ -57,8 +63,27 @@ async def test_markdown_string_viewer_anchor_link(link: int) -> None: """Test https://github.com/Textualize/textual/issues/3094 Also https://github.com/Textualize/textual/pull/3244#issuecomment-1710278718.""" - async with MarkdownStringViewerApp().run_test() as pilot: + async with MarkdownStringViewerApp( + TEST_MARKDOWN.replace("{{file}}", "") + ).run_test() as pilot: # There's not really anything to test *for* here, but the lack of an # exception is the win (before the fix this is testing it would have # been FileNotFoundError). await pilot.click(Markdown, Offset(2, link)) + + +@pytest.mark.parametrize("text", ["Hey [[/test]]", "[i]Hey there[/i]"]) +async def test_headings_that_look_like_they_contain_markup(text: str) -> None: + """Regression test for https://github.com/Textualize/textual/issues/3689. + + Things that look like markup are escaped in markdown headings in the table of contents. + """ + + document = f"# {text}" + async with MarkdownStringViewerApp(document).run_test() as pilot: + assert pilot.app.query_one(MD.MarkdownH1)._text == Text(text) + toc_tree = pilot.app.query_one(MD.MarkdownTableOfContents).query_one(Tree) + # The toc label looks like "I {text}" but the I is styled so we drop it. + toc_label = toc_tree.root.children[0].label + _, text_label = toc_label.divide([2]) + assert text_label == Text(text)