You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			375 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			375 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			Python
		
	
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
 |