Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions src/prompt_toolkit/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +81,9 @@

from .toolbars import SearchToolbar

if TYPE_CHECKING:
from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone

__all__ = [
"TextArea",
"Label",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -844,14 +861,18 @@ 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.
"""
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):
Expand Down
114 changes: 112 additions & 2 deletions tests/test_widgets.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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