Repository: Textualize/textual

Issue:
Border demonstration acts improperly under scrolling
Recipe to reproduce:

Run `textual borders`. Click on `[Heavy`]. Border will change. Scroll down buttons panel to click on `[Wide]`. At top of screen click topmost visible button (in my case `[Heavy]`, but YMMV). _Ensure that only the bottom two rows of the topmost button is visible._

Expected action: Tool selects appropriate border, and is otherwise unchanged.

Actual result: Buttons panel scrolls up to top and the topmost item (`[ascii]`) and selects that border.

The actual response seems to vary depending on exact elements selected and scrolling position, but this report should be sufficient to at least observe the anomalous behaviour.

`textual diagnose` output:
```
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.38.1 |
| Rich    | 13.5.3 |

## Python

| Name           | Value                                                                                   |
|----------------|-----------------------------------------------------------------------------------------|
| Version        | 3.10.13                                                                                 |
| Implementation | CPython                                                                                 |
| Compiler       | Clang 14.0.3 (clang-1403.0.22.14.1)                                                     |
| Executable     | /Users/sholden/Library/Caches/pypoetry/virtualenvs/textutils-sKIyU2bn-py3.10/bin/python |

## Operating System

| Name    | Value                                                                                                |
|---------|------------------------------------------------------------------------------------------------------|
| System  | Darwin                                                                                               |
| Release | 22.1.0                                                                                               |
| Version | Darwin Kernel Version 22.1.0: Sun Oct  9 20:14:30 PDT 2022; root:xnu-8792.41.9~2/RELEASE_ARM64_T8103 |

## Terminal

| Name                 | Value              |
|----------------------|--------------------|
| Terminal Application | iTerm.app (3.4.20) |
| TERM                 | xterm-256color     |
| COLORTERM            | truecolor          |
| FORCE_COLOR          | *Not set*          |
| NO_COLOR             | *Not set*          |

## Rich Console options

| Name           | Value               |
|----------------|---------------------|
| size           | width=80, height=38 |
| legacy_windows | False               |
| min_width      | 1                   |
| max_width      | 80                  |
| is_terminal    | True                |
| encoding       | utf-8               |
| max_height     | 38                  |
| justify        | None                |
| overflow       | None                |
| no_wrap        | False               |
| highlight      | None                |
| markup         | None                |
| height         | None                |
```
Feel free to add screenshots and / or videos. These can be very helpful!



Discussion:
We found the following entries in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [How do I center a widget in a screen?](https://textual.textualize.io/FAQ/#how-do-i-center-a-widget-in-a-screen)
- [Does Textual support images?](https://textual.textualize.io/FAQ/#does-textual-support-images)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Thanks. I can reproduce this.

I'm guessing it is because the scroll in to view is occurring on mouse down, but the button press is registered on mouse up. Or something of that nature.
Great, glad the report gave you the information you needed. Not a major problem, just another small positive improvement :)
I can repro as well.
If I press the mouse button down and hold it down, the scroll into view happens. If I hold for long enough (cf. code below), when I release the mouse button the border preview doesn't change.

https://github.com/Textualize/textual/blob/89991025e9c21fc293ee3233fde5c0b4f679061b/src/textual/driver.py#L103

So, this seems to corroborate Will's suspicion that the scroll/preview change happen on different events.

Resolving patch:
diff --git a/src/textual/driver.py b/src/textual/driver.py
--- 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/pilot.py b/src/textual/pilot.py
--- 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/src/textual/screen.py b/src/textual/screen.py
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -963,12 +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)
-                elif isinstance(event, events.MouseUp) and widget.focusable:
-                    if self.focused is not widget:
-                        self.set_focus(widget)
-                        event.stop()
-                        return
+                    self.set_focus(widget, scroll_visible=False)
                 event.style = self.get_style_at(event.screen_x, event.screen_y)
                 if widget is self:
                     event._set_forwarded()


Test patch:
diff --git a/tests/test_driver.py b/tests/test_driver.py
new file mode 100644
--- /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
--- a/tests/test_focus.py
+++ b/tests/test_focus.py
@@ -311,6 +311,38 @@ def compose(self):
         ]
 
 
+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
+
+
 async def test_focus_pseudo_class():
     """Test focus and blue pseudo classes"""
 
diff --git a/tests/test_pilot.py b/tests/test_pilot.py
--- 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):
diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py
--- 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):