From 1234261a7a0d3ffd20f0d4ebea0f54a30c493d45 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 7 Jan 2022 21:48:23 +1100 Subject: Global menu (#806) * Global menu * Fix flake8 * Refactor code * Add documentation * Fix flake8 * Add support for user flow mentioned in #799 * Move import * Fix flake8 (again) Co-authored-by: Daniel Girtler Co-authored-by: Anton Hvornum --- archinstall/__init__.py | 2 + archinstall/lib/locale_helpers.py | 20 +- archinstall/lib/menu.py | 91 -- archinstall/lib/menu/__init__.py | 1 + archinstall/lib/menu/menu.py | 91 ++ archinstall/lib/menu/selection_menu.py | 392 +++++++ archinstall/lib/menu/simple_menu.py | 1960 ++++++++++++++++++++++++++++++++ archinstall/lib/simple_menu.py | 1960 -------------------------------- archinstall/lib/user_interaction.py | 93 +- 9 files changed, 2548 insertions(+), 2062 deletions(-) delete mode 100644 archinstall/lib/menu.py create mode 100644 archinstall/lib/menu/__init__.py create mode 100644 archinstall/lib/menu/menu.py create mode 100644 archinstall/lib/menu/selection_menu.py create mode 100644 archinstall/lib/menu/simple_menu.py delete mode 100644 archinstall/lib/simple_menu.py (limited to 'archinstall') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 9d7e238d..c81a630f 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -21,6 +21,8 @@ from .lib.storage import * from .lib.systemd import * from .lib.user_interaction import * from .lib.menu import Menu +from .lib.menu.selection_menu import GlobalMenu + parser = ArgumentParser() diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py index 6aa678a6..cbba8d52 100644 --- a/archinstall/lib/locale_helpers.py +++ b/archinstall/lib/locale_helpers.py @@ -1,5 +1,5 @@ import logging -from typing import Iterator +from typing import Iterator, List from .exceptions import ServiceException from .general import SysCommand @@ -11,6 +11,24 @@ def list_keyboard_languages() -> Iterator[str]: yield line.decode('UTF-8').strip() +def list_locales() -> List[str]: + with open('/etc/locale.gen', 'r') as fp: + locales = [] + # before the list of locales begins there's an empty line with a '#' in front + # so we'll collect the localels from bottom up and halt when we're donw + entries = fp.readlines() + entries.reverse() + + for entry in entries: + text = entry[1:].strip() + if text == '': + break + locales.append(text) + + locales.reverse() + return locales + + def list_x11_keyboard_languages() -> Iterator[str]: for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/menu.py b/archinstall/lib/menu.py deleted file mode 100644 index 6f1c2237..00000000 --- a/archinstall/lib/menu.py +++ /dev/null @@ -1,91 +0,0 @@ -from .simple_menu import TerminalMenu - - -class Menu(TerminalMenu): - def __init__(self, title, options, skip=True, multi=False, default_option=None, sort=True): - """ - Creates a new menu - - :param title: Text that will be displayed above the menu - :type title: str - - :param options: Options to be displayed in the menu to chose from; - if dict is specified then the keys of such will be used as options - :type options: list, dict - - :param skip: Indicate if the selection is not mandatory and can be skipped - :type skip: bool - - :param multi: Indicate if multiple options can be selected - :type multi: bool - - :param default_option: The default option to be used in case the selection processes is skipped - :type default_option: str - - :param sort: Indicate if the options should be sorted alphabetically before displaying - :type sort: bool - """ - - if isinstance(options, dict): - options = list(options) - - if sort: - options = sorted(options) - - self.menu_options = options - self.skip = skip - self.default_option = default_option - self.multi = multi - - menu_title = f'\n{title}\n\n' - - if skip: - menu_title += "Use ESC to skip\n\n" - - if default_option: - # if a default value was specified we move that one - # to the top of the list and mark it as default as well - default = f'{default_option} (default)' - self.menu_options = [default] + [o for o in self.menu_options if default_option != o] - - cursor = "> " - main_menu_cursor_style = ("fg_cyan", "bold") - main_menu_style = ("bg_blue", "fg_gray") - - super().__init__( - menu_entries=self.menu_options, - title=menu_title, - menu_cursor=cursor, - menu_cursor_style=main_menu_cursor_style, - menu_highlight_style=main_menu_style, - cycle_cursor=True, - clear_screen=True, - multi_select=multi, - show_search_hint=True - ) - - def _show(self): - idx = self.show() - if idx is not None: - if isinstance(idx, (list, tuple)): - return [self.default_option if ' (default)' in self.menu_options[i] else self.menu_options[i] for i in idx] - else: - selected = self.menu_options[idx] - if ' (default)' in selected and self.default_option: - return self.default_option - return selected - else: - if self.default_option: - if self.multi: - return [self.default_option] - else: - return self.default_option - return None - - def run(self): - ret = self._show() - - if ret is None and not self.skip: - return self.run() - - return ret diff --git a/archinstall/lib/menu/__init__.py b/archinstall/lib/menu/__init__.py new file mode 100644 index 00000000..6e28c8a2 --- /dev/null +++ b/archinstall/lib/menu/__init__.py @@ -0,0 +1 @@ +from .menu import Menu diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py new file mode 100644 index 00000000..65be4956 --- /dev/null +++ b/archinstall/lib/menu/menu.py @@ -0,0 +1,91 @@ +from archinstall.lib.menu.simple_menu import TerminalMenu + + +class Menu(TerminalMenu): + def __init__(self, title, options, skip=True, multi=False, default_option=None, sort=True): + """ + Creates a new menu + + :param title: Text that will be displayed above the menu + :type title: str + + :param options: Options to be displayed in the menu to chose from; + if dict is specified then the keys of such will be used as options + :type options: list, dict + + :param skip: Indicate if the selection is not mandatory and can be skipped + :type skip: bool + + :param multi: Indicate if multiple options can be selected + :type multi: bool + + :param default_option: The default option to be used in case the selection processes is skipped + :type default_option: str + + :param sort: Indicate if the options should be sorted alphabetically before displaying + :type sort: bool + """ + + if isinstance(options, dict): + options = list(options) + + if sort: + options = sorted(options) + + self.menu_options = options + self.skip = skip + self.default_option = default_option + self.multi = multi + + menu_title = f'\n{title}\n\n' + + if skip: + menu_title += "Use ESC to skip\n\n" + + if default_option: + # if a default value was specified we move that one + # to the top of the list and mark it as default as well + default = f'{default_option} (default)' + self.menu_options = [default] + [o for o in self.menu_options if default_option != o] + + cursor = "> " + main_menu_cursor_style = ("fg_cyan", "bold") + main_menu_style = ("bg_blue", "fg_gray") + + super().__init__( + menu_entries=self.menu_options, + title=menu_title, + menu_cursor=cursor, + menu_cursor_style=main_menu_cursor_style, + menu_highlight_style=main_menu_style, + cycle_cursor=True, + clear_screen=True, + multi_select=multi, + show_search_hint=True + ) + + def _show(self): + idx = self.show() + if idx is not None: + if isinstance(idx, (list, tuple)): + return [self.default_option if ' (default)' in self.menu_options[i] else self.menu_options[i] for i in idx] + else: + selected = self.menu_options[idx] + if ' (default)' in selected and self.default_option: + return self.default_option + return selected + else: + if self.default_option: + if self.multi: + return [self.default_option] + else: + return self.default_option + return None + + def run(self): + ret = self._show() + + if ret is None and not self.skip: + return self.run() + + return ret diff --git a/archinstall/lib/menu/selection_menu.py b/archinstall/lib/menu/selection_menu.py new file mode 100644 index 00000000..17cc3347 --- /dev/null +++ b/archinstall/lib/menu/selection_menu.py @@ -0,0 +1,392 @@ +import sys + +import archinstall +from archinstall import Menu + + +class Selector: + def __init__( + self, + description, + func=None, + display_func=None, + default=None, + enabled=False, + dependencies=[], + dependencies_not=[] + ): + """ + Create a new menu selection entry + + :param description: Text that will be displayed as the menu entry + :type description: str + + :param func: Function that is called when the menu entry is selected + :type func: Callable + + :param display_func: After specifying a setting for a menu item it is displayed + on the right side of the item as is; with this function one can modify the entry + to be displayed; e.g. when specifying a password one can display **** instead + :type display_func: Callable + + :param default: Default value for this menu entry + :type default: Any + + :param enabled: Specify if this menu entry should be displayed + :type enabled: bool + + :param dependencies: Specify dependencies for this menu entry; if the dependencies + are not set yet, then this item is not displayed; e.g. disk_layout depends on selectiong + harddrive(s) first + :type dependencies: list + + :param dependencies_not: These are the exclusive options; the menu item will only be + displayed if non of the entries in the list have been specified + :type dependencies_not: list + """ + + self._description = description + self.func = func + self._display_func = display_func + self._current_selection = default + self.enabled = enabled + self.text = self.menu_text() + self._dependencies = dependencies + self._dependencies_not = dependencies_not + + @property + def dependencies(self): + return self._dependencies + + @property + def dependencies_not(self): + return self._dependencies_not + + def set_enabled(self): + self.enabled = True + + def update_description(self, description): + self._description = description + self.text = self.menu_text() + + def menu_text(self): + current = '' + + if self._display_func: + current = self._display_func(self._current_selection) + else: + if self._current_selection is not None: + current = str(self._current_selection) + + if current: + padding = 35 - len(self._description) + current = ' ' * padding + f'SET: {current}' + + return f'{self._description} {current}' + + def set_current_selection(self, current): + self._current_selection = current + self.text = self.menu_text() + + def has_selection(self): + if self._current_selection is None: + return False + return True + + def is_empty(self): + if self._current_selection is None: + return True + elif isinstance(self._current_selection, (str, list, dict)) and len(self._current_selection) == 0: + return True + + return False + + +class GlobalMenu: + def __init__(self): + self._menu_options = {} + self._setup_selection_menu_options() + + def _setup_selection_menu_options(self): + self._menu_options['keyboard-layout'] = \ + Selector('Select keyboard layout', lambda: archinstall.select_language('us'), default='us') + self._menu_options['mirror-region'] = \ + Selector( + 'Select mirror region', + lambda: archinstall.select_mirror_regions(), + display_func=lambda x: list(x.keys()) if x else '[]', + default={}) + self._menu_options['sys-language'] = \ + Selector('Select locale language', lambda: archinstall.select_locale_lang('en_US'), default='en_US') + self._menu_options['sys-encoding'] = \ + Selector('Select locale encoding', lambda: archinstall.select_locale_enc('utf-8'), default='utf-8') + self._menu_options['harddrives'] = \ + Selector( + 'Select harddrives', + lambda: self._select_harddrives()) + self._menu_options['disk_layouts'] = \ + Selector( + 'Select disk layout', + lambda: archinstall.select_disk_layout( + archinstall.arguments['harddrives'], + archinstall.arguments.get('advanced', False) + ), + dependencies=['harddrives']) + self._menu_options['!encryption-password'] = \ + Selector( + 'Set encryption password', + lambda: archinstall.get_password(prompt='Enter disk encryption password (leave blank for no encryption): '), + display_func=lambda x: self._secret(x) if x else 'None', + dependencies=['harddrives']) + self._menu_options['swap'] = \ + Selector( + 'Use swap', + lambda: archinstall.ask_for_swap(), + default=True) + self._menu_options['bootloader'] = \ + Selector( + 'Select bootloader', + lambda: archinstall.ask_for_bootloader(archinstall.arguments.get('advanced', False)),) + self._menu_options['hostname'] = \ + Selector('Specify hostname', lambda: archinstall.ask_hostname()) + self._menu_options['!root-password'] = \ + Selector( + 'Set root password', + lambda: self._set_root_password(), + display_func=lambda x: self._secret(x) if x else 'None') + self._menu_options['!superusers'] = \ + Selector( + 'Specify superuser account', + lambda: self._create_superuser_account(), + dependencies_not=['!root-password'], + display_func=lambda x: list(x.keys()) if x else '') + self._menu_options['!users'] = \ + Selector( + 'Specify user account', + lambda: self._create_user_account(), + default={}, + display_func=lambda x: list(x.keys()) if x else '[]') + self._menu_options['profile'] = \ + Selector( + 'Specify profile', + lambda: self._select_profile(), + display_func=lambda x: x if x else 'None') + self._menu_options['audio'] = \ + Selector( + 'Select audio', + lambda: archinstall.ask_for_audio_selection(archinstall.is_desktop_profile(archinstall.arguments.get('profile', None)))) + self._menu_options['kernels'] = \ + Selector( + 'Select kernels', + lambda: archinstall.select_kernel(), + default='linux') + self._menu_options['packages'] = \ + Selector( + 'Additional packages to install', + lambda: archinstall.ask_additional_packages_to_install(archinstall.arguments.get('packages', None)), + default=[]) + self._menu_options['nic'] = \ + Selector( + 'Configure network', + lambda: archinstall.ask_to_configure_network(), + display_func=lambda x: x if x else 'Not configured, unavailable unless setup manually', + default={}) + self._menu_options['timezone'] = \ + Selector('Select timezone', lambda: archinstall.ask_timezone()) + self._menu_options['ntp'] = \ + Selector( + 'Set automatic time sync (NTP)', + lambda: archinstall.ask_ntp(), + default=True) + self._menu_options['install'] = \ + Selector( + self._install_text(), + enabled=True) + self._menu_options['abort'] = Selector('Abort', enabled=True) + + def enable(self, selector_name, omit_if_set=False): + arg = archinstall.arguments.get(selector_name, None) + + # don't display the menu option if it was defined already + if arg is not None and omit_if_set: + return + + if self._menu_options.get(selector_name, None): + self._menu_options[selector_name].set_enabled() + if arg is not None: + self._menu_options[selector_name].set_current_selection(arg) + else: + print(f'No selector found: {selector_name}') + sys.exit(1) + + def run(self): + while True: + # # Before continuing, set the preferred keyboard layout/language in the current terminal. + # # This will just help the user with the next following questions. + self._set_kb_language() + + enabled_menus = self._menus_to_enable() + menu_text = [m.text for m in enabled_menus.values()] + selection = Menu('Set/Modify the below options', menu_text, sort=False).run() + if selection: + selection = selection.strip() + if 'Abort' in selection: + exit(0) + elif 'Install' in selection: + if self._missing_configs() == 0: + self._post_processing() + break + else: + self._process_selection(selection) + + def _process_selection(self, selection): + # find the selected option in our option list + option = [[k, v] for k, v in self._menu_options.items() if v.text.strip() == selection] + + if len(option) != 1: + raise ValueError(f'Selection not found: {selection}') + + selector_name = option[0][0] + selector = option[0][1] + result = selector.func() + self._menu_options[selector_name].set_current_selection(result) + archinstall.arguments[selector_name] = result + + self._update_install() + + def _update_install(self): + text = self._install_text() + self._menu_options.get('install').update_description(text) + + def _post_processing(self): + if archinstall.arguments.get('harddrives', None) and archinstall.arguments.get('!encryption-password', None): + # If no partitions was marked as encrypted, but a password was supplied and we have some disks to format.. + # Then we need to identify which partitions to encrypt. This will default to / (root). + if len(list(archinstall.encrypted_partitions(archinstall.storage['disk_layouts']))) == 0: + archinstall.storage['disk_layouts'] = archinstall.select_encrypted_partitions( + archinstall.storage['disk_layouts'], archinstall.arguments['!encryption-password']) + + def _install_text(self): + missing = self._missing_configs() + if missing > 0: + return f'Install ({missing} config(s) missing)' + return 'Install' + + def _missing_configs(self): + def check(s): + return self._menu_options.get(s).has_selection() + + missing = 0 + if not check('bootloader'): + missing += 1 + if not check('hostname'): + missing += 1 + if not check('audio'): + missing += 1 + if not check('timezone'): + missing += 1 + if not check('!root-password') and not check('!superusers'): + missing += 1 + if not check('harddrives'): + missing += 1 + if check('harddrives'): + if not self._menu_options.get('harddrives').is_empty() and not check('disk_layouts'): + missing += 1 + + return missing + + def _set_root_password(self): + prompt = 'Enter root password (leave blank to disable root & create superuser): ' + password = archinstall.get_password(prompt=prompt) + + if password is not None: + self._menu_options.get('!superusers').set_current_selection(None) + archinstall.arguments['!users'] = {} + archinstall.arguments['!superusers'] = {} + + return password + + def _select_harddrives(self): + old_haddrives = archinstall.arguments.get('harddrives') + harddrives = archinstall.select_harddrives() + + # in case the harddrives got changed we have to reset the disk layout as well + if old_haddrives != harddrives: + self._menu_options.get('disk_layouts').set_current_selection(None) + archinstall.arguments['disk_layouts'] = {} + + if not harddrives: + prompt = 'You decided to skip harddrive selection\n' + prompt += f"and will use whatever drive-setup is mounted at {archinstall.storage['MOUNT_POINT']} (experimental)\n" + prompt += "WARNING: Archinstall won't check the suitability of this setup\n" + + prompt += 'Do you wish to continue?' + choice = Menu(prompt, ['yes', 'no'], default_option='yes').run() + + if choice == 'no': + return self._select_harddrives() + + return harddrives + + def _secret(self, x): + return '*' * len(x) + + def _select_profile(self): + profile = archinstall.select_profile() + + # Check the potentially selected profiles preparations to get early checks if some additional questions are needed. + if profile and profile.has_prep_function(): + namespace = f'{profile.namespace}.py' + with profile.load_instructions(namespace=namespace) as imported: + if not imported._prep_function(): + archinstall.log(' * Profile\'s preparation requirements was not fulfilled.', fg='red') + exit(1) + + return profile + + def _create_superuser_account(self): + superuser = archinstall.ask_for_superuser_account('Create a required super-user with sudo privileges: ', forced=True) + return superuser + + def _create_user_account(self): + users, superusers = archinstall.ask_for_additional_users('Enter a username to create an additional user: ') + if not archinstall.arguments.get('!superusers', None): + archinstall.arguments['!superusers'] = superusers + else: + archinstall.arguments['!superusers'] = {**archinstall.arguments['!superusers'], **superusers} + + return users + + def _set_kb_language(self): + # Before continuing, set the preferred keyboard layout/language in the current terminal. + # This will just help the user with the next following questions. + if archinstall.arguments.get('keyboard-layout', None) and len(archinstall.arguments['keyboard-layout']): + archinstall.set_keyboard_language(archinstall.arguments['keyboard-layout']) + + def _verify_selection_enabled(self, selection_name): + if selection := self._menu_options.get(selection_name, None): + if not selection.enabled: + return False + + if len(selection.dependencies) > 0: + for d in selection.dependencies: + if not self._verify_selection_enabled(d) or self._menu_options.get(d).is_empty(): + return False + + if len(selection.dependencies_not) > 0: + for d in selection.dependencies_not: + if not self._menu_options.get(d).is_empty(): + return False + + return True + + raise ValueError(f'No selection found: {selection_name}') + + def _menus_to_enable(self): + enabled_menus = {} + + for name, selection in self._menu_options.items(): + if self._verify_selection_enabled(name): + enabled_menus[name] = selection + + return enabled_menus diff --git a/archinstall/lib/menu/simple_menu.py b/archinstall/lib/menu/simple_menu.py new file mode 100644 index 00000000..a9d6d7ec --- /dev/null +++ b/archinstall/lib/menu/simple_menu.py @@ -0,0 +1,1960 @@ +""" +This file is copied over from the simple-term-menu project +(https://github.com/IngoMeyer441/simple-term-menu) +In order to comply with installation methods of Arch Linux. +We here by copy the MIT license attached to the project at the time of copy: + +Copyright 2021 Forschungszentrum Jülich GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" +import argparse +import copy +import ctypes +import io +import locale +import os +import platform +import re +import shlex +import signal +import string +import subprocess +import sys +from locale import getlocale +from types import FrameType +from typing import ( + Any, + Callable, + Dict, + Iterable, + Iterator, + List, + Match, + Optional, + Pattern, + Sequence, + Set, + TextIO, + Tuple, + Union, + cast, +) + +try: + import termios +except ImportError as e: + raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e + +__author__ = "Ingo Meyer" +__email__ = "i.meyer@fz-juelich.de" +__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved." +__license__ = "MIT" +__version_info__ = (1, 4, 1) +__version__ = ".".join(map(str, __version_info__)) + + +DEFAULT_ACCEPT_KEYS = ("enter",) +DEFAULT_CLEAR_MENU_ON_EXIT = True +DEFAULT_CLEAR_SCREEN = False +DEFAULT_CYCLE_CURSOR = True +DEFAULT_EXIT_ON_SHORTCUT = True +DEFAULT_MENU_CURSOR = "> " +DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold") +DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",) +DEFAULT_MULTI_SELECT = False +DEFAULT_MULTI_SELECT_CURSOR = "[*] " +DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",) +DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold") +DEFAULT_MULTI_SELECT_KEYS = (" ", "tab") +DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True +DEFAULT_PREVIEW_BORDER = True +DEFAULT_PREVIEW_SIZE = 0.25 +DEFAULT_PREVIEW_TITLE = "preview" +DEFAULT_SEARCH_CASE_SENSITIVE = False +DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold") +DEFAULT_SEARCH_KEY = "/" +DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",) +DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",) +DEFAULT_SHOW_MULTI_SELECT_HINT = False +DEFAULT_SHOW_SEARCH_HINT = False +DEFAULT_SHOW_SHORTCUT_HINTS = False +DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True +DEFAULT_STATUS_BAR_BELOW_PREVIEW = False +DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black") +MIN_VISIBLE_MENU_ENTRIES_COUNT = 3 + + +class InvalidParameterCombinationError(Exception): + pass + + +class InvalidStyleError(Exception): + pass + + +class NoMenuEntriesError(Exception): + pass + + +class PreviewCommandFailedError(Exception): + pass + + +class UnknownMenuEntryError(Exception): + pass + + +def get_locale() -> str: + user_locale = locale.getlocale()[1] + if user_locale is None: + return "ascii" + else: + return user_locale.lower() + + +def wcswidth(text: str) -> int: + if not hasattr(wcswidth, "libc"): + if platform.system() == "Darwin": + wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore + else: + wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore + user_locale = get_locale() + # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed + # in a `c_wchar_p` + encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace") + return wcswidth.libc.wcswidth( # type: ignore + ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text) + ) + + +def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(f: Callable[..., Any]) -> Callable[..., Any]: + for key, value in variables.items(): + setattr(f, key, value) + return f + + return decorator + + +class BoxDrawingCharacters: + if getlocale()[1] == "UTF-8": + # Unicode box characters + horizontal = "─" + vertical = "│" + upper_left = "┌" + upper_right = "┐" + lower_left = "└" + lower_right = "┘" + else: + # ASCII box characters + horizontal = "-" + vertical = "|" + upper_left = "+" + upper_right = "+" + lower_left = "+" + lower_right = "+" + + +class TerminalMenu: + class Search: + def __init__( + self, + menu_entries: Iterable[str], + search_text: Optional[str] = None, + case_senitive: bool = False, + show_search_hint: bool = False, + ): + self._menu_entries = menu_entries + self._case_sensitive = case_senitive + self._show_search_hint = show_search_hint + self._matches = [] # type: List[Tuple[int, Match[str]]] + self._search_regex = None # type: Optional[Pattern[str]] + self._change_callback = None # type: Optional[Callable[[], None]] + # Use the property setter since it has some more logic + self.search_text = search_text + + def _update_matches(self) -> None: + if self._search_regex is None: + self._matches = [] + else: + matches = [] + for i, menu_entry in enumerate(self._menu_entries): + match_obj = self._search_regex.search(menu_entry) + if match_obj: + matches.append((i, match_obj)) + self._matches = matches + + @property + def matches(self) -> List[Tuple[int, Match[str]]]: + return list(self._matches) + + @property + def search_regex(self) -> Optional[Pattern[str]]: + return self._search_regex + + @property + def search_text(self) -> Optional[str]: + return self._search_text + + @search_text.setter + def search_text(self, text: Optional[str]) -> None: + self._search_text = text + search_text = self._search_text + self._search_regex = None + while search_text and self._search_regex is None: + try: + self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0) + except re.error: + search_text = search_text[:-1] + self._update_matches() + if self._change_callback: + self._change_callback() + + @property + def change_callback(self) -> Optional[Callable[[], None]]: + return self._change_callback + + @change_callback.setter + def change_callback(self, callback: Optional[Callable[[], None]]) -> None: + self._change_callback = callback + + @property + def occupied_lines_count(self) -> int: + if not self and not self._show_search_hint: + return 0 + else: + return 1 + + def __bool__(self) -> bool: + return self._search_text is not None + + def __contains__(self, menu_index: int) -> bool: + return any(i == menu_index for i, _ in self._matches) + + def __len__(self) -> int: + return wcswidth(self._search_text) if self._search_text is not None else 0 + + class Selection: + def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None): + self._num_menu_entries = num_menu_entries + self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set() + + def clear(self) -> None: + self._selected_menu_indices.clear() + + def add(self, menu_index: int) -> None: + self[menu_index] = True + + def remove(self, menu_index: int) -> None: + self[menu_index] = False + + def toggle(self, menu_index: int) -> bool: + self[menu_index] = menu_index not in self._selected_menu_indices + return self[menu_index] + + def __bool__(self) -> bool: + return bool(self._selected_menu_indices) + + def __contains__(self, menu_index: int) -> bool: + return menu_index in self._selected_menu_indices + + def __getitem__(self, menu_index: int) -> bool: + return menu_index in self._selected_menu_indices + + def __setitem__(self, menu_index: int, is_selected: bool) -> None: + if is_selected: + self._selected_menu_indices.add(menu_index) + else: + self._selected_menu_indices.remove(menu_index) + + def __iter__(self) -> Iterator[int]: + return iter(self._selected_menu_indices) + + @property + def selected_menu_indices(self) -> Tuple[int, ...]: + return tuple(sorted(self._selected_menu_indices)) + + class View: + def __init__( + self, + menu_entries: Iterable[str], + search: "TerminalMenu.Search", + selection: "TerminalMenu.Selection", + viewport: "TerminalMenu.Viewport", + cycle_cursor: bool = True, + ): + self._menu_entries = list(menu_entries) + self._search = search + self._selection = selection + self._viewport = viewport + self._cycle_cursor = cycle_cursor + self._active_displayed_index = None # type: Optional[int] + self.update_view() + + def update_view(self) -> None: + if self._search and self._search.search_text != "": + self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches) + else: + self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries))) + self._menu_index_to_displayed_index = { + menu_index: displayed_index + for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) + } + self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None + self._viewport.search_lines_count = self._search.occupied_lines_count + self._viewport.keep_visible(self._active_displayed_index) + + def increment_active_index(self) -> None: + if self._active_displayed_index is not None: + if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index): + self._active_displayed_index += 1 + elif self._cycle_cursor: + self._active_displayed_index = 0 + self._viewport.keep_visible(self._active_displayed_index) + + def decrement_active_index(self) -> None: + if self._active_displayed_index is not None: + if self._active_displayed_index > 0: + self._active_displayed_index -= 1 + elif self._cycle_cursor: + self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1 + self._viewport.keep_visible(self._active_displayed_index) + + def is_visible(self, menu_index: int) -> bool: + return menu_index in self._menu_index_to_displayed_index and ( + self._viewport.lower_index + <= self._menu_index_to_displayed_index[menu_index] + <= self._viewport.upper_index + ) + + def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]: + if menu_index in self._menu_index_to_displayed_index: + return self._menu_index_to_displayed_index[menu_index] + else: + return None + + def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int: + return self._displayed_index_to_menu_index[displayed_index] + + @property + def active_menu_index(self) -> Optional[int]: + if self._active_displayed_index is not None: + return self._displayed_index_to_menu_index[self._active_displayed_index] + else: + return None + + @active_menu_index.setter + def active_menu_index(self, value: int) -> None: + self._selected_index = value + self._active_displayed_index = [ + displayed_index + for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) + if menu_index == value + ][0] + self._viewport.keep_visible(self._active_displayed_index) + + @property + def active_displayed_index(self) -> Optional[int]: + return self._active_displayed_index + + @property + def displayed_selected_indices(self) -> List[int]: + return [ + self._menu_index_to_displayed_index[selected_index] + for selected_index in self._selection + if selected_index in self._menu_index_to_displayed_index + ] + + def __bool__(self) -> bool: + return self._active_displayed_index is not None + + def __iter__(self) -> Iterator[Tuple[int, int, str]]: + for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index): + if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index: + yield (displayed_index, menu_index, self._menu_entries[menu_index]) + + class Viewport: + def __init__( + self, + num_menu_entries: int, + title_lines_count: int, + status_bar_lines_count: int, + preview_lines_count: int, + search_lines_count: int, + ): + self._num_menu_entries = num_menu_entries + self._title_lines_count = title_lines_count + self._status_bar_lines_count = status_bar_lines_count + # Use the property setter since it has some more logic + self.preview_lines_count = preview_lines_count + self.search_lines_count = search_lines_count + self._num_lines = self._calculate_num_lines() + self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1) + self.keep_visible(cursor_position=None, refresh_terminal_size=False) + + def _calculate_num_lines(self) -> int: + return ( + TerminalMenu._num_lines() + - self._title_lines_count + - self._status_bar_lines_count + - self._preview_lines_count + - self._search_lines_count + ) + + def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None: + # Treat `cursor_position=None` like `cursor_position=0` + if cursor_position is None: + cursor_position = 0 + if refresh_terminal_size: + self.update_terminal_size() + if self._viewport[0] <= cursor_position <= self._viewport[1]: + # Cursor is already visible + return + if cursor_position < self._viewport[0]: + scroll_num = cursor_position - self._viewport[0] + else: + scroll_num = cursor_position - self._viewport[1] + self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num) + + def update_terminal_size(self) -> None: + num_lines = self._calculate_num_lines() + if num_lines != self._num_lines: + # First let the upper index grow or shrink + upper_index = min(num_lines, self._num_menu_entries) - 1 + # Then, use as much space as possible for the `lower_index` + lower_index = max(0, upper_index - num_lines) + self._viewport = (lower_index, upper_index) + self._num_lines = num_lines + + @property + def lower_index(self) -> int: + return self._viewport[0] + + @property + def upper_index(self) -> int: + return self._viewport[1] + + @property + def viewport(self) -> Tuple[int, int]: + return self._viewport + + @property + def size(self) -> int: + return self._viewport[1] - self._viewport[0] + 1 + + @property + def num_menu_entries(self) -> int: + return self._num_menu_entries + + @property + def title_lines_count(self) -> int: + return self._title_lines_count + + @property + def status_bar_lines_count(self) -> int: + return self._status_bar_lines_count + + @status_bar_lines_count.setter + def status_bar_lines_count(self, value: int) -> None: + self._status_bar_lines_count = value + + @property + def preview_lines_count(self) -> int: + return self._preview_lines_count + + @preview_lines_count.setter + def preview_lines_count(self, value: int) -> None: + self._preview_lines_count = min( + value if value >= 3 else 0, + TerminalMenu._num_lines() + - self._title_lines_count + - self._status_bar_lines_count + - MIN_VISIBLE_MENU_ENTRIES_COUNT, + ) + + @property + def search_lines_count(self) -> int: + return self._search_lines_count + + @search_lines_count.setter + def search_lines_count(self, value: int) -> None: + self._search_lines_count = value + + @property + def must_scroll(self) -> bool: + return self._num_menu_entries > self._num_lines + + _codename_to_capname = { + "bg_black": "setab 0", + "bg_blue": "setab 4", + "bg_cyan": "setab 6", + "bg_gray": "setab 7", + "bg_green": "setab 2", + "bg_purple": "setab 5", + "bg_red": "setab 1", + "bg_yellow": "setab 3", + "bold": "bold", + "clear": "clear", + "colors": "colors", + "cursor_down": "cud1", + "cursor_invisible": "civis", + "cursor_left": "cub1", + "cursor_right": "cuf1", + "cursor_up": "cuu1", + "cursor_visible": "cnorm", + "delete_line": "dl1", + "down": "kcud1", + "enter_application_mode": "smkx", + "exit_application_mode": "rmkx", + "fg_black": "setaf 0", + "fg_blue": "setaf 4", + "fg_cyan": "setaf 6", + "fg_gray": "setaf 7", + "fg_green": "setaf 2", + "fg_purple": "setaf 5", + "fg_red": "setaf 1", + "fg_yellow": "setaf 3", + "italics": "sitm", + "reset_attributes": "sgr0", + "standout": "smso", + "underline": "smul", + "up": "kcuu1", + } + _name_to_control_character = { + "backspace": "", # Is assigned later in `self._init_backspace_control_character` + "ctrl-j": "\012", + "ctrl-k": "\013", + "enter": "\015", + "escape": "\033", + "tab": "\t", + } + _codenames = tuple(_codename_to_capname.keys()) + _codename_to_terminal_code = None # type: Optional[Dict[str, str]] + _terminal_code_to_codename = None # type: Optional[Dict[str, str]] + + def __init__( + self, + menu_entries: Iterable[str], + *, + accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS, + clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT, + clear_screen: bool = DEFAULT_CLEAR_SCREEN, + cursor_index: Optional[int] = None, + cycle_cursor: bool = DEFAULT_CYCLE_CURSOR, + exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT, + menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR, + menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE, + menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE, + multi_select: bool = DEFAULT_MULTI_SELECT, + multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR, + multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE, + multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE, + multi_select_empty_ok: bool = False, + multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS, + multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT, + preselected_entries: Optional[Iterable[Union[str, int]]] = None, + preview_border: bool = DEFAULT_PREVIEW_BORDER, + preview_command: Optional[Union[str, Callable[[str], str]]] = None, + preview_size: float = DEFAULT_PREVIEW_SIZE, + preview_title: str = DEFAULT_PREVIEW_TITLE, + search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE, + search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE, + search_key: Optional[str] = DEFAULT_SEARCH_KEY, + shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE, + shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE, + show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT, + show_multi_select_hint_text: Optional[str] = None, + show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT, + show_search_hint_text: Optional[str] = None, + show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS, + show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR, + status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None, + status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW, + status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE, + title: Optional[Union[str, Iterable[str]]] = None + ): + def extract_shortcuts_menu_entries_and_preview_arguments( + entries: Iterable[str], + ) -> Tuple[List[str], List[str], List[str]]: + separator_pattern = re.compile(r"([^\\])\|") + escaped_separator_pattern = re.compile(r"\\\|") + menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?") + shortcut_keys = [] + menu_entries = [] + preview_arguments = [] + for entry in entries: + unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry)) + match_obj = menu_entry_pattern.match(unit_separated_entry) + assert match_obj is not None + shortcut_key = match_obj.group(1) + display_text = match_obj.group(2) + preview_argument = match_obj.group(3) + shortcut_keys.append(shortcut_key) + menu_entries.append(display_text) + preview_arguments.append(preview_argument) + return menu_entries, shortcut_keys, preview_arguments + + def convert_preselected_entries_to_indices( + preselected_indices_or_entries: Iterable[Union[str, int]] + ) -> Set[int]: + menu_entry_to_indices = {} # type: Dict[str, Set[int]] + for menu_index, menu_entry in enumerate(self._menu_entries): + menu_entry_to_indices.setdefault(menu_entry, set()) + menu_entry_to_indices[menu_entry].add(menu_index) + preselected_indices = set() + for item in preselected_indices_or_entries: + if isinstance(item, int): + if 0 <= item < len(self._menu_entries): + preselected_indices.add(item) + else: + raise IndexError( + "Error: {} is outside the allowable range of 0..{}.".format( + item, len(self._menu_entries) - 1 + ) + ) + elif isinstance(item, str): + try: + preselected_indices.update(menu_entry_to_indices[item]) + except KeyError as e: + raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e + else: + raise ValueError('"preselected_entries" must either contain integers or strings.') + return preselected_indices + + def setup_title_or_status_bar_lines( + title_or_status_bar: Optional[Union[str, Iterable[str]]], + show_shortcut_hints: bool, + menu_entries: Iterable[str], + shortcut_keys: Iterable[str], + shortcut_hints_in_parentheses: bool, + ) -> Tuple[str, ...]: + if title_or_status_bar is None: + lines = [] # type: List[str] + elif isinstance(title_or_status_bar, str): + lines = title_or_status_bar.split("\n") + else: + lines = list(title_or_status_bar) + if show_shortcut_hints: + shortcut_hints_line = self._get_shortcut_hints_line( + menu_entries, shortcut_keys, shortcut_hints_in_parentheses + ) + if shortcut_hints_line is not None: + lines.append(shortcut_hints_line) + return tuple(lines) + + ( + self._menu_entries, + self._shortcut_keys, + self._preview_arguments, + ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries) + self._shortcuts_defined = any(key is not None for key in self._shortcut_keys) + self._accept_keys = tuple(accept_keys) + self._clear_menu_on_exit = clear_menu_on_exit + self._clear_screen = clear_screen + self._cycle_cursor = cycle_cursor + self._multi_select_empty_ok = multi_select_empty_ok + self._exit_on_shortcut = exit_on_shortcut + self._menu_cursor = menu_cursor if menu_cursor is not None else "" + self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else () + self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else () + self._multi_select = multi_select + self._multi_select_cursor = multi_select_cursor + self._multi_select_cursor_brackets_style = ( + tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else () + ) + self._multi_select_cursor_style = ( + tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else () + ) + self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else () + self._multi_select_select_on_accept = multi_select_select_on_accept + if preselected_entries and not self._multi_select: + raise InvalidParameterCombinationError( + "Multi-select mode must be enabled when preselected entries are given." + ) + self._preselected_indices = ( + convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None + ) + self._preview_border = preview_border + self._preview_command = preview_command + self._preview_size = preview_size + self._preview_title = preview_title + self._search_case_sensitive = search_case_sensitive + self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else () + self._search_key = search_key + self._shortcut_brackets_highlight_style = ( + tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else () + ) + self._shortcut_key_highlight_style = ( + tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else () + ) + self._show_search_hint = show_search_hint + self._show_search_hint_text = show_search_hint_text + self._show_shortcut_hints = show_shortcut_hints + self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar + self._status_bar_func = None # type: Optional[Callable[[str], str]] + self._status_bar_lines = None # type: Optional[Tuple[str, ...]] + if callable(status_bar): + self._status_bar_func = status_bar + else: + self._status_bar_lines = setup_title_or_status_bar_lines( + status_bar, + show_shortcut_hints and show_shortcut_hints_in_status_bar, + self._menu_entries, + self._shortcut_keys, + False, + ) + self._status_bar_below_preview = status_bar_below_preview + self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else () + self._title_lines = setup_title_or_status_bar_lines( + title, + show_shortcut_hints and not show_shortcut_hints_in_status_bar, + self._menu_entries, + self._shortcut_keys, + True, + ) + self._show_multi_select_hint = show_multi_select_hint + self._show_multi_select_hint_text = show_multi_select_hint_text + self._chosen_accept_key = None # type: Optional[str] + self._chosen_menu_index = None # type: Optional[int] + self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]] + self._paint_before_next_read = False + self._previous_displayed_menu_height = None # type: Optional[int] + self._reading_next_key = False + self._search = self.Search( + self._menu_entries, + case_senitive=self._search_case_sensitive, + show_search_hint=self._show_search_hint, + ) + self._selection = self.Selection(len(self._menu_entries), self._preselected_indices) + self._viewport = self.Viewport( + len(self._menu_entries), + len(self._title_lines), + len(self._status_bar_lines) if self._status_bar_lines is not None else 0, + 0, + 0, + ) + self._view = self.View(self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor) + if cursor_index and 0 < cursor_index < len(self._menu_entries): + self._view.active_menu_index = cursor_index + self._search.change_callback = self._view.update_view + self._old_term = None # type: Optional[List[Union[int, List[bytes]]]] + self._new_term = None # type: Optional[List[Union[int, List[bytes]]]] + self._tty_in = None # type: Optional[TextIO] + self._tty_out = None # type: Optional[TextIO] + self._user_locale = get_locale() + self._check_for_valid_styles() + # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead + self._init_backspace_control_character() + self._add_missing_control_characters_for_keys(self._accept_keys) + self._init_terminal_codes() + + @staticmethod + def _get_shortcut_hints_line( + menu_entries: Iterable[str], + shortcut_keys: Iterable[str], + shortcut_hints_in_parentheses: bool, + ) -> Optional[str]: + shortcut_hints_line = ", ".join( + "[{}]: {}".format(shortcut_key, menu_entry) + for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries) + if shortcut_key is not None + ) + if shortcut_hints_line != "": + if shortcut_hints_in_parentheses: + return "(" + shortcut_hints_line + ")" + else: + return shortcut_hints_line + return None + + @staticmethod + def _get_keycode_for_key(key: str) -> str: + if len(key) == 1: + # One letter keys represent themselves + return key + alt_modified_regex = re.compile(r"[Aa]lt-(\S)") + ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)") + match_obj = alt_modified_regex.match(key) + if match_obj: + return "\033" + match_obj.group(1) + match_obj = ctrl_modified_regex.match(key) + if match_obj: + # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64 + ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64 + if ctrl_code_ascii < 0: + # Interpret negative ascii codes as unsigned 7-Bit integers + ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1 + return chr(ctrl_code_ascii) + raise ValueError('Cannot interpret the given key "{}".'.format(key)) + + @classmethod + def _init_backspace_control_character(self) -> None: + try: + with open("/dev/tty", "r") as tty: + stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty) + name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$") + for field in stty_output.split(";"): + match_obj = name_to_keycode_regex.match(field) + if not match_obj: + continue + name, ctrl_code = match_obj.group(1), match_obj.group(2) + if name != "erase": + continue + self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code) + return + except subprocess.CalledProcessError: + pass + # Backspace control character could not be queried, assume `` (is most often used) + self._name_to_control_character["backspace"] = "\177" + + @classmethod + def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None: + for key in keys: + if key not in cls._name_to_control_character and key not in string.ascii_letters: + cls._name_to_control_character[key] = cls._get_keycode_for_key(key) + + @classmethod + def _init_terminal_codes(cls) -> None: + if cls._codename_to_terminal_code is not None: + return + supported_colors = int(cls._query_terminfo_database("colors")) + cls._codename_to_terminal_code = { + codename: cls._query_terminfo_database(codename) + if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8 + else "" + for codename in cls._codenames + } + cls._codename_to_terminal_code.update(cls._name_to_control_character) + cls._terminal_code_to_codename = { + terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items() + } + + @classmethod + def _query_terminfo_database(cls, codename: str) -> str: + if codename in cls._codename_to_capname: + capname = cls._codename_to_capname[codename] + else: + capname = codename + try: + return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True) + except subprocess.CalledProcessError as e: + # The return code 1 indicates a missing terminal capability + if e.returncode == 1: + return "" + raise e + + @classmethod + def _num_lines(self) -> int: + return int(self._query_terminfo_database("lines")) + + @classmethod + def _num_cols(self) -> int: + return int(self._query_terminfo_database("cols")) + + def _check_for_valid_styles(self) -> None: + invalid_styles = [] + for style_tuple in ( + self._menu_cursor_style, + self._menu_highlight_style, + self._search_highlight_style, + self._shortcut_key_highlight_style, + self._shortcut_brackets_highlight_style, + self._status_bar_style, + self._multi_select_cursor_brackets_style, + self._multi_select_cursor_style, + ): + for style in style_tuple: + if style not in self._codename_to_capname: + invalid_styles.append(style) + if invalid_styles: + if len(invalid_styles) == 1: + raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0])) + else: + raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles))) + + def _init_term(self) -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + self._tty_in = open("/dev/tty", "r", encoding=self._user_locale) + self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace") + self._old_term = termios.tcgetattr(self._tty_in.fileno()) + self._new_term = termios.tcgetattr(self._tty_in.fileno()) + # set the terminal to: unbuffered, no echo and no to translation (so sends instead of + # and since generates ) + self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL + self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL + termios.tcsetattr( + self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term) + ) + # Enter terminal application mode to get expected escape codes for arrow keys + self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"]) + self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"]) + if self._clear_screen: + self._tty_out.write(self._codename_to_terminal_code["clear"]) + + def _reset_term(self) -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_in is not None + assert self._tty_out is not None + assert self._old_term is not None + termios.tcsetattr( + self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term) + ) + self._tty_out.write(self._codename_to_terminal_code["cursor_visible"]) + self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"]) + if self._clear_screen: + self._tty_out.write(self._codename_to_terminal_code["clear"]) + self._tty_in.close() + self._tty_out.close() + + def _paint_menu(self) -> None: + def get_status_bar_lines() -> Tuple[str, ...]: + def get_multi_select_hint() -> str: + def get_string_from_keys(keys: Sequence[str]) -> str: + string_to_key = { + " ": "space", + } + keys_string = ", ".join( + "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys + ) + return keys_string + + accept_keys_string = get_string_from_keys(self._accept_keys) + multi_select_keys_string = get_string_from_keys(self._multi_select_keys) + if self._show_multi_select_hint_text is not None: + return self._show_multi_select_hint_text.format( + multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string + ) + else: + return "Press {} for multi-selection and {} to {}accept".format( + multi_select_keys_string, + accept_keys_string, + "select and " if self._multi_select_select_on_accept else "", + ) + + if self._status_bar_func is not None and self._view.active_menu_index is not None: + status_bar_lines = tuple( + self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n") + ) + if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar: + shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False) + if shortcut_hints_line is not None: + status_bar_lines += (shortcut_hints_line,) + elif self._status_bar_lines is not None: + status_bar_lines = self._status_bar_lines + else: + status_bar_lines = tuple() + if self._multi_select and self._show_multi_select_hint: + status_bar_lines += (get_multi_select_hint(),) + return status_bar_lines + + def apply_style( + style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None + ) -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + if file is None: + file = self._tty_out + if reset or style_iterable is None: + file.write(self._codename_to_terminal_code["reset_attributes"]) + if style_iterable is not None: + for style in style_iterable: + file.write(self._codename_to_terminal_code[style]) + + def print_menu_entries() -> int: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + all_cursors_width = wcswidth(self._menu_cursor) + ( + wcswidth(self._multi_select_cursor) if self._multi_select else 0 + ) + current_menu_block_displayed_height = 0 # sum all written lines + num_cols = self._num_cols() + if self._title_lines: + self._tty_out.write( + len(self._title_lines) * self._codename_to_terminal_code["cursor_up"] + + "\r" + + "\n".join( + (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ") + for title_line in self._title_lines + ) + + "\n" + ) + shortcut_string_len = 4 if self._shortcuts_defined else 0 + displayed_index = -1 + for displayed_index, menu_index, menu_entry in self._view: + current_shortcut_key = self._shortcut_keys[menu_index] + self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"]) + if self._shortcuts_defined: + if current_shortcut_key is not None: + apply_style(self._shortcut_brackets_highlight_style) + self._tty_out.write("[") + apply_style(self._shortcut_key_highlight_style) + self._tty_out.write(current_shortcut_key) + apply_style(self._shortcut_brackets_highlight_style) + self._tty_out.write("]") + apply_style() + else: + self._tty_out.write(3 * " ") + self._tty_out.write(" ") + if menu_index == self._view.active_menu_index: + apply_style(self._menu_highlight_style) + if self._search and self._search.search_text != "": + match_obj = self._search.matches[displayed_index][1] + self._tty_out.write( + menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)] + ) + apply_style(self._search_highlight_style) + self._tty_out.write( + menu_entry[ + match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len) + ] + ) + apply_style() + if menu_index == self._view.active_menu_index: + apply_style(self._menu_highlight_style) + self._tty_out.write( + menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len] + ) + else: + self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len]) + if menu_index == self._view.active_menu_index: + apply_style() + self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ") + if displayed_index < self._viewport.upper_index: + self._tty_out.write("\n") + empty_menu_lines = self._viewport.upper_index - displayed_index + self._tty_out.write( + max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ") + ) + self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) + current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines + return current_menu_block_displayed_height + + def print_search_line(current_menu_height: int) -> int: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + current_menu_block_displayed_height = 0 + num_cols = self._num_cols() + if self._search or self._show_search_hint: + self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) + if self._search: + assert self._search.search_text is not None + self._tty_out.write( + ( + (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY) + + self._search.search_text + )[:num_cols] + ) + self._tty_out.write((num_cols - len(self._search) - 1) * " ") + elif self._show_search_hint: + if self._show_search_hint_text is not None: + search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols] + elif self._search_key is not None: + search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols] + else: + search_hint = "(Press any letter key to search)"[:num_cols] + self._tty_out.write(search_hint) + self._tty_out.write((num_cols - wcswidth(search_hint)) * " ") + if self._search or self._show_search_hint: + self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) + current_menu_block_displayed_height = 1 + return current_menu_block_displayed_height + + def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + current_menu_block_displayed_height = 0 # sum all written lines + num_cols = self._num_cols() + if status_bar_lines: + self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) + apply_style(self._status_bar_style) + self._tty_out.write( + "\r" + + "\n".join( + (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ") + for status_bar_line in status_bar_lines + ) + + "\r" + ) + apply_style() + self._tty_out.write( + (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"] + ) + current_menu_block_displayed_height += len(status_bar_lines) + return current_menu_block_displayed_height + + def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + if self._preview_command is None or preview_max_num_lines < 3: + return 0 + + def get_preview_string() -> Optional[str]: + assert self._preview_command is not None + if self._view.active_menu_index is None: + return None + preview_argument = ( + self._preview_arguments[self._view.active_menu_index] + if self._preview_arguments[self._view.active_menu_index] is not None + else self._menu_entries[self._view.active_menu_index] + ) + if preview_argument == "": + return None + if isinstance(self._preview_command, str): + try: + preview_process = subprocess.Popen( + [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert preview_process.stdout is not None + preview_string = ( + io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace") + .read() + .strip() + ) + except subprocess.CalledProcessError as e: + raise PreviewCommandFailedError( + e.stderr.decode(encoding=self._user_locale, errors="replace").strip() + ) from e + else: + preview_string = self._preview_command(preview_argument) + return preview_string + + @static_variables( + # Regex taken from https://stackoverflow.com/a/14693789/5958465 + ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"), + # Modified version of https://stackoverflow.com/a/2188410/5958465 + ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"), + ) + def strip_ansi_codes_except_styling(string: str) -> str: + stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore + lambda match_obj: match_obj.group(0) + if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore + else "", + string, + ) + return cast(str, stripped_string) + + @static_variables( + regular_text_regex=re.compile(r"([^\x1B]+)(.*)"), + ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"), + ) + def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]: + if max_len <= 0: + return "", 0 + string_parts = [] + string_len = 0 + while string: + regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore + if regular_text_match is not None: + regular_text = regular_text_match.group(1) + regular_text_len = wcswidth(regular_text) + if string_len + regular_text_len > max_len: + string_parts.append(regular_text[: max_len - string_len]) + string_len = max_len + break + string_parts.append(regular_text) + string_len += regular_text_len + string = regular_text_match.group(2) + else: + ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore + string + ) + if ansi_escape_match is not None: + # Adopt the ansi escape code but do not count its length + ansi_escape_code_text = ansi_escape_match.group(1) + string_parts.append(ansi_escape_code_text) + string = ansi_escape_match.group(2) + else: + # It looks like an escape code (starts with escape), but it is something else + # -> skip the escape character and continue the loop + string_parts.append("\x1B") + string = string[1:] + return "".join(string_parts), string_len + + num_cols = self._num_cols() + try: + preview_string = get_preview_string() + if preview_string is not None: + preview_string = strip_ansi_codes_except_styling(preview_string) + except PreviewCommandFailedError as e: + preview_string = "The preview command failed with error message:\n\n" + str(e) + self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"]) + if preview_string is not None: + self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r") + if self._preview_border: + self._tty_out.write( + ( + BoxDrawingCharacters.upper_left + + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3] + + " " + + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal + + BoxDrawingCharacters.upper_right + )[:num_cols] + + "\n" + ) + # `finditer` can be used as a generator version of `str.join` + for i, line in enumerate( + match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE) + ): + if i >= preview_max_num_lines - (2 if self._preview_border else 0): + preview_num_lines = preview_max_num_lines + break + limited_line, limited_line_len = limit_string_with_escape_codes( + line, num_cols - (3 if self._preview_border else 0) + ) + self._tty_out.write( + ( + ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "") + + limited_line + + self._codename_to_terminal_code["reset_attributes"] + + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " " + + (BoxDrawingCharacters.vertical if self._preview_border else "") + ) + ) + else: + preview_num_lines = i + (3 if self._preview_border else 1) + if self._preview_border: + self._tty_out.write( + "\n" + + ( + BoxDrawingCharacters.lower_left + + (num_cols - 2) * BoxDrawingCharacters.horizontal + + BoxDrawingCharacters.lower_right + )[:num_cols] + ) + self._tty_out.write("\r") + else: + preview_num_lines = 0 + self._tty_out.write( + (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"] + ) + return preview_num_lines + + def delete_old_menu_lines(displayed_menu_height: int) -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + if ( + self._previous_displayed_menu_height is not None + and self._previous_displayed_menu_height > displayed_menu_height + ): + self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) + self._tty_out.write( + (self._previous_displayed_menu_height - displayed_menu_height) + * self._codename_to_terminal_code["delete_line"] + ) + self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) + + def position_cursor() -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + if self._view.active_displayed_index is None: + return + + cursor_width = wcswidth(self._menu_cursor) + for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1): + if displayed_index == self._view.active_displayed_index: + apply_style(self._menu_cursor_style) + self._tty_out.write(self._menu_cursor) + apply_style() + else: + self._tty_out.write(cursor_width * " ") + self._tty_out.write("\r") + if displayed_index < self._viewport.upper_index: + self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) + self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) + + def print_multi_select_column() -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + if not self._multi_select: + return + + def prepare_multi_select_cursors() -> Tuple[str, str]: + bracket_characters = "([{<)]}>" + bracket_style_escape_codes_io = io.StringIO() + multi_select_cursor_style_escape_codes_io = io.StringIO() + reset_codes_io = io.StringIO() + apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io) + apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io) + apply_style(file=reset_codes_io) + bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue() + multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue() + reset_codes = reset_codes_io.getvalue() + + cursor_with_brackets_only = re.sub( + r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor + ) + cursor_with_brackets_only_styled = re.sub( + r"[{}]+".format(re.escape(bracket_characters)), + lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes, + cursor_with_brackets_only, + ) + cursor_styled = re.sub( + r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)), + lambda match_obj: ( + bracket_style_escape_codes + if match_obj.group(0)[0] in bracket_characters + else multi_select_cursor_style_escape_codes + ) + + match_obj.group(0) + + reset_codes, + self._multi_select_cursor, + ) + return cursor_styled, cursor_with_brackets_only_styled + + if not self._view: + return + checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors() + cursor_width = wcswidth(self._menu_cursor) + displayed_selected_indices = self._view.displayed_selected_indices + displayed_index = 0 + for displayed_index, _, _ in self._view: + self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"]) + if displayed_index in displayed_selected_indices: + self._tty_out.write(checked_multi_select_cursor) + else: + self._tty_out.write(unchecked_multi_select_cursor) + if displayed_index < self._viewport.upper_index: + self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) + self._tty_out.write("\r") + self._tty_out.write( + (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0)) + * self._codename_to_terminal_code["cursor_up"] + ) + + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._tty_out is not None + displayed_menu_height = 0 # sum all written lines + status_bar_lines = get_status_bar_lines() + self._viewport.status_bar_lines_count = len(status_bar_lines) + if self._preview_command is not None: + self._viewport.preview_lines_count = int(self._preview_size * self._num_lines()) + preview_max_num_lines = self._viewport.preview_lines_count + self._viewport.keep_visible(self._view.active_displayed_index) + displayed_menu_height += print_menu_entries() + displayed_menu_height += print_search_line(displayed_menu_height) + if not self._status_bar_below_preview: + displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) + if self._preview_command is not None: + displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines) + if self._status_bar_below_preview: + displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) + delete_old_menu_lines(displayed_menu_height) + position_cursor() + if self._multi_select: + print_multi_select_column() + self._previous_displayed_menu_height = displayed_menu_height + self._tty_out.flush() + + def _clear_menu(self) -> None: + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + assert self._previous_displayed_menu_height is not None + assert self._tty_out is not None + if self._clear_menu_on_exit: + if self._title_lines: + self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]) + self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"]) + self._tty_out.write( + (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"] + ) + else: + self._tty_out.write( + (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"] + ) + self._tty_out.flush() + + def _read_next_key(self, ignore_case: bool = True) -> str: + # pylint: disable=unsubscriptable-object,unsupported-membership-test + assert self._terminal_code_to_codename is not None + assert self._tty_in is not None + # Needed for asynchronous handling of terminal resize events + self._reading_next_key = True + if self._paint_before_next_read: + self._paint_menu() + self._paint_before_next_read = False + # blocks until any amount of bytes is available + code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore") + self._reading_next_key = False + if code in self._terminal_code_to_codename: + return self._terminal_code_to_codename[code] + elif ignore_case: + return code.lower() + else: + return code + + def show(self) -> Optional[Union[int, Tuple[int, ...]]]: + def init_signal_handling() -> None: + # `SIGWINCH` is send on terminal resizes + def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None: + # pylint: disable=unused-argument + if self._reading_next_key: + self._paint_menu() + else: + self._paint_before_next_read = True + + signal.signal(signal.SIGWINCH, handle_sigwinch) + + def reset_signal_handling() -> None: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + + def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None: + letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ") + for keys in menu_action_to_keys.values(): + keys -= letter_keys + + # pylint: disable=unsubscriptable-object + assert self._codename_to_terminal_code is not None + self._init_term() + if self._preselected_indices is None: + self._selection.clear() + self._chosen_accept_key = None + self._chosen_menu_indices = None + self._chosen_menu_index = None + assert self._tty_out is not None + if self._title_lines: + # `print_menu` expects the cursor on the first menu item -> reserve one line for the title + self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"]) + menu_was_interrupted = False + try: + init_signal_handling() + menu_action_to_keys = { + "menu_up": set(("up", "ctrl-k", "k")), + "menu_down": set(("down", "ctrl-j", "j")), + "accept": set(self._accept_keys), + "multi_select": set(self._multi_select_keys), + "quit": set(("escape", "q")), + "search_start": set((self._search_key,)), + "backspace": set(("backspace",)), + } # type: Dict[str, Set[Optional[str]]] + while True: + self._paint_menu() + current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys) + next_key = self._read_next_key(ignore_case=False) + if self._search or self._search_key is None: + remove_letter_keys(current_menu_action_to_keys) + else: + next_key = next_key.lower() + if self._search_key is not None and not self._search and next_key in self._shortcut_keys: + shortcut_menu_index = self._shortcut_keys.index(next_key) + if self._exit_on_shortcut: + self._selection.add(shortcut_menu_index) + break + else: + if self._multi_select: + self._selection.toggle(shortcut_menu_index) + else: + self._view.active_menu_index = shortcut_menu_index + elif next_key in current_menu_action_to_keys["menu_up"]: + self._view.decrement_active_index() + elif next_key in current_menu_action_to_keys["menu_down"]: + self._view.increment_active_index() + elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]: + if self._view.active_menu_index is not None: + self._selection.toggle(self._view.active_menu_index) + elif next_key in current_menu_action_to_keys["accept"]: + if self._view.active_menu_index is not None: + if self._multi_select_select_on_accept or ( + not self._selection and self._multi_select_empty_ok is False + ): + self._selection.add(self._view.active_menu_index) + self._chosen_accept_key = next_key + break + elif next_key in current_menu_action_to_keys["quit"]: + if not self._search: + menu_was_interrupted = True + break + else: + self._search.search_text = None + elif not self._search: + if next_key in current_menu_action_to_keys["search_start"] or ( + self._search_key is None and next_key == DEFAULT_SEARCH_KEY + ): + self._search.search_text = "" + elif self._search_key is None: + self._search.search_text = next_key + else: + assert self._search.search_text is not None + if next_key in ("backspace",): + if self._search.search_text != "": + self._search.search_text = self._search.search_text[:-1] + else: + self._search.search_text = None + elif wcswidth(next_key) >= 0 and not ( + next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == "" + ): + # Only append `next_key` if it is a printable character and the first character is not the + # `search_start` key + self._search.search_text += next_key + except KeyboardInterrupt: + menu_was_interrupted = True + finally: + reset_signal_handling() + self._clear_menu() + self._reset_term() + if not menu_was_interrupted: + chosen_menu_indices = self._selection.selected_menu_indices + if chosen_menu_indices: + if self._multi_select: + self._chosen_menu_indices = chosen_menu_indices + else: + self._chosen_menu_index = chosen_menu_indices[0] + return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index + + @property + def chosen_accept_key(self) -> Optional[str]: + return self._chosen_accept_key + + @property + def chosen_menu_entry(self) -> Optional[str]: + return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None + + @property + def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]: + return ( + tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices) + if self._chosen_menu_indices is not None + else None + ) + + @property + def chosen_menu_index(self) -> Optional[int]: + return self._chosen_menu_index + + @property + def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]: + return self._chosen_menu_indices + + +class AttributeDict(dict): # type: ignore + def __getattr__(self, attr: str) -> Any: + return self[attr] + + def __setattr__(self, attr: str, value: Any) -> None: + self[attr] = value + + +def get_argumentparser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=""" +%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code. +""", + ) + parser.add_argument( + "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive" + ) + parser.add_argument( + "-X", + "--no-clear-menu-on-exit", + action="store_false", + dest="clear_menu_on_exit", + help="do not clear the menu on exit", + ) + parser.add_argument( + "-l", + "--clear-screen", + action="store_true", + dest="clear_screen", + help="clear the screen before the menu is shown", + ) + parser.add_argument( + "--cursor", + action="store", + dest="cursor", + default=DEFAULT_MENU_CURSOR, + help='menu cursor (default: "%(default)s")', + ) + parser.add_argument( + "-i", + "--cursor-index", + action="store", + dest="cursor_index", + type=int, + default=0, + help="initially selected item index", + ) + parser.add_argument( + "--cursor-style", + action="store", + dest="cursor_style", + default=",".join(DEFAULT_MENU_CURSOR_STYLE), + help='style for the menu cursor as comma separated list (default: "%(default)s")', + ) + parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection") + parser.add_argument( + "-E", + "--no-exit-on-shortcut", + action="store_false", + dest="exit_on_shortcut", + help="do not exit on shortcut keys", + ) + parser.add_argument( + "--highlight-style", + action="store", + dest="highlight_style", + default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE), + help='style for the selected menu entry as comma separated list (default: "%(default)s")', + ) + parser.add_argument( + "-m", + "--multi-select", + action="store_true", + dest="multi_select", + help="Allow the selection of multiple entries (implies `--stdout`)", + ) + parser.add_argument( + "--multi-select-cursor", + action="store", + dest="multi_select_cursor", + default=DEFAULT_MULTI_SELECT_CURSOR, + help='multi-select menu cursor (default: "%(default)s")', + ) + parser.add_argument( + "--multi-select-cursor-brackets-style", + action="store", + dest="multi_select_cursor_brackets_style", + default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE), + help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")', + ) + parser.add_argument( + "--multi-select-cursor-style", + action="store", + dest="multi_select_cursor_style", + default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE), + help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")', + ) + parser.add_argument( + "--multi-select-keys", + action="store", + dest="multi_select_keys", + default=",".join(DEFAULT_MULTI_SELECT_KEYS), + help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '), + ) + parser.add_argument( + "--multi-select-no-select-on-accept", + action="store_false", + dest="multi_select_select_on_accept", + help=( + "do not select the currently highlighted menu item when the accept key is pressed " + "(it is still selected if no other item was selected before)" + ), + ) + parser.add_argument( + "--multi-select-empty-ok", + action="store_true", + dest="multi_select_empty_ok", + help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"), + ) + parser.add_argument( + "-p", + "--preview", + action="store", + dest="preview_command", + help=( + "Command to generate a preview for the selected menu entry. " + '"{}" can be used as placeholder for the menu text. ' + 'If the menu entry has a data component (separated by "|"), this is used instead.' + ), + ) + parser.add_argument( + "--no-preview-border", + action="store_false", + dest="preview_border", + help="do not draw a border around the preview window", + ) + parser.add_argument( + "--preview-size", + action="store", + dest="preview_size", + type=float, + default=DEFAULT_PREVIEW_SIZE, + help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")', + ) + parser.add_argument( + "--preview-title", + action="store", + dest="preview_title", + default=DEFAULT_PREVIEW_TITLE, + help='title of the preview window (default: "%(default)s")', + ) + parser.add_argument( + "--search-highlight-style", + action="store", + dest="search_highlight_style", + default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE), + help='style of matched search patterns (default: "%(default)s")', + ) + parser.add_argument( + "--search-key", + action="store", + dest="search_key", + default=DEFAULT_SEARCH_KEY, + help=( + 'key to start a search (default: "%(default)s", ' + '"none" is treated a special value which activates the search on any letter key)' + ), + ) + parser.add_argument( + "--shortcut-brackets-highlight-style", + action="store", + dest="shortcut_brackets_highlight_style", + default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE), + help='style of brackets enclosing shortcut keys (default: "%(default)s")', + ) + parser.add_argument( + "--shortcut-key-highlight-style", + action="store", + dest="shortcut_key_highlight_style", + default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE), + help='style of shortcut keys (default: "%(default)s")', + ) + parser.add_argument( + "--show-multi-select-hint", + action="store_true", + dest="show_multi_select_hint", + help="show a multi-select hint in the status bar", + ) + parser.add_argument( + "--show-multi-select-hint-text", + action="store", + dest="show_multi_select_hint_text", + help=( + "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and " + "{accept_keys} if appropriately." + ), + ) + parser.add_argument( + "--show-search-hint", + action="store_true", + dest="show_search_hint", + help="show a search hint in the search line", + ) + parser.add_argument( + "--show-search-hint-text", + action="store", + dest="show_search_hint_text", + help=( + "Custom text which will be shown as search hint. Use the placeholders {key} for the search key " + "if appropriately." + ), + ) + parser.add_argument( + "--show-shortcut-hints", + action="store_true", + dest="show_shortcut_hints", + help="show shortcut hints in the status bar", + ) + parser.add_argument( + "--show-shortcut-hints-in-title", + action="store_false", + dest="show_shortcut_hints_in_status_bar", + default=True, + help="show shortcut hints in the menu title", + ) + parser.add_argument( + "-b", + "--status-bar", + action="store", + dest="status_bar", + help="status bar text", + ) + parser.add_argument( + "-d", + "--status-bar-below-preview", + action="store_true", + dest="status_bar_below_preview", + help="show the status bar below the preview window if any", + ) + parser.add_argument( + "--status-bar-style", + action="store", + dest="status_bar_style", + default=",".join(DEFAULT_STATUS_BAR_STYLE), + help='style of the status bar lines (default: "%(default)s")', + ) + parser.add_argument( + "--stdout", + action="store_true", + dest="stdout", + help=( + "Print the selected menu index or indices to stdout (in addition to the exit status). " + 'Multiple indices are separated by ";".' + ), + ) + parser.add_argument("-t", "--title", action="store", dest="title", help="menu title") + parser.add_argument( + "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit" + ) + parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show") + group = parser.add_mutually_exclusive_group() + group.add_argument( + "-r", + "--preselected_entries", + action="store", + dest="preselected_entries", + help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.", + ) + group.add_argument( + "-R", + "--preselected_indices", + action="store", + dest="preselected_indices", + help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.", + ) + return parser + + +def parse_arguments() -> AttributeDict: + parser = get_argumentparser() + args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()}) + if not args.print_version and not args.entries: + raise NoMenuEntriesError("No menu entries given!") + if args.cursor_style != "": + args.cursor_style = tuple(args.cursor_style.split(",")) + else: + args.cursor_style = None + if args.highlight_style != "": + args.highlight_style = tuple(args.highlight_style.split(",")) + else: + args.highlight_style = None + if args.search_highlight_style != "": + args.search_highlight_style = tuple(args.search_highlight_style.split(",")) + else: + args.search_highlight_style = None + if args.shortcut_key_highlight_style != "": + args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(",")) + else: + args.shortcut_key_highlight_style = None + if args.shortcut_brackets_highlight_style != "": + args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(",")) + else: + args.shortcut_brackets_highlight_style = None + if args.status_bar_style != "": + args.status_bar_style = tuple(args.status_bar_style.split(",")) + else: + args.status_bar_style = None + if args.multi_select_cursor_brackets_style != "": + args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(",")) + else: + args.multi_select_cursor_brackets_style = None + if args.multi_select_cursor_style != "": + args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(",")) + else: + args.multi_select_cursor_style = None + if args.multi_select_keys != "": + args.multi_select_keys = tuple(args.multi_select_keys.split(",")) + else: + args.multi_select_keys = None + if args.search_key.lower() == "none": + args.search_key = None + if args.show_shortcut_hints_in_status_bar: + args.show_shortcut_hints = True + if args.multi_select: + args.stdout = True + if args.preselected_entries is not None: + args.preselected = list(args.preselected_entries.split(",")) + elif args.preselected_indices is not None: + args.preselected = list(map(int, args.preselected_indices.split(","))) + else: + args.preselected = None + return args + + +def main() -> None: + try: + args = parse_arguments() + except SystemExit: + sys.exit(0) # Error code 0 is the error case in this program + except NoMenuEntriesError as e: + print(str(e), file=sys.stderr) + sys.exit(0) + if args.print_version: + print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__)) + sys.exit(0) + try: + terminal_menu = TerminalMenu( + menu_entries=args.entries, + clear_menu_on_exit=args.clear_menu_on_exit, + clear_screen=args.clear_screen, + cursor_index=args.cursor_index, + cycle_cursor=args.cycle, + exit_on_shortcut=args.exit_on_shortcut, + menu_cursor=args.cursor, + menu_cursor_style=args.cursor_style, + menu_highlight_style=args.highlight_style, + multi_select=args.multi_select, + multi_select_cursor=args.multi_select_cursor, + multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style, + multi_select_cursor_style=args.multi_select_cursor_style, + multi_select_empty_ok=args.multi_select_empty_ok, + multi_select_keys=args.multi_select_keys, + multi_select_select_on_accept=args.multi_select_select_on_accept, + preselected_entries=args.preselected, + preview_border=args.preview_border, + preview_command=args.preview_command, + preview_size=args.preview_size, + preview_title=args.preview_title, + search_case_sensitive=args.case_sensitive, + search_highlight_style=args.search_highlight_style, + search_key=args.search_key, + shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style, + shortcut_key_highlight_style=args.shortcut_key_highlight_style, + show_multi_select_hint=args.show_multi_select_hint, + show_multi_select_hint_text=args.show_multi_select_hint_text, + show_search_hint=args.show_search_hint, + show_search_hint_text=args.show_search_hint_text, + show_shortcut_hints=args.show_shortcut_hints, + show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar, + status_bar=args.status_bar, + status_bar_below_preview=args.status_bar_below_preview, + status_bar_style=args.status_bar_style, + title=args.title, + ) + except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e: + print(str(e), file=sys.stderr) + sys.exit(0) + chosen_entries = terminal_menu.show() + if chosen_entries is None: + sys.exit(0) + else: + if isinstance(chosen_entries, Iterable): + if args.stdout: + print(",".join(str(entry + 1) for entry in chosen_entries)) + sys.exit(chosen_entries[0] + 1) + else: + chosen_entry = chosen_entries + if args.stdout: + print(chosen_entry + 1) + sys.exit(chosen_entry + 1) + + +if __name__ == "__main__": + main() diff --git a/archinstall/lib/simple_menu.py b/archinstall/lib/simple_menu.py deleted file mode 100644 index a9d6d7ec..00000000 --- a/archinstall/lib/simple_menu.py +++ /dev/null @@ -1,1960 +0,0 @@ -""" -This file is copied over from the simple-term-menu project -(https://github.com/IngoMeyer441/simple-term-menu) -In order to comply with installation methods of Arch Linux. -We here by copy the MIT license attached to the project at the time of copy: - -Copyright 2021 Forschungszentrum Jülich GmbH - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -""" -import argparse -import copy -import ctypes -import io -import locale -import os -import platform -import re -import shlex -import signal -import string -import subprocess -import sys -from locale import getlocale -from types import FrameType -from typing import ( - Any, - Callable, - Dict, - Iterable, - Iterator, - List, - Match, - Optional, - Pattern, - Sequence, - Set, - TextIO, - Tuple, - Union, - cast, -) - -try: - import termios -except ImportError as e: - raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e - -__author__ = "Ingo Meyer" -__email__ = "i.meyer@fz-juelich.de" -__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved." -__license__ = "MIT" -__version_info__ = (1, 4, 1) -__version__ = ".".join(map(str, __version_info__)) - - -DEFAULT_ACCEPT_KEYS = ("enter",) -DEFAULT_CLEAR_MENU_ON_EXIT = True -DEFAULT_CLEAR_SCREEN = False -DEFAULT_CYCLE_CURSOR = True -DEFAULT_EXIT_ON_SHORTCUT = True -DEFAULT_MENU_CURSOR = "> " -DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold") -DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",) -DEFAULT_MULTI_SELECT = False -DEFAULT_MULTI_SELECT_CURSOR = "[*] " -DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",) -DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold") -DEFAULT_MULTI_SELECT_KEYS = (" ", "tab") -DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True -DEFAULT_PREVIEW_BORDER = True -DEFAULT_PREVIEW_SIZE = 0.25 -DEFAULT_PREVIEW_TITLE = "preview" -DEFAULT_SEARCH_CASE_SENSITIVE = False -DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold") -DEFAULT_SEARCH_KEY = "/" -DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",) -DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",) -DEFAULT_SHOW_MULTI_SELECT_HINT = False -DEFAULT_SHOW_SEARCH_HINT = False -DEFAULT_SHOW_SHORTCUT_HINTS = False -DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True -DEFAULT_STATUS_BAR_BELOW_PREVIEW = False -DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black") -MIN_VISIBLE_MENU_ENTRIES_COUNT = 3 - - -class InvalidParameterCombinationError(Exception): - pass - - -class InvalidStyleError(Exception): - pass - - -class NoMenuEntriesError(Exception): - pass - - -class PreviewCommandFailedError(Exception): - pass - - -class UnknownMenuEntryError(Exception): - pass - - -def get_locale() -> str: - user_locale = locale.getlocale()[1] - if user_locale is None: - return "ascii" - else: - return user_locale.lower() - - -def wcswidth(text: str) -> int: - if not hasattr(wcswidth, "libc"): - if platform.system() == "Darwin": - wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore - else: - wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore - user_locale = get_locale() - # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed - # in a `c_wchar_p` - encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace") - return wcswidth.libc.wcswidth( # type: ignore - ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text) - ) - - -def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(f: Callable[..., Any]) -> Callable[..., Any]: - for key, value in variables.items(): - setattr(f, key, value) - return f - - return decorator - - -class BoxDrawingCharacters: - if getlocale()[1] == "UTF-8": - # Unicode box characters - horizontal = "─" - vertical = "│" - upper_left = "┌" - upper_right = "┐" - lower_left = "└" - lower_right = "┘" - else: - # ASCII box characters - horizontal = "-" - vertical = "|" - upper_left = "+" - upper_right = "+" - lower_left = "+" - lower_right = "+" - - -class TerminalMenu: - class Search: - def __init__( - self, - menu_entries: Iterable[str], - search_text: Optional[str] = None, - case_senitive: bool = False, - show_search_hint: bool = False, - ): - self._menu_entries = menu_entries - self._case_sensitive = case_senitive - self._show_search_hint = show_search_hint - self._matches = [] # type: List[Tuple[int, Match[str]]] - self._search_regex = None # type: Optional[Pattern[str]] - self._change_callback = None # type: Optional[Callable[[], None]] - # Use the property setter since it has some more logic - self.search_text = search_text - - def _update_matches(self) -> None: - if self._search_regex is None: - self._matches = [] - else: - matches = [] - for i, menu_entry in enumerate(self._menu_entries): - match_obj = self._search_regex.search(menu_entry) - if match_obj: - matches.append((i, match_obj)) - self._matches = matches - - @property - def matches(self) -> List[Tuple[int, Match[str]]]: - return list(self._matches) - - @property - def search_regex(self) -> Optional[Pattern[str]]: - return self._search_regex - - @property - def search_text(self) -> Optional[str]: - return self._search_text - - @search_text.setter - def search_text(self, text: Optional[str]) -> None: - self._search_text = text - search_text = self._search_text - self._search_regex = None - while search_text and self._search_regex is None: - try: - self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0) - except re.error: - search_text = search_text[:-1] - self._update_matches() - if self._change_callback: - self._change_callback() - - @property - def change_callback(self) -> Optional[Callable[[], None]]: - return self._change_callback - - @change_callback.setter - def change_callback(self, callback: Optional[Callable[[], None]]) -> None: - self._change_callback = callback - - @property - def occupied_lines_count(self) -> int: - if not self and not self._show_search_hint: - return 0 - else: - return 1 - - def __bool__(self) -> bool: - return self._search_text is not None - - def __contains__(self, menu_index: int) -> bool: - return any(i == menu_index for i, _ in self._matches) - - def __len__(self) -> int: - return wcswidth(self._search_text) if self._search_text is not None else 0 - - class Selection: - def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None): - self._num_menu_entries = num_menu_entries - self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set() - - def clear(self) -> None: - self._selected_menu_indices.clear() - - def add(self, menu_index: int) -> None: - self[menu_index] = True - - def remove(self, menu_index: int) -> None: - self[menu_index] = False - - def toggle(self, menu_index: int) -> bool: - self[menu_index] = menu_index not in self._selected_menu_indices - return self[menu_index] - - def __bool__(self) -> bool: - return bool(self._selected_menu_indices) - - def __contains__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __getitem__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __setitem__(self, menu_index: int, is_selected: bool) -> None: - if is_selected: - self._selected_menu_indices.add(menu_index) - else: - self._selected_menu_indices.remove(menu_index) - - def __iter__(self) -> Iterator[int]: - return iter(self._selected_menu_indices) - - @property - def selected_menu_indices(self) -> Tuple[int, ...]: - return tuple(sorted(self._selected_menu_indices)) - - class View: - def __init__( - self, - menu_entries: Iterable[str], - search: "TerminalMenu.Search", - selection: "TerminalMenu.Selection", - viewport: "TerminalMenu.Viewport", - cycle_cursor: bool = True, - ): - self._menu_entries = list(menu_entries) - self._search = search - self._selection = selection - self._viewport = viewport - self._cycle_cursor = cycle_cursor - self._active_displayed_index = None # type: Optional[int] - self.update_view() - - def update_view(self) -> None: - if self._search and self._search.search_text != "": - self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches) - else: - self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries))) - self._menu_index_to_displayed_index = { - menu_index: displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - } - self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None - self._viewport.search_lines_count = self._search.occupied_lines_count - self._viewport.keep_visible(self._active_displayed_index) - - def increment_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index): - self._active_displayed_index += 1 - elif self._cycle_cursor: - self._active_displayed_index = 0 - self._viewport.keep_visible(self._active_displayed_index) - - def decrement_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index > 0: - self._active_displayed_index -= 1 - elif self._cycle_cursor: - self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1 - self._viewport.keep_visible(self._active_displayed_index) - - def is_visible(self, menu_index: int) -> bool: - return menu_index in self._menu_index_to_displayed_index and ( - self._viewport.lower_index - <= self._menu_index_to_displayed_index[menu_index] - <= self._viewport.upper_index - ) - - def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]: - if menu_index in self._menu_index_to_displayed_index: - return self._menu_index_to_displayed_index[menu_index] - else: - return None - - def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int: - return self._displayed_index_to_menu_index[displayed_index] - - @property - def active_menu_index(self) -> Optional[int]: - if self._active_displayed_index is not None: - return self._displayed_index_to_menu_index[self._active_displayed_index] - else: - return None - - @active_menu_index.setter - def active_menu_index(self, value: int) -> None: - self._selected_index = value - self._active_displayed_index = [ - displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - if menu_index == value - ][0] - self._viewport.keep_visible(self._active_displayed_index) - - @property - def active_displayed_index(self) -> Optional[int]: - return self._active_displayed_index - - @property - def displayed_selected_indices(self) -> List[int]: - return [ - self._menu_index_to_displayed_index[selected_index] - for selected_index in self._selection - if selected_index in self._menu_index_to_displayed_index - ] - - def __bool__(self) -> bool: - return self._active_displayed_index is not None - - def __iter__(self) -> Iterator[Tuple[int, int, str]]: - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index): - if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index: - yield (displayed_index, menu_index, self._menu_entries[menu_index]) - - class Viewport: - def __init__( - self, - num_menu_entries: int, - title_lines_count: int, - status_bar_lines_count: int, - preview_lines_count: int, - search_lines_count: int, - ): - self._num_menu_entries = num_menu_entries - self._title_lines_count = title_lines_count - self._status_bar_lines_count = status_bar_lines_count - # Use the property setter since it has some more logic - self.preview_lines_count = preview_lines_count - self.search_lines_count = search_lines_count - self._num_lines = self._calculate_num_lines() - self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1) - self.keep_visible(cursor_position=None, refresh_terminal_size=False) - - def _calculate_num_lines(self) -> int: - return ( - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - self._preview_lines_count - - self._search_lines_count - ) - - def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None: - # Treat `cursor_position=None` like `cursor_position=0` - if cursor_position is None: - cursor_position = 0 - if refresh_terminal_size: - self.update_terminal_size() - if self._viewport[0] <= cursor_position <= self._viewport[1]: - # Cursor is already visible - return - if cursor_position < self._viewport[0]: - scroll_num = cursor_position - self._viewport[0] - else: - scroll_num = cursor_position - self._viewport[1] - self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num) - - def update_terminal_size(self) -> None: - num_lines = self._calculate_num_lines() - if num_lines != self._num_lines: - # First let the upper index grow or shrink - upper_index = min(num_lines, self._num_menu_entries) - 1 - # Then, use as much space as possible for the `lower_index` - lower_index = max(0, upper_index - num_lines) - self._viewport = (lower_index, upper_index) - self._num_lines = num_lines - - @property - def lower_index(self) -> int: - return self._viewport[0] - - @property - def upper_index(self) -> int: - return self._viewport[1] - - @property - def viewport(self) -> Tuple[int, int]: - return self._viewport - - @property - def size(self) -> int: - return self._viewport[1] - self._viewport[0] + 1 - - @property - def num_menu_entries(self) -> int: - return self._num_menu_entries - - @property - def title_lines_count(self) -> int: - return self._title_lines_count - - @property - def status_bar_lines_count(self) -> int: - return self._status_bar_lines_count - - @status_bar_lines_count.setter - def status_bar_lines_count(self, value: int) -> None: - self._status_bar_lines_count = value - - @property - def preview_lines_count(self) -> int: - return self._preview_lines_count - - @preview_lines_count.setter - def preview_lines_count(self, value: int) -> None: - self._preview_lines_count = min( - value if value >= 3 else 0, - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - MIN_VISIBLE_MENU_ENTRIES_COUNT, - ) - - @property - def search_lines_count(self) -> int: - return self._search_lines_count - - @search_lines_count.setter - def search_lines_count(self, value: int) -> None: - self._search_lines_count = value - - @property - def must_scroll(self) -> bool: - return self._num_menu_entries > self._num_lines - - _codename_to_capname = { - "bg_black": "setab 0", - "bg_blue": "setab 4", - "bg_cyan": "setab 6", - "bg_gray": "setab 7", - "bg_green": "setab 2", - "bg_purple": "setab 5", - "bg_red": "setab 1", - "bg_yellow": "setab 3", - "bold": "bold", - "clear": "clear", - "colors": "colors", - "cursor_down": "cud1", - "cursor_invisible": "civis", - "cursor_left": "cub1", - "cursor_right": "cuf1", - "cursor_up": "cuu1", - "cursor_visible": "cnorm", - "delete_line": "dl1", - "down": "kcud1", - "enter_application_mode": "smkx", - "exit_application_mode": "rmkx", - "fg_black": "setaf 0", - "fg_blue": "setaf 4", - "fg_cyan": "setaf 6", - "fg_gray": "setaf 7", - "fg_green": "setaf 2", - "fg_purple": "setaf 5", - "fg_red": "setaf 1", - "fg_yellow": "setaf 3", - "italics": "sitm", - "reset_attributes": "sgr0", - "standout": "smso", - "underline": "smul", - "up": "kcuu1", - } - _name_to_control_character = { - "backspace": "", # Is assigned later in `self._init_backspace_control_character` - "ctrl-j": "\012", - "ctrl-k": "\013", - "enter": "\015", - "escape": "\033", - "tab": "\t", - } - _codenames = tuple(_codename_to_capname.keys()) - _codename_to_terminal_code = None # type: Optional[Dict[str, str]] - _terminal_code_to_codename = None # type: Optional[Dict[str, str]] - - def __init__( - self, - menu_entries: Iterable[str], - *, - accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS, - clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT, - clear_screen: bool = DEFAULT_CLEAR_SCREEN, - cursor_index: Optional[int] = None, - cycle_cursor: bool = DEFAULT_CYCLE_CURSOR, - exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT, - menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR, - menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE, - menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE, - multi_select: bool = DEFAULT_MULTI_SELECT, - multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR, - multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE, - multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE, - multi_select_empty_ok: bool = False, - multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS, - multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT, - preselected_entries: Optional[Iterable[Union[str, int]]] = None, - preview_border: bool = DEFAULT_PREVIEW_BORDER, - preview_command: Optional[Union[str, Callable[[str], str]]] = None, - preview_size: float = DEFAULT_PREVIEW_SIZE, - preview_title: str = DEFAULT_PREVIEW_TITLE, - search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE, - search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE, - search_key: Optional[str] = DEFAULT_SEARCH_KEY, - shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE, - shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE, - show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT, - show_multi_select_hint_text: Optional[str] = None, - show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT, - show_search_hint_text: Optional[str] = None, - show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS, - show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR, - status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None, - status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW, - status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE, - title: Optional[Union[str, Iterable[str]]] = None - ): - def extract_shortcuts_menu_entries_and_preview_arguments( - entries: Iterable[str], - ) -> Tuple[List[str], List[str], List[str]]: - separator_pattern = re.compile(r"([^\\])\|") - escaped_separator_pattern = re.compile(r"\\\|") - menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?") - shortcut_keys = [] - menu_entries = [] - preview_arguments = [] - for entry in entries: - unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry)) - match_obj = menu_entry_pattern.match(unit_separated_entry) - assert match_obj is not None - shortcut_key = match_obj.group(1) - display_text = match_obj.group(2) - preview_argument = match_obj.group(3) - shortcut_keys.append(shortcut_key) - menu_entries.append(display_text) - preview_arguments.append(preview_argument) - return menu_entries, shortcut_keys, preview_arguments - - def convert_preselected_entries_to_indices( - preselected_indices_or_entries: Iterable[Union[str, int]] - ) -> Set[int]: - menu_entry_to_indices = {} # type: Dict[str, Set[int]] - for menu_index, menu_entry in enumerate(self._menu_entries): - menu_entry_to_indices.setdefault(menu_entry, set()) - menu_entry_to_indices[menu_entry].add(menu_index) - preselected_indices = set() - for item in preselected_indices_or_entries: - if isinstance(item, int): - if 0 <= item < len(self._menu_entries): - preselected_indices.add(item) - else: - raise IndexError( - "Error: {} is outside the allowable range of 0..{}.".format( - item, len(self._menu_entries) - 1 - ) - ) - elif isinstance(item, str): - try: - preselected_indices.update(menu_entry_to_indices[item]) - except KeyError as e: - raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e - else: - raise ValueError('"preselected_entries" must either contain integers or strings.') - return preselected_indices - - def setup_title_or_status_bar_lines( - title_or_status_bar: Optional[Union[str, Iterable[str]]], - show_shortcut_hints: bool, - menu_entries: Iterable[str], - shortcut_keys: Iterable[str], - shortcut_hints_in_parentheses: bool, - ) -> Tuple[str, ...]: - if title_or_status_bar is None: - lines = [] # type: List[str] - elif isinstance(title_or_status_bar, str): - lines = title_or_status_bar.split("\n") - else: - lines = list(title_or_status_bar) - if show_shortcut_hints: - shortcut_hints_line = self._get_shortcut_hints_line( - menu_entries, shortcut_keys, shortcut_hints_in_parentheses - ) - if shortcut_hints_line is not None: - lines.append(shortcut_hints_line) - return tuple(lines) - - ( - self._menu_entries, - self._shortcut_keys, - self._preview_arguments, - ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries) - self._shortcuts_defined = any(key is not None for key in self._shortcut_keys) - self._accept_keys = tuple(accept_keys) - self._clear_menu_on_exit = clear_menu_on_exit - self._clear_screen = clear_screen - self._cycle_cursor = cycle_cursor - self._multi_select_empty_ok = multi_select_empty_ok - self._exit_on_shortcut = exit_on_shortcut - self._menu_cursor = menu_cursor if menu_cursor is not None else "" - self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else () - self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else () - self._multi_select = multi_select - self._multi_select_cursor = multi_select_cursor - self._multi_select_cursor_brackets_style = ( - tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else () - ) - self._multi_select_cursor_style = ( - tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else () - ) - self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else () - self._multi_select_select_on_accept = multi_select_select_on_accept - if preselected_entries and not self._multi_select: - raise InvalidParameterCombinationError( - "Multi-select mode must be enabled when preselected entries are given." - ) - self._preselected_indices = ( - convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None - ) - self._preview_border = preview_border - self._preview_command = preview_command - self._preview_size = preview_size - self._preview_title = preview_title - self._search_case_sensitive = search_case_sensitive - self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else () - self._search_key = search_key - self._shortcut_brackets_highlight_style = ( - tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else () - ) - self._shortcut_key_highlight_style = ( - tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else () - ) - self._show_search_hint = show_search_hint - self._show_search_hint_text = show_search_hint_text - self._show_shortcut_hints = show_shortcut_hints - self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar - self._status_bar_func = None # type: Optional[Callable[[str], str]] - self._status_bar_lines = None # type: Optional[Tuple[str, ...]] - if callable(status_bar): - self._status_bar_func = status_bar - else: - self._status_bar_lines = setup_title_or_status_bar_lines( - status_bar, - show_shortcut_hints and show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - False, - ) - self._status_bar_below_preview = status_bar_below_preview - self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else () - self._title_lines = setup_title_or_status_bar_lines( - title, - show_shortcut_hints and not show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - True, - ) - self._show_multi_select_hint = show_multi_select_hint - self._show_multi_select_hint_text = show_multi_select_hint_text - self._chosen_accept_key = None # type: Optional[str] - self._chosen_menu_index = None # type: Optional[int] - self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]] - self._paint_before_next_read = False - self._previous_displayed_menu_height = None # type: Optional[int] - self._reading_next_key = False - self._search = self.Search( - self._menu_entries, - case_senitive=self._search_case_sensitive, - show_search_hint=self._show_search_hint, - ) - self._selection = self.Selection(len(self._menu_entries), self._preselected_indices) - self._viewport = self.Viewport( - len(self._menu_entries), - len(self._title_lines), - len(self._status_bar_lines) if self._status_bar_lines is not None else 0, - 0, - 0, - ) - self._view = self.View(self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor) - if cursor_index and 0 < cursor_index < len(self._menu_entries): - self._view.active_menu_index = cursor_index - self._search.change_callback = self._view.update_view - self._old_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._new_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._tty_in = None # type: Optional[TextIO] - self._tty_out = None # type: Optional[TextIO] - self._user_locale = get_locale() - self._check_for_valid_styles() - # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead - self._init_backspace_control_character() - self._add_missing_control_characters_for_keys(self._accept_keys) - self._init_terminal_codes() - - @staticmethod - def _get_shortcut_hints_line( - menu_entries: Iterable[str], - shortcut_keys: Iterable[str], - shortcut_hints_in_parentheses: bool, - ) -> Optional[str]: - shortcut_hints_line = ", ".join( - "[{}]: {}".format(shortcut_key, menu_entry) - for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries) - if shortcut_key is not None - ) - if shortcut_hints_line != "": - if shortcut_hints_in_parentheses: - return "(" + shortcut_hints_line + ")" - else: - return shortcut_hints_line - return None - - @staticmethod - def _get_keycode_for_key(key: str) -> str: - if len(key) == 1: - # One letter keys represent themselves - return key - alt_modified_regex = re.compile(r"[Aa]lt-(\S)") - ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)") - match_obj = alt_modified_regex.match(key) - if match_obj: - return "\033" + match_obj.group(1) - match_obj = ctrl_modified_regex.match(key) - if match_obj: - # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64 - ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64 - if ctrl_code_ascii < 0: - # Interpret negative ascii codes as unsigned 7-Bit integers - ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1 - return chr(ctrl_code_ascii) - raise ValueError('Cannot interpret the given key "{}".'.format(key)) - - @classmethod - def _init_backspace_control_character(self) -> None: - try: - with open("/dev/tty", "r") as tty: - stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty) - name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$") - for field in stty_output.split(";"): - match_obj = name_to_keycode_regex.match(field) - if not match_obj: - continue - name, ctrl_code = match_obj.group(1), match_obj.group(2) - if name != "erase": - continue - self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code) - return - except subprocess.CalledProcessError: - pass - # Backspace control character could not be queried, assume `` (is most often used) - self._name_to_control_character["backspace"] = "\177" - - @classmethod - def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None: - for key in keys: - if key not in cls._name_to_control_character and key not in string.ascii_letters: - cls._name_to_control_character[key] = cls._get_keycode_for_key(key) - - @classmethod - def _init_terminal_codes(cls) -> None: - if cls._codename_to_terminal_code is not None: - return - supported_colors = int(cls._query_terminfo_database("colors")) - cls._codename_to_terminal_code = { - codename: cls._query_terminfo_database(codename) - if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8 - else "" - for codename in cls._codenames - } - cls._codename_to_terminal_code.update(cls._name_to_control_character) - cls._terminal_code_to_codename = { - terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items() - } - - @classmethod - def _query_terminfo_database(cls, codename: str) -> str: - if codename in cls._codename_to_capname: - capname = cls._codename_to_capname[codename] - else: - capname = codename - try: - return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True) - except subprocess.CalledProcessError as e: - # The return code 1 indicates a missing terminal capability - if e.returncode == 1: - return "" - raise e - - @classmethod - def _num_lines(self) -> int: - return int(self._query_terminfo_database("lines")) - - @classmethod - def _num_cols(self) -> int: - return int(self._query_terminfo_database("cols")) - - def _check_for_valid_styles(self) -> None: - invalid_styles = [] - for style_tuple in ( - self._menu_cursor_style, - self._menu_highlight_style, - self._search_highlight_style, - self._shortcut_key_highlight_style, - self._shortcut_brackets_highlight_style, - self._status_bar_style, - self._multi_select_cursor_brackets_style, - self._multi_select_cursor_style, - ): - for style in style_tuple: - if style not in self._codename_to_capname: - invalid_styles.append(style) - if invalid_styles: - if len(invalid_styles) == 1: - raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0])) - else: - raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles))) - - def _init_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._tty_in = open("/dev/tty", "r", encoding=self._user_locale) - self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace") - self._old_term = termios.tcgetattr(self._tty_in.fileno()) - self._new_term = termios.tcgetattr(self._tty_in.fileno()) - # set the terminal to: unbuffered, no echo and no to translation (so sends instead of - # and since generates ) - self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL - self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL - termios.tcsetattr( - self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term) - ) - # Enter terminal application mode to get expected escape codes for arrow keys - self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"]) - self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - - def _reset_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_in is not None - assert self._tty_out is not None - assert self._old_term is not None - termios.tcsetattr( - self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term) - ) - self._tty_out.write(self._codename_to_terminal_code["cursor_visible"]) - self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - self._tty_in.close() - self._tty_out.close() - - def _paint_menu(self) -> None: - def get_status_bar_lines() -> Tuple[str, ...]: - def get_multi_select_hint() -> str: - def get_string_from_keys(keys: Sequence[str]) -> str: - string_to_key = { - " ": "space", - } - keys_string = ", ".join( - "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys - ) - return keys_string - - accept_keys_string = get_string_from_keys(self._accept_keys) - multi_select_keys_string = get_string_from_keys(self._multi_select_keys) - if self._show_multi_select_hint_text is not None: - return self._show_multi_select_hint_text.format( - multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string - ) - else: - return "Press {} for multi-selection and {} to {}accept".format( - multi_select_keys_string, - accept_keys_string, - "select and " if self._multi_select_select_on_accept else "", - ) - - if self._status_bar_func is not None and self._view.active_menu_index is not None: - status_bar_lines = tuple( - self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n") - ) - if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar: - shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False) - if shortcut_hints_line is not None: - status_bar_lines += (shortcut_hints_line,) - elif self._status_bar_lines is not None: - status_bar_lines = self._status_bar_lines - else: - status_bar_lines = tuple() - if self._multi_select and self._show_multi_select_hint: - status_bar_lines += (get_multi_select_hint(),) - return status_bar_lines - - def apply_style( - style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None - ) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if file is None: - file = self._tty_out - if reset or style_iterable is None: - file.write(self._codename_to_terminal_code["reset_attributes"]) - if style_iterable is not None: - for style in style_iterable: - file.write(self._codename_to_terminal_code[style]) - - def print_menu_entries() -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - all_cursors_width = wcswidth(self._menu_cursor) + ( - wcswidth(self._multi_select_cursor) if self._multi_select else 0 - ) - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if self._title_lines: - self._tty_out.write( - len(self._title_lines) * self._codename_to_terminal_code["cursor_up"] - + "\r" - + "\n".join( - (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ") - for title_line in self._title_lines - ) - + "\n" - ) - shortcut_string_len = 4 if self._shortcuts_defined else 0 - displayed_index = -1 - for displayed_index, menu_index, menu_entry in self._view: - current_shortcut_key = self._shortcut_keys[menu_index] - self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"]) - if self._shortcuts_defined: - if current_shortcut_key is not None: - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("[") - apply_style(self._shortcut_key_highlight_style) - self._tty_out.write(current_shortcut_key) - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("]") - apply_style() - else: - self._tty_out.write(3 * " ") - self._tty_out.write(" ") - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - if self._search and self._search.search_text != "": - match_obj = self._search.matches[displayed_index][1] - self._tty_out.write( - menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)] - ) - apply_style(self._search_highlight_style) - self._tty_out.write( - menu_entry[ - match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len) - ] - ) - apply_style() - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - self._tty_out.write( - menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len] - ) - else: - self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len]) - if menu_index == self._view.active_menu_index: - apply_style() - self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ") - if displayed_index < self._viewport.upper_index: - self._tty_out.write("\n") - empty_menu_lines = self._viewport.upper_index - displayed_index - self._tty_out.write( - max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ") - ) - self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines - return current_menu_block_displayed_height - - def print_search_line(current_menu_height: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 - num_cols = self._num_cols() - if self._search or self._show_search_hint: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - if self._search: - assert self._search.search_text is not None - self._tty_out.write( - ( - (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY) - + self._search.search_text - )[:num_cols] - ) - self._tty_out.write((num_cols - len(self._search) - 1) * " ") - elif self._show_search_hint: - if self._show_search_hint_text is not None: - search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols] - elif self._search_key is not None: - search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols] - else: - search_hint = "(Press any letter key to search)"[:num_cols] - self._tty_out.write(search_hint) - self._tty_out.write((num_cols - wcswidth(search_hint)) * " ") - if self._search or self._show_search_hint: - self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height = 1 - return current_menu_block_displayed_height - - def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if status_bar_lines: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - apply_style(self._status_bar_style) - self._tty_out.write( - "\r" - + "\n".join( - (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ") - for status_bar_line in status_bar_lines - ) - + "\r" - ) - apply_style() - self._tty_out.write( - (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"] - ) - current_menu_block_displayed_height += len(status_bar_lines) - return current_menu_block_displayed_height - - def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._preview_command is None or preview_max_num_lines < 3: - return 0 - - def get_preview_string() -> Optional[str]: - assert self._preview_command is not None - if self._view.active_menu_index is None: - return None - preview_argument = ( - self._preview_arguments[self._view.active_menu_index] - if self._preview_arguments[self._view.active_menu_index] is not None - else self._menu_entries[self._view.active_menu_index] - ) - if preview_argument == "": - return None - if isinstance(self._preview_command, str): - try: - preview_process = subprocess.Popen( - [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert preview_process.stdout is not None - preview_string = ( - io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace") - .read() - .strip() - ) - except subprocess.CalledProcessError as e: - raise PreviewCommandFailedError( - e.stderr.decode(encoding=self._user_locale, errors="replace").strip() - ) from e - else: - preview_string = self._preview_command(preview_argument) - return preview_string - - @static_variables( - # Regex taken from https://stackoverflow.com/a/14693789/5958465 - ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"), - # Modified version of https://stackoverflow.com/a/2188410/5958465 - ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"), - ) - def strip_ansi_codes_except_styling(string: str) -> str: - stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore - lambda match_obj: match_obj.group(0) - if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore - else "", - string, - ) - return cast(str, stripped_string) - - @static_variables( - regular_text_regex=re.compile(r"([^\x1B]+)(.*)"), - ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"), - ) - def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]: - if max_len <= 0: - return "", 0 - string_parts = [] - string_len = 0 - while string: - regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore - if regular_text_match is not None: - regular_text = regular_text_match.group(1) - regular_text_len = wcswidth(regular_text) - if string_len + regular_text_len > max_len: - string_parts.append(regular_text[: max_len - string_len]) - string_len = max_len - break - string_parts.append(regular_text) - string_len += regular_text_len - string = regular_text_match.group(2) - else: - ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore - string - ) - if ansi_escape_match is not None: - # Adopt the ansi escape code but do not count its length - ansi_escape_code_text = ansi_escape_match.group(1) - string_parts.append(ansi_escape_code_text) - string = ansi_escape_match.group(2) - else: - # It looks like an escape code (starts with escape), but it is something else - # -> skip the escape character and continue the loop - string_parts.append("\x1B") - string = string[1:] - return "".join(string_parts), string_len - - num_cols = self._num_cols() - try: - preview_string = get_preview_string() - if preview_string is not None: - preview_string = strip_ansi_codes_except_styling(preview_string) - except PreviewCommandFailedError as e: - preview_string = "The preview command failed with error message:\n\n" + str(e) - self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"]) - if preview_string is not None: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r") - if self._preview_border: - self._tty_out.write( - ( - BoxDrawingCharacters.upper_left - + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3] - + " " - + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.upper_right - )[:num_cols] - + "\n" - ) - # `finditer` can be used as a generator version of `str.join` - for i, line in enumerate( - match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE) - ): - if i >= preview_max_num_lines - (2 if self._preview_border else 0): - preview_num_lines = preview_max_num_lines - break - limited_line, limited_line_len = limit_string_with_escape_codes( - line, num_cols - (3 if self._preview_border else 0) - ) - self._tty_out.write( - ( - ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "") - + limited_line - + self._codename_to_terminal_code["reset_attributes"] - + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " " - + (BoxDrawingCharacters.vertical if self._preview_border else "") - ) - ) - else: - preview_num_lines = i + (3 if self._preview_border else 1) - if self._preview_border: - self._tty_out.write( - "\n" - + ( - BoxDrawingCharacters.lower_left - + (num_cols - 2) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.lower_right - )[:num_cols] - ) - self._tty_out.write("\r") - else: - preview_num_lines = 0 - self._tty_out.write( - (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"] - ) - return preview_num_lines - - def delete_old_menu_lines(displayed_menu_height: int) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if ( - self._previous_displayed_menu_height is not None - and self._previous_displayed_menu_height > displayed_menu_height - ): - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write( - (self._previous_displayed_menu_height - displayed_menu_height) - * self._codename_to_terminal_code["delete_line"] - ) - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - - def position_cursor() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._view.active_displayed_index is None: - return - - cursor_width = wcswidth(self._menu_cursor) - for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1): - if displayed_index == self._view.active_displayed_index: - apply_style(self._menu_cursor_style) - self._tty_out.write(self._menu_cursor) - apply_style() - else: - self._tty_out.write(cursor_width * " ") - self._tty_out.write("\r") - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - - def print_multi_select_column() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if not self._multi_select: - return - - def prepare_multi_select_cursors() -> Tuple[str, str]: - bracket_characters = "([{<)]}>" - bracket_style_escape_codes_io = io.StringIO() - multi_select_cursor_style_escape_codes_io = io.StringIO() - reset_codes_io = io.StringIO() - apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io) - apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io) - apply_style(file=reset_codes_io) - bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue() - multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue() - reset_codes = reset_codes_io.getvalue() - - cursor_with_brackets_only = re.sub( - r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor - ) - cursor_with_brackets_only_styled = re.sub( - r"[{}]+".format(re.escape(bracket_characters)), - lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes, - cursor_with_brackets_only, - ) - cursor_styled = re.sub( - r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)), - lambda match_obj: ( - bracket_style_escape_codes - if match_obj.group(0)[0] in bracket_characters - else multi_select_cursor_style_escape_codes - ) - + match_obj.group(0) - + reset_codes, - self._multi_select_cursor, - ) - return cursor_styled, cursor_with_brackets_only_styled - - if not self._view: - return - checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors() - cursor_width = wcswidth(self._menu_cursor) - displayed_selected_indices = self._view.displayed_selected_indices - displayed_index = 0 - for displayed_index, _, _ in self._view: - self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"]) - if displayed_index in displayed_selected_indices: - self._tty_out.write(checked_multi_select_cursor) - else: - self._tty_out.write(unchecked_multi_select_cursor) - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write("\r") - self._tty_out.write( - (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0)) - * self._codename_to_terminal_code["cursor_up"] - ) - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - displayed_menu_height = 0 # sum all written lines - status_bar_lines = get_status_bar_lines() - self._viewport.status_bar_lines_count = len(status_bar_lines) - if self._preview_command is not None: - self._viewport.preview_lines_count = int(self._preview_size * self._num_lines()) - preview_max_num_lines = self._viewport.preview_lines_count - self._viewport.keep_visible(self._view.active_displayed_index) - displayed_menu_height += print_menu_entries() - displayed_menu_height += print_search_line(displayed_menu_height) - if not self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - if self._preview_command is not None: - displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines) - if self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - delete_old_menu_lines(displayed_menu_height) - position_cursor() - if self._multi_select: - print_multi_select_column() - self._previous_displayed_menu_height = displayed_menu_height - self._tty_out.flush() - - def _clear_menu(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._previous_displayed_menu_height is not None - assert self._tty_out is not None - if self._clear_menu_on_exit: - if self._title_lines: - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]) - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"]) - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"] - ) - else: - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"] - ) - self._tty_out.flush() - - def _read_next_key(self, ignore_case: bool = True) -> str: - # pylint: disable=unsubscriptable-object,unsupported-membership-test - assert self._terminal_code_to_codename is not None - assert self._tty_in is not None - # Needed for asynchronous handling of terminal resize events - self._reading_next_key = True - if self._paint_before_next_read: - self._paint_menu() - self._paint_before_next_read = False - # blocks until any amount of bytes is available - code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore") - self._reading_next_key = False - if code in self._terminal_code_to_codename: - return self._terminal_code_to_codename[code] - elif ignore_case: - return code.lower() - else: - return code - - def show(self) -> Optional[Union[int, Tuple[int, ...]]]: - def init_signal_handling() -> None: - # `SIGWINCH` is send on terminal resizes - def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None: - # pylint: disable=unused-argument - if self._reading_next_key: - self._paint_menu() - else: - self._paint_before_next_read = True - - signal.signal(signal.SIGWINCH, handle_sigwinch) - - def reset_signal_handling() -> None: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - - def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None: - letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ") - for keys in menu_action_to_keys.values(): - keys -= letter_keys - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._init_term() - if self._preselected_indices is None: - self._selection.clear() - self._chosen_accept_key = None - self._chosen_menu_indices = None - self._chosen_menu_index = None - assert self._tty_out is not None - if self._title_lines: - # `print_menu` expects the cursor on the first menu item -> reserve one line for the title - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"]) - menu_was_interrupted = False - try: - init_signal_handling() - menu_action_to_keys = { - "menu_up": set(("up", "ctrl-k", "k")), - "menu_down": set(("down", "ctrl-j", "j")), - "accept": set(self._accept_keys), - "multi_select": set(self._multi_select_keys), - "quit": set(("escape", "q")), - "search_start": set((self._search_key,)), - "backspace": set(("backspace",)), - } # type: Dict[str, Set[Optional[str]]] - while True: - self._paint_menu() - current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys) - next_key = self._read_next_key(ignore_case=False) - if self._search or self._search_key is None: - remove_letter_keys(current_menu_action_to_keys) - else: - next_key = next_key.lower() - if self._search_key is not None and not self._search and next_key in self._shortcut_keys: - shortcut_menu_index = self._shortcut_keys.index(next_key) - if self._exit_on_shortcut: - self._selection.add(shortcut_menu_index) - break - else: - if self._multi_select: - self._selection.toggle(shortcut_menu_index) - else: - self._view.active_menu_index = shortcut_menu_index - elif next_key in current_menu_action_to_keys["menu_up"]: - self._view.decrement_active_index() - elif next_key in current_menu_action_to_keys["menu_down"]: - self._view.increment_active_index() - elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]: - if self._view.active_menu_index is not None: - self._selection.toggle(self._view.active_menu_index) - elif next_key in current_menu_action_to_keys["accept"]: - if self._view.active_menu_index is not None: - if self._multi_select_select_on_accept or ( - not self._selection and self._multi_select_empty_ok is False - ): - self._selection.add(self._view.active_menu_index) - self._chosen_accept_key = next_key - break - elif next_key in current_menu_action_to_keys["quit"]: - if not self._search: - menu_was_interrupted = True - break - else: - self._search.search_text = None - elif not self._search: - if next_key in current_menu_action_to_keys["search_start"] or ( - self._search_key is None and next_key == DEFAULT_SEARCH_KEY - ): - self._search.search_text = "" - elif self._search_key is None: - self._search.search_text = next_key - else: - assert self._search.search_text is not None - if next_key in ("backspace",): - if self._search.search_text != "": - self._search.search_text = self._search.search_text[:-1] - else: - self._search.search_text = None - elif wcswidth(next_key) >= 0 and not ( - next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == "" - ): - # Only append `next_key` if it is a printable character and the first character is not the - # `search_start` key - self._search.search_text += next_key - except KeyboardInterrupt: - menu_was_interrupted = True - finally: - reset_signal_handling() - self._clear_menu() - self._reset_term() - if not menu_was_interrupted: - chosen_menu_indices = self._selection.selected_menu_indices - if chosen_menu_indices: - if self._multi_select: - self._chosen_menu_indices = chosen_menu_indices - else: - self._chosen_menu_index = chosen_menu_indices[0] - return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index - - @property - def chosen_accept_key(self) -> Optional[str]: - return self._chosen_accept_key - - @property - def chosen_menu_entry(self) -> Optional[str]: - return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None - - @property - def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]: - return ( - tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices) - if self._chosen_menu_indices is not None - else None - ) - - @property - def chosen_menu_index(self) -> Optional[int]: - return self._chosen_menu_index - - @property - def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]: - return self._chosen_menu_indices - - -class AttributeDict(dict): # type: ignore - def __getattr__(self, attr: str) -> Any: - return self[attr] - - def __setattr__(self, attr: str, value: Any) -> None: - self[attr] = value - - -def get_argumentparser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description=""" -%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code. -""", - ) - parser.add_argument( - "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive" - ) - parser.add_argument( - "-X", - "--no-clear-menu-on-exit", - action="store_false", - dest="clear_menu_on_exit", - help="do not clear the menu on exit", - ) - parser.add_argument( - "-l", - "--clear-screen", - action="store_true", - dest="clear_screen", - help="clear the screen before the menu is shown", - ) - parser.add_argument( - "--cursor", - action="store", - dest="cursor", - default=DEFAULT_MENU_CURSOR, - help='menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "-i", - "--cursor-index", - action="store", - dest="cursor_index", - type=int, - default=0, - help="initially selected item index", - ) - parser.add_argument( - "--cursor-style", - action="store", - dest="cursor_style", - default=",".join(DEFAULT_MENU_CURSOR_STYLE), - help='style for the menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection") - parser.add_argument( - "-E", - "--no-exit-on-shortcut", - action="store_false", - dest="exit_on_shortcut", - help="do not exit on shortcut keys", - ) - parser.add_argument( - "--highlight-style", - action="store", - dest="highlight_style", - default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE), - help='style for the selected menu entry as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "-m", - "--multi-select", - action="store_true", - dest="multi_select", - help="Allow the selection of multiple entries (implies `--stdout`)", - ) - parser.add_argument( - "--multi-select-cursor", - action="store", - dest="multi_select_cursor", - default=DEFAULT_MULTI_SELECT_CURSOR, - help='multi-select menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-brackets-style", - action="store", - dest="multi_select_cursor_brackets_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE), - help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-style", - action="store", - dest="multi_select_cursor_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE), - help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-keys", - action="store", - dest="multi_select_keys", - default=",".join(DEFAULT_MULTI_SELECT_KEYS), - help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '), - ) - parser.add_argument( - "--multi-select-no-select-on-accept", - action="store_false", - dest="multi_select_select_on_accept", - help=( - "do not select the currently highlighted menu item when the accept key is pressed " - "(it is still selected if no other item was selected before)" - ), - ) - parser.add_argument( - "--multi-select-empty-ok", - action="store_true", - dest="multi_select_empty_ok", - help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"), - ) - parser.add_argument( - "-p", - "--preview", - action="store", - dest="preview_command", - help=( - "Command to generate a preview for the selected menu entry. " - '"{}" can be used as placeholder for the menu text. ' - 'If the menu entry has a data component (separated by "|"), this is used instead.' - ), - ) - parser.add_argument( - "--no-preview-border", - action="store_false", - dest="preview_border", - help="do not draw a border around the preview window", - ) - parser.add_argument( - "--preview-size", - action="store", - dest="preview_size", - type=float, - default=DEFAULT_PREVIEW_SIZE, - help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")', - ) - parser.add_argument( - "--preview-title", - action="store", - dest="preview_title", - default=DEFAULT_PREVIEW_TITLE, - help='title of the preview window (default: "%(default)s")', - ) - parser.add_argument( - "--search-highlight-style", - action="store", - dest="search_highlight_style", - default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE), - help='style of matched search patterns (default: "%(default)s")', - ) - parser.add_argument( - "--search-key", - action="store", - dest="search_key", - default=DEFAULT_SEARCH_KEY, - help=( - 'key to start a search (default: "%(default)s", ' - '"none" is treated a special value which activates the search on any letter key)' - ), - ) - parser.add_argument( - "--shortcut-brackets-highlight-style", - action="store", - dest="shortcut_brackets_highlight_style", - default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE), - help='style of brackets enclosing shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--shortcut-key-highlight-style", - action="store", - dest="shortcut_key_highlight_style", - default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE), - help='style of shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--show-multi-select-hint", - action="store_true", - dest="show_multi_select_hint", - help="show a multi-select hint in the status bar", - ) - parser.add_argument( - "--show-multi-select-hint-text", - action="store", - dest="show_multi_select_hint_text", - help=( - "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and " - "{accept_keys} if appropriately." - ), - ) - parser.add_argument( - "--show-search-hint", - action="store_true", - dest="show_search_hint", - help="show a search hint in the search line", - ) - parser.add_argument( - "--show-search-hint-text", - action="store", - dest="show_search_hint_text", - help=( - "Custom text which will be shown as search hint. Use the placeholders {key} for the search key " - "if appropriately." - ), - ) - parser.add_argument( - "--show-shortcut-hints", - action="store_true", - dest="show_shortcut_hints", - help="show shortcut hints in the status bar", - ) - parser.add_argument( - "--show-shortcut-hints-in-title", - action="store_false", - dest="show_shortcut_hints_in_status_bar", - default=True, - help="show shortcut hints in the menu title", - ) - parser.add_argument( - "-b", - "--status-bar", - action="store", - dest="status_bar", - help="status bar text", - ) - parser.add_argument( - "-d", - "--status-bar-below-preview", - action="store_true", - dest="status_bar_below_preview", - help="show the status bar below the preview window if any", - ) - parser.add_argument( - "--status-bar-style", - action="store", - dest="status_bar_style", - default=",".join(DEFAULT_STATUS_BAR_STYLE), - help='style of the status bar lines (default: "%(default)s")', - ) - parser.add_argument( - "--stdout", - action="store_true", - dest="stdout", - help=( - "Print the selected menu index or indices to stdout (in addition to the exit status). " - 'Multiple indices are separated by ";".' - ), - ) - parser.add_argument("-t", "--title", action="store", dest="title", help="menu title") - parser.add_argument( - "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit" - ) - parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show") - group = parser.add_mutually_exclusive_group() - group.add_argument( - "-r", - "--preselected_entries", - action="store", - dest="preselected_entries", - help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.", - ) - group.add_argument( - "-R", - "--preselected_indices", - action="store", - dest="preselected_indices", - help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.", - ) - return parser - - -def parse_arguments() -> AttributeDict: - parser = get_argumentparser() - args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()}) - if not args.print_version and not args.entries: - raise NoMenuEntriesError("No menu entries given!") - if args.cursor_style != "": - args.cursor_style = tuple(args.cursor_style.split(",")) - else: - args.cursor_style = None - if args.highlight_style != "": - args.highlight_style = tuple(args.highlight_style.split(",")) - else: - args.highlight_style = None - if args.search_highlight_style != "": - args.search_highlight_style = tuple(args.search_highlight_style.split(",")) - else: - args.search_highlight_style = None - if args.shortcut_key_highlight_style != "": - args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(",")) - else: - args.shortcut_key_highlight_style = None - if args.shortcut_brackets_highlight_style != "": - args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(",")) - else: - args.shortcut_brackets_highlight_style = None - if args.status_bar_style != "": - args.status_bar_style = tuple(args.status_bar_style.split(",")) - else: - args.status_bar_style = None - if args.multi_select_cursor_brackets_style != "": - args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(",")) - else: - args.multi_select_cursor_brackets_style = None - if args.multi_select_cursor_style != "": - args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(",")) - else: - args.multi_select_cursor_style = None - if args.multi_select_keys != "": - args.multi_select_keys = tuple(args.multi_select_keys.split(",")) - else: - args.multi_select_keys = None - if args.search_key.lower() == "none": - args.search_key = None - if args.show_shortcut_hints_in_status_bar: - args.show_shortcut_hints = True - if args.multi_select: - args.stdout = True - if args.preselected_entries is not None: - args.preselected = list(args.preselected_entries.split(",")) - elif args.preselected_indices is not None: - args.preselected = list(map(int, args.preselected_indices.split(","))) - else: - args.preselected = None - return args - - -def main() -> None: - try: - args = parse_arguments() - except SystemExit: - sys.exit(0) # Error code 0 is the error case in this program - except NoMenuEntriesError as e: - print(str(e), file=sys.stderr) - sys.exit(0) - if args.print_version: - print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__)) - sys.exit(0) - try: - terminal_menu = TerminalMenu( - menu_entries=args.entries, - clear_menu_on_exit=args.clear_menu_on_exit, - clear_screen=args.clear_screen, - cursor_index=args.cursor_index, - cycle_cursor=args.cycle, - exit_on_shortcut=args.exit_on_shortcut, - menu_cursor=args.cursor, - menu_cursor_style=args.cursor_style, - menu_highlight_style=args.highlight_style, - multi_select=args.multi_select, - multi_select_cursor=args.multi_select_cursor, - multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style, - multi_select_cursor_style=args.multi_select_cursor_style, - multi_select_empty_ok=args.multi_select_empty_ok, - multi_select_keys=args.multi_select_keys, - multi_select_select_on_accept=args.multi_select_select_on_accept, - preselected_entries=args.preselected, - preview_border=args.preview_border, - preview_command=args.preview_command, - preview_size=args.preview_size, - preview_title=args.preview_title, - search_case_sensitive=args.case_sensitive, - search_highlight_style=args.search_highlight_style, - search_key=args.search_key, - shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style, - shortcut_key_highlight_style=args.shortcut_key_highlight_style, - show_multi_select_hint=args.show_multi_select_hint, - show_multi_select_hint_text=args.show_multi_select_hint_text, - show_search_hint=args.show_search_hint, - show_search_hint_text=args.show_search_hint_text, - show_shortcut_hints=args.show_shortcut_hints, - show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar, - status_bar=args.status_bar, - status_bar_below_preview=args.status_bar_below_preview, - status_bar_style=args.status_bar_style, - title=args.title, - ) - except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e: - print(str(e), file=sys.stderr) - sys.exit(0) - chosen_entries = terminal_menu.show() - if chosen_entries is None: - sys.exit(0) - else: - if isinstance(chosen_entries, Iterable): - if args.stdout: - print(",".join(str(entry + 1) for entry in chosen_entries)) - sys.exit(chosen_entries[0] + 1) - else: - chosen_entry = chosen_entries - if args.stdout: - print(chosen_entry + 1) - sys.exit(chosen_entry + 1) - - -if __name__ == "__main__": - main() diff --git a/archinstall/lib/user_interaction.py b/archinstall/lib/user_interaction.py index df53ce49..11ce4072 100644 --- a/archinstall/lib/user_interaction.py +++ b/archinstall/lib/user_interaction.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from .disk import BlockDevice, suggest_single_disk_layout, suggest_multi_disk_layout, valid_parted_position, all_disks from .exceptions import RequirementError, UserError, DiskError from .hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics -from .locale_helpers import list_keyboard_languages, list_timezones +from .locale_helpers import list_keyboard_languages, list_timezones, list_locales from .networking import list_interfaces from .menu import Menu from .output import log @@ -27,7 +27,7 @@ from .mirrors import list_mirrors # TODO: Some inconsistencies between the selection processes. # Some return the keys from the options, some the values? -from .. import fs_types +from .. import fs_types, validate_package_list # TODO: These can be removed after the move to simple_menu.py def get_terminal_height() -> int: @@ -264,8 +264,21 @@ class MiniCurses: return response -def ask_for_swap(prompt :str = 'Would you like to use swap on zram? (Y/n): ', forced :bool = False) -> bool: - return True if input(prompt).strip(' ').lower() not in ('n', 'no') else False +def ask_for_swap(prompt='Would you like to use swap on zram?', forced=False): + choice = Menu(prompt, ['yes', 'no'], default_option='yes').run() + return False if choice == 'no' else True + + +def ask_ntp(): + prompt = 'Would you like to use automatic time synchronization (NTP) with the default time servers?' + prompt += 'Hardware time and other post-configuration steps might be required in order for NTP to work. For more information, please check the Arch wiki' + choice = Menu(prompt, ['yes', 'no'], skip=False, default_option='yes').run() + return False if choice == 'no' else True + + +def ask_hostname(): + hostname = input('Desired hostname for the installation: ').strip(' ') + return hostname def ask_for_superuser_account(prompt :str = 'Username for required superuser with sudo privileges: ', forced :bool = False) -> Dict[str, Dict[str, str]]: @@ -324,8 +337,8 @@ def ask_for_bootloader(advanced_options :bool = False) -> str: bootloader = "systemd-bootctl" if has_uefi() else "grub-install" if has_uefi(): if not advanced_options: - bootloader_choice = input("Would you like to use GRUB as a bootloader instead of systemd-boot? [y/N] ").lower() - if bootloader_choice == "y": + bootloader_choice = Menu('Would you like to use GRUB as a bootloader instead of systemd-boot?', ['yes', 'no'], default_option='no').run() + if bootloader_choice == "yes": bootloader = "grub-install" else: # We use the common names for the bootloader as the selection, and map it back to the expected values. @@ -345,10 +358,42 @@ def ask_for_bootloader(advanced_options :bool = False) -> str: def ask_for_audio_selection(desktop :bool = True) -> str: audio = 'pipewire' if desktop else 'none' choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', 'none'] - selected_audio = Menu(f'Choose an audio server or leave blank to use "{audio}"', choices, default_option=audio).run() + selected_audio = Menu( + f'Choose an audio server', + choices, + default_option=audio, + skip=False + ).run() return selected_audio +# TODO: Remove? Moved? +def ask_additional_packages_to_install(packages :List[str] = None) -> List[str]: + # Additional packages (with some light weight error handling for invalid package names) + print( + "Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.") + print("If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.") + while True: + if packages is None: + packages = [p for p in input( + 'Write additional packages to install (space separated, leave blank to skip): ' + ).split(' ') if len(p)] + + if len(packages): + # Verify packages that were given + try: + log("Verifying that additional packages exist (this might take a few seconds)") + validate_package_list(packages) + break + except RequirementError as e: + log(e, fg='red') + else: + # no additional packages were selected, which we'll allow + break + + return packages + + def ask_to_configure_network() -> Dict[str, Any]: # Optionally configure one network interface. # while 1: @@ -750,7 +795,7 @@ def select_profile() -> Optional[str]: return None -def select_language() -> str: +def select_language(default_value :str) -> str: """ Asks the user to select a language Usually this is combined with :ref:`archinstall.list_keyboard_languages`. @@ -764,7 +809,7 @@ def select_language() -> str: # allows for searching anyways sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) - selected_lang = Menu('Select Keyboard layout', sorted_kb_lang, default_option='us', sort=False).run() + selected_lang = Menu('Select Keyboard layout', sorted_kb_lang, default_option=default_value, sort=False).run() return selected_lang @@ -809,7 +854,7 @@ def select_harddrives() -> Optional[str]: if selected_harddrive and len(selected_harddrive) > 0: return [options[i] for i in selected_harddrive] - return None + return [] def select_driver(options :Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str: @@ -866,3 +911,31 @@ def select_kernel() -> List[str]: ).run() return selected_kernels + + +def select_locale_lang(default): + locales = list_locales() + locale_lang = set([locale.split()[0] for locale in locales]) + + selected_locale = Menu( + f'Choose which locale language to use', + locale_lang, + sort=True, + default_option=default + ).run() + + return selected_locale + + +def select_locale_enc(default): + locales = list_locales() + locale_enc = set([locale.split()[1] for locale in locales]) + + selected_locale = Menu( + f'Choose which locale encoding to use', + locale_enc, + sort=True, + default_option=default + ).run() + + return selected_locale -- cgit v1.2.3-54-g00ecf