archinstall/archinstall/lib/menu/menu.py

351 lines
9.9 KiB
Python

from dataclasses import dataclass
from enum import Enum, auto
from os import system
from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable
from simple_term_menu import TerminalMenu # type: ignore
from ..exceptions import RequirementError
from ..output import debug
if TYPE_CHECKING:
_: Any
class MenuSelectionType(Enum):
Selection = auto()
Skip = auto()
Reset = auto()
@dataclass
class MenuSelection:
type_: MenuSelectionType
value: Optional[Union[str, List[str]]] = None
@property
def single_value(self) -> Any:
return self.value
@property
def multi_value(self) -> List[Any]:
return self.value # type: ignore
class Menu(TerminalMenu):
_menu_is_active: bool = False
@staticmethod
def is_menu_active() -> bool:
return Menu._menu_is_active
@classmethod
def back(cls) -> str:
return str(_('← Back'))
@classmethod
def yes(cls) -> str:
return str(_('yes'))
@classmethod
def no(cls) -> str:
return str(_('no'))
@classmethod
def yes_no(cls) -> List[str]:
return [cls.yes(), cls.no()]
def __init__(
self,
title: str,
p_options: Union[List[str], Dict[str, Any]],
skip: bool = True,
multi: bool = False,
default_option: Optional[str] = None,
sort: bool = True,
preset_values: Optional[Union[str, List[str]]] = None,
cursor_index: Optional[int] = None,
preview_command: Optional[Callable[[Any], str | None]] = None,
preview_size: float = 0.0,
preview_title: str = 'Info',
header: Union[List[str], str] = [],
allow_reset: bool = False,
allow_reset_warning_msg: Optional[str] = None,
clear_screen: bool = True,
show_search_hint: bool = True,
cycle_cursor: bool = True,
clear_menu_on_exit: bool = True,
skip_empty_entries: bool = False,
display_back_option: bool = False,
extra_bottom_space: bool = False
):
"""
Creates a new menu
:param title: Text that will be displayed above the menu
:type title: str
:param p_options: Options to be displayed in the menu to chose from;
if dict is specified then the keys of such will be used as options
:type p_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
:param preset_values: Predefined value(s) of the menu. In a multi menu, it selects the options included therein. If the selection is simple, moves the cursor to the position of the value
:type preset_values: str or list
:param cursor_index: The position where the cursor will be located. If it is not in range (number of elements of the menu) it goes to the first position
:type cursor_index: int
:param preview_command: A function that should return a string that will be displayed in a preview window when a menu selection item is in focus
:type preview_command: Callable
:param preview_size: Size of the preview window in ratio to the full window
:type preview_size: float
:param preview_title: Title of the preview window
:type preview_title: str
:param header: one or more header lines for the menu
:type header: string or list
:param allow_reset: This will explicitly handle a ctrl+c instead and return that specific state
:type allow_reset: bool
param allow_reset_warning_msg: If raise_error_on_interrupt is True the warning is set, a user confirmation is displayed
type allow_reset_warning_msg: str
:param extra_bottom_space: Add an extra empty line at the end of the menu
:type extra_bottom_space: bool
"""
if isinstance(p_options, Dict):
options = list(p_options.keys())
else:
options = list(p_options)
if not options:
raise RequirementError('Menu.__init__() requires at least one option to proceed.')
if any([o for o in options if not isinstance(o, str)]):
raise RequirementError('Menu.__init__() requires the options to be of type string')
if sort:
options = sorted(options)
self._menu_options = options
self._skip = skip
self._default_option = default_option
self._multi = multi
self._raise_error_on_interrupt = allow_reset
self._raise_error_warning_msg = allow_reset_warning_msg
action_info = ''
if skip:
action_info += str(_('ESC to skip'))
if self._raise_error_on_interrupt:
action_info += ', ' if len(action_info) > 0 else ''
action_info += str(_('CTRL+C to reset'))
if multi:
action_info += ', ' if len(action_info) > 0 else ''
action_info += str(_('TAB to select'))
if action_info:
action_info += '\n\n'
menu_title = f'\n{action_info}{title}\n'
if header:
if not isinstance(header,(list,tuple)):
header = [header]
menu_title += '\n' + '\n'.join(header)
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
self._menu_options = [self._default_menu_value] + [o for o in self._menu_options if default_option != o]
if display_back_option and not multi and skip:
skip_empty_entries = True
self._menu_options += ['', self.back()]
if extra_bottom_space:
skip_empty_entries = True
self._menu_options += ['']
preset_list: Optional[List[str]] = None
if preset_values and isinstance(preset_values, str):
preset_list = [preset_values]
calc_cursor_idx = self._determine_cursor_pos(preset_list, cursor_index)
# when we're not in multi selection mode we don't care about
# passing the pre-selection list to the menu as the position
# of the cursor is the one determining the pre-selection
if not self._multi:
preset_values = None
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,
multi_select=multi,
preselected_entries=preset_values,
cursor_index=calc_cursor_idx,
preview_command=lambda x: self._show_preview(preview_command, x),
preview_size=preview_size,
preview_title=preview_title,
raise_error_on_interrupt=self._raise_error_on_interrupt,
multi_select_select_on_accept=False,
clear_screen=clear_screen,
show_search_hint=show_search_hint,
cycle_cursor=cycle_cursor,
clear_menu_on_exit=clear_menu_on_exit,
skip_empty_entries=skip_empty_entries
)
@property
def _default_menu_value(self) -> str:
default_str = str(_('(default)'))
return f'{self._default_option} {default_str}'
def _show_preview(
self,
preview_command: Optional[Callable[[Any], str | None]],
selection: str
) -> Optional[str]:
if selection == self.back():
return None
if preview_command:
if self._default_option is not None and self._default_menu_value == selection:
selection = self._default_option
if res := preview_command(selection):
return res.rstrip('\n')
return None
def _show(self) -> MenuSelection:
try:
idx = self.show()
except KeyboardInterrupt:
return MenuSelection(type_=MenuSelectionType.Reset)
def check_default(elem) -> str:
if self._default_option is not None and self._default_menu_value in elem:
return self._default_option
else:
return elem
if idx is not None:
if isinstance(idx, (list, tuple)): # on multi selection
results = []
for i in idx:
option = check_default(self._menu_options[i])
results.append(option)
return MenuSelection(type_=MenuSelectionType.Selection, value=results)
else: # on single selection
result = check_default(self._menu_options[idx])
return MenuSelection(type_=MenuSelectionType.Selection, value=result)
else:
return MenuSelection(type_=MenuSelectionType.Skip)
def run(self) -> MenuSelection:
Menu._menu_is_active = True
selection = self._show()
if selection.type_ == MenuSelectionType.Reset:
if self._raise_error_on_interrupt and self._raise_error_warning_msg is not None:
response = Menu(self._raise_error_warning_msg, Menu.yes_no(), skip=False).run()
if response.value == Menu.no():
return self.run()
elif selection.type_ is MenuSelectionType.Skip:
if not self._skip:
system('clear')
return self.run()
if selection.type_ == MenuSelectionType.Selection:
if selection.value == self.back():
selection.type_ = MenuSelectionType.Skip
selection.value = None
Menu._menu_is_active = False
return selection
def set_cursor_pos(self,pos :int) -> None:
if pos and 0 < pos < len(self._menu_entries):
self._view.active_menu_index = pos
else:
self._view.active_menu_index = 0 # we define a default
def set_cursor_pos_entry(self,value :str) -> None:
pos = self._menu_entries.index(value)
self.set_cursor_pos(pos)
def _determine_cursor_pos(
self,
preset: Optional[List[str]] = None,
cursor_index: Optional[int] = None
) -> Optional[int]:
"""
The priority order to determine the cursor position is:
1. A static cursor position was provided
2. Preset values have been provided so the cursor will be
positioned on those
3. A default value for a selection is given so the cursor
will be placed on such
"""
if cursor_index:
return cursor_index
if preset:
indexes = []
for p in preset:
try:
# the options of the table selection menu
# are already escaped so we have to escape
# the preset values as well for the comparison
if '|' in p:
p = p.replace('|', '\\|')
if p in self._menu_options:
idx = self._menu_options.index(p)
else:
idx = self._menu_options.index(self._default_menu_value)
indexes.append(idx)
except (IndexError, ValueError):
debug(f'Error finding index of {p}: {self._menu_options}')
if len(indexes) == 0:
indexes.append(0)
return indexes[0]
if self._default_option:
return self._menu_options.index(self._default_menu_value)
return None