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:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c37d8f9f5..074af2235 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
   - `is_blank`
 - Constant `Select.BLANK` to flag an empty selection https://github.com/Textualize/textual/pull/3614
 - Added `restrict`, `type`, `max_length`, and `valid_empty` to Input https://github.com/Textualize/textual/pull/3657
+- Added `Pilot.mouse_down` to simulate `MouseDown` events https://github.com/Textualize/textual/pull/3495
+- Added `Pilot.mouse_up` to simulate `MouseUp` events https://github.com/Textualize/textual/pull/3495
 
 ### Changed
 
@@ -39,6 +41,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Breaking change: Setting `Select.value` to `None` no longer clears the selection (See `Select.BLANK` and `Select.clear`) https://github.com/Textualize/textual/pull/3614
 - Breaking change: `Button` no longer inherits from `Static`, now it inherits directly from `Widget` https://github.com/Textualize/textual/issues/3603
 - Rich markup in markdown headings is now escaped when building the TOC https://github.com/Textualize/textual/issues/3689
+- Mechanics behind mouse clicks. See [this](https://github.com/Textualize/textual/pull/3495#issue-1934915047) for more details. https://github.com/Textualize/textual/pull/3495
 
 
 ## [0.41.0] - 2023-10-31
diff --git a/src/textual/driver.py b/src/textual/driver.py
index 267666390..5e472f5bc 100644
--- a/src/textual/driver.py
+++ b/src/textual/driver.py
@@ -4,11 +4,12 @@ import asyncio
 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 @@ class Driver(ABC):
         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 @@ class Driver(ABC):
         """
         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 @@ class Driver(ABC):
 
         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
index 9069f61a3..c3bcbe297 100644
--- a/src/textual/pilot.py
+++ b/src/textual/pilot.py
@@ -15,7 +15,7 @@ import rich.repr
 
 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 @@ class Pilot(Generic[ReturnType]):
             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 @@ class Pilot(Generic[ReturnType]):
         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 @@ class Pilot(Generic[ReturnType]):
             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 @@ class Pilot(Generic[ReturnType]):
             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 @@ class Pilot(Generic[ReturnType]):
             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
index 807edcc11..070e0016b 100644
--- a/src/textual/screen.py
+++ b/src/textual/screen.py
@@ -963,12 +963,7 @@ class Screen(Generic[ScreenResultType], Widget):
                 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
index 000000000..e3b5feba8
--- /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
index 486356bfe..90f3db220 100644
--- a/tests/test_focus.py
+++ b/tests/test_focus.py
@@ -311,6 +311,38 @@ async def test_focus_chain_handles_inherited_visibility():
         ]
 
 
+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
index 43789bb20..0650c258a 100644
--- a/tests/test_pilot.py
+++ b/tests/test_pilot.py
@@ -6,6 +6,7 @@ from textual import events
 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 @@ async def test_pilot_target_widget_under_another_widget(method):
         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 @@ async def test_pilot_target_visible_widget(method):
         ("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
index afb3bfaef..f8d0c0294 100644
--- 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):