index : archinstall32 | |
Archlinux32 installer | gitolite user |
summaryrefslogtreecommitdiff |
-rw-r--r-- | archinstall/lib/menu/__init__.py | 1 | ||||
-rw-r--r-- | archinstall/lib/menu/menu.py | 116 | ||||
-rw-r--r-- | archinstall/lib/menu/selection_menu.py | 392 | ||||
-rw-r--r-- | archinstall/lib/menu/simple_menu.py | 1960 |
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..dfd47a7a --- /dev/null +++ b/archinstall/lib/menu/menu.py @@ -0,0 +1,116 @@ +from archinstall.lib.menu.simple_menu import TerminalMenu +from ..exceptions import RequirementError +from ..output import log + +from collections.abc import Iterable +import sys +import logging + +class Menu(TerminalMenu): + def __init__(self, title, p_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 p_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 + """ + # we guarantee the inmutability of the options outside the class. + # an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case + # we recourse to make them lists before, but thru an exceptions + # this is the old code, which is not maintenable with more types + # options = copy(list(p_options) if isinstance(p_options,(type({}.keys()),type({}.values()))) else p_options) + # We check that the options are iterable. If not we abort. Else we copy them to lists + # it options is a dictionary we use the values as entries of the list + # if options is a string object, each character becomes an entry + # if options is a list, we implictily build a copy to mantain immutability + if not isinstance(p_options,Iterable): + log(f"Objects of type {type(p_options)} is not iterable, and are not supported at Menu",fg="red") + log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) + raise RequirementError("Menu() requires an iterable as option.") + + if isinstance(p_options,dict): + options = list(p_options.keys()) + else: + options = list(p_options) + + if not options: + log(" * Menu didn't find any options to choose from * ", fg='red') + log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) + raise RequirementError('Menu.__init__() requires at least one option to proceed.') + + 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..9eb022a6 --- /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_for_a_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 `<Ctrl-?>` (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 <CR> to <NL> translation (so <enter> sends <CR> instead of + # <NL, this is necessary to distinguish between <enter> and <Ctrl-j> since <Ctrl-j> generates <NL>) + 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() |