from __future__ import annotations import gettext import locale from dataclasses import dataclass from typing import Callable, Optional from user_input import print_input_error, wait_for_enter from utils import print_header # mypy and pylint don't work well with gettext.install lang, _enc = locale.getlocale() lang = lang or 'en' _ = gettext.translation('python_misc', localedir='locale', languages=[lang, lang.split('_')[0], 'en'], fallback=True).gettext @dataclass(frozen=True) class MenuItem: name: str action: Callable[[int], Optional[bool]] key: Optional[str] = None visible: bool = True enabled: bool = True def __str__(self): return self.name class Menu: def __init__(self, title: str, items: list[MenuItem], quit_key: str = 'q'): self.title = title self.items = items self.quit_key = quit_key def add_item(self, name: str, action: Callable[[int], Optional[bool]], key: Optional[str] = None) -> None: item = MenuItem(name, action, key) self._check_for_duplicates(item) self.items.append(item) def remove_item(self, name: str) -> bool: for idx, item in enumerate(self.items): if item.name == name: del self.items[idx] raise ValueError('item not found') def _check_for_duplicates(self, candidate: MenuItem) -> None: for item in self.items: if (candidate.key is not None and item.key is not None and candidate.key.lower() == item.key.lower()): raise ValueError('duplicate key') class MenuUI: def __init__(self, menu: Menu, *, quit_key: str = 'q'): self.menu = menu self.active = False self.quit_key = quit_key self.quit_item = MenuItem('Quit', lambda _: True, self.quit_key) @property def visible_items(self): items = [item for item in self.menu.items if item.visible] items.append(self.quit_item) return items def add_submenu(self, name: str, menu_ui: MenuUI, key: Optional[str] = None) -> None: self.menu.add_item(name, menu_ui.loop, key) def loop(self, call_level: int = 0) -> bool: while True: if call_level > 0: self.quit_item = MenuItem('Back', lambda _: True, self.quit_key) user_choice = self._read_choice() if user_choice == self.quit_item: return True try: test_submenu = user_choice.action(call_level + 1) if not test_submenu: wait_for_enter() except (EOFError, KeyboardInterrupt): pass return False def _read_choice(self) -> MenuItem: while True: try: print_header(self.menu.title, self._calculate_menu_header_length()) self._print_menu_items() user_input = input(_('Your choice: ')).strip().lower() if user_input.isdigit(): idx = int(user_input) if 1 <= idx <= len(self.visible_items): return self.visible_items[idx - 1] else: for idx, item in enumerate(self.visible_items): if user_input == item.key: return self.visible_items[idx] print_input_error(_('invalid choice')) wait_for_enter() except (EOFError, KeyboardInterrupt): print(_('\nExiting...')) return self.quit_item def _calculate_menu_header_length(self): items = self.visible_items if len(items) == 0: max_item_name = 0 else: max_item_name = max(len(item.name) for item in items) pos_indicator = 3 max_list_elem = max_item_name + pos_indicator any_elem_has_key = any(bool(item.key) for item in items) if any_elem_has_key: key_indicator = max(len(item.key) for item in items if item.key is not None) key_indicator += 3 else: key_indicator = 0 max_list_elem += key_indicator return max(len(self.menu.title), max_list_elem) def _print_menu_items(self) -> None: longest_name = max(len(item.name) for item in self.visible_items) for idx, item in enumerate(self.visible_items, start=1): output = f'{idx}) {item.name:<{longest_name}}' output += f' [{item.key}]' if item.key else '' print(output)