351 lines
9.9 KiB
Python
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
|