Repository: Textualize/textual

Issue:
Send mouse move events to screen
It look like mouse move events never make it to the Screen object. I think they should.


Discussion:
(This issue is reserved for the [EuroPython 2023 sprint](https://ep2023.europython.eu/sprints). If you are not participating in the sprint, please refrain from working on this issue. Thanks!)

[Install the Textual devtools](https://textual.textualize.io/guide/devtools/), open the console with `textual console`, and run the app below in a different window with `textual run --dev myapp.py`:

```py
from textual.app import App


class MyApp(App):
    pass


if __name__ == "__main__":
    MyApp().run()
```

If you click the app, the console will log the `Click` event twice, once in the (default) screen and once in the app.
Many other events exhibit this bubbling behaviour, and so should the `MouseMove` event.

More specifically, to be able to see `MouseMove` events in the console, you need to run the console with `textual console -v` to enable verbose logging.
However, even if you do, you won't see any mouse movement events because those aren't being bubbled up to the screen/app.

Try running this app, instead:

```py
from textual.app import App
from textual.screen import Screen
from textual.widgets import Label


class MyWidget(Label):
    def on_mouse_move(self):
        print("widget")


class MyScreen(Screen):
    def compose(self):
        yield MyWidget("Hello, world!")

    def on_mouse_move(self):
        print("screen")


class MyApp(App):
    def on_mount(self):
        self.push_screen(MyScreen())

    def on_mouse_move(self):
        print("app")


if __name__ == "__main__":
    MyApp().run()
```

If you move the mouse on top of the label, you will see the message “widget” printed in the console, next to a `MouseMove` event.
We want to make it so that the `MouseMove` event also reaches the screen and then the app.
Again, compare this to what happens if you click on top of the label.

Good luck and have fun!
I'll try to implement this as part of the sprint today.

Resolving patch:
diff --git a/src/textual/events.py b/src/textual/events.py
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -435,10 +435,10 @@ def _apply_offset(self, x: int, y: int) -> MouseEvent:
 
 
 @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
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -825,22 +825,13 @@ def _handle_mouse_move(self, event: events.MouseMove) -> None:
 
         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:
@@ -861,6 +852,28 @@ def _handle_mouse_move(self, event: events.MouseMove) -> None:
                     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


Test patch:
diff --git a/tests/test_screens.py b/tests/test_screens.py
--- a/tests/test_screens.py
+++ b/tests/test_screens.py
@@ -4,7 +4,9 @@
 
 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 @@ def on_mount(self):
         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)