2023-04-13 03:49:16 +03:00
|
|
|
import re
|
|
|
|
from enum import Enum
|
|
|
|
from typing import List
|
|
|
|
from textual import events
|
|
|
|
from textual.message import Message, MessageTarget
|
|
|
|
from textual.app import ComposeResult
|
|
|
|
from textual.containers import Container, Horizontal, Vertical
|
|
|
|
from textual.geometry import Offset, Region, Size
|
|
|
|
from textual.reactive import var, reactive
|
|
|
|
from textual.widget import Widget
|
|
|
|
from textual.widgets import Button, Static
|
2023-04-19 07:09:40 +03:00
|
|
|
from localization.i18n import markup_hotkey, get_direction
|
2023-04-13 03:49:16 +03:00
|
|
|
|
|
|
|
def to_snake_case(name):
|
|
|
|
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
|
|
|
|
|
|
|
items = var([])
|
|
|
|
focus_index = var(0)
|
|
|
|
|
2023-04-22 09:27:54 +03:00
|
|
|
def __init__(self, items: List['MenuItem|Separator'], **kwargs) -> None:
|
2023-04-13 03:49:16 +03:00
|
|
|
"""Initialize a menu."""
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.items = items
|
2023-04-13 05:07:38 +03:00
|
|
|
self.parent_menu = None
|
2023-04-13 03:49:16 +03:00
|
|
|
|
2023-04-22 09:27:54 +03:00
|
|
|
def watch_items(self, old_items, new_items: List['MenuItem|Separator']) -> None:
|
2023-04-13 05:07:38 +03:00
|
|
|
"""Update the menu items."""
|
|
|
|
for item in old_items:
|
2023-04-22 09:26:42 +03:00
|
|
|
item.remove()
|
2023-04-13 05:07:38 +03:00
|
|
|
for item in new_items:
|
|
|
|
self.mount(item)
|
|
|
|
if item.submenu:
|
|
|
|
self.app.mount(item.submenu)
|
|
|
|
item.submenu.close()
|
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-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-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-13 06:09:34 +03:00
|
|
|
def open(self, parent_menu, parent_menu_item):
|
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
|
|
|
|
for item in self.items:
|
|
|
|
if isinstance(item, MenuItem):
|
|
|
|
if len(item.label.plain) > max_width:
|
|
|
|
max_width = len(item.label.plain)
|
|
|
|
# Split on tab character and align the shortcuts
|
|
|
|
for item in self.items:
|
|
|
|
if isinstance(item, MenuItem):
|
|
|
|
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-13 03:49:16 +03:00
|
|
|
|
|
|
|
class MenuBar(Menu):
|
|
|
|
"""A menu bar widget."""
|
|
|
|
|
2023-04-22 09:27:54 +03:00
|
|
|
def __init__(self, items: List['MenuItem|Separator'], **kwargs) -> 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-22 09:27:54 +03:00
|
|
|
def __init__(self, name: str, action = None, id: str | int | None = None, submenu = None, grayed = False, **kwargs) -> 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-19 01:01:37 +03:00
|
|
|
self.disabled = grayed
|
2023-04-13 03:49:16 +03:00
|
|
|
self.action = action
|
|
|
|
self.submenu = submenu
|
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-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-19 01:01:37 +03:00
|
|
|
|
2023-04-13 05:07:38 +03:00
|
|
|
def __init__(self, **kwargs) -> None:
|
|
|
|
"""Initialize a separator."""
|
2023-04-19 01:01:37 +03:00
|
|
|
super().__init__(mid_line, **kwargs)
|
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
|