Spaces:
Sleeping
Sleeping
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() | |
def in_main_menu() -> bool: | |
return len(self.selected_menu) == 1 | |
def in_sub_menu() -> bool: | |
return len(self.selected_menu) > 1 | |
# Navigation through the main menu. | |
def _left(event: E) -> None: | |
self.selected_menu[0] = max(0, self.selected_menu[0] - 1) | |
def _right(event: E) -> None: | |
self.selected_menu[0] = min( | |
len(self.menu_items) - 1, self.selected_menu[0] + 1 | |
) | |
def _down(event: E) -> None: | |
self.selected_menu.append(0) | |
def _cancel(event: E) -> None: | |
"Leave menu." | |
event.app.layout.focus_last() | |
# Sub menu navigation. | |
def _back(event: E) -> None: | |
"Go back to parent menu." | |
if len(self.selected_menu) > 1: | |
self.selected_menu.pop() | |
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) | |
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() | |
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] | |
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) | |
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") | |
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 | |
def width(self) -> int: | |
if self.children: | |
return max(get_cwidth(c.text) for c in self.children) | |
else: | |
return 0 | |