mirror of
https://github.com/NohamR/JustJustWatch.git
synced 2025-05-23 16:49:26 +00:00
2058 lines
89 KiB
Python
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()
|