index : archinstall32 | |
Archlinux32 installer | gitolite user |
summaryrefslogtreecommitdiff |
author | Andreas Baumann <mail@andreasbaumann.cc> | 2022-05-28 10:36:38 +0200 |
---|---|---|
committer | Andreas Baumann <mail@andreasbaumann.cc> | 2022-05-28 10:36:38 +0200 |
commit | faf925de1882be722d2994d697a802918282e509 (patch) | |
tree | 4856c76b10b36e94875ce3c9add961960bb23bf0 /archinstall/lib/menu | |
parent | 3801bee921d22e23435c781c469d9ec0adfa00bd (diff) | |
parent | 78449f75bc44f0e2b03cb9d909b9b78e4f7ca4c8 (diff) |
-rw-r--r-- | archinstall/lib/menu/global_menu.py | 264 | ||||
-rw-r--r-- | archinstall/lib/menu/list_manager.py | 97 | ||||
-rw-r--r-- | archinstall/lib/menu/menu.py | 159 | ||||
-rw-r--r-- | archinstall/lib/menu/selection_menu.py | 121 | ||||
-rw-r--r-- | archinstall/lib/menu/simple_menu.py | 15 |
diff --git a/archinstall/lib/menu/global_menu.py b/archinstall/lib/menu/global_menu.py index afccd45b..5cb27cab 100644 --- a/archinstall/lib/menu/global_menu.py +++ b/archinstall/lib/menu/global_menu.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Any, List, Optional, Union +from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING + +import archinstall from ..menu import Menu from ..menu.selection_menu import Selector, GeneralMenu @@ -8,8 +10,7 @@ from ..general import SysCommand, secret from ..hardware import has_uefi from ..models import NetworkConfiguration from ..storage import storage -from ..output import log -from ..profiles import is_desktop_profile +from ..profiles import is_desktop_profile, Profile from ..disk import encrypted_partitions from ..user_interaction import get_password, ask_for_a_timezone, save_config @@ -20,7 +21,6 @@ from ..user_interaction import ask_hostname from ..user_interaction import ask_for_audio_selection from ..user_interaction import ask_additional_packages_to_install from ..user_interaction import ask_to_configure_network -from ..user_interaction import ask_for_superuser_account from ..user_interaction import ask_for_additional_users from ..user_interaction import select_language from ..user_interaction import select_mirror_regions @@ -32,126 +32,147 @@ from ..user_interaction import select_encrypted_partitions from ..user_interaction import select_harddrives from ..user_interaction import select_profile from ..user_interaction import select_additional_repositories +from ..models.users import User +from ..user_interaction.partitioning_conf import current_partition_layout +from ..output import FormattedOutput + +if TYPE_CHECKING: + _: Any + class GlobalMenu(GeneralMenu): def __init__(self,data_store): - super().__init__(data_store=data_store, auto_cursor=True) + super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) def _setup_selection_menu_options(self): # archinstall.Language will not use preset values self._menu_options['archinstall-language'] = \ Selector( - _('Select Archinstall language'), - lambda x: self._select_archinstall_language('English'), + _('Archinstall language'), + lambda x: self._select_archinstall_language(x), default='English') self._menu_options['keyboard-layout'] = \ - Selector(_('Select keyboard layout'), lambda preset: select_language('us',preset), default='us') + Selector( + _('Keyboard layout'), + lambda preset: select_language(preset), + default='us') self._menu_options['mirror-region'] = \ Selector( - _('Select mirror region'), - select_mirror_regions, + _('Mirror region'), + lambda preset: select_mirror_regions(preset), display_func=lambda x: list(x.keys()) if x else '[]', default={}) self._menu_options['sys-language'] = \ - Selector(_('Select locale language'), lambda preset: select_locale_lang('en_US',preset), default='en_US') + Selector( + _('Locale language'), + lambda preset: select_locale_lang(preset), + default='en_US') self._menu_options['sys-encoding'] = \ - Selector(_('Select locale encoding'), lambda preset: select_locale_enc('utf-8',preset), default='utf-8') + Selector( + _('Locale encoding'), + lambda preset: select_locale_enc(preset), + default='UTF-8') self._menu_options['harddrives'] = \ Selector( - _('Select harddrives'), - self._select_harddrives) + _('Drive(s)'), + lambda preset: self._select_harddrives(preset), + display_func=lambda x: f'{len(x)} ' + str(_('Drive(s)')) if x is not None and len(x) > 0 else '', + preview_func=self._prev_harddrives, + ) self._menu_options['disk_layouts'] = \ Selector( - _('Select disk layout'), - lambda x: select_disk_layout( + _('Disk layout'), + lambda preset: select_disk_layout( + preset, storage['arguments'].get('harddrives', []), storage['arguments'].get('advanced', False) ), + preview_func=self._prev_disk_layouts, + display_func=lambda x: self._display_disk_layout(x), dependencies=['harddrives']) self._menu_options['!encryption-password'] = \ Selector( - _('Set encryption password'), + _('Encryption password'), lambda x: self._select_encrypted_password(), display_func=lambda x: secret(x) if x else 'None', dependencies=['harddrives']) + self._menu_options['HSM'] = Selector( + description=_('Use HSM to unlock encrypted drive'), + func=lambda preset: self._select_hsm(preset), + dependencies=['!encryption-password'], + default=None + ) self._menu_options['swap'] = \ Selector( - _('Use swap'), + _('Swap'), lambda preset: ask_for_swap(preset), default=True) self._menu_options['bootloader'] = \ Selector( - _('Select bootloader'), + _('Bootloader'), lambda preset: ask_for_bootloader(storage['arguments'].get('advanced', False),preset), default="systemd-bootctl" if has_uefi() else "grub-install") self._menu_options['hostname'] = \ Selector( - _('Specify hostname'), + _('Hostname'), ask_hostname, default='archlinux') # root password won't have preset value self._menu_options['!root-password'] = \ Selector( - _('Set root password'), + _('Root password'), lambda preset:self._set_root_password(), display_func=lambda x: secret(x) if x else 'None') - self._menu_options['!superusers'] = \ - Selector( - _('Specify superuser account'), - lambda preset: self._create_superuser_account(), - default={}, - exec_func=lambda n,v:self._users_resynch(), - dependencies_not=['!root-password'], - display_func=lambda x: self._display_superusers()) self._menu_options['!users'] = \ Selector( - _('Specify user account'), - lambda x: self._create_user_account(), + _('User account'), + lambda x: self._create_user_account(x), default={}, - exec_func=lambda n,v:self._users_resynch(), - display_func=lambda x: list(x.keys()) if x else '[]') + display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else None, + preview_func=self._prev_users) self._menu_options['profile'] = \ Selector( - _('Specify profile'), - lambda x: self._select_profile(), - display_func=lambda x: x if x else 'None') + _('Profile'), + lambda preset: self._select_profile(preset), + display_func=lambda x: x if x else 'None' + ) self._menu_options['audio'] = \ Selector( - _('Select audio'), + _('Audio'), lambda preset: ask_for_audio_selection(is_desktop_profile(storage['arguments'].get('profile', None)),preset), display_func=lambda x: x if x else 'None', default=None ) self._menu_options['kernels'] = \ Selector( - _('Select kernels'), + _('Kernels'), lambda preset: select_kernel(preset), default=['linux']) self._menu_options['packages'] = \ Selector( - _('Additional packages to install'), + _('Additional packages'), # lambda x: ask_additional_packages_to_install(storage['arguments'].get('packages', None)), ask_additional_packages_to_install, default=[]) self._menu_options['additional-repositories'] = \ Selector( - _('Additional repositories to enable'), + _('Optional repositories'), select_additional_repositories, default=[]) self._menu_options['nic'] = \ Selector( - _('Configure network'), + _('Network configuration'), ask_to_configure_network, display_func=lambda x: self._prev_network_configuration(x), default={}) self._menu_options['timezone'] = \ Selector( - _('Select timezone'), + _('Timezone'), lambda preset: ask_for_a_timezone(preset), default='UTC') self._menu_options['ntp'] = \ Selector( - _('Set automatic time sync (NTP)'), + _('Automatic time sync (NTP)'), lambda preset: self._select_ntp(preset), default=True) self._menu_options['__separator__'] = \ @@ -172,7 +193,7 @@ class GlobalMenu(GeneralMenu): def _update_install_text(self, name :str = None, result :Any = None): text = self._install_text() - self._menu_options.get('install').update_description(text) + self._menu_options['install'].update_description(text) def post_callback(self,name :str = None ,result :Any = None): self._update_install_text(name, result) @@ -182,14 +203,21 @@ class GlobalMenu(GeneralMenu): # If no partitions was marked as encrypted, but a password was supplied and we have some disks to format.. # Then we need to identify which partitions to encrypt. This will default to / (root). if len(list(encrypted_partitions(storage['arguments'].get('disk_layouts', [])))) == 0: - storage['arguments']['disk_layouts'] = select_encrypted_partitions( - storage['arguments']['disk_layouts'], storage['arguments']['!encryption-password']) + for blockdevice in storage['arguments']['disk_layouts']: + for partition_index in select_encrypted_partitions( + title="Select which partitions to encrypt:", + partitions=storage['arguments']['disk_layouts'][blockdevice]['partitions'] + ): + + partition = storage['arguments']['disk_layouts'][blockdevice]['partitions'][partition_index] + partition['encrypted'] = True + partition['!password'] = storage['arguments']['!encryption-password'] def _install_text(self): missing = len(self._missing_configs()) if missing > 0: return _('Install ({} config(s) missing)').format(missing) - return 'Install' + return _('Install') def _prev_network_configuration(self, cur_value: Union[NetworkConfiguration, List[NetworkConfiguration]]) -> str: if not cur_value: @@ -201,6 +229,35 @@ class GlobalMenu(GeneralMenu): else: return str(cur_value) + def _prev_harddrives(self) -> Optional[str]: + selector = self._menu_options['harddrives'] + if selector.has_selection(): + drives = selector.current_selection + return '\n\n'.join([d.display_info for d in drives]) + return None + + def _prev_disk_layouts(self) -> Optional[str]: + selector = self._menu_options['disk_layouts'] + if selector.has_selection(): + layouts: Dict[str, Dict[str, Any]] = selector.current_selection + + output = '' + for device, layout in layouts.items(): + output += f'{_("Device")}: {device}\n\n' + output += current_partition_layout(layout['partitions'], with_title=False) + output += '\n\n' + + return output.rstrip() + + return None + + def _display_disk_layout(self, current_value: Optional[Dict[str, Any]]) -> str: + if current_value: + total_partitions = [entry['partitions'] for entry in current_value.values()] + total_nr = sum([len(p) for p in total_partitions]) + return f'{total_nr} {_("Partitions")}' + return '' + def _prev_install_missing_config(self) -> Optional[str]: if missing := self._missing_configs(): text = str(_('Missing configurations:\n')) @@ -209,31 +266,42 @@ class GlobalMenu(GeneralMenu): return text[:-1] # remove last new line return None + def _prev_users(self) -> Optional[str]: + selector = self._menu_options['!users'] + if selector.has_selection(): + users: List[User] = selector.current_selection + return FormattedOutput.as_table(users) + return None + def _missing_configs(self) -> List[str]: def check(s): return self._menu_options.get(s).has_selection() + def has_superuser() -> bool: + users = self._menu_options['!users'].current_selection + return any([u.sudo for u in users]) + missing = [] if not check('bootloader'): missing += ['Bootloader'] if not check('hostname'): missing += ['Hostname'] - if not check('!root-password') and not check('!superusers'): - missing += [str(_('Either root-password or at least 1 superuser must be specified'))] + if not check('!root-password') and not has_superuser(): + missing += [str(_('Either root-password or at least 1 user with sudo privileges must be specified'))] if not check('harddrives'): missing += ['Hard drives'] if check('harddrives'): - if not self._menu_options.get('harddrives').is_empty() and not check('disk_layouts'): + if not self._menu_options['harddrives'].is_empty() and not check('disk_layouts'): missing += ['Disk layout'] return missing - def _set_root_password(self): + def _set_root_password(self) -> Optional[str]: prompt = str(_('Enter root password (leave blank to disable root): ')) password = get_password(prompt=prompt) return password - def _select_encrypted_password(self): + def _select_encrypted_password(self) -> Optional[str]: if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): return passwd else: @@ -247,59 +315,75 @@ class GlobalMenu(GeneralMenu): return ntp - def _select_harddrives(self, old_harddrives : list) -> list: - # old_haddrives = storage['arguments'].get('harddrives', []) + def _select_harddrives(self, old_harddrives : list) -> List: harddrives = select_harddrives(old_harddrives) - # in case the harddrives got changed we have to reset the disk layout as well - if old_harddrives != harddrives: - self._menu_options.get('disk_layouts').set_current_selection(None) - storage['arguments']['disk_layouts'] = {} - - if not harddrives: + if len(harddrives) == 0: prompt = _( "You decided to skip harddrive selection\nand will use whatever drive-setup is mounted at {} (experimental)\n" "WARNING: Archinstall won't check the suitability of this setup\n" "Do you wish to continue?" ).format(storage['MOUNT_POINT']) - choice = Menu(prompt, ['yes', 'no'], default_option='yes').run() + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), skip=False).run() - if choice == 'no': - exit(1) + if choice.value == Menu.no(): + return self._select_harddrives(old_harddrives) + + # in case the harddrives got changed we have to reset the disk layout as well + if old_harddrives != harddrives: + self._menu_options['disk_layouts'].set_current_selection(None) + storage['arguments']['disk_layouts'] = {} return harddrives - def _select_profile(self): - profile = select_profile() + def _select_profile(self, preset): + profile = select_profile(preset) + ret = None + + if profile is None: + if any([ + archinstall.storage.get('profile_minimal', False), + archinstall.storage.get('_selected_servers', None), + archinstall.storage.get('_desktop_profile', None), + archinstall.arguments.get('desktop-environment', None), + archinstall.arguments.get('gfx_driver_packages', None) + ]): + return preset + else: # ctrl+c was actioned and all profile settings have been reset + return None + + servers = archinstall.storage.get('_selected_servers', []) + desktop = archinstall.storage.get('_desktop_profile', None) + desktop_env = archinstall.arguments.get('desktop-environment', None) + gfx_driver = archinstall.arguments.get('gfx_driver_packages', None) # Check the potentially selected profiles preparations to get early checks if some additional questions are needed. if profile and profile.has_prep_function(): namespace = f'{profile.namespace}.py' with profile.load_instructions(namespace=namespace) as imported: - if not imported._prep_function(): - log(' * Profile\'s preparation requirements was not fulfilled.', fg='red') - exit(1) - - return profile - - def _create_superuser_account(self): - superusers = ask_for_superuser_account(str(_('Manage superuser accounts: '))) - return superusers if superusers else None - - def _create_user_account(self): - users = ask_for_additional_users(str(_('Manage ordinary user accounts: '))) + if imported._prep_function(servers=servers, desktop=desktop, desktop_env=desktop_env, gfx_driver=gfx_driver): + ret: Profile = profile + + match ret.name: + case 'minimal': + reset = ['_selected_servers', '_desktop_profile', 'desktop-environment', 'gfx_driver_packages'] + case 'server': + reset = ['_desktop_profile', 'desktop-environment'] + case 'desktop': + reset = ['_selected_servers'] + case 'xorg': + reset = ['_selected_servers', '_desktop_profile', 'desktop-environment'] + + for r in reset: + archinstall.storage[r] = None + else: + return self._select_profile(preset) + elif profile: + ret = profile + + return ret + + def _create_user_account(self, defined_users: List[User]) -> List[User]: + users = ask_for_additional_users(defined_users=defined_users) return users - - def _display_superusers(self): - superusers = self._data_store.get('!superusers', {}) - - if self._menu_options.get('!root-password').has_selection(): - return list(superusers.keys()) if superusers else '[]' - else: - return list(superusers.keys()) if superusers else '' - - def _users_resynch(self): - self.synch('!superusers') - self.synch('!users') - return False diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 4e6dffbe..cb567093 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -84,12 +84,12 @@ The contents in the base class of this methods serve for a very basic usage, and ``` """ +import copy +from os import system +from typing import Union, Any, TYPE_CHECKING, Dict, Optional from .text_input import TextInput from .menu import Menu -from os import system -from copy import copy -from typing import Union, Any, List, TYPE_CHECKING if TYPE_CHECKING: _: Any @@ -144,82 +144,99 @@ class ListManager: self.bottom_list = [self.confirm_action,self.cancel_action] self.bottom_item = [self.cancel_action] self.base_actions = base_actions if base_actions else [str(_('Add')),str(_('Copy')),str(_('Edit')),str(_('Delete'))] - self.base_data = base_list - self._data = copy(base_list) # as refs, changes are immediate + self._original_data = copy.deepcopy(base_list) + self._data = copy.deepcopy(base_list) # as refs, changes are immediate # default values for the null case - self.target = None + self.target: Optional[Any] = None self.action = self._null_action + if len(self._data) == 0 and self._null_action: - self.exec_action(self._data) + self._data = self.exec_action(self._data) def run(self): while True: - self._data_formatted = self.reformat(self._data) - options = self._data_formatted + [self.separator] + # this will return a dictionary with the key as the menu entry to be displayed + # and the value is the original value from the self._data container + data_formatted = self.reformat(self._data) + options = list(data_formatted.keys()) + options.append(self.separator) + if self._default_action: options += self._default_action + options += self.bottom_list + system('clear') - target = Menu(self._prompt, + + target = Menu( + self._prompt, options, sort=False, clear_screen=False, clear_menu_on_exit=False, header=self.header, - skip_empty_entries=True).run() + skip_empty_entries=True, + skip=False + ).run() - if not target or target in self.bottom_list: + if not target.value or target.value in self.bottom_list: self.action = target break - if target and target == self.separator: - continue - if target and target in self._default_action: - self.action = target - target = None + + if target.value and target.value in self._default_action: + self.action = target.value self.target = None - self.exec_action(self._data) + self._data = self.exec_action(self._data) continue + if isinstance(self._data,dict): - key = list(self._data.keys())[self._data_formatted.index(target)] - self.target = {key: self._data[key]} + data_key = data_formatted[target.value] + key = self._data[data_key] + self.target = {data_key: key} + elif isinstance(self._data, list): + self.target = [d for d in self._data if d == data_formatted[target.value]][0] else: - self.target = self._data[self._data_formatted.index(target)] + self.target = self._data[data_formatted[target.value]] + # Possible enhacement. If run_actions returns false a message line indicating the failure - self.run_actions(target) + self.run_actions(target.value) - if not target or target == self.cancel_action: # TODO dubious - return self.base_data # return the original list + if target.value == self.cancel_action: # TODO dubious + return self._original_data # return the original list else: return self._data def run_actions(self,prompt_data=None): options = self.action_list() + self.bottom_item prompt = _("Select an action for < {} >").format(prompt_data if prompt_data else self.target) - self.action = Menu( + choice = Menu( prompt, options, sort=False, clear_screen=False, clear_menu_on_exit=False, preset_values=self.bottom_item, - show_search_hint=False).run() - if not self.action or self.action == self.cancel_action: - return False - else: - return self.exec_action(self._data) + show_search_hint=False + ).run() + + self.action = choice.value + + if self.action and self.action != self.cancel_action: + self._data = self.exec_action(self._data) + """ The following methods are expected to be overwritten by the user if the needs of the list are beyond the simple case """ - def reformat(self, data: Any) -> List[Any]: + def reformat(self, data: Any) -> Dict[str, Any]: """ method to get the data in a format suitable to be shown It is executed once for run loop and processes the whole self._data structure """ if isinstance(data,dict): - return list(map(lambda x:f"{x} : {data[x]}",data)) + return {f'{k}: {v}': k for k, v in data.items()} else: - return list(map(lambda x:str(x),data)) + return {str(k): k for k in data} def action_list(self): """ @@ -238,18 +255,18 @@ class ListManager: # TODO guarantee unicity if isinstance(self._data,list): if self.action == str(_('Add')): - self.target = TextInput(_('Add :'),None).run() + self.target = TextInput(_('Add: '),None).run() self._data.append(self.target) if self.action == str(_('Copy')): while True: - target = TextInput(_('Copy to :'),self.target).run() + target = TextInput(_('Copy to: '),self.target).run() if target != self.target: self._data.append(self.target) break elif self.action == str(_('Edit')): tgt = self.target idx = self._data.index(self.target) - result = TextInput(_('Edite :'),tgt).run() + result = TextInput(_('Edit: '),tgt).run() self._data[idx] = result elif self.action == str(_('Delete')): del self._data[self._data.index(self.target)] @@ -261,8 +278,8 @@ class ListManager: origkey = None origval = None if self.action == str(_('Add')): - key = TextInput(_('Key :'),None).run() - value = TextInput(_('Value :'),None).run() + key = TextInput(_('Key: '),None).run() + value = TextInput(_('Value: '),None).run() self._data[key] = value if self.action == str(_('Copy')): while True: @@ -271,7 +288,9 @@ class ListManager: self._data[key] = origval break elif self.action == str(_('Edit')): - value = TextInput(_(f'Edit {origkey} :'),origval).run() + value = TextInput(_('Edit {}: ').format(origkey), origval).run() self._data[origkey] = value elif self.action == str(_('Delete')): del self._data[origkey] + + return self._data diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index d254e0f9..c34814eb 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -1,4 +1,6 @@ -from typing import Dict, List, Union, Any, TYPE_CHECKING +from dataclasses import dataclass +from enum import Enum, auto +from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional from archinstall.lib.menu.simple_menu import TerminalMenu @@ -12,21 +14,49 @@ import logging if TYPE_CHECKING: _: Any + +class MenuSelectionType(Enum): + Selection = auto() + Esc = auto() + Ctrl_c = auto() + + +@dataclass +class MenuSelection: + type_: MenuSelectionType + value: Optional[Union[str, List[str]]] = None + + class Menu(TerminalMenu): + + @classmethod + def yes(cls): + return str(_('yes')) + + @classmethod + def no(cls): + return str(_('no')) + + @classmethod + def yes_no(cls): + 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 :str = None, + default_option : Optional[str] = None, sort :bool = True, preset_values :Union[str, List[str]] = None, - cursor_index :int = None, + cursor_index : Optional[int] = None, preview_command=None, preview_size=0.75, preview_title='Info', header :Union[List[str],str] = None, + explode_on_interrupt :bool = False, + explode_warning :str = '', **kwargs ): """ @@ -66,9 +96,15 @@ class Menu(TerminalMenu): :param preview_title: Title of the preview window :type preview_title: str - param: header one or more header lines for the menu + param header: one or more header lines for the menu type param: string or list + param explode_on_interrupt: This will explicitly handle a ctrl+c instead and return that specific state + type param: bool + + param explode_warning: If explode_on_interrupt is True and this is non-empty, there will be a warning with a user confirmation displayed + type param: str + :param kwargs : any SimpleTerminal parameter """ # we guarantee the inmutability of the options outside the class. @@ -85,6 +121,8 @@ class Menu(TerminalMenu): log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError("Menu() requires an iterable as option.") + self._default_str = str(_('(default)')) + if isinstance(p_options,dict): options = list(p_options.keys()) else: @@ -103,27 +141,40 @@ class Menu(TerminalMenu): if sort: options = sorted(options) - self.menu_options = options - self.skip = skip - self.default_option = default_option - self.multi = multi + self._menu_options = options + self._skip = skip + self._default_option = default_option + self._multi = multi + self._explode_on_interrupt = explode_on_interrupt + self._explode_warning = explode_warning + menu_title = f'\n{title}\n\n' + if header: - separator = '\n ' if not isinstance(header,(list,tuple)): - header = [header,] - if skip: - menu_title += str(_("Use ESC to skip\n")) - menu_title += separator + separator.join(header) - elif skip: - menu_title += str(_("Use ESC to skip\n\n")) + header = [header] + header = '\n'.join(header) + menu_title += f'\n{header}\n' + + action_info = '' + if skip: + action_info += str(_("Use ESC to skip")) + + if self._explode_on_interrupt: + if len(action_info) > 0: + action_info += '\n' + action_info += str(_('Use CTRL+C to reset current selection\n\n')) + + menu_title += action_info + 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] + default = f'{default_option} {self._default_str}' + self._menu_options = [default] + [o for o in self._menu_options if default_option != o] + + self._preselection(preset_values,cursor_index) - self.preselection(preset_values,cursor_index) cursor = "> " main_menu_cursor_style = ("fg_cyan", "bold") main_menu_style = ("bg_blue", "fg_gray") @@ -131,8 +182,9 @@ class Menu(TerminalMenu): kwargs['clear_screen'] = kwargs.get('clear_screen',True) kwargs['show_search_hint'] = kwargs.get('show_search_hint',True) kwargs['cycle_cursor'] = kwargs.get('cycle_cursor',True) + super().__init__( - menu_entries=self.menu_options, + menu_entries=self._menu_options, title=menu_title, menu_cursor=cursor, menu_cursor_style=main_menu_cursor_style, @@ -146,31 +198,46 @@ class Menu(TerminalMenu): preview_command=preview_command, preview_size=preview_size, preview_title=preview_title, + explode_on_interrupt=self._explode_on_interrupt, + multi_select_select_on_accept=False, **kwargs, ) - def _show(self): - idx = self.show() + def _show(self) -> MenuSelection: + try: + idx = self.show() + except KeyboardInterrupt: + return MenuSelection(type_=MenuSelectionType.Ctrl_c) + + def check_default(elem): + if self._default_option is not None and f'{self._default_option} {self._default_str}' in elem: + return self._default_option + else: + return elem + if idx is not None: if isinstance(idx, (list, tuple)): - return [self.default_option if ' (default)' in self.menu_options[i] else self.menu_options[i] for i in idx] + results = [] + for i in idx: + option = check_default(self._menu_options[i]) + results.append(option) + return MenuSelection(type_=MenuSelectionType.Selection, value=results) else: - selected = self.menu_options[idx] - if ' (default)' in selected and self.default_option: - return self.default_option - return selected + result = check_default(self._menu_options[idx]) + return MenuSelection(type_=MenuSelectionType.Selection, value=result) else: - if self.default_option: - if self.multi: - return [self.default_option] - else: - return self.default_option - return None - - def run(self): + return MenuSelection(type_=MenuSelectionType.Esc) + + def run(self) -> MenuSelection: ret = self._show() - if ret is None and not self.skip: + if ret.type_ == MenuSelectionType.Ctrl_c: + if self._explode_on_interrupt and len(self._explode_warning) > 0: + response = Menu(self._explode_warning, Menu.yes_no(), skip=False).run() + if response.value == Menu.no(): + return self.run() + + if ret.type_ is not MenuSelectionType.Selection and not self._skip: return self.run() return ret @@ -185,15 +252,15 @@ class Menu(TerminalMenu): pos = self._menu_entries.index(value) self.set_cursor_pos(pos) - def preselection(self,preset_values :list = [],cursor_index :int = None): + def _preselection(self,preset_values :Union[str, List[str]] = [], cursor_index : Optional[int] = None): def from_preset_to_cursor(): if preset_values: # if the value is not extant return 0 as cursor index try: if isinstance(preset_values,str): - self.cursor_index = self.menu_options.index(self.preset_values) + self.cursor_index = self._menu_options.index(self.preset_values) else: # should return an error, but this is smoother - self.cursor_index = self.menu_options.index(self.preset_values[0]) + self.cursor_index = self._menu_options.index(self.preset_values[0]) except ValueError: self.cursor_index = 0 @@ -203,13 +270,13 @@ class Menu(TerminalMenu): return self.preset_values = preset_values - if self.default_option: - if isinstance(preset_values,str) and self.default_option == preset_values: - self.preset_values = f"{preset_values} (default)" - elif isinstance(preset_values,(list,tuple)) and self.default_option in preset_values: - idx = preset_values.index(self.default_option) - self.preset_values[idx] = f"{preset_values[idx]} (default)" - if cursor_index is None or not self.multi: + if self._default_option: + if isinstance(preset_values,str) and self._default_option == preset_values: + self.preset_values = f"{preset_values} {self._default_str}" + elif isinstance(preset_values,(list,tuple)) and self._default_option in preset_values: + idx = preset_values.index(self._default_option) + self.preset_values[idx] = f"{preset_values[idx]} {self._default_str}" + if cursor_index is None or not self._multi: from_preset_to_cursor() - if not self.multi: # Not supported by the infraestructure + if not self._multi: # Not supported by the infraestructure self.preset_values = None diff --git a/archinstall/lib/menu/selection_menu.py b/archinstall/lib/menu/selection_menu.py index b2f99423..57e290f1 100644 --- a/archinstall/lib/menu/selection_menu.py +++ b/archinstall/lib/menu/selection_menu.py @@ -2,23 +2,27 @@ from __future__ import annotations import logging import sys +import pathlib from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING -from .menu import Menu +from .menu import Menu, MenuSelectionType from ..locale_helpers import set_keyboard_language from ..output import log from ..translation import Translation +from ..hsm.fido import get_fido2_devices if TYPE_CHECKING: _: Any -def select_archinstall_language(default='English'): + +def select_archinstall_language(preset_value: str) -> Optional[Any]: """ copied from user_interaction/general_conf.py as a temporary measure """ - languages = Translation.get_all_names() - language = Menu(_('Select Archinstall language'), languages, default_option=default).run() - return language + languages = Translation.get_available_lang() + language = Menu(_('Archinstall language'), languages, preset_values=preset_value).run() + return language.value + class Selector: def __init__( @@ -91,6 +95,10 @@ class Selector: self._no_store = no_store @property + def description(self) -> str: + return self._description + + @property def dependencies(self) -> List: return self._dependencies @@ -115,7 +123,7 @@ class Selector: def update_description(self, description :str): self._description = description - def menu_text(self) -> str: + def menu_text(self, padding: int = 0) -> str: if self._description == '': # special menu option for __separator__ return '' @@ -128,14 +136,14 @@ class Selector: current = str(self._current_selection) if current: - padding = 35 - len(str(self._description)) - current = ' ' * padding + f'SET: {current}' - - return f'{self._description} {current}' + padding += 5 + description = str(self._description).ljust(padding, ' ') + current = str(_('set: {}').format(current)) + else: + description = self._description + current = '' - @property - def text(self): - return self.menu_text() + return f'{description} {current}' def set_current_selection(self, current :Optional[str]): self._current_selection = current @@ -262,8 +270,14 @@ class GeneralMenu: return preview() return None + def _get_menu_text_padding(self, entries: List[Selector]): + return max([len(str(selection.description)) for selection in entries]) + def _find_selection(self, selection_name: str) -> Tuple[str, Selector]: - option = [(k, v) for k, v in self._menu_options.items() if v.text.strip() == selection_name.strip()] + enabled_menus = self._menus_to_enable() + padding = self._get_menu_text_padding(list(enabled_menus.values())) + option = [(k, v) for k, v in self._menu_options.items() if v.menu_text(padding).strip() == selection_name.strip()] + if len(option) != 1: raise ValueError(f'Selection not found: {selection_name}') config_name = option[0][0] @@ -275,14 +289,18 @@ class GeneralMenu: # we synch all the options just in case for item in self.list_options(): self.synch(item) - self.post_callback # as all the values can vary i have to exec this callback + + self.post_callback() # as all the values can vary i have to exec this callback cursor_pos = None + while True: # Before continuing, set the preferred keyboard layout/language in the current terminal. # This will just help the user with the next following questions. self._set_kb_language() enabled_menus = self._menus_to_enable() - menu_options = [m.text for m in enabled_menus.values()] + + padding = self._get_menu_text_padding(list(enabled_menus.values())) + menu_options = [m.menu_text(padding) for m in enabled_menus.values()] selection = Menu( _('Set/Modify the below options'), @@ -291,18 +309,31 @@ class GeneralMenu: cursor_index=cursor_pos, preview_command=self._preview_display, preview_size=self.preview_size, - skip_empty_entries=True + skip_empty_entries=True, + skip=False ).run() - if selection and self.auto_cursor: - cursor_pos = menu_options.index(selection) + 1 # before the strip otherwise fails - if cursor_pos >= len(menu_options): - cursor_pos = len(menu_options) - 1 - selection = selection.strip() - if selection: - # if this calls returns false, we exit the menu. We allow for an callback for special processing on realeasing control - if not self._process_selection(selection): - break + if selection.type_ == MenuSelectionType.Selection: + value = selection.value + + if self.auto_cursor: + cursor_pos = menu_options.index(value) + 1 # before the strip otherwise fails + + # in case the new position lands on a "placeholder" we'll skip them as well + while True: + if cursor_pos >= len(menu_options): + cursor_pos = 0 + if len(menu_options[cursor_pos]) > 0: + break + cursor_pos += 1 + + value = value.strip() + + # if this calls returns false, we exit the menu + # we allow for an callback for special processing on realeasing control + if not self._process_selection(value): + break + if not self.is_context_mgr: self.__exit__() @@ -423,15 +454,41 @@ class GeneralMenu: def mandatory_overview(self) -> Tuple[int, int]: mandatory_fields = 0 mandatory_waiting = 0 - for field in self._menu_options: - option = self._menu_options[field] + for field, option in self._menu_options.items(): if option.is_mandatory(): mandatory_fields += 1 if not option.has_selection(): mandatory_waiting += 1 return mandatory_fields, mandatory_waiting - def _select_archinstall_language(self, default_lang): - language = select_archinstall_language(default_lang) - self._translation.activate(language) - return language + def _select_archinstall_language(self, preset_value: str) -> str: + language = select_archinstall_language(preset_value) + if language is not None: + self._translation.activate(language) + return language + + return preset_value + + def _select_hsm(self, preset :Optional[pathlib.Path] = None) -> Optional[pathlib.Path]: + title = _('Select which partitions to mark for formatting:') + title += '\n' + + fido_devices = get_fido2_devices() + + indexes = [] + for index, path in enumerate(fido_devices.keys()): + title += f"{index}: {path} ({fido_devices[path]['manufacturer']} - {fido_devices[path]['product']})" + indexes.append(f"{index}|{fido_devices[path]['product']}") + + title += '\n' + + choice = Menu(title, indexes, multi=False).run() + + match choice.type_: + case MenuSelectionType.Esc: return preset + case MenuSelectionType.Selection: + selection: Any = choice.value + index = int(selection.split('|',1)[0]) + return pathlib.Path(list(fido_devices.keys())[index]) + + return None diff --git a/archinstall/lib/menu/simple_menu.py b/archinstall/lib/menu/simple_menu.py index a0a241bd..947259eb 100644 --- a/archinstall/lib/menu/simple_menu.py +++ b/archinstall/lib/menu/simple_menu.py @@ -596,7 +596,8 @@ class TerminalMenu: 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 + title: Optional[Union[str, Iterable[str]]] = None, + explode_on_interrupt: bool = False ): def extract_shortcuts_menu_entries_and_preview_arguments( entries: Iterable[str], @@ -718,6 +719,7 @@ class TerminalMenu: 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._explode_on_interrupt = explode_on_interrupt self._shortcut_brackets_highlight_style = ( tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else () ) @@ -1538,7 +1540,9 @@ class TerminalMenu: # 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: + except KeyboardInterrupt as e: + if self._explode_on_interrupt: + raise e menu_was_interrupted = True finally: reset_signal_handling() @@ -1842,6 +1846,12 @@ def get_argumentparser() -> argparse.ArgumentParser: ) parser.add_argument("-t", "--title", action="store", dest="title", help="menu title") parser.add_argument( + "--explode-on-interrupt", + action="store_true", + dest="explode_on_interrupt", + help="Instead of quitting the menu, this will raise the KeyboardInterrupt Exception", + ) + 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") @@ -1971,6 +1981,7 @@ def main() -> None: status_bar_below_preview=args.status_bar_below_preview, status_bar_style=args.status_bar_style, title=args.title, + explode_on_interrupt=args.explode_on_interrupt, ) except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e: print(str(e), file=sys.stderr) |