From 8b5f4fd03d6d99c969471471cfea4afb7c37e936 Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Sat, 22 Jul 2023 14:22:17 +0200 Subject: [PATCH] Screen processes mouse move events after forwarding them to the child widget. Signed-off-by: Michael Seifert --- CHANGELOG.md | 1 + docs/events/mouse_move.md | 2 +- src/textual/events.py | 4 +-- src/textual/screen.py | 43 ++++++++++++++++++---------- tests/test_screens.py | 60 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 91 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec1c24b51..1377fe79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed a crash when a `SelectionList` had a prompt wider than itself https://github.com/Textualize/textual/issues/2900 - Fixed a bug where `Click` events were bubbling up from `Switch` widgets https://github.com/Textualize/textual/issues/2366 - Fixed a crash when using empty CSS variables https://github.com/Textualize/textual/issues/1849 +- `MouseMove` events bubble up from widgets. `App` and `Screen` receive `MouseMove` events even if there's no Widget under the cursor. https://github.com/Textualize/textual/issues/2905 ## [0.30.0] - 2023-07-17 diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md index 12cdca5f9..a781a2809 100644 --- a/docs/events/mouse_move.md +++ b/docs/events/mouse_move.md @@ -2,7 +2,7 @@ The `MouseMove` event is sent to a widget when the mouse pointer is moved over a widget. -- [ ] Bubbles +- [x] Bubbles - [x] Verbose ## Attributes diff --git a/src/textual/events.py b/src/textual/events.py index 3f6015dfb..e5e704c12 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -435,10 +435,10 @@ class MouseEvent(InputEvent, bubble=True): @rich.repr.auto -class MouseMove(MouseEvent, bubble=False, verbose=True): +class MouseMove(MouseEvent, bubble=True, verbose=True): """Sent when the mouse cursor moves. - - [ ] Bubbles + - [X] Bubbles - [X] Verbose """ diff --git a/src/textual/screen.py b/src/textual/screen.py index 38705685d..7ee99c5ab 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -818,22 +818,13 @@ class Screen(Generic[ScreenResultType], Widget): else: self.app._set_mouse_over(widget) - mouse_event = events.MouseMove( - event.x - region.x, - event.y - region.y, - event.delta_x, - event.delta_y, - event.button, - event.shift, - event.meta, - event.ctrl, - screen_x=event.screen_x, - screen_y=event.screen_y, - style=event.style, - ) widget.hover_style = event.style - mouse_event._set_forwarded() - widget._forward_event(mouse_event) + if widget is self: + self.post_message(event) + else: + mouse_event = self._translate_mouse_move_event(event, region) + mouse_event._set_forwarded() + widget._forward_event(mouse_event) if not self.app._disable_tooltips: try: @@ -856,6 +847,28 @@ class Screen(Generic[ScreenResultType], Widget): else: tooltip.display = False + @staticmethod + def _translate_mouse_move_event( + event: events.MouseMove, region: Region + ) -> events.MouseMove: + """ + Returns a mouse move event whose relative coordinates are translated to + the origin of the specified region. + """ + return events.MouseMove( + event.x - region.x, + event.y - region.y, + event.delta_x, + event.delta_y, + event.button, + event.shift, + event.meta, + event.ctrl, + screen_x=event.screen_x, + screen_y=event.screen_y, + style=event.style, + ) + def _forward_event(self, event: events.Event) -> None: if event.is_forwarded: return diff --git a/tests/test_screens.py b/tests/test_screens.py index d1d70ad4c..5f587fd0e 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -4,7 +4,9 @@ import threading import pytest -from textual.app import App, ScreenStackError +from textual.app import App, ScreenStackError, ComposeResult +from textual.events import MouseMove +from textual.geometry import Offset from textual.screen import Screen from textual.widgets import Button, Input, Label @@ -350,3 +352,59 @@ async def test_switch_screen_updates_results_callback_stack(): app.switch_screen("b") assert len(app.screen._result_callbacks) == 1 assert app.screen._result_callbacks[-1].callback is None + + +async def test_screen_receives_mouse_move_events(): + class MouseMoveRecordingScreen(Screen): + mouse_events = [] + + def on_mouse_move(self, event: MouseMove) -> None: + MouseMoveRecordingScreen.mouse_events.append(event) + + class SimpleApp(App[None]): + SCREENS = {"a": MouseMoveRecordingScreen()} + + def on_mount(self): + self.push_screen("a") + + mouse_offset = Offset(1, 1) + + async with SimpleApp().run_test() as pilot: + await pilot.hover(None, mouse_offset) + + assert len(MouseMoveRecordingScreen.mouse_events) == 1 + mouse_event = MouseMoveRecordingScreen.mouse_events[0] + assert mouse_event.x, mouse_event.y == mouse_offset + + +async def test_mouse_move_event_bubbles_to_screen_from_widget(): + class MouseMoveRecordingScreen(Screen): + mouse_events = [] + + DEFAULT_CSS = """ + Label { + offset: 10 10; + } + """ + + def compose(self) -> ComposeResult: + yield Label("Any label") + + def on_mouse_move(self, event: MouseMove) -> None: + MouseMoveRecordingScreen.mouse_events.append(event) + + class SimpleApp(App[None]): + SCREENS = {"a": MouseMoveRecordingScreen()} + + def on_mount(self): + self.push_screen("a") + + label_offset = Offset(10, 10) + mouse_offset = Offset(1, 1) + + async with SimpleApp().run_test() as pilot: + await pilot.hover(Label, mouse_offset) + + assert len(MouseMoveRecordingScreen.mouse_events) == 1 + mouse_event = MouseMoveRecordingScreen.mouse_events[0] + assert mouse_event.x, mouse_event.y == (label_offset.x + mouse_offset.x, label_offset.y + mouse_offset.y)