2023-04-13 03:49:16 +03:00
|
|
|
import re
|
2023-04-23 01:54:21 +03:00
|
|
|
from typing import Any, Callable
|
2023-04-13 03:49:16 +03:00
|
|
|
from textual import events
|
2023-04-23 00:49:15 +03:00
|
|
|
from textual.containers import Container
|
|
|
|
from textual.reactive import var
|
2023-04-13 03:49:16 +03:00
|
|
|
from textual.widgets import Button, Static
|
2023-04-24 07:43:50 +03:00
|
|
|
from textual.message import Message
|
2023-04-25 04:02:50 +03:00
|
|
|
from textual.dom import NoScreen
|
2023-04-22 20:53:00 +03:00
|
|
|
from rich.text import Text
|
2023-04-25 04:02:24 +03:00
|
|
|
from localization.i18n import markup_hotkey, get_hotkey, get_direction
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-23 01:54:21 +03:00
|
|
|
def to_snake_case(name: str) -> str:
|
2023-04-13 03:49:16 +03:00
|
|
|
name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
|
|
|
name = re.sub('__([A-Z])', r'_\1', name)
|
|
|
|
name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', name)
|
|
|
|
return name.lower()
|
|
|
|
|
|
|
|
class Menu(Container):
|
2023-04-22 09:26:42 +03:00
|
|
|
"""A menu widget. Note that menus can't be reused in multiple places."""
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-24 08:12:32 +03:00
|
|
|
class StatusInfo(Message):
|
|
|
|
"""Sent when hovering over a menu item, or to reset when leaving a menu item or when menu is closed."""
|
|
|
|
def __init__(self, description: str|None, closed: bool = False) -> None:
|
|
|
|
super().__init__()
|
|
|
|
self.description = description
|
|
|
|
self.closed = closed
|
2023-04-24 07:43:50 +03:00
|
|
|
|
2023-04-13 03:49:16 +03:00
|
|
|
items = var([])
|
|
|
|
focus_index = var(0)
|
|
|
|
|
2023-04-23 04:35:21 +03:00
|
|
|
def __init__(self, items: list['MenuItem|Separator'], **kwargs: Any) -> None:
|
2023-04-13 03:49:16 +03:00
|
|
|
"""Initialize a menu."""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.items = items
|
2023-04-23 01:54:21 +03:00
|
|
|
# These are set when opening a submenu
|
|
|
|
self.parent_menu: Menu | None = None
|
|
|
|
self.parent_menu_item: MenuItem | None = None
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-25 04:02:50 +03:00
|
|
|
|
|
|
|
def mount_items(self) -> None:
|
|
|
|
"""Mount the menu items."""
|
|
|
|
for item in self.items:
|
2023-04-13 05:07:38 +03:00
|
|
|
self.mount(item)
|
|
|
|
if item.submenu:
|
2023-04-25 04:02:50 +03:00
|
|
|
self.screen.mount(item.submenu)
|
2023-04-13 05:07:38 +03:00
|
|
|
item.submenu.close()
|
2023-04-25 03:48:37 +03:00
|
|
|
if isinstance(item, MenuItem):
|
|
|
|
item.parent_menu = self
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-25 04:02:50 +03:00
|
|
|
def watch_items(self, old_items: list['MenuItem|Separator'], new_items: list['MenuItem|Separator']) -> None:
|
|
|
|
"""Update the menu items."""
|
|
|
|
for item in old_items:
|
|
|
|
item.remove()
|
|
|
|
try:
|
|
|
|
self.mount_items()
|
|
|
|
except NoScreen:
|
|
|
|
pass
|
|
|
|
|
|
|
|
def on_mount(self) -> None:
|
|
|
|
"""Called when the menu is mounted."""
|
|
|
|
self.mount_items()
|
|
|
|
|
2023-04-13 03:49:16 +03:00
|
|
|
def on_key(self, event: events.Key) -> None:
|
|
|
|
"""Called when the user presses a key."""
|
|
|
|
|
|
|
|
if event.key == "up":
|
|
|
|
self.focus_index -= 1
|
|
|
|
if self.focus_index < 0:
|
|
|
|
self.focus_index = len(self.items) - 1
|
|
|
|
elif event.key == "down":
|
|
|
|
self.focus_index += 1
|
|
|
|
if self.focus_index >= len(self.items):
|
|
|
|
self.focus_index = 0
|
2023-04-13 05:07:38 +03:00
|
|
|
elif event.key == "escape":
|
|
|
|
self.close()
|
2023-04-16 06:50:27 +03:00
|
|
|
if self.parent_menu:
|
|
|
|
self.parent_menu.focus()
|
2023-04-23 08:43:54 +03:00
|
|
|
elif event.is_printable:
|
2023-04-24 21:56:21 +03:00
|
|
|
# There doesn't seem to be a way to detect if alt is pressed
|
|
|
|
if isinstance(self, MenuBar): #and not event.alt:
|
|
|
|
return
|
2023-04-23 08:43:54 +03:00
|
|
|
for item in self.items:
|
|
|
|
if isinstance(item, MenuItem) and item.hotkey and event.character:
|
|
|
|
if item.hotkey.lower() == event.character.lower():
|
|
|
|
item.press()
|
|
|
|
break
|
|
|
|
|
2023-04-13 05:07:38 +03:00
|
|
|
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
|
|
"""Called when a button is clicked or activated with the keyboard."""
|
|
|
|
|
2023-04-19 01:01:37 +03:00
|
|
|
if isinstance(event.button, MenuItem):
|
2023-04-24 07:56:51 +03:00
|
|
|
if event.button.has_class("grayed"):
|
|
|
|
# TODO: use disabled property once Textual fixes mouse wheel events on disabled buttons
|
|
|
|
# and we have a way to listen for the mouse Enter event on disabled buttons
|
|
|
|
return
|
2023-04-13 05:07:38 +03:00
|
|
|
if event.button.action:
|
|
|
|
event.button.action()
|
|
|
|
root_menu = self
|
|
|
|
while root_menu.parent_menu:
|
|
|
|
root_menu = root_menu.parent_menu
|
|
|
|
root_menu.close()
|
|
|
|
elif event.button.submenu:
|
|
|
|
was_open = event.button.submenu.display
|
|
|
|
for item in self.items:
|
|
|
|
if item.submenu:
|
|
|
|
item.submenu.close()
|
|
|
|
if not was_open:
|
2023-04-13 06:09:34 +03:00
|
|
|
event.button.submenu.open(self, event.button)
|
2023-04-13 05:07:38 +03:00
|
|
|
|
2023-04-23 01:54:21 +03:00
|
|
|
def open(self, parent_menu: 'Menu', parent_menu_item: 'MenuItem') -> None:
|
2023-04-13 05:07:38 +03:00
|
|
|
self.display = True
|
|
|
|
if len(self.items) > 0:
|
|
|
|
self.items[0].focus()
|
|
|
|
self.parent_menu = parent_menu
|
2023-04-13 06:09:34 +03:00
|
|
|
self.parent_menu_item = parent_menu_item
|
2023-04-13 05:42:48 +03:00
|
|
|
self.add_class("menu_popup")
|
2023-04-19 07:09:40 +03:00
|
|
|
|
2023-04-21 00:35:02 +03:00
|
|
|
if isinstance(parent_menu, MenuBar):
|
2023-04-19 07:09:40 +03:00
|
|
|
self.styles.offset = (parent_menu_item.region.x, parent_menu_item.region.y + parent_menu_item.region.height)
|
|
|
|
else:
|
|
|
|
# JS code for reference
|
|
|
|
# https://github.com/1j01/os-gui/blob/bb4df0f0c26969c089858118130975cd137cdac8/MenuBar.js#L618-L644
|
|
|
|
# submenu_popup_el corresponds to self
|
|
|
|
# submenu_popup_rect corresponds to self.region
|
|
|
|
# rect corresponds to parent_menu_item.region
|
|
|
|
# scroll offset doesn't apply here
|
|
|
|
|
|
|
|
# const rect = item_el.getBoundingClientRect();
|
|
|
|
# let submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
|
|
|
|
# submenu_popup_el.style.left = `${(get_direction() === "rtl" ? rect.left - submenu_popup_rect.width : rect.right) + window.scrollX}px`;
|
|
|
|
# submenu_popup_el.style.top = `${rect.top + window.scrollY}px`;
|
|
|
|
# submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
|
|
|
|
# if (get_direction() === "rtl") {
|
|
|
|
# if (submenu_popup_rect.left < 0) {
|
|
|
|
# submenu_popup_el.style.left = `${rect.right}px`;
|
|
|
|
# submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
|
|
|
|
# if (submenu_popup_rect.right > innerWidth) {
|
|
|
|
# submenu_popup_el.style.left = `${innerWidth - submenu_popup_rect.width}px`;
|
|
|
|
# }
|
|
|
|
# }
|
|
|
|
# } else {
|
|
|
|
# if (submenu_popup_rect.right > innerWidth) {
|
|
|
|
# submenu_popup_el.style.left = `${rect.left - submenu_popup_rect.width}px`;
|
|
|
|
# submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
|
|
|
|
# if (submenu_popup_rect.left < 0) {
|
|
|
|
# submenu_popup_el.style.left = "0";
|
|
|
|
# }
|
|
|
|
# }
|
|
|
|
# }
|
|
|
|
|
|
|
|
rect = parent_menu_item.region
|
|
|
|
self.styles.offset = (
|
|
|
|
rect.x - self.region.width if get_direction() == "rtl" else rect.x + rect.width,
|
|
|
|
rect.y
|
|
|
|
)
|
|
|
|
if get_direction() == "rtl":
|
|
|
|
if self.region.x < 0:
|
|
|
|
self.styles.offset = (rect.x + rect.width, rect.y)
|
|
|
|
if self.region.x + self.region.width > self.screen.size.width:
|
|
|
|
self.styles.offset = (self.screen.size.width - self.region.width, rect.y)
|
|
|
|
else:
|
|
|
|
if self.region.x + self.region.width > self.screen.size.width:
|
|
|
|
self.styles.offset = (rect.x - self.region.width, rect.y)
|
|
|
|
if self.region.x < 0:
|
|
|
|
self.styles.offset = (0, rect.y)
|
2023-04-19 03:43:51 +03:00
|
|
|
|
|
|
|
# Find the widest label
|
|
|
|
max_width = 0
|
2023-04-23 08:03:52 +03:00
|
|
|
# any_submenus = False
|
2023-04-19 03:43:51 +03:00
|
|
|
for item in self.items:
|
|
|
|
if isinstance(item, MenuItem):
|
2023-04-22 20:53:00 +03:00
|
|
|
assert isinstance(item.label, Text)
|
2023-04-19 03:43:51 +03:00
|
|
|
if len(item.label.plain) > max_width:
|
|
|
|
max_width = len(item.label.plain)
|
2023-04-23 08:03:52 +03:00
|
|
|
if item.submenu:
|
|
|
|
# any_submenus = True
|
|
|
|
# Add a right pointing triangle to the label
|
|
|
|
# TODO: Make this work generally. Right now I've just spaced it for View > Zoom in English.
|
|
|
|
# Also, all this layout stuff should ideally use the width, not the length, of text.
|
|
|
|
# And it's stupid that this code applies multiple times to the same menu,
|
|
|
|
# and has to be idempotent.
|
|
|
|
# Basically I should rewrite this whole thing.
|
|
|
|
# I'd like to try using the built-in ListView widget for menus.
|
|
|
|
if not item.label.plain.endswith("▶"):
|
|
|
|
item.label = item.label.markup + "\t ▶"
|
2023-04-19 03:43:51 +03:00
|
|
|
# Split on tab character and align the shortcuts
|
|
|
|
for item in self.items:
|
|
|
|
if isinstance(item, MenuItem):
|
2023-04-22 20:53:00 +03:00
|
|
|
assert isinstance(item.label, Text)
|
2023-04-19 03:43:51 +03:00
|
|
|
markup_parts = item.label.markup.split("\t")
|
|
|
|
plain_parts = item.label.plain.split("\t")
|
|
|
|
if len(markup_parts) > 1:
|
|
|
|
item.label = markup_parts[0] + " " * (max_width - len(plain_parts[0])) + markup_parts[1]
|
2023-04-13 05:07:38 +03:00
|
|
|
|
|
|
|
def close(self):
|
|
|
|
for item in self.items:
|
|
|
|
if item.submenu:
|
|
|
|
item.submenu.close()
|
|
|
|
if not isinstance(self, MenuBar):
|
|
|
|
self.display = False
|
2023-04-24 08:12:32 +03:00
|
|
|
self.post_message(Menu.StatusInfo(None, closed=True))
|
2023-04-24 19:53:20 +03:00
|
|
|
|
|
|
|
def any_menus_open(self) -> bool:
|
|
|
|
for item in self.items:
|
|
|
|
if item.submenu and item.submenu.display:
|
|
|
|
return True
|
|
|
|
return False
|
2023-04-13 03:49:16 +03:00
|
|
|
|
|
|
|
class MenuBar(Menu):
|
|
|
|
"""A menu bar widget."""
|
|
|
|
|
2023-04-23 04:35:21 +03:00
|
|
|
def __init__(self, items: list['MenuItem|Separator'], **kwargs: Any) -> None:
|
2023-04-13 03:49:16 +03:00
|
|
|
"""Initialize a menu bar."""
|
|
|
|
super().__init__(items, **kwargs)
|
|
|
|
|
|
|
|
|
2023-04-13 05:07:38 +03:00
|
|
|
class MenuItem(Button):
|
|
|
|
"""A menu item widget."""
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-23 01:54:21 +03:00
|
|
|
def __init__(self,
|
|
|
|
name: str,
|
2023-04-26 20:19:06 +03:00
|
|
|
action: Callable[[], Any] | None = None,
|
2023-04-23 01:54:21 +03:00
|
|
|
id: str | int | None = None,
|
|
|
|
submenu: Menu | None = None,
|
2023-04-24 07:43:50 +03:00
|
|
|
description: str | None = None,
|
2023-04-23 01:54:21 +03:00
|
|
|
grayed: bool = False,
|
|
|
|
**kwargs: Any
|
|
|
|
) -> None:
|
2023-04-13 03:49:16 +03:00
|
|
|
"""Initialize a menu item."""
|
2023-04-19 03:43:51 +03:00
|
|
|
super().__init__(markup_hotkey(name), **kwargs)
|
2023-04-23 08:43:54 +03:00
|
|
|
self.hotkey: str|None = get_hotkey(name)
|
2023-04-24 07:56:51 +03:00
|
|
|
# self.disabled = grayed # This breaks scroll wheel over the menu item, as of Textual 0.20.1
|
|
|
|
if grayed:
|
|
|
|
self.add_class("grayed")
|
|
|
|
self.can_focus = False
|
2023-04-13 03:49:16 +03:00
|
|
|
self.action = action
|
|
|
|
self.submenu = submenu
|
2023-04-24 07:43:50 +03:00
|
|
|
self.description = description
|
2023-04-24 22:16:38 +03:00
|
|
|
self.parent_menu: Menu | None = None # set when mounted
|
2023-04-19 01:01:37 +03:00
|
|
|
if isinstance(id, str):
|
2023-04-13 05:07:38 +03:00
|
|
|
self.id = id
|
2023-04-19 01:01:37 +03:00
|
|
|
elif id:
|
|
|
|
self.id = "rc_" + str(id)
|
2023-04-13 05:07:38 +03:00
|
|
|
else:
|
|
|
|
self.id = "menu_item_" + to_snake_case(name)
|
2023-04-24 07:43:50 +03:00
|
|
|
|
|
|
|
def on_enter(self, event: events.Enter) -> None:
|
2023-04-24 22:16:38 +03:00
|
|
|
if isinstance(self.parent_menu, MenuBar):
|
|
|
|
# The message is only reset to the default help text on close, so don't change it while no menu is open.
|
|
|
|
# (The top level menus don't have descriptions anyway.)
|
|
|
|
return
|
2023-04-24 08:12:32 +03:00
|
|
|
self.post_message(Menu.StatusInfo(self.description))
|
|
|
|
def on_leave(self, event: events.Leave) -> None:
|
2023-04-24 22:16:38 +03:00
|
|
|
if isinstance(self.parent_menu, MenuBar):
|
|
|
|
# The message is only reset to the default help text on close, so don't clear it while no menu is open.
|
|
|
|
return
|
2023-04-24 08:12:32 +03:00
|
|
|
self.post_message(Menu.StatusInfo(None))
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-19 01:01:37 +03:00
|
|
|
|
|
|
|
mid_line = "─" * 100
|
2023-04-13 05:07:38 +03:00
|
|
|
class Separator(Static):
|
|
|
|
"""A menu separator widget."""
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-23 04:35:21 +03:00
|
|
|
def __init__(self, **kwargs: Any) -> None:
|
2023-04-13 05:07:38 +03:00
|
|
|
"""Initialize a separator."""
|
2023-04-19 01:01:37 +03:00
|
|
|
super().__init__(mid_line, **kwargs)
|
2023-04-23 08:43:54 +03:00
|
|
|
self.hotkey = None
|
2023-04-19 21:03:25 +03:00
|
|
|
# self.disabled = True # This breaks scroll wheel over the separator, as of Textual 0.20.1
|
|
|
|
self.disabled = False
|
2023-04-19 01:01:37 +03:00
|
|
|
self.action = None
|
|
|
|
self.submenu = None
|