JustJustWatch/simple_term_menu.py
2024-11-19 00:28:35 +01:00

2058 lines
89 KiB
Python

#!/usr/bin/env python3
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, 6, 4)
__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_QUIT_KEYS = ("escape", "q", "ctrl-g")
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"):
try:
if platform.system() == "Darwin":
wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore
else:
wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore
except OSError:
wcswidth.libc = None # type: ignore
if wcswidth.libc is not None: # type: ignore
try:
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)
)
except AttributeError:
pass
return len(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, preselected_indices: Optional[Iterable[int]] = None):
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,
skip_indices: List[int] = [],
):
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._skip_indices = skip_indices
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.num_displayed_menu_entries = len(self._displayed_index_to_menu_index)
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 < self._viewport.num_displayed_menu_entries:
self._active_displayed_index += 1
elif self._cycle_cursor:
self._active_displayed_index = 0
self._viewport.keep_visible(self._active_displayed_index)
if self._displayed_index_to_menu_index[self._active_displayed_index] in self._skip_indices:
self.increment_active_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 = self._viewport.num_displayed_menu_entries - 1
self._viewport.keep_visible(self._active_displayed_index)
if self._displayed_index_to_menu_index[self._active_displayed_index] in self._skip_indices:
self.decrement_active_index()
def page_down(self) -> None:
if self._active_displayed_index is None:
return
self._viewport.page_down()
self._active_displayed_index = min(
self._active_displayed_index + self._viewport.size, self._viewport.num_displayed_menu_entries - 1
)
def page_up(self) -> None:
if self._active_displayed_index is None:
return
self._viewport.page_up()
self._active_displayed_index = max(self._active_displayed_index - self._viewport.size, 0)
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.active_displayed_index = self._menu_index_to_displayed_index[value]
@property
def active_displayed_index(self) -> Optional[int]:
return self._active_displayed_index
@active_displayed_index.setter
def active_displayed_index(self, value: int) -> None:
self._active_displayed_index = value
self._viewport.keep_visible(self._active_displayed_index)
@property
def max_displayed_index(self) -> int:
return self._viewport.num_displayed_menu_entries - 1
@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_displayed_menu_entries: int,
title_lines_count: int,
status_bar_lines_count: int,
preview_lines_count: int,
search_lines_count: int,
):
self._num_displayed_menu_entries = num_displayed_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_displayed_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 page_down(self) -> None:
self.scroll(self.size)
def page_up(self) -> None:
self.scroll(-self.size)
def scroll(self, number_of_lines: int) -> None:
if number_of_lines < 0:
scroll_num = max(-self._viewport[0], number_of_lines)
else:
scroll_num = min(max(0, self._num_displayed_menu_entries - self._viewport[1] - 1), number_of_lines)
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_displayed_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_displayed_menu_entries(self) -> int:
return self._num_displayed_menu_entries
@num_displayed_menu_entries.setter
def num_displayed_menu_entries(self, num_displayed_menu_entries: int) -> None:
self._num_displayed_menu_entries = num_displayed_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_displayed_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",
"end": "kend",
"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",
"home": "khome",
"italics": "sitm",
"page_down": "knp",
"page_up": "kpp",
"reset_attributes": "sgr0",
"standout": "smso",
"underline": "smul",
"up": "kcuu1",
}
_name_to_control_character = {
"backspace": "", # Is assigned later in `self._init_backspace_control_character`
"ctrl-a": "\001",
"ctrl-b": "\002",
"ctrl-e": "\005",
"ctrl-f": "\006",
"ctrl-g": "\007",
"ctrl-j": "\012",
"ctrl-k": "\013",
"ctrl-n": "\016",
"ctrl-p": "\020",
"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,
quit_keys: Iterable[str] = DEFAULT_QUIT_KEYS,
raise_error_on_interrupt: bool = False,
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,
skip_empty_entries: bool = False,
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 check_for_terminal_environment() -> None:
if "TERM" not in os.environ or os.environ["TERM"] == "":
if "PYCHARM_HOSTED" in os.environ:
raise NotImplementedError(
"simple-term-menu does not work in the PyCharm output console. Use a terminal instead (Alt + "
'F12) or activate "Emulate terminal in output console".'
)
raise NotImplementedError("simple-term-menu can only be used in a terminal emulator")
def extract_shortcuts_menu_entries_and_preview_arguments(
entries: Iterable[str],
) -> Tuple[List[str], List[Optional[str]], List[Optional[str]], List[int]]:
separator_pattern = re.compile(r"([^\\])\|")
escaped_separator_pattern = re.compile(r"\\\|")
menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?")
shortcut_keys = [] # type: List[Optional[str]]
menu_entries = [] # type: List[str]
preview_arguments = [] # type: List[Optional[str]]
skip_indices = [] # type: List[int]
for idx, entry in enumerate(entries):
if entry is None or (entry == "" and skip_empty_entries):
shortcut_keys.append(None)
menu_entries.append("")
preview_arguments.append(None)
skip_indices.append(idx)
else:
unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry))
match_obj = menu_entry_pattern.match(unit_separated_entry)
# this is none in case the entry was an emtpy string which
# will be interpreted as a separator
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, skip_indices
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[Optional[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)
check_for_terminal_environment()
(
self._menu_entries,
self._shortcut_keys,
self._preview_arguments,
self._skip_indices,
) = 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._quit_keys = tuple(quit_keys)
self._raise_error_on_interrupt = raise_error_on_interrupt
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(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, self._skip_indices
)
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._add_missing_control_characters_for_keys(self._quit_keys)
self._init_terminal_codes()
@staticmethod
def _get_shortcut_hints_line(
menu_entries: Iterable[str],
shortcut_keys: Iterable[Optional[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) if preview_argument is not None else ""
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 - wcswidth(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 self._skip_indices:
self._tty_out.write("")
elif 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("utf-8", 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: int, frame: Optional[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", "ctrl-p", "k")),
"menu_down": set(("down", "ctrl-j", "ctrl-n", "j")),
"menu_page_up": set(("page_up", "ctrl-b")),
"menu_page_down": set(("page_down", "ctrl-f")),
"menu_start": set(("home", "ctrl-a")),
"menu_end": set(("end", "ctrl-e")),
"accept": set(self._accept_keys),
"multi_select": set(self._multi_select_keys),
"quit": set(self._quit_keys),
"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 next_key in current_menu_action_to_keys["menu_page_up"]:
self._view.page_up()
elif next_key in current_menu_action_to_keys["menu_page_down"]:
self._view.page_down()
elif next_key in current_menu_action_to_keys["menu_start"]:
self._view.active_displayed_index = 0
elif next_key in current_menu_action_to_keys["menu_end"]:
self._view.active_displayed_index = self._view.max_displayed_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 self._multi_select is False
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 as e:
if self._raise_error_on_interrupt:
raise e
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(
"--skip-empty-entries",
action="store_true",
dest="skip_empty_entries",
help="Interpret an empty string in menu entries as an empty menu entry",
)
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.skip_empty_entries:
args.entries = [entry if entry != "None" else None for entry in args.entries]
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,
skip_empty_entries=args.skip_empty_entries,
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()