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:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e9bfe956..5535d3101 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed unintuitive sizing behaviour of TabbedContent https://github.com/Textualize/textual/issues/2411
 - Fixed relative units not always expanding auto containers https://github.com/Textualize/textual/pull/3059
 - Fixed background refresh https://github.com/Textualize/textual/issues/3055
+- `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
 
 ### Added
 - Added an interface for replacing prompt of an individual option in an `OptionList` https://github.com/Textualize/textual/issues/2603 
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 ccc2cc0cb..5c7ffaa2b 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 1a9b9af2e..12514bdf3 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -825,22 +825,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:
@@ -861,6 +852,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


Test patch:
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)