123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- 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
|