diff --git a/src/prompt_toolkit/widgets/__init__.py b/src/prompt_toolkit/widgets/__init__.py index 9d1d4e3de..431e427da 100644 --- a/src/prompt_toolkit/widgets/__init__.py +++ b/src/prompt_toolkit/widgets/__init__.py @@ -32,6 +32,7 @@ SystemToolbar, ValidationToolbar, ) +from .vertmenu import VertMenu __all__ = [ # Base. @@ -59,4 +60,6 @@ # Menus. "MenuContainer", "MenuItem", + # Vertical menu. + "VertMenu", ] diff --git a/src/prompt_toolkit/widgets/vertmenu.py b/src/prompt_toolkit/widgets/vertmenu.py new file mode 100644 index 000000000..bdeaa8e0f --- /dev/null +++ b/src/prompt_toolkit/widgets/vertmenu.py @@ -0,0 +1,146 @@ +"""Vertical menu widget""" + +from typing import Callable, Iterable, Optional, Tuple + +from prompt_toolkit.application import get_app +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.layout.containers import Container, Window + +from .vertmenuuicontrol import Item, VertMenuUIControl + +E = KeyPressEvent + + +class VertMenu: + def __init__( + self, + items: Iterable[Item], + selected_item: Optional[Item] = None, + selected_handler: Optional[ + Callable[[Optional[Item], Optional[int]], None] + ] = None, + accept_handler: Optional[Callable[[Item], None]] = None, + focusable: bool = True, + max_width: Optional[int] = None, + ): + self.accept_handler = accept_handler + self.control = VertMenuUIControl( + items, + focusable=focusable, + key_bindings=self._init_key_bindings(), + selected_handler=selected_handler, + ) + self.max_width = max_width + self.window = Window( + self.control, width=self.preferred_width, style=self.get_style + ) + self.focus_window: Container = self.window + if selected_item is not None: + self.control.selected_item = selected_item + + def _init_key_bindings(self) -> KeyBindings: + kb = KeyBindings() + + @kb.add("c-home") + @kb.add("escape", "home") + @kb.add("c-pageup") + def _first(event: E) -> None: + self.control.go_first() + + @kb.add("c-end") + @kb.add("escape", "end") + @kb.add("c-pagedown") + def _last(event: E) -> None: + self.control.go_last() + + @kb.add("up") + def _up(event: E) -> None: + self.control.go_relative(-1) + + @kb.add("down") + def _down(event: E) -> None: + self.control.go_relative(1) + + @kb.add("pageup") + def _pageup(event: E) -> None: + w = self.window + if w.render_info: + self.control.go_relative(-len(w.render_info.displayed_lines)) + + @kb.add("pagedown") + def _pagedown(event: E) -> None: + w = self.window + if w.render_info: + self.control.go_relative(len(w.render_info.displayed_lines)) + + @kb.add(" ") + @kb.add("enter") + def _enter(event: E) -> None: + self.handle_accept() + + return kb + + def get_style(self) -> str: + if get_app().layout.has_focus(self.focus_window): + return "class:vertmenu.focused" + else: + return "class:vertmenu.unfocused" + + def handle_selected(self) -> None: + self.control.handle_selected() + + def handle_accept(self) -> None: + if self.accept_handler is not None and self.control.selected_item is not None: + self.accept_handler(self.control.selected_item) + + def preferred_width(self) -> int: + width = self.control.preferred_width(0) + assert width + if self.max_width is not None: + return min(width, self.max_width) + return width + + @property + def items(self) -> Tuple[Item, ...]: + return self.control.items + + @items.setter + def items(self, items: Iterable[Item]) -> None: + self.control.items = tuple(items) + + @property + def selected(self) -> Optional[int]: + return self.control.selected + + @selected.setter + def selected(self, selected: int) -> None: + self.control.selected = selected + + @property + def selected_item(self) -> Optional[Item]: + return self.control.selected_item + + @selected_item.setter + def selected_item(self, item: Item) -> None: + self.control.selected_item = item + + @property + def selected_handler(self) -> Optional[Callable[[Optional[Item], int], None]]: + return self.control.selected_handler + + @selected_handler.setter + def selected_handler( + self, + selected_handler: Optional[Callable[[Optional[Item], Optional[int]], None]], + ) -> None: + self.control.selected_handler = selected_handler + + def __pt_container__(self) -> Container: + return self.window + + +__all__ = [ + "VertMenu", + "Item", +] diff --git a/src/prompt_toolkit/widgets/vertmenuuicontrol.py b/src/prompt_toolkit/widgets/vertmenuuicontrol.py new file mode 100644 index 000000000..5d1eab6d2 --- /dev/null +++ b/src/prompt_toolkit/widgets/vertmenuuicontrol.py @@ -0,0 +1,239 @@ +"""Vertical menu widget UIControl""" + +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterable, + Iterator, + NewType, + Optional, + Tuple, + cast, +) + +from prompt_toolkit.data_structures import Point +from prompt_toolkit.filters import FilterOrBool, to_filter +from prompt_toolkit.formatted_text import ( + AnyFormattedText, + StyleAndTextTuples, + split_lines, + to_formatted_text, + to_plain_text, +) +from prompt_toolkit.key_binding.key_bindings import KeyBindingsBase +from prompt_toolkit.layout.controls import GetLinePrefixCallable, UIContent, UIControl +from prompt_toolkit.mouse_events import MouseEvent, MouseEventType + +if TYPE_CHECKING: + from prompt_toolkit.key_binding.key_bindings import NotImplementedOrNone + +Item = Tuple[AnyFormattedText, Any] +Index = NewType("Index", int) + + +class VertMenuUIControl(UIControl): + """UIControl optimized for VertMenu""" + + def __init__( + self, + items: Iterable[Item], + focusable: FilterOrBool = True, + key_bindings: Optional[KeyBindingsBase] = None, + selected_handler: Optional[ + Callable[[Optional[Item], Optional[int]], None] + ] = None, + ): + self._items = tuple(items) + self._selected: Optional[Index] = Index(0) + self.focusable = to_filter(focusable) + self.key_bindings = key_bindings + self.selected_handler = selected_handler + self._width = 30 + # Mark if the last movement we did was down: + self._moved_down = False + # ^ We use this to show the complete label of the item at the + # bottom of the screen when it's the selected one. + self._lineno_to_index: Dict[int, Index] = {} + self._index_to_lineno: Dict[Index, int] = {} + self._gen_lineno_mappings() + self.handle_selected() + + def handle_selected(self) -> None: + if self.selected_handler is not None: + self.selected_handler(self.selected_item, self.selected) + + def _items_enumerate(self) -> Iterator[Tuple[Index, Item]]: + for index, item in enumerate(self._items): + yield Index(index), item + + def _gen_lineno_mappings(self) -> None: + # Create the lineno <-> item mappings: + self._lineno_to_index.clear() + self._index_to_lineno.clear() + lineno = 0 + self._width = 30 + for index, item in self._items_enumerate(): + self._index_to_lineno[Index(index)] = lineno + for formatted_line in split_lines(to_formatted_text(item[0])): + line = to_plain_text(formatted_line) + self._lineno_to_index[lineno] = index + lineno += 1 + self._width = max(self._width, len(line)) + + @property + def items(self) -> Tuple[Item, ...]: + return self._items + + @items.setter + def items(self, items: Iterable[Item]) -> None: + previous = None + if self._items and self._selected is not None: + previous = self._items[self._selected] + self._items = tuple(items) + if self._items: + self._selected = Index(0) + else: + self._selected = None + self._moved_down = False + self._gen_lineno_mappings() + if previous is None: + self.handle_selected() + return + # We keep the same selected item, if possible: + try: + self.selected_item = previous + except IndexError: + # Not possible, let's just handle the current item: + self.handle_selected() + + @property + def selected(self) -> Optional[int]: + if self._selected is None or not self._items: + return None + return cast(int, self._selected) + + @selected.setter + def selected(self, selected: Optional[int]) -> None: + previous = self._selected + if selected is None: + self._selected = None + else: + selected = max(0, selected) + selected = min(selected, len(self._items) - 1) + self._selected = Index(selected) + if previous is not None: + self._moved_down = self._selected > previous + if self._selected != previous: + self.handle_selected() + + @property + def selected_item(self) -> Optional[Item]: + if self._selected is None or not self._items: + return None + return self._items[self._selected] + + @selected_item.setter + def selected_item(self, item: Optional[Item]) -> None: + if item is None: + self._selected = None + return + for index, current in self._items_enumerate(): + if current == item: + self._selected = index + return + raise IndexError + + def preferred_width(self, max_available_width: int) -> Optional[int]: + return self._width + + def preferred_height( + self, + width: int, + max_available_height: int, + wrap_lines: bool, + get_line_prefix: Optional[GetLinePrefixCallable], + ) -> Optional[int]: + return len(self._lineno_to_index) + + def is_focusable(self) -> bool: + return self.focusable() + + def _get_line(self, lineno: int) -> StyleAndTextTuples: + index = self._lineno_to_index[lineno] + item = self._items[index] + itemlines = list(split_lines(to_formatted_text(item[0]))) + line = itemlines[lineno - self._index_to_lineno[index]] + if self.selected_item == item: + style = "class:vertmenu.selected" + else: + style = "class:vertmenu.item" + return [(frag[0] + " " + style if frag[0] else style, frag[1]) for frag in line] + + def _cursor_position(self) -> Point: + item = self.selected_item + if item is None: + return Point(x=0, y=0) + if self._selected is None: + return Point(x=0, y=0) + lineno = self._index_to_lineno[self._selected] + if self._moved_down: + # Put the cursor in the last line of a multi-line item if + # we have moved down to show the full label if it is at + # the bottom of the screen: + while self._lineno_to_index.get(lineno + 1) == self.selected: + lineno += 1 + return Point(x=0, y=lineno) + + def create_content(self, width: int, height: int) -> UIContent: + return UIContent( + get_line=self._get_line, + line_count=len(self._lineno_to_index), + show_cursor=False, + cursor_position=self._cursor_position(), + ) + + def mouse_handler(self, mouse_event: MouseEvent) -> "NotImplementedOrNone": + if mouse_event.event_type != MouseEventType.MOUSE_DOWN: + return NotImplemented + index = self._lineno_to_index.get(mouse_event.position.y) + if index is not None: + self.selected = index + return None + + def move_cursor_down(self) -> None: + self.go_relative(1) + # Unmark _moved_down because this is only called when the + # cursor is at the top: + self._moved_down = False + + def move_cursor_up(self) -> None: + self.go_relative(-1) + # Mark _moved_down because this called when the cursor is at + # the bottom: + self._moved_down = True + + def get_key_bindings(self) -> Optional[KeyBindingsBase]: + return self.key_bindings + + def go_first(self) -> None: + if not self._items: + self._selected = None + return + self.selected = 0 + + def go_last(self) -> None: + if not self._items: + self._selected = None + return + self.selected = len(self.items) - 1 + + def go_relative(self, positions: int) -> None: + if not self._items: + self._selected = None + return + if self.selected is None: + self.selected = 0 + else: + self.selected += positions diff --git a/tests/test_vertmenuuicontrol.py b/tests/test_vertmenuuicontrol.py new file mode 100644 index 000000000..2138427f7 --- /dev/null +++ b/tests/test_vertmenuuicontrol.py @@ -0,0 +1,211 @@ +"""ptvertmenuuicontrol tests""" + +import unittest +from typing import List + +from prompt_toolkit.data_structures import Point +from prompt_toolkit.mouse_events import MouseButton, MouseEvent, MouseEventType +from prompt_toolkit.widgets.vertmenuuicontrol import Item, VertMenuUIControl + + +def mouse_click(lineno: int) -> MouseEvent: + return MouseEvent( + Point(x=0, y=lineno), MouseEventType.MOUSE_DOWN, MouseButton.LEFT, frozenset() + ) + + +class TestVertMenuUIControlSingle(unittest.TestCase): + def setUp(self) -> None: + self.items = [ + (item, item) for item in ["breakfast", "lunch", "dinner", "midnight"] + ] + self.control = VertMenuUIControl(self.items) + + def test_methods(self) -> None: + """Just call all methods and let exceptions fail""" + self.control.items = self.control.items + self.control.selected = self.control.selected + self.control.selected_item = self.control.selected_item + self.control.preferred_width(999) + self.control.preferred_height(999, 999, False, None) + self.control.is_focusable() + for i in range(len(self.items)): + self.control._get_line(i) + self.control._cursor_position() + self.control.create_content(30, len(self.items)) + self.control.mouse_handler(mouse_click(0)) + self.control.move_cursor_down() + self.control.move_cursor_up() + self.control.get_key_bindings() + + def check_lines(self, selected: int) -> None: + self.assertEqual(selected, self.control.selected) + self.assertEqual(selected, self.control._cursor_position().y) + self.assertEqual(self.items[selected], self.control.selected_item) + lines = [self.control._get_line(i) for i in range(len(self.items))] + for lineno, line in enumerate(lines): + # Each line has one fragment + self.assertEqual(len(line), 1) + # The fragment is a tuple: (style, text) + self.assertEqual(len(line[0]), 2) + if lineno == selected: + self.assertEqual(line[0][0], "class:vertmenu.selected") + else: + self.assertEqual(line[0][0], "class:vertmenu.item") + + def test_default(self) -> None: + self.assertTrue(self.control.is_focusable()) + self.assertEqual(self.control.selected, 0) + self.assertEqual(self.control.selected_item, ("breakfast", "breakfast")) + self.check_lines(0) + + def test_dimensions(self) -> None: + self.assertEqual(self.control.preferred_width(999), 30) + self.assertEqual( + self.control.preferred_height(999, 999, False, None), len(self.items) + ) + + def test_selected(self) -> None: + assert self.control.selected is not None + self.control.selected += 1 + self.assertEqual(self.control.selected, 1) + self.assertEqual(self.control.selected_item, ("lunch", "lunch")) + self.check_lines(1) + + def test_selected_item(self) -> None: + self.control.selected_item = ("dinner", "dinner") + self.assertEqual(self.control.selected, 2) + self.assertEqual(self.control.selected_item, ("dinner", "dinner")) + self.check_lines(2) + + def test_selected_item_invalid(self) -> None: + with self.assertRaises(IndexError): + self.control.selected_item = ("Sunday", "Sunday") + self.check_lines(0) + + def test_selected_limits(self) -> None: + self.control.selected = -1 + self.assertEqual(self.control.selected, 0) + self.control.selected = len(self.items) + 1 + self.assertEqual(self.control.selected, len(self.items) - 1) + + def test_items(self) -> None: + self.control.selected = 1 + self.assertEqual(self.control.selected_item, ("lunch", "lunch")) + self.control.items = tuple(self.items[:2]) + self.assertEqual(self.control.items, tuple(self.items[:2])) + self.assertEqual(self.control.selected, 1) + self.assertEqual(self.control.selected_item, ("lunch", "lunch")) + self.control.items = tuple(self.items[1:]) + self.assertEqual(self.control.selected, 0) + self.assertEqual(self.control.selected_item, ("lunch", "lunch")) + self.control.items = tuple([self.items[0]] + self.items[2:]) + self.assertEqual(self.control.selected, 0) + self.assertEqual(self.control.selected_item, ("breakfast", "breakfast")) + self.control.items = () + self.assertEqual(self.control.selected, None) + self.assertEqual(self.control.selected_item, None) + self.control.items = tuple(self.items) + self.assertEqual(self.control.items, tuple(self.items)) + self.assertEqual(self.control.selected, 0) + self.assertEqual(self.control.selected_item, ("breakfast", "breakfast")) + + def test_items_width_update(self) -> None: + bigitem = ", ".join(str(i) for i in range(50)) + self.control.items = ((bigitem, bigitem),) + width = self.control.preferred_width(999) + self.assertEqual(width, len(bigitem)) + # Check if using a smaller bigitem updates the width + bigitem = ", ".join(str(i) for i in range(30)) + self.control.items = ((bigitem, bigitem),) + width = self.control.preferred_width(999) + self.assertEqual(width, len(bigitem)) + + def test_select_none(self) -> None: + self.control.selected = None + self.assertEqual(self.control.selected_item, None) + + def test_select_item_none(self) -> None: + self.control.selected_item = None + self.assertEqual(self.control.selected, None) + + def test_mouse(self) -> None: + for i in range(len(self.items)): + self.control.mouse_handler(mouse_click(i)) + self.check_lines(i) + + +class TestVertMenuUIControlEmpty(unittest.TestCase): + def setUp(self) -> None: + self.items: tuple[Item, ...] = () + self.control = VertMenuUIControl(self.items) + + def test_methods(self) -> None: + """Just call all methods and let exceptions fail""" + self.control.items = self.control.items + self.assertEqual(self.control.selected, None) + self.control.selected = self.control.selected + self.assertEqual(self.control.selected_item, None) + self.control.selected_item = None + self.control.preferred_width(999) + self.control.preferred_height(999, 999, False, None) + self.control.is_focusable() + with self.assertRaises(KeyError): + self.control._get_line(0) + self.control._cursor_position() + self.control.create_content(30, len(self.items)) + self.control.mouse_handler(mouse_click(0)) + self.control.move_cursor_down() + self.control.move_cursor_up() + self.control.get_key_bindings() + + def test_dimensions(self) -> None: + self.assertEqual(self.control.preferred_width(999), 30) + self.assertEqual(self.control.preferred_height(999, 999, False, None), 0) + + +class TestVertMenuUIControlMultiLine(unittest.TestCase): + LINES = 3 + + def setUp(self) -> None: + self.items: List[tuple[str, str]] = [] + for i in range(5): + self.items.append((self.label(i), self.label(i))) + self.control = VertMenuUIControl(self.items) + + def label(self, item: int) -> str: + return "\n".join(f"item {item} line {lineno}" for lineno in range(self.LINES)) + + def test_down_up(self) -> None: + assert self.control.selected is not None + self.control.selected += 1 + # Check that we are in the bottom line of the item: + self.assertEqual(self.control.selected, 1) + self.assertEqual(self.control._cursor_position().y, 5) + # Now go down and up + self.control.selected += 1 + self.control.selected -= 1 + # We are back in the same item, but on the first line: + self.assertEqual(self.control._cursor_position().y, 3) + + def test_scroll_down(self) -> None: + self.control.move_cursor_down() + # If we scroll the first item out of the screen, we go to the + # first line of the second item: + self.assertEqual(self.control.selected, 1) + self.assertEqual(self.control._cursor_position().y, 3) + + def test_scroll_up(self) -> None: + self.control.selected = len(self.items) - 1 + # If we scroll the last item out of the screen, we go to the + # last line of the previous item: + self.control.move_cursor_up() + selected = len(self.items) - 2 + self.assertEqual(self.control.selected, selected) + lineno = self.LINES * selected + self.LINES - 1 + self.assertEqual(self.control._cursor_position().y, lineno) + + def test_mouse(self) -> None: + for i in reversed(range(self.LINES * len(self.items))): + self.control.mouse_handler(mouse_click(i)) + self.assertEqual(self.control.selected, i // self.LINES)