textual-paint/menus.py

273 lines
11 KiB
Python
Raw Normal View History

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
from textual.message import Message
from textual.dom import NoScreen
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):
"""A menu widget. Note that menus can't be reused in multiple places."""
2023-04-13 03:49:16 +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-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
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:
self.screen.mount(item.submenu)
2023-04-13 05:07:38 +03:00
item.submenu.close()
if isinstance(item, MenuItem):
item.parent_menu = self
2023-04-13 03:49:16 +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()
if self.parent_menu:
self.parent_menu.focus()
2023-04-23 08:43:54 +03:00
elif event.is_printable:
# 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."""
if isinstance(event.button, MenuItem):
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:
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
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
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
# any_submenus = False
2023-04-19 03:43:51 +03:00
for item in self.items:
if isinstance(item, MenuItem):
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)
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):
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
self.post_message(Menu.StatusInfo(None, closed=True))
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,
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)
# 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
self.description = description
self.parent_menu: Menu | None = None # set when mounted
if isinstance(id, str):
2023-04-13 05:07:38 +03:00
self.id = id
elif id:
self.id = "rc_" + str(id)
2023-04-13 05:07:38 +03:00
else:
self.id = "menu_item_" + to_snake_case(name)
def on_enter(self, event: events.Enter) -> None:
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
self.post_message(Menu.StatusInfo(self.description))
def on_leave(self, event: events.Leave) -> None:
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
self.post_message(Menu.StatusInfo(None))
2023-04-13 03:49:16 +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."""
super().__init__(mid_line, **kwargs)
2023-04-23 08:43:54 +03:00
self.hotkey = None
# self.disabled = True # This breaks scroll wheel over the separator, as of Textual 0.20.1
self.disabled = False
self.action = None
self.submenu = None