Repository: Textualize/textual

Issue:
Restrict Pilot.click to visible area
I suspect that `Pilot.click` will allow clicking of widgets that may not be in the visible area of the screen.

Since we are simulating a real user operating the mouse, we should make that not work. Probably by throwing an exception.

It also looks like it is possible to supply an `offset` that would click outside of the visible area. This should probably also result in an exception.


Discussion:
(Not sure why it shows my face in the first comment; that was written by Will.)
`Pilot.click` already has a note saying that the click _may_ not land on the widget if it is not visible or covered by other widget(s).
Same thing for `Pilot.hover`.

The app below shows an example of a click that doesn't land on the widget we want because the widget is scrolled out of view.

I think it makes sense to do something if the user tries to click/hover something outside of the screen.
Either raise an error or return a Boolean indicating whether the thing we tried to click/hover was on the screen.

<details>
<summary>Code showing a click that doesn't land on the target.</summary>

```py
import asyncio

from textual.app import App
from textual.widgets import Button, Label


class MyApp(App):
    clicked = False

    def compose(self):
        for idx in range(200):
            yield Label(str(idx))
        yield Button()

    def on_button_pressed(self):
        self.clicked = True


async def main():
    app = MyApp()
    async with app.run_test(size=(80, 24)) as pilot:
        await pilot.click(Button, offset=(1, 1))

    assert not app.clicked


if __name__ == "__main__":
    asyncio.run(main())
```

</details>
Regarding the methods `Pilot.click` and `Pilot.hover`, I propose that we raise an exception `OutOfBounds` if the widget that's selected is outside the visible screen or if the offset moves the click outside of the selected widget/screen.

Furthermore, I propose that both methods return a Boolean value indicating whether the action made it to the intended widget.

Resolving patch:
diff --git a/src/textual/pilot.py b/src/textual/pilot.py
--- a/src/textual/pilot.py
+++ b/src/textual/pilot.py
@@ -16,6 +16,7 @@
 from ._wait import wait_for_idle
 from .app import App, ReturnType
 from .events import Click, MouseDown, MouseMove, MouseUp
+from .geometry import Offset
 from .widget import Widget
 
 
@@ -44,6 +45,10 @@ def _get_mouse_message_arguments(
     return message_arguments
 
 
+class OutOfBounds(Exception):
+    """Raised when the pilot mouse target is outside of the (visible) screen."""
+
+
 class WaitForScreenTimeout(Exception):
     """Exception raised if messages aren't being processed quickly enough.
 
@@ -83,19 +88,30 @@ async def click(
         shift: bool = False,
         meta: bool = False,
         control: bool = False,
-    ) -> None:
-        """Simulate clicking with the mouse.
+    ) -> bool:
+        """Simulate clicking with the mouse at a specified position.
+
+        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.
 
         Args:
-            selector: The widget that should be clicked. If None, then the click
-                will occur relative to the screen. Note that this simply causes
-                a click to occur at the location of the widget. If the widget is
-                currently hidden or obscured by another widget, then the click may
-                not land on it.
-            offset: The offset to click within the selected widget.
+            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
+                relative to the screen. You can use this parameter to try to click on a
+                specific widget. However, if the widget is currently hidden or obscured by
+                another widget, the click may not land on the widget you specified.
+            offset: The offset to click. The offset is relative to the selector provided
+                or to the screen, if no selector is provided.
             shift: Click with the shift key held down.
             meta: Click with the meta key held down.
             control: Click with the control key held down.
+
+        Raises:
+            OutOfBounds: If the position to be clicked is outside of the (visible) screen.
+
+        Returns:
+            True if no selector was specified or if the click landed on the selected
+                widget, False otherwise.
         """
         app = self.app
         screen = app.screen
@@ -107,27 +123,51 @@ async def click(
         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."
+            )
+
         app.post_message(MouseDown(**message_arguments))
-        await self.pause(0.1)
+        await self.pause()
         app.post_message(MouseUp(**message_arguments))
-        await self.pause(0.1)
+        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(0.1)
+        await self.pause()
+
+        return selector is None or widget_at is target_widget
 
     async def hover(
         self,
         selector: type[Widget] | str | None | None = None,
         offset: tuple[int, int] = (0, 0),
-    ) -> None:
-        """Simulate hovering with the mouse cursor.
+    ) -> bool:
+        """Simulate hovering with the mouse cursor at a specified position.
+
+        The final position to be hovered is computed based on the selector provided and
+        the offset specified and it must be within the visible area of the screen.
 
         Args:
-            selector: The widget that should be hovered. If None, then the click
-                will occur relative to the screen. Note that this simply causes
-                a hover to occur at the location of the widget. If the widget is
-                currently hidden or obscured by another widget, then the hover may
-                not land on it.
-            offset: The offset to hover over within the selected widget.
+            selector: A selector to specify a widget that should be used as the reference
+                for the hover offset. If this is not specified, the offset is interpreted
+                relative to the screen. You can use this parameter to try to hover a
+                specific widget. However, if the widget is currently hidden or obscured by
+                another widget, the hover may not land on the widget you specified.
+            offset: The offset to hover. The offset is relative to the selector provided
+                or to the screen, if no selector is provided.
+
+        Raises:
+            OutOfBounds: If the position to be hovered is outside of the (visible) screen.
+
+        Returns:
+            True if no selector was specified or if the hover landed on the selected
+                widget, False otherwise.
         """
         app = self.app
         screen = app.screen
@@ -139,10 +179,20 @@ async def hover(
         message_arguments = _get_mouse_message_arguments(
             target_widget, offset, button=0
         )
+
+        hover_offset = Offset(message_arguments["x"], message_arguments["y"])
+        if hover_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, _ = 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:
         """Wait for the current screen and its children to have processed all pending events.
 


Test patch:
diff --git a/tests/input/test_input_mouse.py b/tests/input/test_input_mouse.py
--- a/tests/input/test_input_mouse.py
+++ b/tests/input/test_input_mouse.py
@@ -34,7 +34,7 @@ def compose(self) -> ComposeResult:
         (TEXT_SINGLE, 10, 10),
         (TEXT_SINGLE, len(TEXT_SINGLE) - 1, len(TEXT_SINGLE) - 1),
         (TEXT_SINGLE, len(TEXT_SINGLE), len(TEXT_SINGLE)),
-        (TEXT_SINGLE, len(TEXT_SINGLE) * 2, len(TEXT_SINGLE)),
+        (TEXT_SINGLE, len(TEXT_SINGLE) + 10, len(TEXT_SINGLE)),
         # Double-width characters
         (TEXT_DOUBLE, 0, 0),
         (TEXT_DOUBLE, 1, 0),
@@ -55,7 +55,7 @@ def compose(self) -> ComposeResult:
         (TEXT_MIXED, 13, 9),
         (TEXT_MIXED, 14, 9),
         (TEXT_MIXED, 15, 10),
-        (TEXT_MIXED, 1000, 10),
+        (TEXT_MIXED, 60, 10),
     ),
 )
 async def test_mouse_clicks_within(text, click_at, should_land):
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -644,6 +644,7 @@ def test_blur_on_disabled(snap_compare):
 def test_tooltips_in_compound_widgets(snap_compare):
     # https://github.com/Textualize/textual/issues/2641
     async def run_before(pilot) -> None:
+        await pilot.pause()
         await pilot.hover("ProgressBar")
         await pilot.pause(0.3)
         await pilot.pause()
diff --git a/tests/test_data_table.py b/tests/test_data_table.py
--- a/tests/test_data_table.py
+++ b/tests/test_data_table.py
@@ -815,7 +815,7 @@ async def test_hover_mouse_leave():
         await pilot.hover(DataTable, offset=Offset(1, 1))
         assert table._show_hover_cursor
         # Move our cursor away from the DataTable, and the hover cursor is hidden
-        await pilot.hover(DataTable, offset=Offset(-1, -1))
+        await pilot.hover(DataTable, offset=Offset(20, 20))
         assert not table._show_hover_cursor
 
 
diff --git a/tests/test_pilot.py b/tests/test_pilot.py
--- a/tests/test_pilot.py
+++ b/tests/test_pilot.py
@@ -5,12 +5,43 @@
 from textual import events
 from textual.app import App, ComposeResult
 from textual.binding import Binding
-from textual.widgets import Label
+from textual.containers import Center, Middle
+from textual.pilot import OutOfBounds
+from textual.widgets import Button, Label
 
 KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation
 """Test some "simple" characters (letters + digits) and all punctuation."""
 
 
+class CenteredButtonApp(App):
+    CSS = """  # Ensure the button is 16 x 3
+    Button {
+        min-width: 16;
+        max-width: 16;
+        width: 16;
+        min-height: 3;
+        max-height: 3;
+        height: 3;
+    }
+    """
+
+    def compose(self):
+        with Center():
+            with Middle():
+                yield Button()
+
+
+class ManyLabelsApp(App):
+    """Auxiliary app with a button following many labels."""
+
+    AUTO_FOCUS = None  # So that there's no auto-scrolling.
+
+    def compose(self):
+        for idx in range(100):
+            yield Label(f"label {idx}", id=f"label{idx}")
+        yield Button()
+
+
 async def test_pilot_press_ascii_chars():
     """Test that the pilot can press most ASCII characters as keys."""
     keys_pressed = []
@@ -70,3 +101,179 @@ async def test_pilot_hover_screen():
 
     async with App().run_test() as pilot:
         await pilot.hover("Screen")
+
+
+@pytest.mark.parametrize(
+    ["method", "screen_size", "offset"],
+    [
+        #
+        ("click", (80, 24), (100, 12)),  # Right of screen.
+        ("click", (80, 24), (100, 36)),  # Bottom-right of screen.
+        ("click", (80, 24), (50, 36)),  # Under screen.
+        ("click", (80, 24), (-10, 36)),  # Bottom-left of screen.
+        ("click", (80, 24), (-10, 12)),  # Left of screen.
+        ("click", (80, 24), (-10, -2)),  # Top-left of screen.
+        ("click", (80, 24), (50, -2)),  # Above screen.
+        ("click", (80, 24), (100, -2)),  # Top-right of screen.
+        #
+        ("click", (5, 5), (7, 3)),  # Right of screen.
+        ("click", (5, 5), (7, 7)),  # Bottom-right of screen.
+        ("click", (5, 5), (3, 7)),  # Under screen.
+        ("click", (5, 5), (-1, 7)),  # Bottom-left of screen.
+        ("click", (5, 5), (-1, 3)),  # Left of screen.
+        ("click", (5, 5), (-1, -1)),  # Top-left of screen.
+        ("click", (5, 5), (3, -1)),  # Above screen.
+        ("click", (5, 5), (7, -1)),  # Top-right of screen.
+        #
+        ("hover", (80, 24), (100, 12)),  # Right of screen.
+        ("hover", (80, 24), (100, 36)),  # Bottom-right of screen.
+        ("hover", (80, 24), (50, 36)),  # Under screen.
+        ("hover", (80, 24), (-10, 36)),  # Bottom-left of screen.
+        ("hover", (80, 24), (-10, 12)),  # Left of screen.
+        ("hover", (80, 24), (-10, -2)),  # Top-left of screen.
+        ("hover", (80, 24), (50, -2)),  # Above screen.
+        ("hover", (80, 24), (100, -2)),  # Top-right of screen.
+        #
+        ("hover", (5, 5), (7, 3)),  # Right of screen.
+        ("hover", (5, 5), (7, 7)),  # Bottom-right of screen.
+        ("hover", (5, 5), (3, 7)),  # Under screen.
+        ("hover", (5, 5), (-1, 7)),  # Bottom-left of screen.
+        ("hover", (5, 5), (-1, 3)),  # Left of 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.
+    ],
+)
+async def test_pilot_target_outside_screen_errors(method, screen_size, offset):
+    """Make sure that targeting a click/hover completely outside of the screen errors."""
+    app = App()
+    async with app.run_test(size=screen_size) as pilot:
+        pilot_method = getattr(pilot, method)
+        with pytest.raises(OutOfBounds):
+            await pilot_method(offset=offset)
+
+
+@pytest.mark.parametrize(
+    ["method", "offset"],
+    [
+        ("click", (0, 0)),  # Top-left corner.
+        ("click", (40, 0)),  # Top edge.
+        ("click", (79, 0)),  # Top-right corner.
+        ("click", (79, 12)),  # Right edge.
+        ("click", (79, 23)),  # Bottom-right corner.
+        ("click", (40, 23)),  # Bottom edge.
+        ("click", (40, 23)),  # Bottom-left corner.
+        ("click", (0, 12)),  # Left edge.
+        ("click", (40, 12)),  # Right in the middle.
+        #
+        ("hover", (0, 0)),  # Top-left corner.
+        ("hover", (40, 0)),  # Top edge.
+        ("hover", (79, 0)),  # Top-right corner.
+        ("hover", (79, 12)),  # Right edge.
+        ("hover", (79, 23)),  # Bottom-right corner.
+        ("hover", (40, 23)),  # Bottom edge.
+        ("hover", (40, 23)),  # Bottom-left corner.
+        ("hover", (0, 12)),  # Left edge.
+        ("hover", (40, 12)),  # Right in the middle.
+    ],
+)
+async def test_pilot_target_inside_screen_is_fine_with_correct_coordinate_system(
+    method, offset
+):
+    """Make sure that the coordinate system for the click is the correct one.
+
+    Especially relevant because I kept getting confused about the way it works.
+    """
+    app = ManyLabelsApp()
+    async with app.run_test(size=(80, 24)) as pilot:
+        app.query_one("#label99").scroll_visible(animate=False)
+        await pilot.pause()
+
+        pilot_method = getattr(pilot, method)
+        await pilot_method(offset=offset)
+
+
+@pytest.mark.parametrize(
+    ["method", "target"],
+    [
+        ("click", "#label0"),
+        ("click", "#label90"),
+        ("click", Button),
+        #
+        ("hover", "#label0"),
+        ("hover", "#label90"),
+        ("hover", Button),
+    ],
+)
+async def test_pilot_target_on_widget_that_is_not_visible_errors(method, target):
+    """Make sure that clicking a widget that is not scrolled into view raises an error."""
+    app = ManyLabelsApp()
+    async with app.run_test(size=(80, 5)) as pilot:
+        app.query_one("#label50").scroll_visible(animate=False)
+        await pilot.pause()
+
+        pilot_method = getattr(pilot, method)
+        with pytest.raises(OutOfBounds):
+            await pilot_method(target)
+
+
+@pytest.mark.parametrize("method", ["click", "hover"])
+async def test_pilot_target_widget_under_another_widget(method):
+    """The targeting method should return False when the targeted widget is covered."""
+
+    class ObscuredButton(App):
+        CSS = """
+        Label {
+            width: 30;
+            height: 5;
+        }
+        """
+
+        def compose(self):
+            yield Button()
+            yield Label()
+
+        def on_mount(self):
+            self.query_one(Label).styles.offset = (0, -3)
+
+    app = ObscuredButton()
+    async with app.run_test() as pilot:
+        await pilot.pause()
+        pilot_method = getattr(pilot, method)
+        assert (await pilot_method(Button)) is False
+
+
+@pytest.mark.parametrize("method", ["click", "hover"])
+async def test_pilot_target_visible_widget(method):
+    """The targeting method should return True when the targeted widget is hit."""
+
+    class ObscuredButton(App):
+        def compose(self):
+            yield Button()
+
+    app = ObscuredButton()
+    async with app.run_test() as pilot:
+        await pilot.pause()
+        pilot_method = getattr(pilot, method)
+        assert (await pilot_method(Button)) is True
+
+
+@pytest.mark.parametrize(
+    ["method", "offset"],
+    [
+        ("click", (0, 0)),
+        ("click", (2, 0)),
+        ("click", (10, 23)),
+        ("click", (70, 0)),
+        #
+        ("hover", (0, 0)),
+        ("hover", (2, 0)),
+        ("hover", (10, 23)),
+        ("hover", (70, 0)),
+    ],
+)
+async def test_pilot_target_screen_always_true(method, offset):
+    app = ManyLabelsApp()
+    async with app.run_test(size=(80, 24)) as pilot:
+        pilot_method = getattr(pilot, method)
+        assert (await pilot_method(offset=offset)) is True