from __future__ import annotations from typing import Callable, Iterable, Sequence from prompt_toolkit.application.current import get_app from prompt_toolkit.filters import Condition from prompt_toolkit.formatted_text.base import OneStyleAndTextTuple, StyleAndTextTuples from prompt_toolkit.key_binding.key_bindings import KeyBindings, KeyBindingsBase from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.keys import Keys from prompt_toolkit.layout.containers import ( AnyContainer, ConditionalContainer, Container, Float, FloatContainer, HSplit, Window, ) from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.mouse_events import MouseEvent, MouseEventType from prompt_toolkit.utils import get_cwidth from prompt_toolkit.widgets import Shadow from .base import Border __all__ = [ "MenuContainer", "MenuItem", ] E = KeyPressEvent class MenuContainer: """ :param floats: List of extra Float objects to display. :param menu_items: List of `MenuItem` objects. """ def __init__( self, body: AnyContainer, menu_items: list[MenuItem], floats: list[Float] | None = None, key_bindings: KeyBindingsBase | None = None, ) -> None: self.body = body self.menu_items = menu_items self.selected_menu = [0] # Key bindings. kb = KeyBindings() @Condition def in_main_menu() -> bool: return len(self.selected_menu) == 1 @Condition def in_sub_menu() -> bool: return len(self.selected_menu) > 1 # Navigation through the main menu. @kb.add("left", filter=in_main_menu) def _left(event: E) -> None: self.selected_menu[0] = max(0, self.selected_menu[0] - 1) @kb.add("right", filter=in_main_menu) def _right(event: E) -> None: self.selected_menu[0] = min( len(self.menu_items) - 1, self.selected_menu[0] + 1 ) @kb.add("down", filter=in_main_menu) def _down(event: E) -> None: self.selected_menu.append(0) @kb.add("c-c", filter=in_main_menu) @kb.add("c-g", filter=in_main_menu) def _cancel(event: E) -> None: "Leave menu." event.app.layout.focus_last() # Sub menu navigation. @kb.add("left", filter=in_sub_menu) @kb.add("c-g", filter=in_sub_menu) @kb.add("c-c", filter=in_sub_menu) def _back(event: E) -> None: "Go back to parent menu." if len(self.selected_menu) > 1: self.selected_menu.pop() @kb.add("right", filter=in_sub_menu) def _submenu(event: E) -> None: "go into sub menu." if self._get_menu(len(self.selected_menu) - 1).children: self.selected_menu.append(0) # If This item does not have a sub menu. Go up in the parent menu. elif ( len(self.selected_menu) == 2 and self.selected_menu[0] < len(self.menu_items) - 1 ): self.selected_menu = [ min(len(self.menu_items) - 1, self.selected_menu[0] + 1) ] if self.menu_items[self.selected_menu[0]].children: self.selected_menu.append(0) @kb.add("up", filter=in_sub_menu) def _up_in_submenu(event: E) -> None: "Select previous (enabled) menu item or return to main menu." # Look for previous enabled items in this sub menu. menu = self._get_menu(len(self.selected_menu) - 2) index = self.selected_menu[-1] previous_indexes = [ i for i, item in enumerate(menu.children) if i < index and not item.disabled ] if previous_indexes: self.selected_menu[-1] = previous_indexes[-1] elif len(self.selected_menu) == 2: # Return to main menu. self.selected_menu.pop() @kb.add("down", filter=in_sub_menu) def _down_in_submenu(event: E) -> None: "Select next (enabled) menu item." menu = self._get_menu(len(self.selected_menu) - 2) index = self.selected_menu[-1] next_indexes = [ i for i, item in enumerate(menu.children) if i > index and not item.disabled ] if next_indexes: self.selected_menu[-1] = next_indexes[0] @kb.add("enter") def _click(event: E) -> None: "Click the selected menu item." item = self._get_menu(len(self.selected_menu) - 1) if item.handler: event.app.layout.focus_last() item.handler() # Controls. self.control = FormattedTextControl( self._get_menu_fragments, key_bindings=kb, focusable=True, show_cursor=False ) self.window = Window(height=1, content=self.control, style="class:menu-bar") submenu = self._submenu(0) submenu2 = self._submenu(1) submenu3 = self._submenu(2) @Condition def has_focus() -> bool: return get_app().layout.current_window == self.window self.container = FloatContainer( content=HSplit( [ # The titlebar. self.window, # The 'body', like defined above. body, ] ), floats=[ Float( xcursor=True, ycursor=True, content=ConditionalContainer( content=Shadow(body=submenu), filter=has_focus ), ), Float( attach_to_window=submenu, xcursor=True, ycursor=True, allow_cover_cursor=True, content=ConditionalContainer( content=Shadow(body=submenu2), filter=has_focus & Condition(lambda: len(self.selected_menu) >= 1), ), ), Float( attach_to_window=submenu2, xcursor=True, ycursor=True, allow_cover_cursor=True, content=ConditionalContainer( content=Shadow(body=submenu3), filter=has_focus & Condition(lambda: len(self.selected_menu) >= 2), ), ), # -- ] + (floats or []), key_bindings=key_bindings, ) def _get_menu(self, level: int) -> MenuItem: menu = self.menu_items[self.selected_menu[0]] for i, index in enumerate(self.selected_menu[1:]): if i < level: try: menu = menu.children[index] except IndexError: return MenuItem("debug") return menu def _get_menu_fragments(self) -> StyleAndTextTuples: focused = get_app().layout.has_focus(self.window) # This is called during the rendering. When we discover that this # widget doesn't have the focus anymore. Reset menu state. if not focused: self.selected_menu = [0] # Generate text fragments for the main menu. def one_item(i: int, item: MenuItem) -> Iterable[OneStyleAndTextTuple]: def mouse_handler(mouse_event: MouseEvent) -> None: hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE if ( mouse_event.event_type == MouseEventType.MOUSE_DOWN or hover and focused ): # Toggle focus. app = get_app() if not hover: if app.layout.has_focus(self.window): if self.selected_menu == [i]: app.layout.focus_last() else: app.layout.focus(self.window) self.selected_menu = [i] yield ("class:menu-bar", " ", mouse_handler) if i == self.selected_menu[0] and focused: yield ("[SetMenuPosition]", "", mouse_handler) style = "class:menu-bar.selected-item" else: style = "class:menu-bar" yield style, item.text, mouse_handler result: StyleAndTextTuples = [] for i, item in enumerate(self.menu_items): result.extend(one_item(i, item)) return result def _submenu(self, level: int = 0) -> Window: def get_text_fragments() -> StyleAndTextTuples: result: StyleAndTextTuples = [] if level < len(self.selected_menu): menu = self._get_menu(level) if menu.children: result.append(("class:menu", Border.TOP_LEFT)) result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4))) result.append(("class:menu", Border.TOP_RIGHT)) result.append(("", "\n")) try: selected_item = self.selected_menu[level + 1] except IndexError: selected_item = -1 def one_item( i: int, item: MenuItem ) -> Iterable[OneStyleAndTextTuple]: def mouse_handler(mouse_event: MouseEvent) -> None: if item.disabled: # The arrow keys can't interact with menu items that are disabled. # The mouse shouldn't be able to either. return hover = mouse_event.event_type == MouseEventType.MOUSE_MOVE if ( mouse_event.event_type == MouseEventType.MOUSE_UP or hover ): app = get_app() if not hover and item.handler: app.layout.focus_last() item.handler() else: self.selected_menu = self.selected_menu[ : level + 1 ] + [i] if i == selected_item: yield ("[SetCursorPosition]", "") style = "class:menu-bar.selected-item" else: style = "" yield ("class:menu", Border.VERTICAL) if item.text == "-": yield ( style + "class:menu-border", f"{Border.HORIZONTAL * (menu.width + 3)}", mouse_handler, ) else: yield ( style, f" {item.text}".ljust(menu.width + 3), mouse_handler, ) if item.children: yield (style, ">", mouse_handler) else: yield (style, " ", mouse_handler) if i == selected_item: yield ("[SetMenuPosition]", "") yield ("class:menu", Border.VERTICAL) yield ("", "\n") for i, item in enumerate(menu.children): result.extend(one_item(i, item)) result.append(("class:menu", Border.BOTTOM_LEFT)) result.append(("class:menu", Border.HORIZONTAL * (menu.width + 4))) result.append(("class:menu", Border.BOTTOM_RIGHT)) return result return Window(FormattedTextControl(get_text_fragments), style="class:menu") @property def floats(self) -> list[Float] | None: return self.container.floats def __pt_container__(self) -> Container: return self.container class MenuItem: def __init__( self, text: str = "", handler: Callable[[], None] | None = None, children: list[MenuItem] | None = None, shortcut: Sequence[Keys | str] | None = None, disabled: bool = False, ) -> None: self.text = text self.handler = handler self.children = children or [] self.shortcut = shortcut self.disabled = disabled self.selected_item = 0 @property def width(self) -> int: if self.children: return max(get_cwidth(c.text) for c in self.children) else: return 0