From 7fdc1e51d955e3689d0f3e90dee6b856a40291ed Mon Sep 17 00:00:00 2001 From: fancidev Date: Sun, 27 Jul 2025 18:51:48 +0800 Subject: [PATCH 1/2] 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 fc9f4baa61..6203163ccb 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -100,9 +100,12 @@ def parse_mouse_code(self, code: str) -> Message | None: 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 bb51262bab..29271cb1ab 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 fef4d011d7..e60b8ecf37 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -113,7 +113,12 @@ _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 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: 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 39b46dc078..609313232c 100644 --- a/tests/test_xterm_parser.py +++ b/tests/test_xterm_parser.py @@ -8,6 +8,8 @@ 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. From 207cb38a2b65865a619fe2cec941b8fe5d1f6f4e Mon Sep 17 00:00:00 2001 From: fancidev Date: Mon, 28 Jul 2025 06:36:57 +0800 Subject: [PATCH 2/2] Update docs with MouseScrollLeft and MouseScrollRight. --- docs/events/click.md | 2 ++ docs/events/enter.md | 2 ++ docs/events/leave.md | 2 ++ docs/events/mouse_down.md | 2 ++ docs/events/mouse_move.md | 2 ++ docs/events/mouse_scroll_down.md | 2 ++ docs/events/mouse_scroll_left.md | 22 ++++++++++++++++++++++ docs/events/mouse_scroll_right.md | 22 ++++++++++++++++++++++ docs/events/mouse_scroll_up.md | 2 ++ docs/events/mouse_up.md | 2 ++ docs/guide/input.md | 2 ++ 11 files changed, 62 insertions(+) create mode 100644 docs/events/mouse_scroll_left.md create mode 100644 docs/events/mouse_scroll_right.md diff --git a/docs/events/click.md b/docs/events/click.md index e93dd8d33b..a9252077d2 100644 --- a/docs/events/click.md +++ b/docs/events/click.md @@ -20,5 +20,7 @@ See [MouseEvent][textual.events.MouseEvent] for the list of properties and metho - [MouseEvent][textual.events.MouseEvent] - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/enter.md b/docs/events/enter.md index d6d532b913..5b81987cf6 100644 --- a/docs/events/enter.md +++ b/docs/events/enter.md @@ -9,5 +9,7 @@ - [MouseDown](mouse_down.md) - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/leave.md b/docs/events/leave.md index e6231890da..a0ae675ae1 100644 --- a/docs/events/leave.md +++ b/docs/events/leave.md @@ -9,5 +9,7 @@ - [MouseDown](mouse_down.md) - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_down.md b/docs/events/mouse_down.md index 83d5e21cf4..a8c64d9dc3 100644 --- a/docs/events/mouse_down.md +++ b/docs/events/mouse_down.md @@ -16,5 +16,7 @@ See [MouseEvent][textual.events.MouseEvent] for the full list of properties and - [MouseEvent][textual.events.MouseEvent] - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_move.md b/docs/events/mouse_move.md index 3ac6632265..73d688e9df 100644 --- a/docs/events/mouse_move.md +++ b/docs/events/mouse_move.md @@ -16,5 +16,7 @@ See [MouseEvent][textual.events.MouseEvent] for the full list of properties and - [MouseDown](mouse_down.md) - [MouseEvent][textual.events.MouseEvent] - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_scroll_down.md b/docs/events/mouse_scroll_down.md index bf51c7ff83..e4e79a3770 100644 --- a/docs/events/mouse_scroll_down.md +++ b/docs/events/mouse_scroll_down.md @@ -16,5 +16,7 @@ See [MouseEvent][textual.events.MouseEvent] for the full list of properties and - [MouseDown](mouse_down.md) - [MouseEvent][textual.events.MouseEvent] - [MouseMove](mouse_move.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_scroll_left.md b/docs/events/mouse_scroll_left.md new file mode 100644 index 0000000000..67e25b3f56 --- /dev/null +++ b/docs/events/mouse_scroll_left.md @@ -0,0 +1,22 @@ +--- +title: MouseScrollLeft +--- + +::: textual.events.MouseScrollLeft + options: + heading_level: 1 + +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. + +## See also + +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollRight](mouse_scroll_right.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_scroll_right.md b/docs/events/mouse_scroll_right.md new file mode 100644 index 0000000000..77aa6372e0 --- /dev/null +++ b/docs/events/mouse_scroll_right.md @@ -0,0 +1,22 @@ +--- +title: MouseScrollRight +--- + +::: textual.events.MouseScrollRight + options: + heading_level: 1 + +See [MouseEvent][textual.events.MouseEvent] for the full list of properties and methods. + +## See also + +- [Click](click.md) +- [Enter](enter.md) +- [Leave](leave.md) +- [MouseDown](mouse_down.md) +- [MouseEvent][textual.events.MouseEvent] +- [MouseMove](mouse_move.md) +- [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollUp](mouse_scroll_up.md) +- [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_scroll_up.md b/docs/events/mouse_scroll_up.md index facba94fdc..d864747e69 100644 --- a/docs/events/mouse_scroll_up.md +++ b/docs/events/mouse_scroll_up.md @@ -17,4 +17,6 @@ See [MouseEvent][textual.events.MouseEvent] for the full list of properties and - [MouseEvent][textual.events.MouseEvent] - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseUp](mouse_up.md) diff --git a/docs/events/mouse_up.md b/docs/events/mouse_up.md index 97baf1d4ae..e472fb193d 100644 --- a/docs/events/mouse_up.md +++ b/docs/events/mouse_up.md @@ -17,4 +17,6 @@ See [MouseEvent][textual.events.MouseEvent] for the full list of properties and - [MouseEvent][textual.events.MouseEvent] - [MouseMove](mouse_move.md) - [MouseScrollDown](mouse_scroll_down.md) +- [MouseScrollLeft](mouse_scroll_left.md) +- [MouseScrollRight](mouse_scroll_right.md) - [MouseScrollUp](mouse_scroll_up.md) diff --git a/docs/guide/input.md b/docs/guide/input.md index 8d216eda9e..012794a1e8 100644 --- a/docs/guide/input.md +++ b/docs/guide/input.md @@ -262,6 +262,8 @@ If you want your app to respond to a mouse click you should prefer the Click eve Most mice have a scroll wheel which you can use to scroll the window underneath the cursor. Scrollable containers in Textual will handle these automatically, but you can handle [MouseScrollDown](../events/mouse_scroll_down.md) and [MouseScrollUp](../events/mouse_scroll_up.md) if you want build your own scrolling functionality. +For terminals that support horizontal mouse wheel, Textual sends [MouseScrollLeft](../events/mouse_scroll_left.md) and [MouseScrollRight](../events/mouse_scroll_right.md), and scrollable containers handle them automatically. + !!! information Terminal emulators will typically convert trackpad gestures into scroll events.