From 7fdc1e51d955e3689d0f3e90dee6b856a40291ed Mon Sep 17 00:00:00 2001 From: fancidev Date: Sun, 27 Jul 2025 18:51:48 +0800 Subject: [PATCH] Support MouseScrollLeft and MouseScrollRight control sequences. --- src/textual/_xterm_parser.py | 9 ++++--- src/textual/events.py | 18 +++++++++++++ src/textual/widget.py | 19 ++++++++++++- tests/test_xterm_parser.py | 52 ++++++++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index fc9f4baa6..6203163cc 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -100,9 +100,12 @@ class XTermParser(Parser[Message]): event_class: type[events.MouseEvent] if buttons & 64: - event_class = ( - events.MouseScrollDown if buttons & 1 else events.MouseScrollUp - ) + event_class = [ + events.MouseScrollUp, + events.MouseScrollDown, + events.MouseScrollLeft, + events.MouseScrollRight, + ][buttons & 3] button = 0 else: button = (buttons + 1) & 3 diff --git a/src/textual/events.py b/src/textual/events.py index bb51262ba..29271cb1a 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -606,6 +606,24 @@ class MouseScrollUp(MouseEvent, bubble=True, verbose=True): """ +@rich.repr.auto +class MouseScrollRight(MouseEvent, bubble=True, verbose=True): + """Sent when the mouse wheel is scrolled *right*. + + - [X] Bubbles + - [X] Verbose + """ + + +@rich.repr.auto +class MouseScrollLeft(MouseEvent, bubble=True, verbose=True): + """Sent when the mouse wheel is scrolled *left*. + + - [X] Bubbles + - [X] Verbose + """ + + class Click(MouseEvent, bubble=True): """Sent when a widget is clicked. diff --git a/src/textual/widget.py b/src/textual/widget.py index fef4d011d..e60b8ecf3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -113,7 +113,12 @@ _JUSTIFY_MAP: dict[str, JustifyMethod] = { _MOUSE_EVENTS_DISALLOW_IF_DISABLED = (events.MouseEvent, events.Enter, events.Leave) -_MOUSE_EVENTS_ALLOW_IF_DISABLED = (events.MouseScrollDown, events.MouseScrollUp) +_MOUSE_EVENTS_ALLOW_IF_DISABLED = ( + events.MouseScrollDown, + events.MouseScrollUp, + events.MouseScrollRight, + events.MouseScrollLeft, +) @rich.repr.auto @@ -4569,6 +4574,18 @@ class Widget(DOMNode): if self._scroll_up_for_pointer(animate=False): event.stop() + def _on_mouse_scroll_right(self, event: events.MouseScrollRight) -> None: + if self.allow_horizontal_scroll: + self.release_anchor() + if self._scroll_right_for_pointer(animate=False): + event.stop() + + def _on_mouse_scroll_left(self, event: events.MouseScrollLeft) -> None: + if self.allow_horizontal_scroll: + self.release_anchor() + if self._scroll_left_for_pointer(animate=False): + event.stop() + def _on_scroll_to(self, message: ScrollTo) -> None: if self._allow_scroll: self.release_anchor() diff --git a/tests/test_xterm_parser.py b/tests/test_xterm_parser.py index 39b46dc07..609313232 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -8,6 +8,8 @@ from textual.events import ( MouseDown, MouseMove, MouseScrollDown, + MouseScrollLeft, + MouseScrollRight, MouseScrollUp, MouseUp, Paste, @@ -283,6 +285,56 @@ def test_mouse_scroll_down(parser, sequence, shift, meta): assert event.meta is meta +@pytest.mark.parametrize( + "sequence, shift, meta", + [ + ("\x1b[<66;18;25M", False, False), + ("\x1b[<70;18;25M", True, False), + ("\x1b[<74;18;25M", False, True), + ], +) +def test_mouse_scroll_left(parser, sequence, shift, meta): + """Scrolling the mouse with and without modifiers held down. + We don't currently capture modifier keys in scroll events. + """ + events = list(parser.feed(sequence)) + + assert len(events) == 1 + + event = events[0] + + assert isinstance(event, MouseScrollLeft) + assert event.x == 17 + assert event.y == 24 + assert event.shift is shift + assert event.meta is meta + + +@pytest.mark.parametrize( + "sequence, shift, meta", + [ + ("\x1b[<67;18;25M", False, False), + ("\x1b[<71;18;25M", True, False), + ("\x1b[<75;18;25M", False, True), + ], +) +def test_mouse_scroll_right(parser, sequence, shift, meta): + """Scrolling the mouse with and without modifiers held down. + We don't currently capture modifier keys in scroll events. + """ + events = list(parser.feed(sequence)) + + assert len(events) == 1 + + event = events[0] + + assert isinstance(event, MouseScrollRight) + assert event.x == 17 + assert event.y == 24 + assert event.shift is shift + assert event.meta is meta + + def test_mouse_event_detected_but_info_not_parsed(parser): # I don't know if this can actually happen in reality, but # there's a branch in the code that allows for the possibility.