Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
path: root/archinstall
diff options
context:
space:
mode:
Diffstat (limited to 'archinstall')
-rw-r--r--archinstall/__init__.py2
-rw-r--r--archinstall/lib/disk/validators.py18
-rw-r--r--archinstall/lib/locale_helpers.py5
-rw-r--r--archinstall/lib/menu.py91
-rw-r--r--archinstall/lib/simple_menu.py1961
-rw-r--r--archinstall/lib/user_interaction.py564
6 files changed, 2294 insertions, 347 deletions
diff --git a/archinstall/__init__.py b/archinstall/__init__.py
index 4b13fc18..b0c938ad 100644
--- a/archinstall/__init__.py
+++ b/archinstall/__init__.py
@@ -20,6 +20,7 @@ from .lib.services import *
from .lib.storage import *
from .lib.systemd import *
from .lib.user_interaction import *
+from .lib.menu import Menu
parser = ArgumentParser()
@@ -88,7 +89,6 @@ if arguments.get('plugin', None):
# TODO: Learn the dark arts of argparse... (I summon thee dark spawn of cPython)
-
def run_as_a_module():
"""
Since we're running this as a 'python -m archinstall' module OR
diff --git a/archinstall/lib/disk/validators.py b/archinstall/lib/disk/validators.py
index e0ab6a86..464f0d73 100644
--- a/archinstall/lib/disk/validators.py
+++ b/archinstall/lib/disk/validators.py
@@ -16,7 +16,8 @@ def valid_parted_position(pos :str):
return False
-def valid_fs_type(fstype :str) -> bool:
+
+def fs_types():
# https://www.gnu.org/software/parted/manual/html_node/mkpart.html
# Above link doesn't agree with `man parted` /mkpart documentation:
"""
@@ -27,16 +28,19 @@ def valid_fs_type(fstype :str) -> bool:
"linux-swap", "ntfs", "reis‐
erfs", "udf", or "xfs".
"""
-
- return fstype.lower() in [
+ return [
"btrfs",
"ext2",
- "ext3", "ext4", # `man parted` allows these
+ "ext3", "ext4", # `man parted` allows these
"fat16", "fat32",
- "hfs", "hfs+", # "hfsx", not included in `man parted`
+ "hfs", "hfs+", # "hfsx", not included in `man parted`
"linux-swap",
"ntfs",
"reiserfs",
- "udf", # "ufs", not included in `man parted`
- "xfs", # `man parted` allows this
+ "udf", # "ufs", not included in `man parted`
+ "xfs", # `man parted` allows this
]
+
+
+def valid_fs_type(fstype :str) -> bool:
+ return fstype.lower() in fs_types()
diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py
index 36228edc..ad85ea1b 100644
--- a/archinstall/lib/locale_helpers.py
+++ b/archinstall/lib/locale_helpers.py
@@ -47,3 +47,8 @@ def set_keyboard_language(locale):
return True
return False
+
+
+def list_timezones():
+ for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}):
+ yield line.decode('UTF-8').strip()
diff --git a/archinstall/lib/menu.py b/archinstall/lib/menu.py
new file mode 100644
index 00000000..c3436803
--- /dev/null
+++ b/archinstall/lib/menu.py
@@ -0,0 +1,91 @@
+from .simple_menu import TerminalMenu
+
+
+class Menu(TerminalMenu):
+ def __init__(self, title, options, skip=True, multi=False, default_option=None, sort=True):
+ """
+ Creates a new menu
+
+ :param title: Text that will be displayed above the menu
+ :type title: str
+
+ :param options: Options to be displayed in the menu to chose from;
+ if dict is specified then the keys of such will be used as options
+ :type options: list, dict
+
+ :param skip: Indicate if the selection is not mandatory and can be skipped
+ :type skip: bool
+
+ :param multi: Indicate if multiple options can be selected
+ :type multi: bool
+
+ :param default_option: The default option to be used in case the selection processes is skipped
+ :type default_option: str
+
+ :param sort: Indicate if the options should be sorted alphabetically before displaying
+ :type sort: bool
+ """
+
+ if isinstance(options, dict):
+ options = list(options)
+
+ if sort:
+ options = sorted(options)
+
+ self.menu_options = options
+ self.skip = skip
+ self.default_option = default_option
+ self.multi = multi
+
+ menu_title = f'\n{title}\n\n'
+
+ if skip:
+ menu_title += "Use ESC to skip\n\n"
+
+ if default_option:
+ # if a default value was specified we move that one
+ # to the top of the list and mark it as default as well
+ default = f'{default_option} (default)'
+ self.menu_options = [default] + [o for o in self.menu_options if default_option != o]
+
+ cursor = "> "
+ main_menu_cursor_style = ("fg_cyan", "bold")
+ main_menu_style = ("bg_blue", "fg_gray")
+
+ super().__init__(
+ menu_entries=self.menu_options,
+ title=menu_title,
+ menu_cursor=cursor,
+ menu_cursor_style=main_menu_cursor_style,
+ menu_highlight_style=main_menu_style,
+ cycle_cursor=True,
+ clear_screen=True,
+ multi_select=multi,
+ show_search_hint=True
+ )
+
+ def _show(self):
+ idx = self.show()
+ if idx is not None:
+ if isinstance(idx, (list, tuple)):
+ return [self.menu_options[i] for i in idx]
+ else:
+ selected = self.menu_options[idx]
+ if ' (default)' in selected and self.default_option:
+ return self.default_option
+ return selected
+ else:
+ if self.default_option:
+ if self.multi:
+ return [self.default_option]
+ else:
+ return self.default_option
+ return None
+
+ def run(self):
+ ret = self._show()
+
+ if ret is None and not self.skip:
+ return self.run()
+
+ return ret
diff --git a/archinstall/lib/simple_menu.py b/archinstall/lib/simple_menu.py
new file mode 100644
index 00000000..6e4853ea
--- /dev/null
+++ b/archinstall/lib/simple_menu.py
@@ -0,0 +1,1961 @@
+"""
+This file is copied over from the simple-term-menu project
+(https://github.com/IngoMeyer441/simple-term-menu)
+In order to comply with installation methods of Arch Linux.
+We here by copy the MIT license attached to the project at the time of copy:
+
+Copyright 2021 Forschungszentrum Jülich GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+"""
+import argparse
+import copy
+import ctypes
+import io
+import locale
+import os
+import platform
+import re
+import shlex
+import signal
+import string
+import subprocess
+import sys
+from locale import getlocale
+from types import FrameType
+from typing import (
+ Any,
+ Callable,
+ Dict,
+ Iterable,
+ Iterator,
+ List,
+ Match,
+ Optional,
+ Pattern,
+ Sequence,
+ Set,
+ TextIO,
+ Tuple,
+ Union,
+ cast,
+)
+
+try:
+ import termios
+except ImportError as e:
+ raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e
+
+
+__author__ = "Ingo Meyer"
+__email__ = "i.meyer@fz-juelich.de"
+__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved."
+__license__ = "MIT"
+__version_info__ = (1, 4, 1)
+__version__ = ".".join(map(str, __version_info__))
+
+
+DEFAULT_ACCEPT_KEYS = ("enter",)
+DEFAULT_CLEAR_MENU_ON_EXIT = True
+DEFAULT_CLEAR_SCREEN = False
+DEFAULT_CYCLE_CURSOR = True
+DEFAULT_EXIT_ON_SHORTCUT = True
+DEFAULT_MENU_CURSOR = "> "
+DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold")
+DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",)
+DEFAULT_MULTI_SELECT = False
+DEFAULT_MULTI_SELECT_CURSOR = "[*] "
+DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",)
+DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold")
+DEFAULT_MULTI_SELECT_KEYS = (" ", "tab")
+DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True
+DEFAULT_PREVIEW_BORDER = True
+DEFAULT_PREVIEW_SIZE = 0.25
+DEFAULT_PREVIEW_TITLE = "preview"
+DEFAULT_SEARCH_CASE_SENSITIVE = False
+DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold")
+DEFAULT_SEARCH_KEY = "/"
+DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",)
+DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",)
+DEFAULT_SHOW_MULTI_SELECT_HINT = False
+DEFAULT_SHOW_SEARCH_HINT = False
+DEFAULT_SHOW_SHORTCUT_HINTS = False
+DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True
+DEFAULT_STATUS_BAR_BELOW_PREVIEW = False
+DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black")
+MIN_VISIBLE_MENU_ENTRIES_COUNT = 3
+
+
+class InvalidParameterCombinationError(Exception):
+ pass
+
+
+class InvalidStyleError(Exception):
+ pass
+
+
+class NoMenuEntriesError(Exception):
+ pass
+
+
+class PreviewCommandFailedError(Exception):
+ pass
+
+
+class UnknownMenuEntryError(Exception):
+ pass
+
+
+def get_locale() -> str:
+ user_locale = locale.getlocale()[1]
+ if user_locale is None:
+ return "ascii"
+ else:
+ return user_locale.lower()
+
+
+def wcswidth(text: str) -> int:
+ if not hasattr(wcswidth, "libc"):
+ if platform.system() == "Darwin":
+ wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore
+ else:
+ wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore
+ user_locale = get_locale()
+ # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed
+ # in a `c_wchar_p`
+ encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace")
+ return wcswidth.libc.wcswidth( # type: ignore
+ ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text)
+ )
+
+
+def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
+ def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
+ for key, value in variables.items():
+ setattr(f, key, value)
+ return f
+
+ return decorator
+
+
+class BoxDrawingCharacters:
+ if getlocale()[1] == "UTF-8":
+ # Unicode box characters
+ horizontal = "─"
+ vertical = "│"
+ upper_left = "┌"
+ upper_right = "┐"
+ lower_left = "└"
+ lower_right = "┘"
+ else:
+ # ASCII box characters
+ horizontal = "-"
+ vertical = "|"
+ upper_left = "+"
+ upper_right = "+"
+ lower_left = "+"
+ lower_right = "+"
+
+
+class TerminalMenu:
+ class Search:
+ def __init__(
+ self,
+ menu_entries: Iterable[str],
+ search_text: Optional[str] = None,
+ case_senitive: bool = False,
+ show_search_hint: bool = False,
+ ):
+ self._menu_entries = menu_entries
+ self._case_sensitive = case_senitive
+ self._show_search_hint = show_search_hint
+ self._matches = [] # type: List[Tuple[int, Match[str]]]
+ self._search_regex = None # type: Optional[Pattern[str]]
+ self._change_callback = None # type: Optional[Callable[[], None]]
+ # Use the property setter since it has some more logic
+ self.search_text = search_text
+
+ def _update_matches(self) -> None:
+ if self._search_regex is None:
+ self._matches = []
+ else:
+ matches = []
+ for i, menu_entry in enumerate(self._menu_entries):
+ match_obj = self._search_regex.search(menu_entry)
+ if match_obj:
+ matches.append((i, match_obj))
+ self._matches = matches
+
+ @property
+ def matches(self) -> List[Tuple[int, Match[str]]]:
+ return list(self._matches)
+
+ @property
+ def search_regex(self) -> Optional[Pattern[str]]:
+ return self._search_regex
+
+ @property
+ def search_text(self) -> Optional[str]:
+ return self._search_text
+
+ @search_text.setter
+ def search_text(self, text: Optional[str]) -> None:
+ self._search_text = text
+ search_text = self._search_text
+ self._search_regex = None
+ while search_text and self._search_regex is None:
+ try:
+ self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0)
+ except re.error:
+ search_text = search_text[:-1]
+ self._update_matches()
+ if self._change_callback:
+ self._change_callback()
+
+ @property
+ def change_callback(self) -> Optional[Callable[[], None]]:
+ return self._change_callback
+
+ @change_callback.setter
+ def change_callback(self, callback: Optional[Callable[[], None]]) -> None:
+ self._change_callback = callback
+
+ @property
+ def occupied_lines_count(self) -> int:
+ if not self and not self._show_search_hint:
+ return 0
+ else:
+ return 1
+
+ def __bool__(self) -> bool:
+ return self._search_text is not None
+
+ def __contains__(self, menu_index: int) -> bool:
+ return any(i == menu_index for i, _ in self._matches)
+
+ def __len__(self) -> int:
+ return wcswidth(self._search_text) if self._search_text is not None else 0
+
+ class Selection:
+ def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None):
+ self._num_menu_entries = num_menu_entries
+ self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set()
+
+ def clear(self) -> None:
+ self._selected_menu_indices.clear()
+
+ def add(self, menu_index: int) -> None:
+ self[menu_index] = True
+
+ def remove(self, menu_index: int) -> None:
+ self[menu_index] = False
+
+ def toggle(self, menu_index: int) -> bool:
+ self[menu_index] = menu_index not in self._selected_menu_indices
+ return self[menu_index]
+
+ def __bool__(self) -> bool:
+ return bool(self._selected_menu_indices)
+
+ def __contains__(self, menu_index: int) -> bool:
+ return menu_index in self._selected_menu_indices
+
+ def __getitem__(self, menu_index: int) -> bool:
+ return menu_index in self._selected_menu_indices
+
+ def __setitem__(self, menu_index: int, is_selected: bool) -> None:
+ if is_selected:
+ self._selected_menu_indices.add(menu_index)
+ else:
+ self._selected_menu_indices.remove(menu_index)
+
+ def __iter__(self) -> Iterator[int]:
+ return iter(self._selected_menu_indices)
+
+ @property
+ def selected_menu_indices(self) -> Tuple[int, ...]:
+ return tuple(sorted(self._selected_menu_indices))
+
+ class View:
+ def __init__(
+ self,
+ menu_entries: Iterable[str],
+ search: "TerminalMenu.Search",
+ selection: "TerminalMenu.Selection",
+ viewport: "TerminalMenu.Viewport",
+ cycle_cursor: bool = True,
+ ):
+ self._menu_entries = list(menu_entries)
+ self._search = search
+ self._selection = selection
+ self._viewport = viewport
+ self._cycle_cursor = cycle_cursor
+ self._active_displayed_index = None # type: Optional[int]
+ self.update_view()
+
+ def update_view(self) -> None:
+ if self._search and self._search.search_text != "":
+ self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches)
+ else:
+ self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries)))
+ self._menu_index_to_displayed_index = {
+ menu_index: displayed_index
+ for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index)
+ }
+ self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None
+ self._viewport.search_lines_count = self._search.occupied_lines_count
+ self._viewport.keep_visible(self._active_displayed_index)
+
+ def increment_active_index(self) -> None:
+ if self._active_displayed_index is not None:
+ if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index):
+ self._active_displayed_index += 1
+ elif self._cycle_cursor:
+ self._active_displayed_index = 0
+ self._viewport.keep_visible(self._active_displayed_index)
+
+ def decrement_active_index(self) -> None:
+ if self._active_displayed_index is not None:
+ if self._active_displayed_index > 0:
+ self._active_displayed_index -= 1
+ elif self._cycle_cursor:
+ self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1
+ self._viewport.keep_visible(self._active_displayed_index)
+
+ def is_visible(self, menu_index: int) -> bool:
+ return menu_index in self._menu_index_to_displayed_index and (
+ self._viewport.lower_index
+ <= self._menu_index_to_displayed_index[menu_index]
+ <= self._viewport.upper_index
+ )
+
+ def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]:
+ if menu_index in self._menu_index_to_displayed_index:
+ return self._menu_index_to_displayed_index[menu_index]
+ else:
+ return None
+
+ def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int:
+ return self._displayed_index_to_menu_index[displayed_index]
+
+ @property
+ def active_menu_index(self) -> Optional[int]:
+ if self._active_displayed_index is not None:
+ return self._displayed_index_to_menu_index[self._active_displayed_index]
+ else:
+ return None
+
+ @active_menu_index.setter
+ def active_menu_index(self, value: int) -> None:
+ self._selected_index = value
+ self._active_displayed_index = [
+ displayed_index
+ for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index)
+ if menu_index == value
+ ][0]
+ self._viewport.keep_visible(self._active_displayed_index)
+
+ @property
+ def active_displayed_index(self) -> Optional[int]:
+ return self._active_displayed_index
+
+ @property
+ def displayed_selected_indices(self) -> List[int]:
+ return [
+ self._menu_index_to_displayed_index[selected_index]
+ for selected_index in self._selection
+ if selected_index in self._menu_index_to_displayed_index
+ ]
+
+ def __bool__(self) -> bool:
+ return self._active_displayed_index is not None
+
+ def __iter__(self) -> Iterator[Tuple[int, int, str]]:
+ for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index):
+ if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index:
+ yield (displayed_index, menu_index, self._menu_entries[menu_index])
+
+ class Viewport:
+ def __init__(
+ self,
+ num_menu_entries: int,
+ title_lines_count: int,
+ status_bar_lines_count: int,
+ preview_lines_count: int,
+ search_lines_count: int,
+ ):
+ self._num_menu_entries = num_menu_entries
+ self._title_lines_count = title_lines_count
+ self._status_bar_lines_count = status_bar_lines_count
+ # Use the property setter since it has some more logic
+ self.preview_lines_count = preview_lines_count
+ self.search_lines_count = search_lines_count
+ self._num_lines = self._calculate_num_lines()
+ self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1)
+ self.keep_visible(cursor_position=None, refresh_terminal_size=False)
+
+ def _calculate_num_lines(self) -> int:
+ return (
+ TerminalMenu._num_lines()
+ - self._title_lines_count
+ - self._status_bar_lines_count
+ - self._preview_lines_count
+ - self._search_lines_count
+ )
+
+ def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None:
+ # Treat `cursor_position=None` like `cursor_position=0`
+ if cursor_position is None:
+ cursor_position = 0
+ if refresh_terminal_size:
+ self.update_terminal_size()
+ if self._viewport[0] <= cursor_position <= self._viewport[1]:
+ # Cursor is already visible
+ return
+ if cursor_position < self._viewport[0]:
+ scroll_num = cursor_position - self._viewport[0]
+ else:
+ scroll_num = cursor_position - self._viewport[1]
+ self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num)
+
+ def update_terminal_size(self) -> None:
+ num_lines = self._calculate_num_lines()
+ if num_lines != self._num_lines:
+ # First let the upper index grow or shrink
+ upper_index = min(num_lines, self._num_menu_entries) - 1
+ # Then, use as much space as possible for the `lower_index`
+ lower_index = max(0, upper_index - num_lines)
+ self._viewport = (lower_index, upper_index)
+ self._num_lines = num_lines
+
+ @property
+ def lower_index(self) -> int:
+ return self._viewport[0]
+
+ @property
+ def upper_index(self) -> int:
+ return self._viewport[1]
+
+ @property
+ def viewport(self) -> Tuple[int, int]:
+ return self._viewport
+
+ @property
+ def size(self) -> int:
+ return self._viewport[1] - self._viewport[0] + 1
+
+ @property
+ def num_menu_entries(self) -> int:
+ return self._num_menu_entries
+
+ @property
+ def title_lines_count(self) -> int:
+ return self._title_lines_count
+
+ @property
+ def status_bar_lines_count(self) -> int:
+ return self._status_bar_lines_count
+
+ @status_bar_lines_count.setter
+ def status_bar_lines_count(self, value: int) -> None:
+ self._status_bar_lines_count = value
+
+ @property
+ def preview_lines_count(self) -> int:
+ return self._preview_lines_count
+
+ @preview_lines_count.setter
+ def preview_lines_count(self, value: int) -> None:
+ self._preview_lines_count = min(
+ value if value >= 3 else 0,
+ TerminalMenu._num_lines()
+ - self._title_lines_count
+ - self._status_bar_lines_count
+ - MIN_VISIBLE_MENU_ENTRIES_COUNT,
+ )
+
+ @property
+ def search_lines_count(self) -> int:
+ return self._search_lines_count
+
+ @search_lines_count.setter
+ def search_lines_count(self, value: int) -> None:
+ self._search_lines_count = value
+
+ @property
+ def must_scroll(self) -> bool:
+ return self._num_menu_entries > self._num_lines
+
+ _codename_to_capname = {
+ "bg_black": "setab 0",
+ "bg_blue": "setab 4",
+ "bg_cyan": "setab 6",
+ "bg_gray": "setab 7",
+ "bg_green": "setab 2",
+ "bg_purple": "setab 5",
+ "bg_red": "setab 1",
+ "bg_yellow": "setab 3",
+ "bold": "bold",
+ "clear": "clear",
+ "colors": "colors",
+ "cursor_down": "cud1",
+ "cursor_invisible": "civis",
+ "cursor_left": "cub1",
+ "cursor_right": "cuf1",
+ "cursor_up": "cuu1",
+ "cursor_visible": "cnorm",
+ "delete_line": "dl1",
+ "down": "kcud1",
+ "enter_application_mode": "smkx",
+ "exit_application_mode": "rmkx",
+ "fg_black": "setaf 0",
+ "fg_blue": "setaf 4",
+ "fg_cyan": "setaf 6",
+ "fg_gray": "setaf 7",
+ "fg_green": "setaf 2",
+ "fg_purple": "setaf 5",
+ "fg_red": "setaf 1",
+ "fg_yellow": "setaf 3",
+ "italics": "sitm",
+ "reset_attributes": "sgr0",
+ "standout": "smso",
+ "underline": "smul",
+ "up": "kcuu1",
+ }
+ _name_to_control_character = {
+ "backspace": "", # Is assigned later in `self._init_backspace_control_character`
+ "ctrl-j": "\012",
+ "ctrl-k": "\013",
+ "enter": "\015",
+ "escape": "\033",
+ "tab": "\t",
+ }
+ _codenames = tuple(_codename_to_capname.keys())
+ _codename_to_terminal_code = None # type: Optional[Dict[str, str]]
+ _terminal_code_to_codename = None # type: Optional[Dict[str, str]]
+
+ def __init__(
+ self,
+ menu_entries: Iterable[str],
+ *,
+ accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS,
+ clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT,
+ clear_screen: bool = DEFAULT_CLEAR_SCREEN,
+ cursor_index: Optional[int] = None,
+ cycle_cursor: bool = DEFAULT_CYCLE_CURSOR,
+ exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT,
+ menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR,
+ menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE,
+ menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE,
+ multi_select: bool = DEFAULT_MULTI_SELECT,
+ multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR,
+ multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE,
+ multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE,
+ multi_select_empty_ok: bool = False,
+ multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS,
+ multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT,
+ preselected_entries: Optional[Iterable[Union[str, int]]] = None,
+ preview_border: bool = DEFAULT_PREVIEW_BORDER,
+ preview_command: Optional[Union[str, Callable[[str], str]]] = None,
+ preview_size: float = DEFAULT_PREVIEW_SIZE,
+ preview_title: str = DEFAULT_PREVIEW_TITLE,
+ search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE,
+ search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE,
+ search_key: Optional[str] = DEFAULT_SEARCH_KEY,
+ shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE,
+ shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE,
+ show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT,
+ show_multi_select_hint_text: Optional[str] = None,
+ show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT,
+ show_search_hint_text: Optional[str] = None,
+ show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS,
+ show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR,
+ status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None,
+ status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW,
+ status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE,
+ title: Optional[Union[str, Iterable[str]]] = None
+ ):
+ def extract_shortcuts_menu_entries_and_preview_arguments(
+ entries: Iterable[str],
+ ) -> Tuple[List[str], List[str], List[str]]:
+ separator_pattern = re.compile(r"([^\\])\|")
+ escaped_separator_pattern = re.compile(r"\\\|")
+ menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?")
+ shortcut_keys = []
+ menu_entries = []
+ preview_arguments = []
+ for entry in entries:
+ unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry))
+ match_obj = menu_entry_pattern.match(unit_separated_entry)
+ assert match_obj is not None
+ shortcut_key = match_obj.group(1)
+ display_text = match_obj.group(2)
+ preview_argument = match_obj.group(3)
+ shortcut_keys.append(shortcut_key)
+ menu_entries.append(display_text)
+ preview_arguments.append(preview_argument)
+ return menu_entries, shortcut_keys, preview_arguments
+
+ def convert_preselected_entries_to_indices(
+ preselected_indices_or_entries: Iterable[Union[str, int]]
+ ) -> Set[int]:
+ menu_entry_to_indices = {} # type: Dict[str, Set[int]]
+ for menu_index, menu_entry in enumerate(self._menu_entries):
+ menu_entry_to_indices.setdefault(menu_entry, set())
+ menu_entry_to_indices[menu_entry].add(menu_index)
+ preselected_indices = set()
+ for item in preselected_indices_or_entries:
+ if isinstance(item, int):
+ if 0 <= item < len(self._menu_entries):
+ preselected_indices.add(item)
+ else:
+ raise IndexError(
+ "Error: {} is outside the allowable range of 0..{}.".format(
+ item, len(self._menu_entries) - 1
+ )
+ )
+ elif isinstance(item, str):
+ try:
+ preselected_indices.update(menu_entry_to_indices[item])
+ except KeyError as e:
+ raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e
+ else:
+ raise ValueError('"preselected_entries" must either contain integers or strings.')
+ return preselected_indices
+
+ def setup_title_or_status_bar_lines(
+ title_or_status_bar: Optional[Union[str, Iterable[str]]],
+ show_shortcut_hints: bool,
+ menu_entries: Iterable[str],
+ shortcut_keys: Iterable[str],
+ shortcut_hints_in_parentheses: bool,
+ ) -> Tuple[str, ...]:
+ if title_or_status_bar is None:
+ lines = [] # type: List[str]
+ elif isinstance(title_or_status_bar, str):
+ lines = title_or_status_bar.split("\n")
+ else:
+ lines = list(title_or_status_bar)
+ if show_shortcut_hints:
+ shortcut_hints_line = self._get_shortcut_hints_line(
+ menu_entries, shortcut_keys, shortcut_hints_in_parentheses
+ )
+ if shortcut_hints_line is not None:
+ lines.append(shortcut_hints_line)
+ return tuple(lines)
+
+ (
+ self._menu_entries,
+ self._shortcut_keys,
+ self._preview_arguments,
+ ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries)
+ self._shortcuts_defined = any(key is not None for key in self._shortcut_keys)
+ self._accept_keys = tuple(accept_keys)
+ self._clear_menu_on_exit = clear_menu_on_exit
+ self._clear_screen = clear_screen
+ self._cycle_cursor = cycle_cursor
+ self._multi_select_empty_ok = multi_select_empty_ok
+ self._exit_on_shortcut = exit_on_shortcut
+ self._menu_cursor = menu_cursor if menu_cursor is not None else ""
+ self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else ()
+ self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else ()
+ self._multi_select = multi_select
+ self._multi_select_cursor = multi_select_cursor
+ self._multi_select_cursor_brackets_style = (
+ tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else ()
+ )
+ self._multi_select_cursor_style = (
+ tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else ()
+ )
+ self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else ()
+ self._multi_select_select_on_accept = multi_select_select_on_accept
+ if preselected_entries and not self._multi_select:
+ raise InvalidParameterCombinationError(
+ "Multi-select mode must be enabled when preselected entries are given."
+ )
+ self._preselected_indices = (
+ convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None
+ )
+ self._preview_border = preview_border
+ self._preview_command = preview_command
+ self._preview_size = preview_size
+ self._preview_title = preview_title
+ self._search_case_sensitive = search_case_sensitive
+ self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else ()
+ self._search_key = search_key
+ self._shortcut_brackets_highlight_style = (
+ tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else ()
+ )
+ self._shortcut_key_highlight_style = (
+ tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else ()
+ )
+ self._show_search_hint = show_search_hint
+ self._show_search_hint_text = show_search_hint_text
+ self._show_shortcut_hints = show_shortcut_hints
+ self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar
+ self._status_bar_func = None # type: Optional[Callable[[str], str]]
+ self._status_bar_lines = None # type: Optional[Tuple[str, ...]]
+ if callable(status_bar):
+ self._status_bar_func = status_bar
+ else:
+ self._status_bar_lines = setup_title_or_status_bar_lines(
+ status_bar,
+ show_shortcut_hints and show_shortcut_hints_in_status_bar,
+ self._menu_entries,
+ self._shortcut_keys,
+ False,
+ )
+ self._status_bar_below_preview = status_bar_below_preview
+ self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else ()
+ self._title_lines = setup_title_or_status_bar_lines(
+ title,
+ show_shortcut_hints and not show_shortcut_hints_in_status_bar,
+ self._menu_entries,
+ self._shortcut_keys,
+ True,
+ )
+ self._show_multi_select_hint = show_multi_select_hint
+ self._show_multi_select_hint_text = show_multi_select_hint_text
+ self._chosen_accept_key = None # type: Optional[str]
+ self._chosen_menu_index = None # type: Optional[int]
+ self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]]
+ self._paint_before_next_read = False
+ self._previous_displayed_menu_height = None # type: Optional[int]
+ self._reading_next_key = False
+ self._search = self.Search(
+ self._menu_entries,
+ case_senitive=self._search_case_sensitive,
+ show_search_hint=self._show_search_hint,
+ )
+ self._selection = self.Selection(len(self._menu_entries), self._preselected_indices)
+ self._viewport = self.Viewport(
+ len(self._menu_entries),
+ len(self._title_lines),
+ len(self._status_bar_lines) if self._status_bar_lines is not None else 0,
+ 0,
+ 0,
+ )
+ self._view = self.View(self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor)
+ if cursor_index and 0 < cursor_index < len(self._menu_entries):
+ self._view.active_menu_index = cursor_index
+ self._search.change_callback = self._view.update_view
+ self._old_term = None # type: Optional[List[Union[int, List[bytes]]]]
+ self._new_term = None # type: Optional[List[Union[int, List[bytes]]]]
+ self._tty_in = None # type: Optional[TextIO]
+ self._tty_out = None # type: Optional[TextIO]
+ self._user_locale = get_locale()
+ self._check_for_valid_styles()
+ # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead
+ self._init_backspace_control_character()
+ self._add_missing_control_characters_for_keys(self._accept_keys)
+ self._init_terminal_codes()
+
+ @staticmethod
+ def _get_shortcut_hints_line(
+ menu_entries: Iterable[str],
+ shortcut_keys: Iterable[str],
+ shortcut_hints_in_parentheses: bool,
+ ) -> Optional[str]:
+ shortcut_hints_line = ", ".join(
+ "[{}]: {}".format(shortcut_key, menu_entry)
+ for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries)
+ if shortcut_key is not None
+ )
+ if shortcut_hints_line != "":
+ if shortcut_hints_in_parentheses:
+ return "(" + shortcut_hints_line + ")"
+ else:
+ return shortcut_hints_line
+ return None
+
+ @staticmethod
+ def _get_keycode_for_key(key: str) -> str:
+ if len(key) == 1:
+ # One letter keys represent themselves
+ return key
+ alt_modified_regex = re.compile(r"[Aa]lt-(\S)")
+ ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)")
+ match_obj = alt_modified_regex.match(key)
+ if match_obj:
+ return "\033" + match_obj.group(1)
+ match_obj = ctrl_modified_regex.match(key)
+ if match_obj:
+ # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64
+ ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64
+ if ctrl_code_ascii < 0:
+ # Interpret negative ascii codes as unsigned 7-Bit integers
+ ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1
+ return chr(ctrl_code_ascii)
+ raise ValueError('Cannot interpret the given key "{}".'.format(key))
+
+ @classmethod
+ def _init_backspace_control_character(self) -> None:
+ try:
+ with open("/dev/tty", "r") as tty:
+ stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty)
+ name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$")
+ for field in stty_output.split(";"):
+ match_obj = name_to_keycode_regex.match(field)
+ if not match_obj:
+ continue
+ name, ctrl_code = match_obj.group(1), match_obj.group(2)
+ if name != "erase":
+ continue
+ self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code)
+ return
+ except subprocess.CalledProcessError:
+ pass
+ # Backspace control character could not be queried, assume `<Ctrl-?>` (is most often used)
+ self._name_to_control_character["backspace"] = "\177"
+
+ @classmethod
+ def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None:
+ for key in keys:
+ if key not in cls._name_to_control_character and key not in string.ascii_letters:
+ cls._name_to_control_character[key] = cls._get_keycode_for_key(key)
+
+ @classmethod
+ def _init_terminal_codes(cls) -> None:
+ if cls._codename_to_terminal_code is not None:
+ return
+ supported_colors = int(cls._query_terminfo_database("colors"))
+ cls._codename_to_terminal_code = {
+ codename: cls._query_terminfo_database(codename)
+ if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8
+ else ""
+ for codename in cls._codenames
+ }
+ cls._codename_to_terminal_code.update(cls._name_to_control_character)
+ cls._terminal_code_to_codename = {
+ terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items()
+ }
+
+ @classmethod
+ def _query_terminfo_database(cls, codename: str) -> str:
+ if codename in cls._codename_to_capname:
+ capname = cls._codename_to_capname[codename]
+ else:
+ capname = codename
+ try:
+ return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True)
+ except subprocess.CalledProcessError as e:
+ # The return code 1 indicates a missing terminal capability
+ if e.returncode == 1:
+ return ""
+ raise e
+
+ @classmethod
+ def _num_lines(self) -> int:
+ return int(self._query_terminfo_database("lines"))
+
+ @classmethod
+ def _num_cols(self) -> int:
+ return int(self._query_terminfo_database("cols"))
+
+ def _check_for_valid_styles(self) -> None:
+ invalid_styles = []
+ for style_tuple in (
+ self._menu_cursor_style,
+ self._menu_highlight_style,
+ self._search_highlight_style,
+ self._shortcut_key_highlight_style,
+ self._shortcut_brackets_highlight_style,
+ self._status_bar_style,
+ self._multi_select_cursor_brackets_style,
+ self._multi_select_cursor_style,
+ ):
+ for style in style_tuple:
+ if style not in self._codename_to_capname:
+ invalid_styles.append(style)
+ if invalid_styles:
+ if len(invalid_styles) == 1:
+ raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0]))
+ else:
+ raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles)))
+
+ def _init_term(self) -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ self._tty_in = open("/dev/tty", "r", encoding=self._user_locale)
+ self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace")
+ self._old_term = termios.tcgetattr(self._tty_in.fileno())
+ self._new_term = termios.tcgetattr(self._tty_in.fileno())
+ # set the terminal to: unbuffered, no echo and no <CR> to <NL> translation (so <enter> sends <CR> instead of
+ # <NL, this is necessary to distinguish between <enter> and <Ctrl-j> since <Ctrl-j> generates <NL>)
+ self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL
+ self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL
+ termios.tcsetattr(
+ self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term)
+ )
+ # Enter terminal application mode to get expected escape codes for arrow keys
+ self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"])
+ self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"])
+ if self._clear_screen:
+ self._tty_out.write(self._codename_to_terminal_code["clear"])
+
+ def _reset_term(self) -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_in is not None
+ assert self._tty_out is not None
+ assert self._old_term is not None
+ termios.tcsetattr(
+ self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term)
+ )
+ self._tty_out.write(self._codename_to_terminal_code["cursor_visible"])
+ self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"])
+ if self._clear_screen:
+ self._tty_out.write(self._codename_to_terminal_code["clear"])
+ self._tty_in.close()
+ self._tty_out.close()
+
+ def _paint_menu(self) -> None:
+ def get_status_bar_lines() -> Tuple[str, ...]:
+ def get_multi_select_hint() -> str:
+ def get_string_from_keys(keys: Sequence[str]) -> str:
+ string_to_key = {
+ " ": "space",
+ }
+ keys_string = ", ".join(
+ "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys
+ )
+ return keys_string
+
+ accept_keys_string = get_string_from_keys(self._accept_keys)
+ multi_select_keys_string = get_string_from_keys(self._multi_select_keys)
+ if self._show_multi_select_hint_text is not None:
+ return self._show_multi_select_hint_text.format(
+ multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string
+ )
+ else:
+ return "Press {} for multi-selection and {} to {}accept".format(
+ multi_select_keys_string,
+ accept_keys_string,
+ "select and " if self._multi_select_select_on_accept else "",
+ )
+
+ if self._status_bar_func is not None and self._view.active_menu_index is not None:
+ status_bar_lines = tuple(
+ self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n")
+ )
+ if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar:
+ shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False)
+ if shortcut_hints_line is not None:
+ status_bar_lines += (shortcut_hints_line,)
+ elif self._status_bar_lines is not None:
+ status_bar_lines = self._status_bar_lines
+ else:
+ status_bar_lines = tuple()
+ if self._multi_select and self._show_multi_select_hint:
+ status_bar_lines += (get_multi_select_hint(),)
+ return status_bar_lines
+
+ def apply_style(
+ style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None
+ ) -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ if file is None:
+ file = self._tty_out
+ if reset or style_iterable is None:
+ file.write(self._codename_to_terminal_code["reset_attributes"])
+ if style_iterable is not None:
+ for style in style_iterable:
+ file.write(self._codename_to_terminal_code[style])
+
+ def print_menu_entries() -> int:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ all_cursors_width = wcswidth(self._menu_cursor) + (
+ wcswidth(self._multi_select_cursor) if self._multi_select else 0
+ )
+ current_menu_block_displayed_height = 0 # sum all written lines
+ num_cols = self._num_cols()
+ if self._title_lines:
+ self._tty_out.write(
+ len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]
+ + "\r"
+ + "\n".join(
+ (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ")
+ for title_line in self._title_lines
+ )
+ + "\n"
+ )
+ shortcut_string_len = 4 if self._shortcuts_defined else 0
+ displayed_index = -1
+ for displayed_index, menu_index, menu_entry in self._view:
+ current_shortcut_key = self._shortcut_keys[menu_index]
+ self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"])
+ if self._shortcuts_defined:
+ if current_shortcut_key is not None:
+ apply_style(self._shortcut_brackets_highlight_style)
+ self._tty_out.write("[")
+ apply_style(self._shortcut_key_highlight_style)
+ self._tty_out.write(current_shortcut_key)
+ apply_style(self._shortcut_brackets_highlight_style)
+ self._tty_out.write("]")
+ apply_style()
+ else:
+ self._tty_out.write(3 * " ")
+ self._tty_out.write(" ")
+ if menu_index == self._view.active_menu_index:
+ apply_style(self._menu_highlight_style)
+ if self._search and self._search.search_text != "":
+ match_obj = self._search.matches[displayed_index][1]
+ self._tty_out.write(
+ menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)]
+ )
+ apply_style(self._search_highlight_style)
+ self._tty_out.write(
+ menu_entry[
+ match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len)
+ ]
+ )
+ apply_style()
+ if menu_index == self._view.active_menu_index:
+ apply_style(self._menu_highlight_style)
+ self._tty_out.write(
+ menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len]
+ )
+ else:
+ self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len])
+ if menu_index == self._view.active_menu_index:
+ apply_style()
+ self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ")
+ if displayed_index < self._viewport.upper_index:
+ self._tty_out.write("\n")
+ empty_menu_lines = self._viewport.upper_index - displayed_index
+ self._tty_out.write(
+ max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ")
+ )
+ self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"])
+ current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines
+ return current_menu_block_displayed_height
+
+ def print_search_line(current_menu_height: int) -> int:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ current_menu_block_displayed_height = 0
+ num_cols = self._num_cols()
+ if self._search or self._show_search_hint:
+ self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
+ if self._search:
+ assert self._search.search_text is not None
+ self._tty_out.write(
+ (
+ (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY)
+ + self._search.search_text
+ )[:num_cols]
+ )
+ self._tty_out.write((num_cols - len(self._search) - 1) * " ")
+ elif self._show_search_hint:
+ if self._show_search_hint_text is not None:
+ search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols]
+ elif self._search_key is not None:
+ search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols]
+ else:
+ search_hint = "(Press any letter key to search)"[:num_cols]
+ self._tty_out.write(search_hint)
+ self._tty_out.write((num_cols - wcswidth(search_hint)) * " ")
+ if self._search or self._show_search_hint:
+ self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"])
+ current_menu_block_displayed_height = 1
+ return current_menu_block_displayed_height
+
+ def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ current_menu_block_displayed_height = 0 # sum all written lines
+ num_cols = self._num_cols()
+ if status_bar_lines:
+ self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
+ apply_style(self._status_bar_style)
+ self._tty_out.write(
+ "\r"
+ + "\n".join(
+ (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ")
+ for status_bar_line in status_bar_lines
+ )
+ + "\r"
+ )
+ apply_style()
+ self._tty_out.write(
+ (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"]
+ )
+ current_menu_block_displayed_height += len(status_bar_lines)
+ return current_menu_block_displayed_height
+
+ def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ if self._preview_command is None or preview_max_num_lines < 3:
+ return 0
+
+ def get_preview_string() -> Optional[str]:
+ assert self._preview_command is not None
+ if self._view.active_menu_index is None:
+ return None
+ preview_argument = (
+ self._preview_arguments[self._view.active_menu_index]
+ if self._preview_arguments[self._view.active_menu_index] is not None
+ else self._menu_entries[self._view.active_menu_index]
+ )
+ if preview_argument == "":
+ return None
+ if isinstance(self._preview_command, str):
+ try:
+ preview_process = subprocess.Popen(
+ [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ assert preview_process.stdout is not None
+ preview_string = (
+ io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace")
+ .read()
+ .strip()
+ )
+ except subprocess.CalledProcessError as e:
+ raise PreviewCommandFailedError(
+ e.stderr.decode(encoding=self._user_locale, errors="replace").strip()
+ ) from e
+ else:
+ preview_string = self._preview_command(preview_argument)
+ return preview_string
+
+ @static_variables(
+ # Regex taken from https://stackoverflow.com/a/14693789/5958465
+ ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"),
+ # Modified version of https://stackoverflow.com/a/2188410/5958465
+ ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"),
+ )
+ def strip_ansi_codes_except_styling(string: str) -> str:
+ stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore
+ lambda match_obj: match_obj.group(0)
+ if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore
+ else "",
+ string,
+ )
+ return cast(str, stripped_string)
+
+ @static_variables(
+ regular_text_regex=re.compile(r"([^\x1B]+)(.*)"),
+ ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"),
+ )
+ def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]:
+ if max_len <= 0:
+ return "", 0
+ string_parts = []
+ string_len = 0
+ while string:
+ regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore
+ if regular_text_match is not None:
+ regular_text = regular_text_match.group(1)
+ regular_text_len = wcswidth(regular_text)
+ if string_len + regular_text_len > max_len:
+ string_parts.append(regular_text[: max_len - string_len])
+ string_len = max_len
+ break
+ string_parts.append(regular_text)
+ string_len += regular_text_len
+ string = regular_text_match.group(2)
+ else:
+ ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore
+ string
+ )
+ if ansi_escape_match is not None:
+ # Adopt the ansi escape code but do not count its length
+ ansi_escape_code_text = ansi_escape_match.group(1)
+ string_parts.append(ansi_escape_code_text)
+ string = ansi_escape_match.group(2)
+ else:
+ # It looks like an escape code (starts with escape), but it is something else
+ # -> skip the escape character and continue the loop
+ string_parts.append("\x1B")
+ string = string[1:]
+ return "".join(string_parts), string_len
+
+ num_cols = self._num_cols()
+ try:
+ preview_string = get_preview_string()
+ if preview_string is not None:
+ preview_string = strip_ansi_codes_except_styling(preview_string)
+ except PreviewCommandFailedError as e:
+ preview_string = "The preview command failed with error message:\n\n" + str(e)
+ self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"])
+ if preview_string is not None:
+ self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r")
+ if self._preview_border:
+ self._tty_out.write(
+ (
+ BoxDrawingCharacters.upper_left
+ + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3]
+ + " "
+ + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal
+ + BoxDrawingCharacters.upper_right
+ )[:num_cols]
+ + "\n"
+ )
+ # `finditer` can be used as a generator version of `str.join`
+ for i, line in enumerate(
+ match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE)
+ ):
+ if i >= preview_max_num_lines - (2 if self._preview_border else 0):
+ preview_num_lines = preview_max_num_lines
+ break
+ limited_line, limited_line_len = limit_string_with_escape_codes(
+ line, num_cols - (3 if self._preview_border else 0)
+ )
+ self._tty_out.write(
+ (
+ ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "")
+ + limited_line
+ + self._codename_to_terminal_code["reset_attributes"]
+ + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " "
+ + (BoxDrawingCharacters.vertical if self._preview_border else "")
+ )
+ )
+ else:
+ preview_num_lines = i + (3 if self._preview_border else 1)
+ if self._preview_border:
+ self._tty_out.write(
+ "\n"
+ + (
+ BoxDrawingCharacters.lower_left
+ + (num_cols - 2) * BoxDrawingCharacters.horizontal
+ + BoxDrawingCharacters.lower_right
+ )[:num_cols]
+ )
+ self._tty_out.write("\r")
+ else:
+ preview_num_lines = 0
+ self._tty_out.write(
+ (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"]
+ )
+ return preview_num_lines
+
+ def delete_old_menu_lines(displayed_menu_height: int) -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ if (
+ self._previous_displayed_menu_height is not None
+ and self._previous_displayed_menu_height > displayed_menu_height
+ ):
+ self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
+ self._tty_out.write(
+ (self._previous_displayed_menu_height - displayed_menu_height)
+ * self._codename_to_terminal_code["delete_line"]
+ )
+ self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"])
+
+ def position_cursor() -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ if self._view.active_displayed_index is None:
+ return
+
+ cursor_width = wcswidth(self._menu_cursor)
+ for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1):
+ if displayed_index == self._view.active_displayed_index:
+ apply_style(self._menu_cursor_style)
+ self._tty_out.write(self._menu_cursor)
+ apply_style()
+ else:
+ self._tty_out.write(cursor_width * " ")
+ self._tty_out.write("\r")
+ if displayed_index < self._viewport.upper_index:
+ self._tty_out.write(self._codename_to_terminal_code["cursor_down"])
+ self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"])
+
+ def print_multi_select_column() -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ if not self._multi_select:
+ return
+
+ def prepare_multi_select_cursors() -> Tuple[str, str]:
+ bracket_characters = "([{<)]}>"
+ bracket_style_escape_codes_io = io.StringIO()
+ multi_select_cursor_style_escape_codes_io = io.StringIO()
+ reset_codes_io = io.StringIO()
+ apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io)
+ apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io)
+ apply_style(file=reset_codes_io)
+ bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue()
+ multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue()
+ reset_codes = reset_codes_io.getvalue()
+
+ cursor_with_brackets_only = re.sub(
+ r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor
+ )
+ cursor_with_brackets_only_styled = re.sub(
+ r"[{}]+".format(re.escape(bracket_characters)),
+ lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes,
+ cursor_with_brackets_only,
+ )
+ cursor_styled = re.sub(
+ r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)),
+ lambda match_obj: (
+ bracket_style_escape_codes
+ if match_obj.group(0)[0] in bracket_characters
+ else multi_select_cursor_style_escape_codes
+ )
+ + match_obj.group(0)
+ + reset_codes,
+ self._multi_select_cursor,
+ )
+ return cursor_styled, cursor_with_brackets_only_styled
+
+ if not self._view:
+ return
+ checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors()
+ cursor_width = wcswidth(self._menu_cursor)
+ displayed_selected_indices = self._view.displayed_selected_indices
+ displayed_index = 0
+ for displayed_index, _, _ in self._view:
+ self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"])
+ if displayed_index in displayed_selected_indices:
+ self._tty_out.write(checked_multi_select_cursor)
+ else:
+ self._tty_out.write(unchecked_multi_select_cursor)
+ if displayed_index < self._viewport.upper_index:
+ self._tty_out.write(self._codename_to_terminal_code["cursor_down"])
+ self._tty_out.write("\r")
+ self._tty_out.write(
+ (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0))
+ * self._codename_to_terminal_code["cursor_up"]
+ )
+
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._tty_out is not None
+ displayed_menu_height = 0 # sum all written lines
+ status_bar_lines = get_status_bar_lines()
+ self._viewport.status_bar_lines_count = len(status_bar_lines)
+ if self._preview_command is not None:
+ self._viewport.preview_lines_count = int(self._preview_size * self._num_lines())
+ preview_max_num_lines = self._viewport.preview_lines_count
+ self._viewport.keep_visible(self._view.active_displayed_index)
+ displayed_menu_height += print_menu_entries()
+ displayed_menu_height += print_search_line(displayed_menu_height)
+ if not self._status_bar_below_preview:
+ displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines)
+ if self._preview_command is not None:
+ displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines)
+ if self._status_bar_below_preview:
+ displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines)
+ delete_old_menu_lines(displayed_menu_height)
+ position_cursor()
+ if self._multi_select:
+ print_multi_select_column()
+ self._previous_displayed_menu_height = displayed_menu_height
+ self._tty_out.flush()
+
+ def _clear_menu(self) -> None:
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ assert self._previous_displayed_menu_height is not None
+ assert self._tty_out is not None
+ if self._clear_menu_on_exit:
+ if self._title_lines:
+ self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"])
+ self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"])
+ self._tty_out.write(
+ (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"]
+ )
+ else:
+ self._tty_out.write(
+ (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]
+ )
+ self._tty_out.flush()
+
+ def _read_next_key(self, ignore_case: bool = True) -> str:
+ # pylint: disable=unsubscriptable-object,unsupported-membership-test
+ assert self._terminal_code_to_codename is not None
+ assert self._tty_in is not None
+ # Needed for asynchronous handling of terminal resize events
+ self._reading_next_key = True
+ if self._paint_before_next_read:
+ self._paint_menu()
+ self._paint_before_next_read = False
+ # blocks until any amount of bytes is available
+ code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore")
+ self._reading_next_key = False
+ if code in self._terminal_code_to_codename:
+ return self._terminal_code_to_codename[code]
+ elif ignore_case:
+ return code.lower()
+ else:
+ return code
+
+ def show(self) -> Optional[Union[int, Tuple[int, ...]]]:
+ def init_signal_handling() -> None:
+ # `SIGWINCH` is send on terminal resizes
+ def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None:
+ # pylint: disable=unused-argument
+ if self._reading_next_key:
+ self._paint_menu()
+ else:
+ self._paint_before_next_read = True
+
+ signal.signal(signal.SIGWINCH, handle_sigwinch)
+
+ def reset_signal_handling() -> None:
+ signal.signal(signal.SIGWINCH, signal.SIG_DFL)
+
+ def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None:
+ letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ")
+ for keys in menu_action_to_keys.values():
+ keys -= letter_keys
+
+ # pylint: disable=unsubscriptable-object
+ assert self._codename_to_terminal_code is not None
+ self._init_term()
+ if self._preselected_indices is None:
+ self._selection.clear()
+ self._chosen_accept_key = None
+ self._chosen_menu_indices = None
+ self._chosen_menu_index = None
+ assert self._tty_out is not None
+ if self._title_lines:
+ # `print_menu` expects the cursor on the first menu item -> reserve one line for the title
+ self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"])
+ menu_was_interrupted = False
+ try:
+ init_signal_handling()
+ menu_action_to_keys = {
+ "menu_up": set(("up", "ctrl-k", "k")),
+ "menu_down": set(("down", "ctrl-j", "j")),
+ "accept": set(self._accept_keys),
+ "multi_select": set(self._multi_select_keys),
+ "quit": set(("escape", "q")),
+ "search_start": set((self._search_key,)),
+ "backspace": set(("backspace",)),
+ } # type: Dict[str, Set[Optional[str]]]
+ while True:
+ self._paint_menu()
+ current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys)
+ next_key = self._read_next_key(ignore_case=False)
+ if self._search or self._search_key is None:
+ remove_letter_keys(current_menu_action_to_keys)
+ else:
+ next_key = next_key.lower()
+ if self._search_key is not None and not self._search and next_key in self._shortcut_keys:
+ shortcut_menu_index = self._shortcut_keys.index(next_key)
+ if self._exit_on_shortcut:
+ self._selection.add(shortcut_menu_index)
+ break
+ else:
+ if self._multi_select:
+ self._selection.toggle(shortcut_menu_index)
+ else:
+ self._view.active_menu_index = shortcut_menu_index
+ elif next_key in current_menu_action_to_keys["menu_up"]:
+ self._view.decrement_active_index()
+ elif next_key in current_menu_action_to_keys["menu_down"]:
+ self._view.increment_active_index()
+ elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]:
+ if self._view.active_menu_index is not None:
+ self._selection.toggle(self._view.active_menu_index)
+ elif next_key in current_menu_action_to_keys["accept"]:
+ if self._view.active_menu_index is not None:
+ if self._multi_select_select_on_accept or (
+ not self._selection and self._multi_select_empty_ok is False
+ ):
+ self._selection.add(self._view.active_menu_index)
+ self._chosen_accept_key = next_key
+ break
+ elif next_key in current_menu_action_to_keys["quit"]:
+ if not self._search:
+ menu_was_interrupted = True
+ break
+ else:
+ self._search.search_text = None
+ elif not self._search:
+ if next_key in current_menu_action_to_keys["search_start"] or (
+ self._search_key is None and next_key == DEFAULT_SEARCH_KEY
+ ):
+ self._search.search_text = ""
+ elif self._search_key is None:
+ self._search.search_text = next_key
+ else:
+ assert self._search.search_text is not None
+ if next_key in ("backspace",):
+ if self._search.search_text != "":
+ self._search.search_text = self._search.search_text[:-1]
+ else:
+ self._search.search_text = None
+ elif wcswidth(next_key) >= 0 and not (
+ next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == ""
+ ):
+ # Only append `next_key` if it is a printable character and the first character is not the
+ # `search_start` key
+ self._search.search_text += next_key
+ except KeyboardInterrupt:
+ menu_was_interrupted = True
+ finally:
+ reset_signal_handling()
+ self._clear_menu()
+ self._reset_term()
+ if not menu_was_interrupted:
+ chosen_menu_indices = self._selection.selected_menu_indices
+ if chosen_menu_indices:
+ if self._multi_select:
+ self._chosen_menu_indices = chosen_menu_indices
+ else:
+ self._chosen_menu_index = chosen_menu_indices[0]
+ return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index
+
+ @property
+ def chosen_accept_key(self) -> Optional[str]:
+ return self._chosen_accept_key
+
+ @property
+ def chosen_menu_entry(self) -> Optional[str]:
+ return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None
+
+ @property
+ def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]:
+ return (
+ tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices)
+ if self._chosen_menu_indices is not None
+ else None
+ )
+
+ @property
+ def chosen_menu_index(self) -> Optional[int]:
+ return self._chosen_menu_index
+
+ @property
+ def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]:
+ return self._chosen_menu_indices
+
+
+class AttributeDict(dict): # type: ignore
+ def __getattr__(self, attr: str) -> Any:
+ return self[attr]
+
+ def __setattr__(self, attr: str, value: Any) -> None:
+ self[attr] = value
+
+
+def get_argumentparser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ description="""
+%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code.
+""",
+ )
+ parser.add_argument(
+ "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive"
+ )
+ parser.add_argument(
+ "-X",
+ "--no-clear-menu-on-exit",
+ action="store_false",
+ dest="clear_menu_on_exit",
+ help="do not clear the menu on exit",
+ )
+ parser.add_argument(
+ "-l",
+ "--clear-screen",
+ action="store_true",
+ dest="clear_screen",
+ help="clear the screen before the menu is shown",
+ )
+ parser.add_argument(
+ "--cursor",
+ action="store",
+ dest="cursor",
+ default=DEFAULT_MENU_CURSOR,
+ help='menu cursor (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "-i",
+ "--cursor-index",
+ action="store",
+ dest="cursor_index",
+ type=int,
+ default=0,
+ help="initially selected item index",
+ )
+ parser.add_argument(
+ "--cursor-style",
+ action="store",
+ dest="cursor_style",
+ default=",".join(DEFAULT_MENU_CURSOR_STYLE),
+ help='style for the menu cursor as comma separated list (default: "%(default)s")',
+ )
+ parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection")
+ parser.add_argument(
+ "-E",
+ "--no-exit-on-shortcut",
+ action="store_false",
+ dest="exit_on_shortcut",
+ help="do not exit on shortcut keys",
+ )
+ parser.add_argument(
+ "--highlight-style",
+ action="store",
+ dest="highlight_style",
+ default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE),
+ help='style for the selected menu entry as comma separated list (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "-m",
+ "--multi-select",
+ action="store_true",
+ dest="multi_select",
+ help="Allow the selection of multiple entries (implies `--stdout`)",
+ )
+ parser.add_argument(
+ "--multi-select-cursor",
+ action="store",
+ dest="multi_select_cursor",
+ default=DEFAULT_MULTI_SELECT_CURSOR,
+ help='multi-select menu cursor (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--multi-select-cursor-brackets-style",
+ action="store",
+ dest="multi_select_cursor_brackets_style",
+ default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE),
+ help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--multi-select-cursor-style",
+ action="store",
+ dest="multi_select_cursor_style",
+ default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE),
+ help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--multi-select-keys",
+ action="store",
+ dest="multi_select_keys",
+ default=",".join(DEFAULT_MULTI_SELECT_KEYS),
+ help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '),
+ )
+ parser.add_argument(
+ "--multi-select-no-select-on-accept",
+ action="store_false",
+ dest="multi_select_select_on_accept",
+ help=(
+ "do not select the currently highlighted menu item when the accept key is pressed "
+ "(it is still selected if no other item was selected before)"
+ ),
+ )
+ parser.add_argument(
+ "--multi-select-empty-ok",
+ action="store_true",
+ dest="multi_select_empty_ok",
+ help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"),
+ )
+ parser.add_argument(
+ "-p",
+ "--preview",
+ action="store",
+ dest="preview_command",
+ help=(
+ "Command to generate a preview for the selected menu entry. "
+ '"{}" can be used as placeholder for the menu text. '
+ 'If the menu entry has a data component (separated by "|"), this is used instead.'
+ ),
+ )
+ parser.add_argument(
+ "--no-preview-border",
+ action="store_false",
+ dest="preview_border",
+ help="do not draw a border around the preview window",
+ )
+ parser.add_argument(
+ "--preview-size",
+ action="store",
+ dest="preview_size",
+ type=float,
+ default=DEFAULT_PREVIEW_SIZE,
+ help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--preview-title",
+ action="store",
+ dest="preview_title",
+ default=DEFAULT_PREVIEW_TITLE,
+ help='title of the preview window (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--search-highlight-style",
+ action="store",
+ dest="search_highlight_style",
+ default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE),
+ help='style of matched search patterns (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--search-key",
+ action="store",
+ dest="search_key",
+ default=DEFAULT_SEARCH_KEY,
+ help=(
+ 'key to start a search (default: "%(default)s", '
+ '"none" is treated a special value which activates the search on any letter key)'
+ ),
+ )
+ parser.add_argument(
+ "--shortcut-brackets-highlight-style",
+ action="store",
+ dest="shortcut_brackets_highlight_style",
+ default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE),
+ help='style of brackets enclosing shortcut keys (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--shortcut-key-highlight-style",
+ action="store",
+ dest="shortcut_key_highlight_style",
+ default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE),
+ help='style of shortcut keys (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--show-multi-select-hint",
+ action="store_true",
+ dest="show_multi_select_hint",
+ help="show a multi-select hint in the status bar",
+ )
+ parser.add_argument(
+ "--show-multi-select-hint-text",
+ action="store",
+ dest="show_multi_select_hint_text",
+ help=(
+ "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and "
+ "{accept_keys} if appropriately."
+ ),
+ )
+ parser.add_argument(
+ "--show-search-hint",
+ action="store_true",
+ dest="show_search_hint",
+ help="show a search hint in the search line",
+ )
+ parser.add_argument(
+ "--show-search-hint-text",
+ action="store",
+ dest="show_search_hint_text",
+ help=(
+ "Custom text which will be shown as search hint. Use the placeholders {key} for the search key "
+ "if appropriately."
+ ),
+ )
+ parser.add_argument(
+ "--show-shortcut-hints",
+ action="store_true",
+ dest="show_shortcut_hints",
+ help="show shortcut hints in the status bar",
+ )
+ parser.add_argument(
+ "--show-shortcut-hints-in-title",
+ action="store_false",
+ dest="show_shortcut_hints_in_status_bar",
+ default=True,
+ help="show shortcut hints in the menu title",
+ )
+ parser.add_argument(
+ "-b",
+ "--status-bar",
+ action="store",
+ dest="status_bar",
+ help="status bar text",
+ )
+ parser.add_argument(
+ "-d",
+ "--status-bar-below-preview",
+ action="store_true",
+ dest="status_bar_below_preview",
+ help="show the status bar below the preview window if any",
+ )
+ parser.add_argument(
+ "--status-bar-style",
+ action="store",
+ dest="status_bar_style",
+ default=",".join(DEFAULT_STATUS_BAR_STYLE),
+ help='style of the status bar lines (default: "%(default)s")',
+ )
+ parser.add_argument(
+ "--stdout",
+ action="store_true",
+ dest="stdout",
+ help=(
+ "Print the selected menu index or indices to stdout (in addition to the exit status). "
+ 'Multiple indices are separated by ";".'
+ ),
+ )
+ parser.add_argument("-t", "--title", action="store", dest="title", help="menu title")
+ parser.add_argument(
+ "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit"
+ )
+ parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show")
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument(
+ "-r",
+ "--preselected_entries",
+ action="store",
+ dest="preselected_entries",
+ help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.",
+ )
+ group.add_argument(
+ "-R",
+ "--preselected_indices",
+ action="store",
+ dest="preselected_indices",
+ help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.",
+ )
+ return parser
+
+
+def parse_arguments() -> AttributeDict:
+ parser = get_argumentparser()
+ args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()})
+ if not args.print_version and not args.entries:
+ raise NoMenuEntriesError("No menu entries given!")
+ if args.cursor_style != "":
+ args.cursor_style = tuple(args.cursor_style.split(","))
+ else:
+ args.cursor_style = None
+ if args.highlight_style != "":
+ args.highlight_style = tuple(args.highlight_style.split(","))
+ else:
+ args.highlight_style = None
+ if args.search_highlight_style != "":
+ args.search_highlight_style = tuple(args.search_highlight_style.split(","))
+ else:
+ args.search_highlight_style = None
+ if args.shortcut_key_highlight_style != "":
+ args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(","))
+ else:
+ args.shortcut_key_highlight_style = None
+ if args.shortcut_brackets_highlight_style != "":
+ args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(","))
+ else:
+ args.shortcut_brackets_highlight_style = None
+ if args.status_bar_style != "":
+ args.status_bar_style = tuple(args.status_bar_style.split(","))
+ else:
+ args.status_bar_style = None
+ if args.multi_select_cursor_brackets_style != "":
+ args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(","))
+ else:
+ args.multi_select_cursor_brackets_style = None
+ if args.multi_select_cursor_style != "":
+ args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(","))
+ else:
+ args.multi_select_cursor_style = None
+ if args.multi_select_keys != "":
+ args.multi_select_keys = tuple(args.multi_select_keys.split(","))
+ else:
+ args.multi_select_keys = None
+ if args.search_key.lower() == "none":
+ args.search_key = None
+ if args.show_shortcut_hints_in_status_bar:
+ args.show_shortcut_hints = True
+ if args.multi_select:
+ args.stdout = True
+ if args.preselected_entries is not None:
+ args.preselected = list(args.preselected_entries.split(","))
+ elif args.preselected_indices is not None:
+ args.preselected = list(map(int, args.preselected_indices.split(",")))
+ else:
+ args.preselected = None
+ return args
+
+
+def main() -> None:
+ try:
+ args = parse_arguments()
+ except SystemExit:
+ sys.exit(0) # Error code 0 is the error case in this program
+ except NoMenuEntriesError as e:
+ print(str(e), file=sys.stderr)
+ sys.exit(0)
+ if args.print_version:
+ print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__))
+ sys.exit(0)
+ try:
+ terminal_menu = TerminalMenu(
+ menu_entries=args.entries,
+ clear_menu_on_exit=args.clear_menu_on_exit,
+ clear_screen=args.clear_screen,
+ cursor_index=args.cursor_index,
+ cycle_cursor=args.cycle,
+ exit_on_shortcut=args.exit_on_shortcut,
+ menu_cursor=args.cursor,
+ menu_cursor_style=args.cursor_style,
+ menu_highlight_style=args.highlight_style,
+ multi_select=args.multi_select,
+ multi_select_cursor=args.multi_select_cursor,
+ multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style,
+ multi_select_cursor_style=args.multi_select_cursor_style,
+ multi_select_empty_ok=args.multi_select_empty_ok,
+ multi_select_keys=args.multi_select_keys,
+ multi_select_select_on_accept=args.multi_select_select_on_accept,
+ preselected_entries=args.preselected,
+ preview_border=args.preview_border,
+ preview_command=args.preview_command,
+ preview_size=args.preview_size,
+ preview_title=args.preview_title,
+ search_case_sensitive=args.case_sensitive,
+ search_highlight_style=args.search_highlight_style,
+ search_key=args.search_key,
+ shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style,
+ shortcut_key_highlight_style=args.shortcut_key_highlight_style,
+ show_multi_select_hint=args.show_multi_select_hint,
+ show_multi_select_hint_text=args.show_multi_select_hint_text,
+ show_search_hint=args.show_search_hint,
+ show_search_hint_text=args.show_search_hint_text,
+ show_shortcut_hints=args.show_shortcut_hints,
+ show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar,
+ status_bar=args.status_bar,
+ status_bar_below_preview=args.status_bar_below_preview,
+ status_bar_style=args.status_bar_style,
+ title=args.title,
+ )
+ except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e:
+ print(str(e), file=sys.stderr)
+ sys.exit(0)
+ chosen_entries = terminal_menu.show()
+ if chosen_entries is None:
+ sys.exit(0)
+ else:
+ if isinstance(chosen_entries, Iterable):
+ if args.stdout:
+ print(",".join(str(entry + 1) for entry in chosen_entries))
+ sys.exit(chosen_entries[0] + 1)
+ else:
+ chosen_entry = chosen_entries
+ if args.stdout:
+ print(chosen_entry + 1)
+ sys.exit(chosen_entry + 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/archinstall/lib/user_interaction.py b/archinstall/lib/user_interaction.py
index 7d90915f..2314b762 100644
--- a/archinstall/lib/user_interaction.py
+++ b/archinstall/lib/user_interaction.py
@@ -1,7 +1,6 @@
import getpass
import ipaddress
import logging
-import pathlib
import re
import select # Used for char by char polling of sys.stdin
import shutil
@@ -9,17 +8,20 @@ import signal
import sys
import time
-from .disk import BlockDevice, valid_fs_type, suggest_single_disk_layout, suggest_multi_disk_layout, valid_parted_position
+from .disk import BlockDevice, suggest_single_disk_layout, suggest_multi_disk_layout, valid_parted_position, all_disks
from .exceptions import RequirementError, UserError, DiskError
from .hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics
-from .locale_helpers import list_keyboard_languages, verify_keyboard_layout, search_keyboard_layout
+from .locale_helpers import list_keyboard_languages, list_timezones
from .networking import list_interfaces
+from .menu import Menu
from .output import log
from .profiles import Profile, list_profiles
from .storage import storage
+from .mirrors import list_mirrors
# TODO: Some inconsistencies between the selection processes.
# Some return the keys from the options, some the values?
+from .. import fs_types
def get_terminal_height():
@@ -117,81 +119,6 @@ def print_large_list(options, padding=5, margin_bottom=0, separator=': '):
return column, row
-def generic_multi_select(options, text="Select one or more of the options above (leave blank to continue): ", sort=True, default=None, allow_empty=False):
- # Checking if the options are different from `list` or `dict` or if they are empty
- if type(options) not in [list, dict, type({}.keys()), type({}.values())]:
- log(f" * Generic multi-select doesn't support ({type(options)}) as type of options * ", fg='red')
- log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
- raise RequirementError("generic_multi_select() requires list or dictionary as options.")
- if not options:
- log(" * Generic multi-select didn't find any options to choose from * ", fg='red')
- log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
- raise RequirementError('generic_multi_select() requires at least one option to proceed.')
- # After passing the checks, function continues to work
- if type(options) == dict:
- options = list(options.values())
- elif type(options) in (type({}.keys()), type({}.values())):
- options = list(options)
- if sort:
- options = sorted(options)
-
- section = MiniCurses(get_terminal_width(), len(options))
-
- selected_options = []
-
- while True:
- if not selected_options and default in options:
- selected_options.append(default)
-
- printed_options = []
- for option in options:
- if option in selected_options:
- printed_options.append(f'>> {option}')
- else:
- printed_options.append(f'{option}')
-
- section.clear(0, get_terminal_height() - section._cursor_y - 1)
- print_large_list(printed_options, margin_bottom=2)
- section._cursor_y = len(printed_options)
- section._cursor_x = 0
- section.write_line(text)
- section.input_pos = section._cursor_x
- selected_option = section.get_keyboard_input(end=None)
- # This string check is necessary to correct work with it
- # Without this, Python will raise AttributeError because of stripping `None`
- # It also allows to remove empty spaces if the user accidentally entered them.
- if isinstance(selected_option, str):
- selected_option = selected_option.strip()
- try:
- if not selected_option:
- if not selected_options and default:
- selected_options = [default]
- elif selected_options or allow_empty:
- break
- else:
- raise RequirementError('Please select at least one option to continue')
- elif selected_option.isnumeric():
- if (selected_option := int(selected_option)) >= len(options):
- raise RequirementError(f'Selected option "{selected_option}" is out of range')
- selected_option = options[selected_option]
- if selected_option in selected_options:
- selected_options.remove(selected_option)
- else:
- selected_options.append(selected_option)
- elif selected_option in options:
- if selected_option in selected_options:
- selected_options.remove(selected_option)
- else:
- selected_options.append(selected_option)
- else:
- raise RequirementError(f'Selected option "{selected_option}" does not exist in available options')
- except RequirementError as e:
- log(f" * {e} * ", fg='red')
-
- sys.stdout.write('\n')
- sys.stdout.flush()
- return selected_options
-
def select_encrypted_partitions(block_devices :dict, password :str) -> dict:
for device in block_devices:
for partition in block_devices[device]['partitions']:
@@ -208,6 +135,7 @@ def select_encrypted_partitions(block_devices :dict, password :str) -> dict:
# TODO: Next version perhaps we can support mixed multiple encrypted partitions
# Users might want to single out a partition for non-encryption to share between dualboot etc.
+
class MiniCurses:
def __init__(self, width, height):
self.width = width
@@ -370,18 +298,17 @@ def ask_for_additional_users(prompt='Any additional users to install (leave blan
def ask_for_a_timezone():
- while True:
- timezone = input('Enter a valid timezone (examples: Europe/Stockholm, US/Eastern) or press enter to use UTC: ').strip().strip('*.')
- if timezone == '':
- timezone = 'UTC'
- if (pathlib.Path("/usr") / "share" / "zoneinfo" / timezone).exists():
- return timezone
- else:
- log(
- f"Specified timezone {timezone} does not exist.",
- level=logging.WARNING,
- fg='red'
- )
+ timezones = list_timezones()
+ default = 'UTC'
+
+ selected_tz = Menu(
+ f'Select a timezone or leave blank to use default "{default}"',
+ timezones,
+ skip=False,
+ default_option=default
+ ).run()
+
+ return selected_tz
def ask_for_bootloader(advanced_options=False) -> str:
@@ -394,7 +321,7 @@ def ask_for_bootloader(advanced_options=False) -> str:
else:
# We use the common names for the bootloader as the selection, and map it back to the expected values.
choices = ['systemd-boot', 'grub', 'efistub']
- selection = generic_select(choices, f'Choose a bootloader or leave blank to use systemd-boot: ', options_output=True)
+ selection = Menu('Choose a bootloader or leave blank to use systemd-boot', choices).run()
if selection != "":
if selection == 'systemd-boot':
bootloader = 'systemd-bootctl'
@@ -409,11 +336,8 @@ def ask_for_bootloader(advanced_options=False) -> str:
def ask_for_audio_selection(desktop=True):
audio = 'pipewire' if desktop else 'none'
choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', 'none']
- selection = generic_select(choices, f'Choose an audio server or leave blank to use {audio}: ', options_output=True)
- if selection != "":
- audio = selection
-
- return audio
+ selected_audio = Menu(f'Choose an audio server or leave blank to use "{audio}"', choices, default_option=audio).run()
+ return selected_audio
def ask_to_configure_network():
@@ -426,7 +350,8 @@ def ask_to_configure_network():
**list_interfaces()
}
- nic = generic_select(interfaces, "Select one network interface to configure (leave blank to skip): ")
+ nic = Menu('Select one network interface to configure', interfaces.values()).run()
+
if nic and nic != 'Copy ISO network configuration to installation':
if nic == 'Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)':
return {'nic': nic, 'NetworkManager': True}
@@ -436,11 +361,15 @@ def ask_to_configure_network():
# printing out this part separate from options, passed in
# `generic_select`
modes = ['DHCP (auto detect)', 'IP (static)']
- for index, mode in enumerate(modes):
- print(f"{index}: {mode}")
+ default_mode = 'DHCP (auto detect)'
+
+ mode = Menu(
+ f'Select which mode to configure for "{nic}" or leave blank for default "{default_mode}"',
+ modes,
+ default_option=default_mode
+ ).run()
- mode = generic_select(['DHCP', 'IP'], f"Select which mode to configure for {nic} or leave blank for DHCP: ", options_output=False)
- if mode == 'IP':
+ if mode == 'IP (static)':
while 1:
ip = input(f"Enter the IP and subnet for {nic} (example: 192.168.0.5/24): ").strip()
# Implemented new check for correct IP/subnet input
@@ -483,15 +412,9 @@ def ask_to_configure_network():
return {}
-def ask_for_disk_layout():
- options = {
- 'keep-existing': 'Keep existing partition layout and select which ones to use where',
- 'format-all': 'Format entire drive and setup a basic partition scheme',
- 'abort': 'Abort the installation',
- }
-
- value = generic_select(options, "Found partitions on the selected drive, (select by number) what you want to do: ", allow_empty_input=False, sort=True)
- return next((key for key, val in options.items() if val == value), None)
+def partition_overlap(partitions :list, start :str, end :str) -> bool:
+ # TODO: Implement sanity check
+ return False
def ask_for_main_filesystem_format(advanced_options=False):
@@ -509,77 +432,64 @@ def ask_for_main_filesystem_format(advanced_options=False):
if advanced_options:
options.update(advanced)
- value = generic_select(options, "Select which filesystem your main partition should use (by number or name): ", allow_empty_input=False)
- return next((key for key, val in options.items() if val == value), None)
+ return Menu('Select which filesystem your main partition should use', options, skip=False).run()
-def generic_select(options, input_text="Select one of the above by index or absolute value: ", allow_empty_input=True, options_output=True, sort=False):
- """
- A generic select function that does not output anything
- other than the options and their indexes. As an example:
+def current_partition_layout(partitions, with_idx=False):
+ def do_padding(name, max_len):
+ spaces = abs(len(str(name)) - max_len) + 2
+ pad_left = int(spaces / 2)
+ pad_right = spaces - pad_left
+ return f'{pad_right * " "}{name}{pad_left * " "}|'
- generic_select(["first", "second", "third option"])
- 0: first
- 1: second
- 2: third option
+ column_names = {}
- When the user has entered the option correctly,
- this function returns an item from list, a string, or None
- """
+ # this will add an initial index to the table for each partition
+ if with_idx:
+ column_names['index'] = max([len(str(len(partitions))), len('index')])
- # Checking if the options are different from `list` or `dict` or if they are empty
- if type(options) not in [list, dict]:
- log(f" * Generic select doesn't support ({type(options)}) as type of options * ", fg='red')
- log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
- raise RequirementError("generic_select() requires list or dictionary as options.")
- if not options:
- log(" * Generic select didn't find any options to choose from * ", fg='red')
- log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
- raise RequirementError('generic_select() requires at least one option to proceed.')
- # After passing the checks, function continues to work
- if type(options) == dict:
- # To allow only `list` and `dict`, converting values of options here.
- # Therefore, now we can only provide the dictionary itself
- options = list(options.values())
- if sort:
- # As we pass only list and dict (converted to list), we can skip converting to list
- options = sorted(options)
-
- # Added ability to disable the output of options items,
- # if another function displays something different from this
- if options_output:
- for index, option in enumerate(options):
- print(f"{index}: {option}")
+ # determine all attribute names and the max length
+ # of the value among all partitions to know the width
+ # of the table cells
+ for p in partitions:
+ for attribute, value in p.items():
+ if attribute in column_names.keys():
+ column_names[attribute] = max([column_names[attribute], len(str(value)), len(attribute)])
+ else:
+ column_names[attribute] = max([len(str(value)), len(attribute)])
- # The new changes introduce a single while loop for all inputs processed by this function
- # Now the try...except block handles validation for invalid input from the user
- while True:
- try:
- selected_option = input(input_text).strip()
- if not selected_option:
- # `allow_empty_input` parameter handles return of None on empty input, if necessary
- # Otherwise raise `RequirementError`
- if allow_empty_input:
- return None
- raise RequirementError('Please select an option to continue')
- # Replaced `isdigit` with` isnumeric` to discard all negative numbers
- elif selected_option.isnumeric():
- if (selected_option := int(selected_option)) >= len(options):
- raise RequirementError(f'Selected option "{selected_option}" is out of range')
- selected_option = options[selected_option]
- break
- elif selected_option in options:
- break # We gave a correct absolute value
+ current_layout = ''
+ for name, max_len in column_names.items():
+ current_layout += do_padding(name, max_len)
+
+ current_layout = f'{current_layout[:-1]}\n{"-" * len(current_layout)}\n'
+
+ for idx, p in enumerate(partitions):
+ row = ''
+ for name, max_len in column_names.items():
+ if name == 'index':
+ row += do_padding(str(idx), max_len)
+ elif name in p:
+ row += do_padding(p[name], max_len)
else:
- raise RequirementError(f'Selected option "{selected_option}" does not exist in available options')
- except RequirementError as err:
- log(f" * {err} * ", fg='red')
+ row += ' ' * (max_len + 2) + '|'
- return selected_option
+ current_layout += f'{row[:-1]}\n'
-def partition_overlap(partitions :list, start :str, end :str) -> bool:
- # TODO: Implement sanity check
- return False
+ return f'\n\nCurrent partition layout:\n\n{current_layout}'
+
+
+def select_partition(title, partitions, multiple=False):
+ partition_indexes = list(map(str, range(len(partitions))))
+ partition = Menu(title, partition_indexes, multi=multiple).run()
+
+ if partition is not None:
+ if isinstance(partition, list):
+ return [int(p) for p in partition]
+ else:
+ return int(partition)
+
+ return None
def get_default_partition_layout(block_devices, advanced_options=False):
if len(block_devices) == 1:
@@ -587,7 +497,6 @@ def get_default_partition_layout(block_devices, advanced_options=False):
else:
return suggest_multi_disk_layout(block_devices, advanced_options=advanced_options)
- # TODO: Implement sane generic layout for 2+ drives
def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# if has_uefi():
@@ -624,7 +533,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# return struct
block_device_struct = {
- "partitions" : [partition.__dump__() for partition in block_device.partitions.values()]
+ "partitions": [partition.__dump__() for partition in block_device.partitions.values()]
}
# Test code: [part.__dump__() for part in block_device.partitions.values()]
# TODO: Squeeze in BTRFS subvolumes here
@@ -632,25 +541,27 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
while True:
modes = [
"Create a new partition",
- f"Suggest partition layout for {block_device}",
- "Delete a partition" if len(block_device_struct) else "",
- "Clear/Delete all partitions" if len(block_device_struct) else "",
- "Assign mount-point for a partition" if len(block_device_struct) else "",
- "Mark/Unmark a partition to be formatted (wipes data)" if len(block_device_struct) else "",
- "Mark/Unmark a partition as encrypted" if len(block_device_struct) else "",
- "Mark/Unmark a partition as bootable (automatic for /boot)" if len(block_device_struct) else "",
- "Set desired filesystem for a partition" if len(block_device_struct) else "",
+ f"Suggest partition layout for {block_device}"
]
- # Print current partition layout:
+ if len(block_device_struct['partitions']):
+ modes += [
+ "Delete a partition",
+ "Clear/Delete all partitions",
+ "Assign mount-point for a partition",
+ "Mark/Unmark a partition to be formatted (wipes data)",
+ "Mark/Unmark a partition as encrypted",
+ "Mark/Unmark a partition as bootable (automatic for /boot)",
+ "Set desired filesystem for a partition",
+ ]
+
+ title = f'Select what to do with \n{block_device}'
+
+ # show current partition layout:
if len(block_device_struct["partitions"]):
- print('Current partition layout:')
- for partition in block_device_struct["partitions"]:
- print(partition)
- print()
+ title += current_partition_layout(block_device_struct['partitions']) + '\n'
- task = generic_select(modes,
- input_text=f"Select what to do with {block_device} (leave blank when done): ")
+ task = Menu(title, modes, sort=False).run()
if not task:
break
@@ -661,7 +572,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# # https://www.gnu.org/software/parted/manual/html_node/mklabel.html
# name = input("Enter a desired name for the partition: ").strip()
- fstype = input("Enter a desired filesystem type for the partition: ").strip()
+ fstype = Menu('Enter a desired filesystem type for the partition', fs_types(), skip=False).run()
start = input(f"Enter the start sector (percentage or block number, default: {block_device.first_free_sector}): ").strip()
if not start.strip():
@@ -669,17 +580,19 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
end_suggested = block_device.first_end_sector
else:
end_suggested = '100%'
+
end = input(f"Enter the end sector of the partition (percentage or block number, ex: {end_suggested}): ").strip()
+
if not end.strip():
end = end_suggested
- if valid_parted_position(start) and valid_parted_position(end) and valid_fs_type(fstype):
+ if valid_parted_position(start) and valid_parted_position(end):
if partition_overlap(block_device_struct["partitions"], start, end):
log(f"This partition overlaps with other partitions on the drive! Ignoring this partition creation.", fg="red")
continue
block_device_struct["partitions"].append({
- "type" : "primary", # Strictly only allowed under MSDOS, but GPT accepts it so it's "safe" to inject
+ "type" : "primary", # Strictly only allowed under MSDOS, but GPT accepts it so it's "safe" to inject
"start" : start,
"size" : end,
"mountpoint" : None,
@@ -689,7 +602,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
}
})
else:
- log(f"Invalid start ({valid_parted_position(start)}), end ({valid_parted_position(end)}) or fstype ({valid_fs_type(fstype)}) for this partition. Ignoring this partition creation.", fg="red")
+ log(f"Invalid start ({valid_parted_position(start)}) or end ({valid_parted_position(end)}) for this partition. Ignoring this partition creation.", fg="red")
continue
elif task[:len("Suggest partition layout")] == "Suggest partition layout":
if len(block_device_struct["partitions"]):
@@ -700,77 +613,83 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
elif task is None:
return block_device_struct
else:
- for index, partition in enumerate(block_device_struct["partitions"]):
- print(f"{index}: Start: {partition['start']}, End: {partition['size']} ({partition['filesystem']['format']}{', mounting at: '+partition['mountpoint'] if partition['mountpoint'] else ''})")
+ current_layout = current_partition_layout(block_device_struct['partitions'], with_idx=True)
if task == "Delete a partition":
- if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to delete: ', options_output=False)):
- del(block_device_struct["partitions"][block_device_struct["partitions"].index(partition)])
+ title = f'{current_layout}\n\nSelect by index which partitions to delete'
+ to_delete = select_partition(title, block_device_struct["partitions"], multiple=True)
+
+ if to_delete:
+ block_device_struct['partitions'] = [p for idx, p in enumerate(block_device_struct['partitions']) if idx not in to_delete]
elif task == "Clear/Delete all partitions":
block_device_struct["partitions"] = []
elif task == "Assign mount-point for a partition":
- if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mount where: ', options_output=False)):
+ title = f'{current_layout}\n\nSelect by index which partition to mount where'
+ partition = select_partition(title, block_device_struct["partitions"])
+
+ if partition is not None:
print(' * Partition mount-points are relative to inside the installation, the boot would be /boot as an example.')
mountpoint = input('Select where to mount partition (leave blank to remove mountpoint): ').strip()
if len(mountpoint):
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['mountpoint'] = mountpoint
+ block_device_struct["partitions"][partition]['mountpoint'] = mountpoint
if mountpoint == '/boot':
log(f"Marked partition as bootable because mountpoint was set to /boot.", fg="yellow")
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['boot'] = True
else:
- del(block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['mountpoint'])
+ del(block_device_struct["partitions"][partition]['mountpoint'])
elif task == "Mark/Unmark a partition to be formatted (wipes data)":
- if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mask for formatting: ', options_output=False)):
+ title = f'{current_layout}\n\nSelect which partition to mask for formatting'
+ partition = select_partition(title, block_device_struct["partitions"])
+
+ if partition is not None:
# If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really
# without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set,
# it's safe to change the filesystem for this partition.
- if block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('filesystem', {}).get('format', 'crypto_LUKS') == 'crypto_LUKS':
- if not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('filesystem', None):
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem'] = {}
+ if block_device_struct["partitions"][partition].get('filesystem', {}).get('format', 'crypto_LUKS') == 'crypto_LUKS':
+ if not block_device_struct["partitions"][partition].get('filesystem', None):
+ block_device_struct["partitions"][partition]['filesystem'] = {}
- while True:
- fstype = input("Enter a desired filesystem type for the partition: ").strip()
- if not valid_fs_type(fstype):
- log(f"Desired filesystem {fstype} is not a valid filesystem.", level=logging.ERROR, fg="red")
- continue
- break
+ fstype = Menu('Enter a desired filesystem type for the partition', fs_types(), skip=False).run()
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem']['format'] = fstype
+ block_device_struct["partitions"][partition]['filesystem']['format'] = fstype
# Negate the current wipe marking
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['format'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('format', False)
+ block_device_struct["partitions"][partition]['format'] = not block_device_struct["partitions"][partition].get('format', False)
elif task == "Mark/Unmark a partition as encrypted":
- if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mark as encrypted: ', options_output=False)):
+ title = f'{current_layout}\n\nSelect which partition to mark as encrypted'
+ partition = select_partition(title, block_device_struct["partitions"])
+
+ if partition is not None:
# Negate the current encryption marking
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['encrypted'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('encrypted', False)
+ block_device_struct["partitions"][partition]['encrypted'] = not block_device_struct["partitions"][partition].get('encrypted', False)
elif task == "Mark/Unmark a partition as bootable (automatic for /boot)":
- if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mark as bootable: ', options_output=False)):
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['boot'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('boot', False)
+ title = f'{current_layout}\n\nSelect which partition to mark as bootable'
+ partition = select_partition(title, block_device_struct["partitions"])
+
+ if partition is not None:
+ block_device_struct["partitions"][partition]['boot'] = not block_device_struct["partitions"][partition].get('boot', False)
elif task == "Set desired filesystem for a partition":
- if not block_device_struct["partitions"]:
- log("No partitions found. Create some partitions first", level=logging.WARNING, fg='yellow')
- continue
- elif (partition := generic_select(block_device_struct["partitions"], 'Select which partition to set a filesystem on: ', options_output=False)):
- if not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('filesystem', None):
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem'] = {}
+ title = f'{current_layout}\n\nSelect which partition to set a filesystem on'
+ partition = select_partition(title, block_device_struct["partitions"])
- while True:
- fstype = input("Enter a desired filesystem type for the partition: ").strip()
- if not valid_fs_type(fstype):
- log(f"Desired filesystem {fstype} is not a valid filesystem.", level=logging.ERROR, fg="red")
- continue
- break
+ if partition is not None:
+ if not block_device_struct["partitions"][partition].get('filesystem', None):
+ block_device_struct["partitions"][partition]['filesystem'] = {}
- block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem']['format'] = fstype
+ fstype_title = 'Enter a desired filesystem type for the partition: '
+ fstype = Menu(fstype_title, fs_types(), skip=False).run()
+
+ block_device_struct["partitions"][partition]['filesystem']['format'] = fstype
return block_device_struct
-def select_individual_blockdevice_usage(block_devices :list):
+
+def select_individual_blockdevice_usage(block_devices: list):
result = {}
for device in block_devices:
@@ -787,7 +706,7 @@ def select_disk_layout(block_devices :list, advanced_options=False):
"Select what to do with each individual drive (followed by partition usage)"
]
- mode = generic_select(modes, input_text=f"Select what you wish to do with the selected block devices: ")
+ mode = Menu('Select what you wish to do with the selected block devices', modes, skip=False).run()
if mode == 'Wipe all selected drives and use a best-effort default partition layout':
return get_default_partition_layout(block_devices, advanced_options)
@@ -812,7 +731,8 @@ def select_disk(dict_o_disks):
print(f"{index}: {drive} ({dict_o_disks[drive]['size'], dict_o_disks[drive].device, dict_o_disks[drive]['label']})")
log("You can skip selecting a drive and partitioning and use whatever drive-setup is mounted at /mnt (experimental)", fg="yellow")
- drive = generic_select(drives, 'Select one of the above disks (by name or number) or leave blank to use /mnt: ', options_output=False)
+
+ drive = Menu('Select one of the disks or skip and use "/mnt" as default"', drives).run()
if not drive:
return drive
@@ -824,128 +744,90 @@ def select_disk(dict_o_disks):
def select_profile():
"""
- Asks the user to select a profile from the available profiles.
+ # Asks the user to select a profile from the available profiles.
+ #
+ # :return: The name/dictionary key of the selected profile
+ # :rtype: str
+ # """
+ top_level_profiles = sorted(list(list_profiles(filter_top_level_profiles=True)))
+ options = {}
- :return: The name/dictionary key of the selected profile
- :rtype: str
- """
- shown_profiles = sorted(list(list_profiles(filter_top_level_profiles=True)))
- actual_profiles_raw = shown_profiles + sorted([profile for profile in list_profiles() if profile not in shown_profiles])
+ for profile in top_level_profiles:
+ profile = Profile(None, profile)
+ description = profile.get_profile_description()
- if len(shown_profiles) >= 1:
- for index, profile in enumerate(shown_profiles):
- description = Profile(None, profile).get_profile_description()
- print(f"{index}: {profile}: {description}")
+ option = f'{profile.profile}: {description}'
+ options[option] = profile
- print(' -- The above list is a set of pre-programmed profiles. --')
- print(' -- They might make it easier to install things like desktop environments. --')
- print(' -- (Leave blank and hit enter to skip this step and continue) --')
+ title = 'This is a list of pre-programmed profiles, ' \
+ 'they might make it easier to install things like desktop environments'
- selected_profile = generic_select(actual_profiles_raw, 'Enter a pre-programmed profile name if you want to install one: ', options_output=False)
- if selected_profile:
- return Profile(None, selected_profile)
- else:
- raise RequirementError("Selecting profiles require a least one profile to be given as an option.")
+ selection = Menu(title=title, options=options.keys()).run()
+ if selection is not None:
+ return options[selection]
-def select_language(options, show_only_country_codes=True, input_text='Select one of the above keyboard languages (by number or full name): '):
- """
- Asks the user to select a language from the `options` dictionary parameter.
- Usually this is combined with :ref:`archinstall.list_keyboard_languages`.
+ return None
- :param options: A `generator` or `list` where keys are the language name, value should be a dict containing language information.
- :type options: generator or list
- :param show_only_country_codes: Filters out languages that are not len(lang) == 2. This to limit the number of results from stuff like dvorak and x-latin1 alternatives.
- :type show_only_country_codes: bool
+def select_language():
+ """
+ Asks the user to select a language
+ Usually this is combined with :ref:`archinstall.list_keyboard_languages`.
:return: The language/dictionary key of the selected language
:rtype: str
"""
- default_keyboard_language = 'us'
-
- if show_only_country_codes:
- languages = sorted([language for language in list(options) if len(language) == 2])
- else:
- languages = sorted(list(options))
+ kb_lang = list_keyboard_languages()
+ # sort alphabetically and then by length
+ # it's fine if the list is big because the Menu
+ # allows for searching anyways
+ sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len)
- if len(languages) >= 1:
- print_large_list(languages, margin_bottom=4)
+ selected_lang = Menu('Select Keyboard layout', sorted_kb_lang, default_option='us', sort=False).run()
+ return selected_lang
- print(" -- You can choose a layout that isn't in this list, but whose name you know --")
- print(f" -- Also, you can enter '?' or 'help' to search for more languages, or skip to use {default_keyboard_language} layout --")
- while True:
- selected_language = input(input_text)
- if not selected_language:
- return default_keyboard_language
- elif selected_language.lower() in ('?', 'help'):
- while True:
- filter_string = input("Search for layout containing (example: \"sv-\") or enter 'exit' to exit from search: ")
-
- if filter_string.lower() == 'exit':
- return select_language(list_keyboard_languages())
-
- new_options = list(search_keyboard_layout(filter_string))
-
- if len(new_options) <= 0:
- log(f"Search string '{filter_string}' yielded no results, please try another search.", fg='yellow')
- continue
-
- return select_language(new_options, show_only_country_codes=False)
- elif selected_language.isnumeric():
- selected_language = int(selected_language)
- if selected_language >= len(languages):
- log(' * Selected option is out of range * ', fg='red')
- continue
- return languages[selected_language]
- elif verify_keyboard_layout(selected_language):
- return selected_language
- else:
- log(" * Given language wasn't found * ", fg='red')
-
- raise RequirementError("Selecting languages require a least one language to be given as an option.")
-
-
-def select_mirror_regions(mirrors, show_top_mirrors=True):
+def select_mirror_regions():
"""
- Asks the user to select a mirror or region from the `mirrors` dictionary parameter.
+ Asks the user to select a mirror or region
Usually this is combined with :ref:`archinstall.list_mirrors`.
- :param mirrors: A `dict` where keys are the mirror region name, value should be a dict containing mirror information.
- :type mirrors: dict
-
- :param show_top_mirrors: Will limit the list to the top 10 fastest mirrors based on rank-mirror *(Currently not implemented but will be)*.
- :type show_top_mirrors: bool
-
:return: The dictionary information about a mirror/region.
:rtype: dict
"""
# TODO: Support multiple options and country codes, SE,UK for instance.
- regions = sorted(list(mirrors.keys()))
- selected_mirrors = {}
- if len(regions) >= 1:
- print_large_list(regions, margin_bottom=4)
+ mirrors = list_mirrors()
+ selected_mirror = Menu('Select one of the regions to download packages from', mirrors.keys()).run()
- print(' -- You can skip this step by leaving the option blank --')
- selected_mirror = generic_select(regions, 'Select one of the above regions to download packages from (by number or full name): ', options_output=False)
- if not selected_mirror:
- # Returning back empty options which can be both used to
- # do "if x:" logic as well as do `x.get('mirror', {}).get('sub', None)` chaining
- return {}
+ if selected_mirror is not None:
+ return {selected_mirror: mirrors[selected_mirror]}
- # I'm leaving "mirrors" on purpose here.
- # Since region possibly contains a known region of
- # all possible regions, and we might want to write
- # for instance Sweden (if we know that exists) without having to
- # go through the search step.
+ return {}
+
+
+def select_harddrives():
+ """
+ Asks the user to select one or multiple hard drives
- selected_mirrors[selected_mirror] = mirrors[selected_mirror]
- return selected_mirrors
+ :return: List of selected hard drives
+ :rtype: list
+ """
+ hard_drives = all_disks().values()
+ options = {f'{option}': option for option in hard_drives}
- raise RequirementError("Selecting mirror region require a least one region to be given as an option.")
+ selected_harddrive = Menu(
+ 'Select one or more hard drives to use and configure',
+ options.keys(),
+ multi=True
+ ).run()
+
+ if selected_harddrive and len(selected_harddrive) > 0:
+ return [options[i] for i in selected_harddrive]
+
+ return None
def select_driver(options=AVAILABLE_GFX_DRIVERS):
@@ -961,15 +843,18 @@ def select_driver(options=AVAILABLE_GFX_DRIVERS):
if drivers:
arguments = storage.get('arguments', {})
+ title = ''
+
if has_amd_graphics():
- print('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')
+ title += 'For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.\n'
if has_intel_graphics():
- print('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.')
+ title += 'For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n'
if has_nvidia_graphics():
- print('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.')
+ title += 'For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n'
if not arguments.get('gfx_driver', None):
- arguments['gfx_driver'] = generic_select(drivers, input_text="Select a graphics driver or leave blank to install all open-source drivers: ")
+ title += '\n\nSelect a graphics driver or leave blank to install all open-source drivers'
+ arguments['gfx_driver'] = Menu(title, drivers).run()
if arguments.get('gfx_driver', None) is None:
arguments['gfx_driver'] = "All open-source (default)"
@@ -979,22 +864,23 @@ def select_driver(options=AVAILABLE_GFX_DRIVERS):
raise RequirementError("Selecting drivers require a least one profile to be given as an option.")
-def select_kernel(options):
+def select_kernel():
"""
Asks the user to select a kernel for system.
- :param options: A `list` with kernel options
- :type options: list
-
:return: The string as a selected kernel
:rtype: string
"""
+ kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"]
default_kernel = "linux"
- kernels = sorted(list(options))
-
- if kernels:
- return generic_multi_select(kernels, f"Choose which kernels to use (leave blank for default: {default_kernel}): ", default=default_kernel, sort=False)
+ selected_kernels = Menu(
+ f'Choose which kernels to use or leave blank for default "{default_kernel}"',
+ kernels,
+ sort=True,
+ multi=True,
+ default_option=default_kernel
+ ).run()
- raise RequirementError("Selecting kernels require a least one kernel to be given as an option.")
+ return selected_kernels