From 4f2eeeabefb1bfd43419a1153a17c632ee82b0ad Mon Sep 17 00:00:00 2001 From: leeshengis Date: Sat, 31 Oct 2026 10:35:21 +0800 Subject: [PATCH] Fix mouse-wheel scrolling in radio and checkbox lists --- src/prompt_toolkit/widgets/base.py | 39 +++++++--- tests/test_widgets.py | 114 ++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/src/prompt_toolkit/widgets/base.py b/src/prompt_toolkit/widgets/base.py index 6c59fa8ea..7771cbc89 100644 --- a/src/prompt_toolkit/widgets/base.py +++ b/src/prompt_toolkit/widgets/base.py @@ -17,7 +17,7 @@ from collections.abc import Callable, Sequence from functools import partial -from typing import Generic, TypeVar +from typing import TYPE_CHECKING, Generic, TypeVar from prompt_toolkit.application.current import get_app from prompt_toolkit.auto_suggest import AutoSuggest, DynamicAutoSuggest @@ -81,6 +81,9 @@ from .toolbars import SearchToolbar +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + __all__ = [ "TextArea", "Label", @@ -753,22 +756,28 @@ def __init__( else: self._selected_index = 0 + def move_cursor_up() -> None: + self._selected_index = max(0, self._selected_index - 1) + if select_on_focus: + self._handle_enter() + + def move_cursor_down() -> None: + self._selected_index = min(len(self.values) - 1, self._selected_index + 1) + if select_on_focus: + self._handle_enter() + # Key bindings. kb = KeyBindings() @kb.add("up") @kb.add("k") # Vi-like. def _up(event: E) -> None: - self._selected_index = max(0, self._selected_index - 1) - if select_on_focus: - self._handle_enter() + move_cursor_up() @kb.add("down") @kb.add("j") # Vi-like. def _down(event: E) -> None: - self._selected_index = min(len(self.values) - 1, self._selected_index + 1) - if select_on_focus: - self._handle_enter() + move_cursor_down() @kb.add("pageup") def _pageup(event: E) -> None: @@ -813,8 +822,16 @@ def _select_i(event: E, index: int = i) -> None: if select_on_focus: self._handle_enter() + # Let Window keep the cursor visible when scrolling with the mouse. + class DialogListControl(FormattedTextControl): + def move_cursor_up(self) -> None: + move_cursor_up() + + def move_cursor_down(self) -> None: + move_cursor_down() + # Control and window. - self.control = FormattedTextControl( + self.control = DialogListControl( self._get_text_fragments, key_bindings=kb, focusable=True, @@ -844,7 +861,7 @@ def _handle_enter(self) -> None: self.current_value = self.values[self._selected_index][0] def _get_text_fragments(self) -> StyleAndTextTuples: - def mouse_handler(mouse_event: MouseEvent) -> None: + def mouse_handler(mouse_event: MouseEvent) -> NotImplementedOrNone: """ Set `_selected_index` and `current_value` according to the y position of the mouse click event. @@ -852,6 +869,10 @@ def mouse_handler(mouse_event: MouseEvent) -> None: if mouse_event.event_type == MouseEventType.MOUSE_UP: self._selected_index = mouse_event.position.y self._handle_enter() + return None + + # Let Window handle scrolling. + return NotImplemented result: StyleAndTextTuples = [] for i, value in enumerate(self.values): diff --git a/tests/test_widgets.py b/tests/test_widgets.py index ee7745a2d..37a4af1a7 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,8 +1,19 @@ from __future__ import annotations +import pytest + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import set_app +from prompt_toolkit.data_structures import Point from prompt_toolkit.formatted_text import fragment_list_to_text -from prompt_toolkit.layout import to_window -from prompt_toolkit.widgets import Button +from prompt_toolkit.input import DummyInput +from prompt_toolkit.layout import Layout, to_window +from prompt_toolkit.layout.containers import HSplit +from prompt_toolkit.layout.mouse_handlers import MouseHandlers +from prompt_toolkit.layout.screen import Screen, WritePosition +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.widgets import Button, CheckboxList, RadioList def _to_text(button: Button) -> str: @@ -18,3 +29,102 @@ def test_default_button(): def test_custom_button(): button = Button("Exit", left_symbol="[", right_symbol="]") assert _to_text(button) == "[ Exit ]" + + +def _render_list(app, widget): + """Render a short viewport, including the real screen-coordinate handlers.""" + app.layout.update_parents_relations() + app.render_counter += 1 + handlers = MouseHandlers() + widget.window.write_to_screen( + Screen(), handlers, WritePosition(0, 0, 20, 3), "", True, None + ) + return handlers + + +def _mouse_event(handlers, event_type): + return handlers.mouse_handlers[1][1]( + MouseEvent( + Point(x=1, y=1), + event_type, + MouseButton.LEFT + if event_type == MouseEventType.MOUSE_UP + else MouseButton.NONE, + frozenset(), + ) + ) + + +@pytest.mark.parametrize("widget_class", [RadioList, CheckboxList]) +@pytest.mark.parametrize("focused", [True, False]) +def test_dialog_list_mouse_wheel_scrolls(widget_class, focused): + widget = widget_class([(i, str(i)) for i in range(10)]) + app = Application(layout=Layout(widget), input=DummyInput(), output=DummyOutput()) + if not focused: + button = Button("Other") + app.layout = Layout(HSplit([widget, button]), focused_element=button) + current_value = widget.current_value + current_values = widget.current_values[:] + + with set_app(app): + handlers = _render_list(app, widget) + for expected_scroll in [1, 2, 3, 4, 5, 6, 7, 7, 7]: + _mouse_event(handlers, MouseEventType.SCROLL_DOWN) + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == expected_scroll + assert 0 <= widget._selected_index < 10 + assert widget.current_value == current_value + assert widget.current_values == current_values + + for expected_scroll in [6, 5, 4, 3, 2, 1, 0, 0, 0]: + _mouse_event(handlers, MouseEventType.SCROLL_UP) + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == expected_scroll + assert 0 <= widget._selected_index < 10 + assert widget.current_value == current_value + assert widget.current_values == current_values + + # Screen coordinates must still select the correct item after scrolling. + for _ in range(2): + _mouse_event(handlers, MouseEventType.SCROLL_DOWN) + handlers = _render_list(app, widget) + _mouse_event(handlers, MouseEventType.MOUSE_UP) + assert widget._selected_index == 3 + if isinstance(widget, CheckboxList): + assert widget.current_values == [3] + handlers = _render_list(app, widget) + _mouse_event(handlers, MouseEventType.MOUSE_UP) + assert widget.current_values == [] + else: + assert widget.current_value == 3 + + +def test_radio_list_mouse_wheel_select_on_focus(): + widget = RadioList( + [(i, str(i)) for i in range(10)], default=2, select_on_focus=True + ) + app = Application(layout=Layout(widget), input=DummyInput(), output=DummyOutput()) + + with set_app(app): + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == 0 + # Scrolling does not change selection while the cursor remains visible. + for expected_scroll in [1, 2]: + _mouse_event(handlers, MouseEventType.SCROLL_DOWN) + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == expected_scroll + assert widget.current_value == 2 + # Once needed, move the cursor and honor select_on_focus. + _mouse_event(handlers, MouseEventType.SCROLL_DOWN) + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == 3 + assert widget._selected_index == widget.current_value == 3 + for expected_scroll in [2, 1]: + _mouse_event(handlers, MouseEventType.SCROLL_UP) + handlers = _render_list(app, widget) + assert widget.window.vertical_scroll == expected_scroll + assert widget.current_value == 3 + _mouse_event(handlers, MouseEventType.SCROLL_UP) + _render_list(app, widget) + assert widget.window.vertical_scroll == 0 + assert widget._selected_index == widget.current_value == 2