diff --git a/.github/ISSUE_TEMPLATE/01_bug.yml b/.github/ISSUE_TEMPLATE/01_bug.yml index 058e7e9e86..ab40754fc9 100644 --- a/.github/ISSUE_TEMPLATE/01_bug.yml +++ b/.github/ISSUE_TEMPLATE/01_bug.yml @@ -24,7 +24,7 @@ body: - type: textarea id: bug-report attributes: - label: The installation log + label: The installation log description: 'note: located at `/var/log/archinstall/install.log`' placeholder: | Hardware model detected: Dell Inc. Precision 7670; UEFI mode: True @@ -82,4 +82,4 @@ body: **Note**: Feel free to modify the textarea above as you wish. But it will grately help us in testing if we can generate the specific qemu command line, for instance via: - `sudo virsh domxml-to-native qemu-argv --domain my-arch-machine` \ No newline at end of file + `sudo virsh domxml-to-native qemu-argv --domain my-arch-machine` diff --git a/.github/ISSUE_TEMPLATE/02_feature.yml b/.github/ISSUE_TEMPLATE/02_feature.yml index 81caea3546..37efd4e114 100644 --- a/.github/ISSUE_TEMPLATE/02_feature.yml +++ b/.github/ISSUE_TEMPLATE/02_feature.yml @@ -14,4 +14,4 @@ body: description: > Feel free to write any feature you think others might benefit from: validations: - required: true \ No newline at end of file + required: true diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index d3ba0f46c5..8b79d1debf 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -20,4 +20,4 @@ jobs: - run: python --version - run: mypy --version - name: run mypy - run: mypy + run: mypy --config-file pyproject.toml diff --git a/.gitignore b/.gitignore index b03bd3a522..be0646552a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ venv requirements.txt /.gitconfig /actions-runner -/cmd_output.txt \ No newline at end of file +/cmd_output.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c481d36cea..8188400965 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,6 +38,9 @@ repos: rev: v1.13.0 hooks: - id: mypy + args: [ + '--config-file=pyproject.toml' + ] fail_fast: true additional_dependencies: - pydantic diff --git a/.pypirc b/.pypirc index 7b926de7bd..f6c546224b 100644 --- a/.pypirc +++ b/.pypirc @@ -3,4 +3,4 @@ index-servers = pypi [pypi] -repository = https://upload.pypi.org/legacy/ \ No newline at end of file +repository = https://upload.pypi.org/legacy/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 02e90da993..781f85f3e4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -12,4 +12,4 @@ sphinx: build: os: "ubuntu-22.04" tools: - python: "3.11" \ No newline at end of file + python: "3.11" diff --git a/archinstall/__init__.py b/archinstall/__init__.py index aec47685cf..d3543adba2 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Dict, Union from .lib import disk -from .lib import menu from .lib import models from .lib import packages from .lib import exceptions @@ -32,6 +31,7 @@ from .lib.translationhandler import TranslationHandler, Language, DeferredTranslation from .lib.plugins import plugins, load_plugin from .lib.configuration import ConfigurationOutput +from .tui import Tui from .lib.general import ( generate_password, locate_binary, clear_vt100_escape_codes, @@ -330,24 +330,6 @@ def main() -> None: importlib.import_module(mod_name) -def _shutdown_curses() -> None: - try: - curses.nocbreak() - - try: - from archinstall.tui.curses_menu import tui - tui.screen.keypad(False) - except Exception: - pass - - curses.echo() - curses.curs_set(True) - curses.endwin() - except Exception: - # this may happen when curses has not been initialized - pass - - def run_as_a_module() -> None: exc = None @@ -357,7 +339,7 @@ def run_as_a_module() -> None: exc = e finally: # restore the terminal to the original state - _shutdown_curses() + Tui.shutdown() if exc: err = ''.join(traceback.format_exception(exc)) diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 493d09a29f..9099c17bd9 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -1,10 +1,14 @@ from typing import Any, TYPE_CHECKING, List, Optional, Dict -from archinstall.lib import menu from archinstall.lib.output import info from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.default_profiles.profile import Profile, ProfileType, SelectResult, GreeterType +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, ResultType, PreviewStyle +) + if TYPE_CHECKING: from archinstall.lib.installer import Installer _: Any @@ -52,22 +56,36 @@ def _do_on_select_profiles(self) -> None: for profile in self.current_selection: profile.do_on_select() - def do_on_select(self) -> SelectResult: - choice = profile_handler.select_profile( - profile_handler.get_desktop_profiles(), - self.current_selection, - title=str(_('Select your desired desktop environment')), - multi=True - ) + def do_on_select(self) -> Optional[SelectResult]: + items = [ + MenuItem( + p.name, + value=p, + preview_action=lambda x: x.value.preview_text() + ) for p in profile_handler.get_desktop_profiles() + ] - match choice.type_: - case menu.MenuSelectionType.Selection: - self.current_selection = choice.value # type: ignore + group = MenuItemGroup(items, sort_items=True) + group.set_selected_by_value(self.current_selection) + + result = SelectMenu( + group, + multi=True, + allow_reset=True, + allow_skip=True, + preview_style=PreviewStyle.RIGHT, + preview_size='auto', + preview_frame=FrameProperties.max('Info') + ).run() + + match result.type_: + case ResultType.Selection: + self.current_selection = result.get_values() self._do_on_select_profiles() return SelectResult.NewSelection - case menu.MenuSelectionType.Skip: + case ResultType.Skip: return SelectResult.SameSelection - case menu.MenuSelectionType.Reset: + case ResultType.Reset: return SelectResult.ResetCurrent def post_install(self, install_session: 'Installer') -> None: diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index 63c3fb60ee..0442aefcbc 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -1,9 +1,12 @@ from enum import Enum from typing import List, Optional, TYPE_CHECKING, Any -from archinstall.default_profiles.profile import ProfileType, GreeterType +from archinstall.default_profiles.profile import ProfileType, GreeterType, SelectResult from archinstall.default_profiles.xorg import XorgProfile -from archinstall.lib.menu import Menu +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, ResultType, Alignment +) if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -49,20 +52,30 @@ def services(self) -> List[str]: def _ask_seat_access(self) -> None: # need to activate seat service and add to seat group - title = str(_('Hyprland needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')) - title += str(_('\n\nChoose an option to give Hyprland access to your hardware')) + header = str(_('Sway needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')) + header += '\n' + str(_('Choose an option to give Sway access to your hardware')) + '\n' - options = [e.value for e in SeatAccess] - default = None + items = [MenuItem(s.value, value=s) for s in SeatAccess] + group = MenuItemGroup(items, sort_items=True) - if seat := self.custom_settings.get('seat_access', None): - default = seat + default = self.custom_settings.get('seat_access', None) + group.set_default_by_value(default) - choice = Menu(title, options, skip=False, preset_values=default).run() - self.custom_settings['seat_access'] = choice.single_value + result = SelectMenu( + group, + header=header, + allow_skip=False, + frame=FrameProperties.min(str(_('Seat access'))), + alignment=Alignment.CENTER + ).run() - def do_on_select(self): + if result.type_ == ResultType.Selection: + if result.item() is not None: + self.custom_settings['seat_access'] = result.get_value() + + def do_on_select(self) -> Optional[SelectResult]: self._ask_seat_access() + return None def install(self, install_session: 'Installer') -> None: super().install(install_session) diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index a1a1c56e23..efc733d12a 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -1,9 +1,13 @@ from enum import Enum from typing import List, Optional, TYPE_CHECKING, Any -from archinstall.default_profiles.profile import ProfileType, GreeterType +from archinstall.default_profiles.profile import ProfileType, GreeterType, SelectResult from archinstall.default_profiles.xorg import XorgProfile -from archinstall.lib.menu import Menu + +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType +) if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -58,20 +62,30 @@ def services(self) -> List[str]: def _ask_seat_access(self) -> None: # need to activate seat service and add to seat group - title = str(_('Sway needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')) - title += str(_('\n\nChoose an option to give Sway access to your hardware')) + header = str(_('Sway needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)')) + header += '\n' + str(_('Choose an option to give Sway access to your hardware')) + '\n' - options = [e.value for e in SeatAccess] - default = None + items = [MenuItem(s.value, value=s) for s in SeatAccess] + group = MenuItemGroup(items, sort_items=True) - if seat := self.custom_settings.get('seat_access', None): - default = seat + default = self.custom_settings.get('seat_access', None) + group.set_default_by_value(default) + + result = SelectMenu( + group, + header=header, + allow_skip=False, + frame=FrameProperties.min(str(_('Seat access'))), + alignment=Alignment.CENTER + ).run() - choice = Menu(title, options, skip=False, preset_values=default).run() - self.custom_settings['seat_access'] = choice.single_value + if result.type_ == ResultType.Selection: + if result.item() is not None: + self.custom_settings['seat_access'] = result.get_value() - def do_on_select(self): + def do_on_select(self) -> Optional[SelectResult]: self._ask_seat_access() + return None def install(self, install_session: 'Installer') -> None: super().install(install_session) diff --git a/archinstall/default_profiles/profile.py b/archinstall/default_profiles/profile.py index 036eb727fc..2b52602ea8 100644 --- a/archinstall/default_profiles/profile.py +++ b/archinstall/default_profiles/profile.py @@ -4,7 +4,6 @@ from enum import Enum, auto from typing import List, Optional, Any, Dict, TYPE_CHECKING -from ..lib.utils.util import format_cols from ..lib.storage import storage if TYPE_CHECKING: @@ -126,7 +125,7 @@ def json(self) -> Dict: """ return {} - def do_on_select(self) -> SelectResult: + def do_on_select(self) -> Optional[SelectResult]: """ Hook that will be called when a profile is selected """ @@ -187,24 +186,20 @@ def preview_text(self) -> Optional[str]: """ return self.packages_text() - def packages_text(self, include_sub_packages: bool = False) -> Optional[str]: - header = str(_('Installed packages')) - - text = '' - packages = [] + def packages_text(self, include_sub_packages: bool = False) -> str: + packages = set() if self.packages: - packages = self.packages + packages = set(self.packages) if include_sub_packages: - for p in self.current_selection: - if p.packages: - packages += p.packages + for sub_profile in self.current_selection: + if sub_profile.packages: + packages.update(sub_profile.packages) - text += format_cols(sorted(set(packages))) + text = str(_('Installed packages')) + ':\n' - if text: - text = f'{header}: \n{text}' - return text + for pkg in sorted(packages): + text += f'\t- {pkg}\n' - return None + return text diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index 7d157cf377..64ef3b0799 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -1,10 +1,14 @@ -from typing import Any, TYPE_CHECKING, List +from typing import Any, TYPE_CHECKING, List, Optional from archinstall.lib.output import info -from archinstall.lib.menu import MenuSelectionType from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.default_profiles.profile import ProfileType, Profile, SelectResult +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, ResultType, PreviewStyle +) + if TYPE_CHECKING: from archinstall.lib.installer import Installer _: Any @@ -19,23 +23,36 @@ def __init__(self, current_value: List[Profile] = []): current_selection=current_value ) - def do_on_select(self) -> SelectResult: - available_servers = profile_handler.get_server_profiles() + def do_on_select(self) -> Optional[SelectResult]: + items = [ + MenuItem( + p.name, + value=p, + preview_action=lambda x: x.value.preview_text() + ) for p in profile_handler.get_server_profiles() + ] + + group = MenuItemGroup(items, sort_items=True) + group.set_selected_by_value(self.current_selection) - choice = profile_handler.select_profile( - available_servers, - self.current_selection, - title=str(_('Choose which servers to install, if none then a minimal installation will be done')), + result = SelectMenu( + group, + allow_reset=True, + allow_skip=True, + preview_style=PreviewStyle.RIGHT, + preview_size='auto', + preview_frame=FrameProperties.max('Info'), multi=True - ) + ).run() - match choice.type_: - case MenuSelectionType.Selection: - self.current_selection = choice.value # type: ignore + match result.type_: + case ResultType.Selection: + selections = result.get_values() + self.current_selection = selections return SelectResult.NewSelection - case MenuSelectionType.Skip: + case ResultType.Skip: return SelectResult.SameSelection - case MenuSelectionType.Reset: + case ResultType.Reset: return SelectResult.ResetCurrent def post_install(self, install_session: 'Installer') -> None: diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 8cac7ffaff..9d8ab6ade8 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -5,10 +5,16 @@ from pathlib import Path from typing import Optional, Dict, Any, TYPE_CHECKING -from .menu import Menu, MenuSelectionType from .storage import storage from .general import JSON, UNSAFE_JSON -from .output import debug, info, warn +from .output import debug, warn +from .utils.util import prompt_dir + +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + PreviewStyle, Orientation, Tui +) if TYPE_CHECKING: _: Any @@ -68,12 +74,35 @@ def user_credentials_to_json(self) -> Optional[str]: return json.dumps(self._user_credentials, indent=4, sort_keys=True, cls=UNSAFE_JSON) return None - def show(self) -> None: - print(_('\nThis is your chosen configuration:')) + def write_debug(self) -> None: debug(" -- Chosen configuration --") - - info(self.user_config_to_json()) - print() + debug(self.user_config_to_json()) + + def confirm_config(self) -> bool: + header = f'{str(_("The specified configuration will be applied"))}. ' + header += str(_('Would you like to continue?')) + '\n' + + with Tui(): + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.yes() + group.set_preview_for_all(lambda x: self.user_config_to_json()) + + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, + allow_skip=False, + preview_size='auto', + preview_style=PreviewStyle.BOTTOM, + preview_frame=FrameProperties.max(str(_('Configuration'))) + ).run() + + if result.item() != MenuItem.yes(): + return False + + return True def _is_valid_path(self, dest_path: Path) -> bool: dest_path_ok = dest_path.exists() and dest_path.is_dir() @@ -105,9 +134,9 @@ def save(self, dest_path: Optional[Path] = None) -> None: self.save_user_creds(dest_path) -def save_config(config: Dict) -> None: - def preview(selection: str) -> Optional[str]: - match options[selection]: +def save_config(config: Dict[str, Any]) -> None: + def preview(item: MenuItem) -> Optional[str]: + match item.value: case "user_config": serialized = config_output.user_config_to_json() return f"{config_output.user_configuration_file}\n{serialized}" @@ -122,60 +151,80 @@ def preview(selection: str) -> Optional[str]: return '\n'.join(output) return None - try: - config_output = ConfigurationOutput(config) - - options = { - str(_("Save user configuration (including disk layout)")): "user_config", - str(_("Save user credentials")): "user_creds", - str(_("Save all")): "all", - } - - save_choice = Menu( - _("Choose which configuration to save"), - list(options), - sort=False, - skip=True, - preview_size=0.75, - preview_command=preview, - ).run() - - if save_choice.type_ == MenuSelectionType.Skip: - return - - readline.set_completer_delims("\t\n=") - readline.parse_and_bind("tab: complete") - while True: - path = input( - _( - "Enter a directory for the configuration(s) to be saved (tab completion enabled)\nSave directory: " - ) - ).strip(" ") - dest_path = Path(path) - if dest_path.exists() and dest_path.is_dir(): - break - info(_("Not a valid directory: {}").format(dest_path), fg="red") - - if not path: + config_output = ConfigurationOutput(config) + + items = [ + MenuItem( + str(_("Save user configuration (including disk layout)")), + value="user_config", + preview_action=lambda x: preview(x) + ), + MenuItem( + str(_("Save user credentials")), + value="user_creds", + preview_action=lambda x: preview(x) + ), + MenuItem( + str(_("Save all")), + value="all", + preview_action=lambda x: preview(x) + ) + ] + + group = MenuItemGroup(items) + result = SelectMenu( + group, + allow_skip=True, + preview_frame=FrameProperties.max(str(_('Configuration'))), + preview_size='auto', + preview_style=PreviewStyle.RIGHT + ).run() + + match result.type_: + case ResultType.Skip: return + case ResultType.Selection: + save_option = result.get_value() + case _: + raise ValueError('Unhandled return type') - prompt = _( - "Do you want to save {} configuration file(s) in the following location?\n\n{}" - ).format(options[str(save_choice.value)], dest_path.absolute()) - - save_confirmation = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run() - if save_confirmation == Menu.no(): - return + readline.set_completer_delims("\t\n=") + readline.parse_and_bind("tab: complete") - debug("Saving {} configuration files to {}".format(options[str(save_choice.value)], dest_path.absolute())) + dest_path = prompt_dir( + str(_('Directory')), + str(_('Enter a directory for the configuration(s) to be saved (tab completion enabled)')) + '\n', + allow_skip=True + ) - match options[str(save_choice.value)]: - case "user_config": - config_output.save_user_config(dest_path) - case "user_creds": - config_output.save_user_creds(dest_path) - case "all": - config_output.save(dest_path) - - except (KeyboardInterrupt, EOFError): + if not dest_path: return + + header = str(_("Do you want to save the configuration file(s) to {}?")).format(dest_path) + + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.yes() + + result = SelectMenu( + group, + header=header, + allow_skip=False, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL + ).run() + + match result.type_: + case ResultType.Selection: + if result.item() == MenuItem.no(): + return + + debug("Saving configuration files to {}".format(dest_path.absolute())) + + match save_option: + case "user_config": + config_output.save_user_config(dest_path) + case "user_creds": + config_output.save_user_creds(dest_path) + case "all": + config_output.save(dest_path) diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 214526e7c4..7f49b4448d 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -7,11 +7,12 @@ ) from ..interactions import select_disk_config from ..interactions.disk_conf import select_lvm_config -from ..menu import ( - Selector, - AbstractSubMenu -) from ..output import FormattedOutput +from ..menu import AbstractSubMenu + +from archinstall.tui import ( + MenuItemGroup, MenuItem +) if TYPE_CHECKING: _: Any @@ -21,37 +22,38 @@ class DiskLayoutConfigurationMenu(AbstractSubMenu): def __init__( self, disk_layout_config: Optional[DiskLayoutConfiguration], - data_store: Dict[str, Any], advanced: bool = False ): self._disk_layout_config = disk_layout_config self._advanced = advanced - - super().__init__(data_store=data_store, preview_size=0.5) - - def setup_selection_menu_options(self) -> None: - self._menu_options['disk_config'] = \ - Selector( - _('Partitioning'), - lambda x: self._select_disk_layout_config(x), - display_func=lambda x: self._display_disk_layout(x), - preview_func=self._prev_disk_layouts, - default=self._disk_layout_config, - enabled=True - ) - self._menu_options['lvm_config'] = \ - Selector( - f'{_('LVM - Logical Volume Management')} (BETA)', - lambda x: self._select_lvm_config(x), - display_func=lambda x: self.defined_text if x else '', - preview_func=self._prev_lvm_config, - default=self._disk_layout_config.lvm_config if self._disk_layout_config else None, + self._data_store: Dict[str, Any] = {} + + menu_optioons = self._define_menu_options() + self._item_group = MenuItemGroup(menu_optioons, sort_items=False, checkmarks=True) + + super().__init__(self._item_group, data_store=self._data_store, allow_reset=True) + + def _define_menu_options(self) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Partitioning')), + action=lambda x: self._select_disk_layout_config(x), + value=self._disk_layout_config, + preview_action=self._prev_disk_layouts, + key='disk_config' + ), + MenuItem( + text='LVM (BETA)', + action=lambda x: self._select_lvm_config(x), + value=self._disk_layout_config.lvm_config if self._disk_layout_config else None, + preview_action=self._prev_lvm_config, dependencies=[self._check_dep_lvm], - enabled=True - ) + key='lvm_config' + ), + ] - def run(self, allow_reset: bool = True) -> Optional[DiskLayoutConfiguration]: - super().run(allow_reset=allow_reset) + def run(self) -> Optional[DiskLayoutConfiguration]: + super().run() disk_layout_config: Optional[DiskLayoutConfiguration] = self._data_store.get('disk_config', None) @@ -61,7 +63,7 @@ def run(self, allow_reset: bool = True) -> Optional[DiskLayoutConfiguration]: return disk_layout_config def _check_dep_lvm(self) -> bool: - disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_item_group.find_by_key('disk_config').value if disk_layout_conf and disk_layout_conf.config_type == DiskLayoutType.Default: return True @@ -75,66 +77,72 @@ def _select_disk_layout_config( disk_config = select_disk_config(preset, advanced_option=self._advanced) if disk_config != preset: - self._menu_options['lvm_config'].set_current_selection(None) + self._menu_item_group.find_by_key('lvm_config').value = None return disk_config def _select_lvm_config(self, preset: Optional[LvmConfiguration]) -> Optional[LvmConfiguration]: - disk_config: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + disk_config: Optional[DiskLayoutConfiguration] = self._item_group.find_by_key('disk_config').value + if disk_config: return select_lvm_config(disk_config, preset=preset) + return preset - def _display_disk_layout(self, current_value: Optional[DiskLayoutConfiguration] = None) -> str: - if current_value: - return current_value.config_type.display_msg() - return '' + def _prev_disk_layouts(self, item: MenuItem) -> Optional[str]: + if not item.value: + return None - def _prev_disk_layouts(self) -> Optional[str]: - disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + disk_layout_conf: DiskLayoutConfiguration = item.get_value() - if disk_layout_conf: - device_mods: List[DeviceModification] = \ - list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications)) + if disk_layout_conf.config_type == DiskLayoutType.Pre_mount: + msg = str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg()) + '\n' + msg += str(_('Mountpoint')) + ': ' + str(disk_layout_conf.mountpoint) + return msg - if device_mods: - output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg()) - output_btrfs = '' + device_mods: List[DeviceModification] = \ + list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications)) - for mod in device_mods: - # create partition table - partition_table = FormattedOutput.as_table(mod.partitions) + if device_mods: + output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg()) + output_btrfs = '' - output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n' - output_partition += partition_table + '\n' + for mod in device_mods: + # create partition table + partition_table = FormattedOutput.as_table(mod.partitions) - # create btrfs table - btrfs_partitions = list( - filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions) - ) - for partition in btrfs_partitions: - output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n' + output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n' + output_partition += partition_table + '\n' - output = output_partition + output_btrfs - return output.rstrip() + # create btrfs table + btrfs_partitions = list( + filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions) + ) + for partition in btrfs_partitions: + output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n' + + output = output_partition + output_btrfs + return output.rstrip() return None - def _prev_lvm_config(self) -> Optional[str]: - lvm_config: Optional[LvmConfiguration] = self._menu_options['lvm_config'].current_selection + def _prev_lvm_config(self, item: MenuItem) -> Optional[str]: + if not item.value: + return None + + lvm_config: LvmConfiguration = item.value - if lvm_config: - output = '{}: {}\n'.format(str(_('Configuration')), lvm_config.config_type.display_msg()) + output = '{}: {}\n'.format(str(_('Configuration')), lvm_config.config_type.display_msg()) - for vol_gp in lvm_config.vol_groups: - pv_table = FormattedOutput.as_table(vol_gp.pvs) - output += '{}:\n{}'.format(str(_('Physical volumes')), pv_table) + for vol_gp in lvm_config.vol_groups: + pv_table = FormattedOutput.as_table(vol_gp.pvs) + output += '{}:\n{}'.format(str(_('Physical volumes')), pv_table) - output += f'\nVolume Group: {vol_gp.name}' + output += f'\nVolume Group: {vol_gp.name}' - lvm_volumes = FormattedOutput.as_table(vol_gp.volumes) - output += '\n\n{}:\n{}'.format(str(_('Volumes')), lvm_volumes) + lvm_volumes = FormattedOutput.as_table(vol_gp.volumes) + output += '\n\n{}:\n{}'.format(str(_('Volumes')), lvm_volumes) - return output + return output return None diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index ccc1baceac..24c4937cab 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -9,17 +9,17 @@ DiskEncryption, EncryptionType ) -from ..menu import ( - Selector, - AbstractSubMenu, - MenuSelectionType, - TableMenu -) -from ..interactions.utils import get_password -from ..menu import Menu -from ..general import secret +from ..menu import AbstractSubMenu from .fido import Fido2Device, Fido2 from ..output import FormattedOutput +from ..utils.util import get_password + +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType +) +from archinstall.lib.menu.menu_helper import MenuHelper + if TYPE_CHECKING: _: Any @@ -29,7 +29,6 @@ class DiskEncryptionMenu(AbstractSubMenu): def __init__( self, disk_config: DiskLayoutConfiguration, - data_store: Dict[str, Any], preset: Optional[DiskEncryption] = None ): if preset: @@ -37,57 +36,56 @@ def __init__( else: self._preset = DiskEncryption() + self._data_store: Dict[str, Any] = {} self._disk_config = disk_config - super().__init__(data_store=data_store) - - def setup_selection_menu_options(self) -> None: - self._menu_options['encryption_type'] = \ - Selector( - _('Encryption type'), - func=lambda preset: select_encryption_type(self._disk_config, preset), - display_func=lambda x: EncryptionType.type_to_text(x) if x else None, - default=self._preset.encryption_type, - enabled=True, - ) - self._menu_options['encryption_password'] = \ - Selector( - _('Encryption password'), - lambda x: select_encrypted_password(), + + menu_optioons = self._define_menu_options() + self._item_group = MenuItemGroup(menu_optioons, sort_items=False, checkmarks=True) + + super().__init__(self._item_group, data_store=self._data_store, allow_reset=True) + + def _define_menu_options(self) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Encryption type')), + action=lambda x: select_encryption_type(self._disk_config, x), + value=self._preset.encryption_type, + preview_action=self._preview, + key='encryption_type' + ), + MenuItem( + text=str(_('Encryption password')), + action=lambda x: select_encrypted_password(), + value=self._preset.encryption_password, dependencies=[self._check_dep_enc_type], - display_func=lambda x: secret(x) if x else '', - default=self._preset.encryption_password, - enabled=True - ) - self._menu_options['partitions'] = \ - Selector( - _('Partitions'), - func=lambda preset: select_partitions_to_encrypt(self._disk_config.device_modifications, preset), - display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None, + preview_action=self._preview, + key='encryption_password' + ), + MenuItem( + text=str(_('Partitions')), + action=lambda x: select_partitions_to_encrypt(self._disk_config.device_modifications, x), + value=self._preset.partitions, dependencies=[self._check_dep_partitions], - default=self._preset.partitions, - preview_func=self._prev_partitions, - enabled=True - ) - self._menu_options['lvm_vols'] = \ - Selector( - _('LVM volumes'), - func=lambda preset: self._select_lvm_vols(preset), - display_func=lambda x: f'{len(x)} {_("LVM volumes")}' if x else None, + preview_action=self._preview, + key='partitions' + ), + MenuItem( + text=str(_('LVM volumes')), + action=lambda x: self._select_lvm_vols(x), + value=self._preset.lvm_volumes, dependencies=[self._check_dep_lvm_vols], - default=self._preset.lvm_volumes, - preview_func=self._prev_lvm_vols, - enabled=True - ) - self._menu_options['HSM'] = \ - Selector( - description=_('Use HSM to unlock encrypted drive'), - func=lambda preset: select_hsm(preset), - display_func=lambda x: self._display_hsm(x), - preview_func=self._prev_hsm, + preview_action=self._preview, + key='lvm_vols' + ), + MenuItem( + text=str(_('HSM')), + action=lambda x: select_hsm(x), + value=self._preset.hsm_device, dependencies=[self._check_dep_enc_type], - default=self._preset.hsm_device, - enabled=True - ) + preview_action=self._preview, + key='HSM' + ), + ] def _select_lvm_vols(self, preset: List[LvmVolume]) -> List[LvmVolume]: if self._disk_config.lvm_config: @@ -95,30 +93,34 @@ def _select_lvm_vols(self, preset: List[LvmVolume]) -> List[LvmVolume]: return [] def _check_dep_enc_type(self) -> bool: - enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + enc_type: Optional[EncryptionType] = self._item_group.find_by_key('encryption_type').value if enc_type and enc_type != EncryptionType.NoEncryption: return True return False def _check_dep_partitions(self) -> bool: - enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + enc_type: Optional[EncryptionType] = self._item_group.find_by_key('encryption_type').value if enc_type and enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks]: return True return False def _check_dep_lvm_vols(self) -> bool: - enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + enc_type: Optional[EncryptionType] = self._item_group.find_by_key('encryption_type').value if enc_type and enc_type == EncryptionType.LuksOnLvm: return True return False - def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]: - super().run(allow_reset=allow_reset) + def run(self) -> Optional[DiskEncryption]: + super().run() + + enc_type: Optional[EncryptionType] = self._item_group.find_by_key('encryption_type').value + enc_password: Optional[str] = self._item_group.find_by_key('encryption_password').value + enc_partitions = self._item_group.find_by_key('partitions').value + enc_lvm_vols = self._item_group.find_by_key('lvm_vols').value - enc_type = self._data_store.get('encryption_type', None) - enc_password = self._data_store.get('encryption_password', None) - enc_partitions = self._data_store.get('partitions', None) - enc_lvm_vols = self._data_store.get('lvm_vols', None) + assert enc_type is not None + assert enc_partitions is not None + assert enc_lvm_vols is not None if enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and enc_partitions: enc_lvm_vols = [] @@ -137,14 +139,50 @@ def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]: return None - def _display_hsm(self, device: Optional[Fido2Device]) -> Optional[str]: - if device: - return device.manufacturer + def _preview(self, item: MenuItem) -> Optional[str]: + output = '' + + if (enc_type := self._prev_type()) is not None: + output += enc_type + + if (enc_pwd := self._prev_password()) is not None: + output += f'\n{enc_pwd}' + + if (fido_device := self._prev_hsm()) is not None: + output += f'\n{fido_device}' + + if (partitions := self._prev_partitions()) is not None: + output += f'\n\n{partitions}' + + if (lvm := self._prev_lvm_vols()) is not None: + output += f'\n\n{lvm}' + + if not output: + return None + + return output + + def _prev_type(self) -> Optional[str]: + enc_type = self._item_group.find_by_key('encryption_type').value + + if enc_type: + enc_text = EncryptionType.type_to_text(enc_type) + return f'{str(_("Encryption type"))}: {enc_text}' + + return None + + def _prev_password(self) -> Optional[str]: + enc_pwd = self._item_group.find_by_key('encryption_password').value + + if enc_pwd: + pwd_text = '*' * len(enc_pwd) + return f'{str(_("Encryption password"))}: {pwd_text}' return None def _prev_partitions(self) -> Optional[str]: - partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection + partitions: Optional[List[PartitionModification]] = self._item_group.find_by_key('partitions').value + if partitions: output = str(_('Partitions to be encrypted')) + '\n' output += FormattedOutput.as_table(partitions) @@ -153,7 +191,8 @@ def _prev_partitions(self) -> Optional[str]: return None def _prev_lvm_vols(self) -> Optional[str]: - volumes: Optional[List[PartitionModification]] = self._menu_options['lvm_vols'].current_selection + volumes: Optional[List[PartitionModification]] = self._item_group.find_by_key('lvm_vols').value + if volumes: output = str(_('LVM volumes to be encrypted')) + '\n' output += FormattedOutput.as_table(volumes) @@ -162,51 +201,57 @@ def _prev_lvm_vols(self) -> Optional[str]: return None def _prev_hsm(self) -> Optional[str]: - try: - Fido2.get_fido2_devices() - except ValueError: - return str(_('Unable to determine fido2 devices. Is libfido2 installed?')) + fido_device: Optional[Fido2Device] = self._item_group.find_by_key('HSM').value - fido_device: Optional[Fido2Device] = self._menu_options['HSM'].current_selection + if not fido_device: + return None - if fido_device: - output = '{}: {}'.format(str(_('Path')), fido_device.path) - output += '{}: {}'.format(str(_('Manufacturer')), fido_device.manufacturer) - output += '{}: {}'.format(str(_('Product')), fido_device.product) - return output - - return None + output = str(fido_device.path) + output += f' ({fido_device.manufacturer}, {fido_device.product})' + return f'{str(_("HSM device"))}: {output}' def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: EncryptionType) -> Optional[EncryptionType]: - title = str(_('Select disk encryption option')) + options: List[EncryptionType] = [] + preset_value = EncryptionType.type_to_text(preset) if disk_config.lvm_config: - options = [ - EncryptionType.type_to_text(EncryptionType.LvmOnLuks), - EncryptionType.type_to_text(EncryptionType.LuksOnLvm) - ] + options = [EncryptionType.LvmOnLuks, EncryptionType.LuksOnLvm] else: - options = [EncryptionType.type_to_text(EncryptionType.Luks)] + options = [EncryptionType.Luks] - preset_value = EncryptionType.type_to_text(preset) + items = [MenuItem(EncryptionType.type_to_text(o), value=o) for o in options] + group = MenuItemGroup(items) + group.set_focus_by_value(preset_value) - choice = Menu(title, options, preset_values=preset_value).run() + result = SelectMenu( + group, + allow_skip=True, + allow_reset=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Encryption type'))) + ).run() - match choice.type_: - case MenuSelectionType.Reset: return None - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return EncryptionType.text_to_type(choice.value) # type: ignore + match result.type_: + case ResultType.Reset: return None + case ResultType.Skip: return preset + case ResultType.Selection: + return result.get_value() def select_encrypted_password() -> Optional[str]: - if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): - return passwd - return None + header = str(_('Enter disk encryption password (leave blank for no encryption)')) + '\n' + password = get_password( + text=str(_('Disk encryption password')), + header=header, + allow_skip=True + ) + + return password def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: - title = _('Select a FIDO2 device to use for HSM') + header = str(_('Select a FIDO2 device to use for HSM')) try: fido_devices = Fido2.get_fido2_devices() @@ -214,14 +259,20 @@ def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: return None if fido_devices: - choice = TableMenu(title, data=fido_devices).run() - match choice.type_: - case MenuSelectionType.Reset: - return None - case MenuSelectionType.Skip: - return preset - case MenuSelectionType.Selection: - return choice.value # type: ignore + group, table_header = MenuHelper.create_table(data=fido_devices) + header = f'{header}\n\n{table_header}' + + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, + ).run() + + match result.type_: + case ResultType.Reset: return None + case ResultType.Skip: return preset + case ResultType.Selection: + return result.get_value() return None @@ -240,23 +291,22 @@ def select_partitions_to_encrypt( avail_partitions = list(filter(lambda x: not x.exists(), partitions)) if avail_partitions: - title = str(_('Select which partitions to encrypt')) - partition_table = FormattedOutput.as_table(avail_partitions) + group, header = MenuHelper.create_table(data=avail_partitions) - choice = TableMenu( - title, - table_data=(avail_partitions, partition_table), - preset=preset, + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, multi=True ).run() - match choice.type_: - case MenuSelectionType.Reset: - return [] - case MenuSelectionType.Skip: - return preset - case MenuSelectionType.Selection: - return choice.multi_value + match result.type_: + case ResultType.Reset: return [] + case ResultType.Skip: return preset + case ResultType.Selection: + partitions = result.get_values() + return partitions + return [] @@ -267,22 +317,20 @@ def select_lvm_vols_to_encrypt( volumes: List[LvmVolume] = lvm_config.get_all_volumes() if volumes: - title = str(_('Select which LVM volumes to encrypt')) - partition_table = FormattedOutput.as_table(volumes) + group, header = MenuHelper.create_table(data=volumes) - choice = TableMenu( - title, - table_data=(volumes, partition_table), - preset=preset, + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, multi=True ).run() - match choice.type_: - case MenuSelectionType.Reset: - return [] - case MenuSelectionType.Skip: - return preset - case MenuSelectionType.Selection: - return choice.multi_value + match result.type_: + case ResultType.Reset: return [] + case ResultType.Skip: return preset + case ResultType.Selection: + volumes = result.get_values() + return volumes return [] diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index a59c4342c5..4fa42a3102 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -1,11 +1,10 @@ from __future__ import annotations -import signal -import sys import time from pathlib import Path from typing import Any, Optional, TYPE_CHECKING, List, Dict, Set +from ..interactions.general_conf import ask_abort from .device_handler import device_handler from .device_model import ( DiskLayoutConfiguration, DiskLayoutType, PartitionTable, @@ -15,8 +14,11 @@ ) from ..hardware import SysInfo from ..luks import Luks2 -from ..menu import Menu from ..output import debug, info +from archinstall.tui import ( + Tui +) + if TYPE_CHECKING: _: Any @@ -44,12 +46,8 @@ def perform_filesystem_operations(self, show_countdown: bool = True) -> None: device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods]) - # Issue a final warning before we continue with something un-revertable. - # We mention the drive one last time, and count from 5 to 0. - print(str(_(' ! Formatting {} in ')).format(device_paths)) - if show_countdown: - self._do_countdown() + self._final_warning(device_paths) # Setup the blockdevice, filesystem (and optionally encryption). # Once that's done, we'll hand over to perform_installation() @@ -339,40 +337,19 @@ def _lvm_vol_handle_e2scrub(self, vol_gp: LvmVolumeGroup) -> None: Size(256, Unit.MiB, SectorSize.default()) ) - def _do_countdown(self) -> bool: - SIG_TRIGGER = False - - def kill_handler(sig: int, frame: Any) -> None: - print() - exit(0) - - def sig_handler(sig: int, frame: Any) -> None: - signal.signal(signal.SIGINT, kill_handler) - - original_sigint_handler = signal.getsignal(signal.SIGINT) - signal.signal(signal.SIGINT, sig_handler) - - for i in range(5, 0, -1): - print(f"{i}", end='') + def _final_warning(self, device_paths: str) -> bool: + # Issue a final warning before we continue with something un-revertable. + # We mention the drive one last time, and count from 5 to 0. + out = str(_(' ! Formatting {} in ')).format(device_paths) + Tui.print(out, row=0, endl='', clear_screen=True) - for x in range(4): - sys.stdout.flush() + try: + countdown = '\n5...4...3...2...1' + for c in countdown: + Tui.print(c, row=0, endl='') time.sleep(0.25) - print(".", end='') - - if SIG_TRIGGER: - prompt = _('Do you really want to abort?') - choice = Menu(prompt, Menu.yes_no(), skip=False).run() - if choice.value == Menu.yes(): - exit(0) - - if SIG_TRIGGER is False: - sys.stdin.read() - - SIG_TRIGGER = False - signal.signal(signal.SIGINT, sig_handler) - - print() - signal.signal(signal.SIGINT, original_sigint_handler) + except KeyboardInterrupt: + with Tui(): + ask_abort() return True diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 6842721390..4ea9e0e66e 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -5,16 +5,23 @@ from typing import Any, TYPE_CHECKING, List, Optional, Tuple from dataclasses import dataclass +from ..utils.util import prompt_dir from .device_model import ( PartitionModification, FilesystemType, BDevice, Size, Unit, PartitionType, PartitionFlag, ModificationStatus, DeviceGeometry, SectorSize, BtrfsMountOption ) from ..hardware import SysInfo -from ..menu import Menu, ListManager, MenuSelection, TextInput -from ..output import FormattedOutput, warn +from ..menu import ListManager +from ..output import FormattedOutput from .subvolume_menu import SubvolumeMenu +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, EditMenu, + Orientation, ResultType +) + if TYPE_CHECKING: _: Any @@ -26,9 +33,6 @@ class DefaultFreeSector: class PartitioningList(ListManager): - """ - subclass of ListManager for the managing of user accounts - """ def __init__(self, prompt: str, device: BDevice, device_partitions: List[PartitionModification]): self._device = device self._actions = { @@ -49,7 +53,10 @@ def __init__(self, prompt: str, device: BDevice, device_partitions: List[Partiti super().__init__(prompt, device_partitions, display_actions[:2], display_actions[3:]) def selected_action_display(self, partition: PartitionModification) -> str: - return str(_('Partition')) + if partition.status == ModificationStatus.Create: + return str(_('Partition - New')) + else: + return str(partition.dev_path) def filter_options(self, selection: PartitionModification, options: List[str]) -> List[str]: not_filter = [] @@ -100,8 +107,7 @@ def handle_action( if len(new_partitions) > 0: data = new_partitions case 'remove_added_partitions': - choice = self._reset_confirmation() - if choice.value == Menu.yes(): + if self._reset_confirmation(): data = [part for part in data if part.is_exists_or_modify()] case 'assign_mountpoint' if entry: entry.mountpoint = self._prompt_mountpoint() @@ -169,7 +175,7 @@ def _toggle_mount_option( def _set_btrfs_subvolumes(self, partition: PartitionModification) -> None: partition.btrfs_subvols = SubvolumeMenu( - _("Manage btrfs subvolumes for current partition"), + str(_("Manage btrfs subvolumes for current partition")), partition.btrfs_subvols ).run() @@ -185,7 +191,7 @@ def _prompt_formatting(self, partition: PartitionModification) -> None: # 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 partition.fs_type == FilesystemType.Crypto_luks: - prompt = str(_('This partition is currently encrypted, to format it a filesystem has to be specified')) + prompt = str(_('This partition is currently encrypted, to format it a filesystem has to be specified')) + '\n' fs_type = self._prompt_partition_fs_type(prompt) partition.fs_type = fs_type @@ -195,25 +201,31 @@ def _prompt_formatting(self, partition: PartitionModification) -> None: def _prompt_mountpoint(self) -> Path: header = str(_('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.')) + '\n' header += str(_('If mountpoint /boot is set, then the partition will also be marked as bootable.')) + '\n' - prompt = str(_('Mountpoint: ')) - - print(header) - - while True: - value = TextInput(prompt).run().strip() + prompt = str(_('Mountpoint')) - if value: - mountpoint = Path(value) - break + mountpoint = prompt_dir(prompt, header, allow_skip=False) + assert mountpoint return mountpoint - def _prompt_partition_fs_type(self, prompt: str = '') -> FilesystemType: - options = {fs.value: fs for fs in FilesystemType if fs != FilesystemType.Crypto_luks} + def _prompt_partition_fs_type(self, prompt: Optional[str] = None) -> FilesystemType: + fs_types = filter(lambda fs: fs != FilesystemType.Crypto_luks, FilesystemType) + items = [MenuItem(fs.value, value=fs) for fs in fs_types] + group = MenuItemGroup(items, sort_items=False) + + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Filesystem'))), + allow_skip=False + ).run() - prompt = prompt + '\n' + str(_('Enter a desired filesystem type for the partition')) - choice = Menu(prompt, options, sort=False, skip=False).run() - return options[choice.single_value] + match result.type_: + case ResultType.Selection: + return result.get_value() + case _: + raise ValueError('Unhandled result type') def _validate_value( self, @@ -246,22 +258,34 @@ def _enter_size( self, sector_size: SectorSize, total_size: Size, - prompt: str, + text: str, + header: str, default: Size, start: Optional[Size], ) -> Size: - while True: - value = TextInput(prompt).run().strip() - size: Optional[Size] = None - if not value: - size = default - else: - size = self._validate_value(sector_size, total_size, value, start) + def validate(value: str) -> Optional[str]: + size = self._validate_value(sector_size, total_size, value, start) + if not size: + return str(_('Invalid size')) + return None - if size: - return size + result = EditMenu( + text, + header=f'{header}\b', + allow_skip=True, + validator=validate + ).input() - warn(f'Invalid value: {value}') + size: Optional[Size] = None + value = result.text() + + if value is None: + size = default + else: + size = self._validate_value(sector_size, total_size, value, start) + + assert size + return size def _prompt_size(self) -> Tuple[Size, Size]: device_info = self._device.device_info @@ -276,7 +300,6 @@ def _prompt_size(self) -> Tuple[Size, Size]: prompt += str(_('Total: {} / {}')).format(total_sectors, total_bytes) + '\n\n' prompt += str(_('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...')) + '\n' prompt += str(_('If no unit is provided, the value is interpreted as sectors')) + '\n' - print(prompt) default_free_sector = self._find_default_free_space() @@ -287,27 +310,32 @@ def _prompt_size(self) -> Tuple[Size, Size]: ) # prompt until a valid start sector was entered - start_prompt = str(_('Enter start (default: sector {}): ')).format(default_free_sector.start.value) + start_text = str(_('Start (default: sector {}): ')).format(default_free_sector.start.value) start_size = self._enter_size( device_info.sector_size, device_info.total_size, - start_prompt, + start_text, + prompt, default_free_sector.start, None ) + prompt += f'\nStart: {start_size.as_text()}\n' + if start_size.value == default_free_sector.start.value and default_free_sector.end.value != 0: end_size = default_free_sector.end else: end_size = device_info.total_size # prompt until valid end sector was entered - end_prompt = str(_('Enter end (default: {}): ')).format(end_size.as_text()) + end_text = str(_('End (default: {}): ')).format(end_size.as_text()) + end_size = self._enter_size( device_info.sector_size, device_info.total_size, - end_prompt, + end_text, + prompt, end_size, start_size ) @@ -358,9 +386,6 @@ def _create_new_partition(self) -> PartitionModification: start_size, end_size = self._prompt_size() length = end_size - start_size - # new line for the next prompt - print() - mountpoint = None if fs_type != FilesystemType.Btrfs: mountpoint = self._prompt_mountpoint() @@ -381,17 +406,26 @@ def _create_new_partition(self) -> PartitionModification: return partition - def _reset_confirmation(self) -> MenuSelection: - prompt = str(_('This will remove all newly added partitions, continue?')) - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() - return choice + def _reset_confirmation(self) -> bool: + prompt = str(_('This will remove all newly added partitions, continue?')) + '\n' + + result = SelectMenu( + MenuItemGroup.yes_no(), + header=prompt, + alignment=Alignment.CENTER, + orientation=Orientation.HORIZONTAL, + columns=2, + reset_warning_msg=prompt, + allow_skip=False + ).run() + + return result.item() == MenuItem.yes() def _suggest_partition_layout(self, data: List[PartitionModification]) -> List[PartitionModification]: # if modifications have been done already, inform the user # that this operation will erase those modifications if any([not entry.exists() for entry in data]): - choice = self._reset_confirmation() - if choice.value == Menu.no(): + if not self._reset_confirmation(): return [] from ..interactions.disk_conf import suggest_single_disk_layout diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index ea77149d5c..bb22653623 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -2,7 +2,12 @@ from typing import List, Optional, Any, TYPE_CHECKING from .device_model import SubvolumeModification -from ..menu import TextInput, ListManager +from ..menu import ListManager +from ..utils.util import prompt_dir + +from archinstall.tui import ( + Alignment, EditMenu, ResultType +) if TYPE_CHECKING: _: Any @@ -20,18 +25,34 @@ def __init__(self, prompt: str, btrfs_subvols: List[SubvolumeModification]): def selected_action_display(self, subvolume: SubvolumeModification) -> str: return str(subvolume.name) - def _add_subvolume(self, editing: Optional[SubvolumeModification] = None) -> Optional[SubvolumeModification]: - name = TextInput(f'\n\n{_("Subvolume name")}: ', editing.name if editing else '').run() + def _add_subvolume(self, preset: Optional[SubvolumeModification] = None) -> Optional[SubvolumeModification]: + result = EditMenu( + str(_('Subvolume name')), + alignment=Alignment.CENTER, + allow_skip=True, + default_text=str(preset.name) if preset else None + ).input() - if not name: - return None + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + name = result.text() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + header = f"{str(_('Subvolume name'))}: {name}\n" - mountpoint = TextInput(f'{_("Subvolume mountpoint")}: ', str(editing.mountpoint) if editing else '').run() + path = prompt_dir( + str(_("Subvolume mountpoint")), + header=header, + allow_skip=True + ) - if not mountpoint: + if not path: return None - return SubvolumeModification(Path(name), Path(mountpoint)) + return SubvolumeModification(Path(name), path) def handle_action( self, diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 7a172ef3e7..5b4e766436 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -6,29 +6,32 @@ from .general import secret from .hardware import SysInfo from .locale.locale_menu import LocaleConfiguration, LocaleMenu -from .menu import Selector, AbstractMenu +from .menu import AbstractMenu from .mirrors import MirrorConfiguration, MirrorMenu from .models import NetworkConfiguration, NicType from .models.bootloader import Bootloader -from .models.audio_configuration import Audio, AudioConfiguration +from .models.audio_configuration import AudioConfiguration from .models.users import User from .output import FormattedOutput from .profile.profile_menu import ProfileConfiguration -from .configuration import save_config -from .interactions import add_number_of_parallel_downloads -from .interactions import ask_additional_packages_to_install from .interactions import ask_for_additional_users -from .interactions import ask_for_audio_selection -from .interactions import ask_for_bootloader -from .interactions import ask_for_uki -from .interactions import ask_for_swap -from .interactions import ask_hostname -from .interactions import ask_to_configure_network -from .interactions import get_password, ask_for_a_timezone -from .interactions import select_additional_repositories -from .interactions import select_kernel +from .interactions import ( + ask_for_audio_selection, ask_for_swap, + ask_for_bootloader, ask_for_uki, ask_hostname, + add_number_of_parallel_downloads, select_kernel, + ask_additional_packages_to_install, select_additional_repositories, + ask_for_a_timezone, ask_ntp, ask_to_configure_network +) +from .utils.util import get_password from .utils.util import format_cols -from .interactions import ask_ntp +from .configuration import save_config + +from archinstall.tui import ( + MenuItemGroup, MenuItem +) + + +from .translationhandler import Language, TranslationHandler if TYPE_CHECKING: _: Any @@ -36,175 +39,211 @@ class GlobalMenu(AbstractMenu): def __init__(self, data_store: Dict[str, Any]): - super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) - - def setup_selection_menu_options(self) -> None: - # archinstall.Language will not use preset values - self._menu_options['archinstall-language'] = \ - Selector( - _('Archinstall language'), - lambda x: self._select_archinstall_language(x), - display_func=lambda x: x.display_name, - default=self.translation_handler.get_language_by_abbr('en')) - self._menu_options['locale_config'] = \ - Selector( - _('Locales'), - lambda preset: self._locale_selection(preset), - preview_func=self._prev_locale, - display_func=lambda x: self.defined_text if x else '') - self._menu_options['mirror_config'] = \ - Selector( - _('Mirrors'), - lambda preset: self._mirror_configuration(preset), - display_func=lambda x: self.defined_text if x else '', - preview_func=self._prev_mirror_config - ) - self._menu_options['disk_config'] = \ - Selector( - _('Disk configuration'), - lambda preset: self._select_disk_config(preset), - preview_func=self._prev_disk_config, - display_func=lambda x: self.defined_text if x else '', - ) - self._menu_options['disk_encryption'] = \ - Selector( - _('Disk encryption'), - lambda preset: self._disk_encryption(preset), - preview_func=self._prev_disk_encryption, - display_func=lambda x: self._display_disk_encryption(x), + self._data_store = data_store + self._translation_handler = TranslationHandler() + + if 'archinstall-language' not in data_store: + data_store['archinstall-language'] = self._translation_handler.get_language_by_abbr('en') + + menu_optioons = self._get_menu_options(data_store) + self._item_group = MenuItemGroup( + menu_optioons, + sort_items=False, + checkmarks=True + ) + + super().__init__(self._item_group, data_store) + + def _get_menu_options(self, data_store: Dict[str, Any]) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Archinstall language')), + action=lambda x: self._select_archinstall_language(x), + display_action=lambda x: x.display_name if x else '', + key='archinstall-language' + ), + MenuItem( + text=str(_('Locales')), + action=lambda x: self._locale_selection(x), + preview_action=self._prev_locale, + key='locale_config' + ), + MenuItem( + text=str(_('Mirrors')), + action=lambda x: self._mirror_configuration(x), + preview_action=self._prev_mirror_config, + key='mirror_config' + ), + MenuItem( + text=str(_('Disk configuration')), + action=lambda x: self._select_disk_config(x), + preview_action=self._prev_disk_config, + mandatory=True, + key='disk_config' + ), + MenuItem( + text=str(_('Disk encryption')), + action=lambda x: self._disk_encryption(x), + preview_action=self._prev_disk_encryption, + key='disk_encryption', dependencies=['disk_config'] + ), + MenuItem( + text=str(_('Swap')), + value=True, + action=lambda x: ask_for_swap(x), + preview_action=self._prev_swap, + key='swap', + ), + MenuItem( + text=str(_('Bootloader')), + value=Bootloader.get_default(), + action=lambda x: self._select_bootloader(x), + preview_action=self._prev_bootloader, + mandatory=True, + key='bootloader', + ), + MenuItem( + text=str(_('Unified kernel images')), + value=False, + action=lambda x: ask_for_uki(x), + preview_action=self._prev_uki, + key='uki', + ), + MenuItem( + text=str(_('Hostname')), + value='archlinux', + action=lambda x: ask_hostname(x), + preview_action=self._prev_hostname, + key='hostname', + ), + MenuItem( + text=str(_('Root password')), + action=lambda x: self._set_root_password(x), + preview_action=self._prev_root_pwd, + key='!root-password', + ), + MenuItem( + text=str(_('User account')), + action=lambda x: self._create_user_account(x), + preview_action=self._prev_users, + key='!users' + ), + MenuItem( + text=str(_('Profile')), + action=lambda x: self._select_profile(x), + preview_action=self._prev_profile, + key='profile_config' + ), + MenuItem( + text=str(_('Audio')), + action=lambda x: ask_for_audio_selection(x), + preview_action=self._prev_audio, + key='audio_config' + ), + MenuItem( + text=str(_('Kernels')), + value=['linux'], + action=lambda x: select_kernel(x), + preview_action=self._prev_kernel, + mandatory=True, + key='kernels' + ), + MenuItem( + text=str(_('Network configuration')), + action=lambda x: ask_to_configure_network(x), + value={}, + preview_action=self._prev_network_config, + key='network_config' + ), + MenuItem( + text=str(_('Parallel Downloads')), + action=lambda x: add_number_of_parallel_downloads(x), + value=0, + preview_action=self._prev_parallel_dw, + key='parallel downloads' + ), + MenuItem( + text=str(_('Additional packages')), + action=lambda x: ask_additional_packages_to_install(x), + value=[], + preview_action=self._prev_additional_pkgs, + key='packages' + ), + MenuItem( + text=str(_('Optional repositories')), + action=lambda x: select_additional_repositories(x), + value=[], + preview_action=self._prev_additional_repos, + key='additional-repositories' + ), + MenuItem( + text=str(_('Timezone')), + action=lambda x: ask_for_a_timezone(x), + value='UTC', + preview_action=self._prev_tz, + key='timezone' + ), + MenuItem( + text=str(_('Automatic time sync (NTP)')), + action=lambda x: ask_ntp(x), + value=True, + preview_action=self._prev_ntp, + key='ntp' + ), + MenuItem( + text='' + ), + MenuItem( + text=str(_('Save configuration')), + action=lambda x: self._safe_config(), + key='save_config' + ), + MenuItem( + text=str(_('Install')), + preview_action=self._prev_install_invalid_config, + key='install' + ), + MenuItem( + text=str(_('Abort')), + action=lambda x: exit(1), + key='abort' ) - self._menu_options['swap'] = \ - Selector( - _('Swap'), - lambda preset: ask_for_swap(preset), - default=True) - self._menu_options['bootloader'] = \ - Selector( - _('Bootloader'), - lambda preset: ask_for_bootloader(preset), - display_func=lambda x: x.value, - default=Bootloader.get_default()) - self._menu_options['uki'] = \ - Selector( - _('Unified kernel images'), - lambda preset: ask_for_uki(preset), - default=False) - self._menu_options['hostname'] = \ - Selector( - _('Hostname'), - lambda preset: ask_hostname(preset), - default='archlinux') - # root password won't have preset value - self._menu_options['!root-password'] = \ - Selector( - _('Root password'), - lambda preset: self._set_root_password(), - display_func=lambda x: secret(x) if x else '') - self._menu_options['!users'] = \ - Selector( - _('User account'), - lambda x: self._create_user_account(x), - default=[], - display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else '', - preview_func=self._prev_users) - self._menu_options['profile_config'] = \ - Selector( - _('Profile'), - lambda preset: self._select_profile(preset), - display_func=lambda x: x.profile.name if x else '', - preview_func=self._prev_profile - ) - self._menu_options['audio_config'] = \ - Selector( - _('Audio'), - lambda preset: self._select_audio(preset), - display_func=lambda x: self._display_audio(x) - ) - self._menu_options['parallel downloads'] = \ - Selector( - _('Parallel Downloads'), - lambda preset: add_number_of_parallel_downloads(preset), - display_func=lambda x: x if x else '0', - default=0 - ) - self._menu_options['kernels'] = \ - Selector( - _('Kernels'), - lambda preset: select_kernel(preset), - display_func=lambda x: ', '.join(x) if x else None, - default=['linux']) - self._menu_options['packages'] = \ - Selector( - _('Additional packages'), - lambda preset: ask_additional_packages_to_install(preset), - display_func=lambda x: self.defined_text if x else '', - preview_func=self._prev_additional_pkgs, - default=[]) - self._menu_options['additional-repositories'] = \ - Selector( - _('Optional repositories'), - lambda preset: select_additional_repositories(preset), - display_func=lambda x: ', '.join(x) if x else None, - default=[]) - self._menu_options['network_config'] = \ - Selector( - _('Network configuration'), - lambda preset: ask_to_configure_network(preset), - display_func=lambda x: self._display_network_conf(x), - preview_func=self._prev_network_config, - default={}) - self._menu_options['timezone'] = \ - Selector( - _('Timezone'), - lambda preset: ask_for_a_timezone(preset), - default='UTC') - self._menu_options['ntp'] = \ - Selector( - _('Automatic time sync (NTP)'), - lambda preset: ask_ntp(preset), - default=True) - self._menu_options['__separator__'] = \ - Selector('') - self._menu_options['save_config'] = \ - Selector( - _('Save configuration'), - lambda preset: save_config(self._data_store), - no_store=True) - self._menu_options['install'] = \ - Selector( - self._install_text(), - exec_func=lambda n, v: self._is_config_valid(), - preview_func=self._prev_install_invalid_config, - no_store=True) - - self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n, v: exit(1)) + ] + + def _safe_config(self) -> None: + data: Dict[str, Any] = {} + for item in self._item_group.items: + if item.key is not None: + data[item.key] = item.value + + save_config(data) def _missing_configs(self) -> List[str]: - def check(s: str) -> bool: - obj = self._menu_options.get(s) - if obj and obj.has_selection(): - return True - return False + def check(s) -> bool: + item = self._item_group.find_by_key(s) + return item.has_value() def has_superuser() -> bool: - sel = self._menu_options['!users'] - if sel.current_selection: - return any([u.sudo for u in sel.current_selection]) + item = self._item_group.find_by_key('!users') + + if item.has_value(): + users = item.value + if users: + return any([u.sudo for u in users]) return False - mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items())) missing = set() - for key, selector in mandatory_fields.items(): - if key in ['!root-password', '!users']: + for item in self._item_group.items: + if item.key in ['!root-password', '!users']: if not check('!root-password') and not has_superuser(): missing.add( str(_('Either root-password or at least 1 user with sudo privileges must be specified')) ) - elif key == 'disk_config': - if not check('disk_config'): - missing.add(self._menu_options['disk_config'].description) + elif item.mandatory: + if not check(item.key): + missing.add(item.text) return list(missing) @@ -216,36 +255,28 @@ def _is_config_valid(self) -> bool: return False return self._validate_bootloader() is None - def _update_uki_display(self, name: Optional[str] = None) -> None: - if bootloader := self._menu_options['bootloader'].current_selection: - if not SysInfo.has_uefi() or not bootloader.has_uki_support(): - self._menu_options['uki'].set_current_selection(False) - self._menu_options['uki'].set_enabled(False) - elif name and name == 'bootloader': - self._menu_options['uki'].set_enabled(True) - - def _update_install_text(self, name: Optional[str] = None, value: Any = None) -> None: - text = self._install_text() - self._menu_options['install'].update_description(text) + def _select_archinstall_language(self, preset: Language) -> Language: + from .interactions.general_conf import select_archinstall_language + language = select_archinstall_language(self._translation_handler.translated_languages, preset) + self._translation_handler.activate(language) - def post_callback(self, name: Optional[str] = None, value: Any = None) -> None: - self._update_uki_display(name) - self._update_install_text(name, value) + self._upate_lang_text() - def _install_text(self) -> str: - missing = len(self._missing_configs()) - if missing > 0: - return _('Install ({} config(s) missing)').format(missing) - return _('Install') + return language - def _display_network_conf(self, config: Optional[NetworkConfiguration]) -> str: - if not config: - return str(_('Not configured, unavailable unless setup manually')) + def _upate_lang_text(self) -> None: + """ + The options for the global menu are generated with a static text; + each entry of the menu needs to be updated with the new translation + """ + new_options = self._get_menu_options(self._data_store) - return config.type.display_msg() + for o in new_options: + if o.key is not None: + self._item_group.find_by_key(o.key).text = o.text def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]: - disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + disk_config: Optional[disk.DiskLayoutConfiguration] = self._item_group.find_by_key('disk_config').value if not disk_config: # this should not happen as the encryption menu has the disk_config as dependency @@ -254,91 +285,140 @@ def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[di if not disk.DiskEncryption.validate_enc(disk_config): return None - data_store: Dict[str, Any] = {} - disk_encryption = disk.DiskEncryptionMenu(disk_config, data_store, preset=preset).run() + disk_encryption = disk.DiskEncryptionMenu(disk_config, preset=preset).run() return disk_encryption def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration: - data_store: Dict[str, Any] = {} - locale_config = LocaleMenu(data_store, preset).run() + locale_config = LocaleMenu(preset).run() return locale_config - def _prev_locale(self) -> Optional[str]: - selector = self._menu_options['locale_config'] - if selector.has_selection(): - config: LocaleConfiguration = selector.current_selection # type: ignore - output = '{}: {}\n'.format(str(_('Keyboard layout')), config.kb_layout) - output += '{}: {}\n'.format(str(_('Locale language')), config.sys_lang) - output += '{}: {}'.format(str(_('Locale encoding')), config.sys_enc) + def _prev_locale(self, item: MenuItem) -> Optional[str]: + if not item.value: + return None + + config: LocaleConfiguration = item.value + return config.preview() + + def _prev_network_config(self, item: MenuItem) -> Optional[str]: + if item.value: + network_config: NetworkConfiguration = item.value + if network_config.type == NicType.MANUAL: + output = FormattedOutput.as_table(network_config.nics) + else: + output = f'{str(_('Network configuration'))}:\n{network_config.type.display_msg()}' + return output return None - def _prev_network_config(self) -> Optional[str]: - selector: Optional[NetworkConfiguration] = self._menu_options['network_config'].current_selection - if selector: - if selector.type == NicType.MANUAL: - output = FormattedOutput.as_table(selector.nics) - return output + def _prev_additional_pkgs(self, item: MenuItem) -> Optional[str]: + if item.value: + return format_cols(item.value, None) return None - def _prev_additional_pkgs(self) -> Optional[str]: - selector = self._menu_options['packages'] - if selector.current_selection: - packages: List[str] = selector.current_selection - return format_cols(packages, None) + def _prev_additional_repos(self, item: MenuItem) -> Optional[str]: + if item.value: + repos = ', '.join(item.value) + return f'{str(_("Additional repositories"))}: {repos}' return None - def _prev_disk_config(self) -> Optional[str]: - selector = self._menu_options['disk_config'] - disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection + def _prev_tz(self, item: MenuItem) -> Optional[str]: + if item.value: + return f'{str(_("Timezone"))}: {item.value}' + return None + + def _prev_ntp(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + output = f'{str(_("NTP"))}: ' + output += str(_('Enabled')) if item.value else str(_('Disabled')) + return output + return None + + def _prev_disk_config(self, item: MenuItem) -> Optional[str]: + disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = item.value - output = '' if disk_layout_conf: - output += str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg()) + output = str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg()) + '\n' + + if disk_layout_conf.config_type == disk.DiskLayoutType.Pre_mount: + output += str(_('Mountpoint')) + ': ' + str(disk_layout_conf.mountpoint) if disk_layout_conf.lvm_config: - output += '\n{}: {}'.format(str(_('LVM configuration type')), disk_layout_conf.lvm_config.config_type.display_msg()) + output += '{}: {}'.format(str(_('LVM configuration type')), disk_layout_conf.lvm_config.config_type.display_msg()) - if output: return output return None - def _display_disk_config(self, current_value: Optional[disk.DiskLayoutConfiguration] = None) -> str: - if current_value: - return current_value.config_type.display_msg() - return '' + def _prev_swap(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + output = f'{str(_("Swap on zram"))}: ' + output += str(_('Enabled')) if item.value else str(_('Disabled')) + return output + return None + + def _prev_uki(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + output = f'{str(_('Unified kernel images'))}: ' + output += str(_('Enabled')) if item.value else str(_('Disabled')) + return output + return None + + def _prev_hostname(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + return f'{str(_("Hostname"))}: {item.value}' + return None + + def _prev_root_pwd(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + return f'{str(_("Root password"))}: {secret(item.value)}' + return None + + def _prev_audio(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + config: AudioConfiguration = item.value + return f'{str(_("Audio"))}: {config.audio.value}' + return None + + def _prev_parallel_dw(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + return f'{str(_("Parallel Downloads"))}: {item.value}' + return None + + def _prev_kernel(self, item: MenuItem) -> Optional[str]: + if item.value: + kernel = ', '.join(item.value) + return f'{str(_("Kernel"))}: {kernel}' + return None - def _prev_disk_encryption(self) -> Optional[str]: - disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + def _prev_bootloader(self, item: MenuItem) -> Optional[str]: + if item.value is not None: + return f'{str(_("Bootloader"))}: {item.value.value}' + return None + + def _prev_disk_encryption(self, item: MenuItem) -> Optional[str]: + disk_config: Optional[disk.DiskLayoutConfiguration] = self._item_group.find_by_key('disk_config').value + enc_config: Optional[disk.DiskEncryption] = item.value if disk_config and not disk.DiskEncryption.validate_enc(disk_config): return str(_('LVM disk encryption with more than 2 partitions is currently not supported')) - encryption: Optional[disk.DiskEncryption] = self._menu_options['disk_encryption'].current_selection - - if encryption: - enc_type = disk.EncryptionType.type_to_text(encryption.encryption_type) + if enc_config: + enc_type = disk.EncryptionType.type_to_text(enc_config.encryption_type) output = str(_('Encryption type')) + f': {enc_type}\n' - output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n' + output += str(_('Password')) + f': {secret(enc_config.encryption_password)}\n' - if encryption.partitions: - output += 'Partitions: {} selected'.format(len(encryption.partitions)) + '\n' - elif encryption.lvm_volumes: - output += 'LVM volumes: {} selected'.format(len(encryption.lvm_volumes)) + '\n' + if enc_config.partitions: + output += 'Partitions: {} selected'.format(len(enc_config.partitions)) + '\n' + elif enc_config.lvm_volumes: + output += 'LVM volumes: {} selected'.format(len(enc_config.lvm_volumes)) + '\n' - if encryption.hsm_device: - output += f'HSM: {encryption.hsm_device.manufacturer}' + if enc_config.hsm_device: + output += f'HSM: {enc_config.hsm_device.manufacturer}' return output return None - def _display_disk_encryption(self, current_value: Optional[disk.DiskEncryption]) -> str: - if current_value: - return disk.EncryptionType.type_to_text(current_value.encryption_type) - return '' - def _validate_bootloader(self) -> Optional[str]: """ Checks the selected bootloader is valid for the selected filesystem @@ -350,10 +430,10 @@ def _validate_bootloader(self) -> Optional[str]: XXX: The caller is responsible for wrapping the string with the translation shim if necessary. """ - bootloader = self._menu_options['bootloader'].current_selection + bootloader = self._item_group.find_by_key('bootloader').value boot_partition: Optional[disk.PartitionModification] = None - if disk_config := self._menu_options['disk_config'].current_selection: + if disk_config := self._item_group.find_by_key('disk_config').value: for layout in disk_config.device_modifications: if boot_partition := layout.get_boot_partition(): break @@ -369,7 +449,7 @@ def _validate_bootloader(self) -> Optional[str]: return None - def _prev_install_invalid_config(self) -> Optional[str]: + def _prev_install_invalid_config(self, item: MenuItem) -> Optional[str]: if missing := self._missing_configs(): text = str(_('Missing configurations:\n')) for m in missing: @@ -381,17 +461,15 @@ def _prev_install_invalid_config(self) -> Optional[str]: return None - def _prev_users(self) -> Optional[str]: - selector = self._menu_options['!users'] - users: Optional[List[User]] = selector.current_selection + def _prev_users(self, item: MenuItem) -> Optional[str]: + users: Optional[List[User]] = item.value if users: return FormattedOutput.as_table(users) return None - def _prev_profile(self) -> Optional[str]: - selector = self._menu_options['profile_config'] - profile_config: Optional[ProfileConfiguration] = selector.current_selection + def _prev_profile(self, item: MenuItem) -> Optional[str]: + profile_config: Optional[ProfileConfiguration] = item.value if profile_config and profile_config.profile: output = str(_('Profiles')) + ': ' @@ -410,63 +488,59 @@ def _prev_profile(self) -> Optional[str]: return None - def _set_root_password(self) -> Optional[str]: - prompt = str(_('Enter root password (leave blank to disable root): ')) - password = get_password(prompt=prompt) + def _set_root_password(self, preset: Optional[str] = None) -> Optional[str]: + password = get_password(text=str(_('Root password')), allow_skip=True) return password def _select_disk_config( self, preset: Optional[disk.DiskLayoutConfiguration] = None ) -> Optional[disk.DiskLayoutConfiguration]: - data_store: Dict[str, Any] = {} - disk_config = disk.DiskLayoutConfigurationMenu(preset, data_store).run() + disk_config = disk.DiskLayoutConfigurationMenu(preset).run() if disk_config != preset: - self._menu_options['disk_encryption'].set_current_selection(None) + self._menu_item_group.find_by_key('disk_encryption').value = None return disk_config + def _select_bootloader(self, preset: Optional[Bootloader]) -> Optional[Bootloader]: + bootloader = ask_for_bootloader(preset) + + if bootloader: + uki = self._item_group.find_by_key('uki') + if not SysInfo.has_uefi() or not bootloader.has_uki_support(): + uki.value = False + uki.enabled = False + else: + uki.enabled = True + + return bootloader + def _select_profile(self, current_profile: Optional[ProfileConfiguration]): from .profile.profile_menu import ProfileMenu - store: Dict[str, Any] = {} - profile_config = ProfileMenu(store, preset=current_profile).run() + profile_config = ProfileMenu(preset=current_profile).run() return profile_config - def _select_audio( - self, - current: Optional[AudioConfiguration] = None - ) -> Optional[AudioConfiguration]: - selection = ask_for_audio_selection(current) - return selection - - def _display_audio(self, current: Optional[AudioConfiguration]) -> str: - if not current: - return Audio.no_audio_text() - else: - return current.audio.name - - def _create_user_account(self, defined_users: List[User]) -> List[User]: - users = ask_for_additional_users(defined_users=defined_users) + def _create_user_account(self, preset: Optional[List[User]] = None) -> List[User]: + preset = [] if preset is None else preset + users = ask_for_additional_users(defined_users=preset) return users def _mirror_configuration(self, preset: Optional[MirrorConfiguration] = None) -> Optional[MirrorConfiguration]: - data_store: Dict[str, Any] = {} - mirror_configuration = MirrorMenu(data_store, preset=preset).run() + mirror_configuration = MirrorMenu(preset=preset).run() return mirror_configuration - def _prev_mirror_config(self) -> Optional[str]: - selector = self._menu_options['mirror_config'] + def _prev_mirror_config(self, item: MenuItem) -> Optional[str]: + if not item.value: + return None - if selector.has_selection(): - mirror_config: MirrorConfiguration = selector.current_selection # type: ignore - output = '' - if mirror_config.regions: - output += '{}: {}\n\n'.format(str(_('Mirror regions')), mirror_config.regions) - if mirror_config.custom_mirrors: - table = FormattedOutput.as_table(mirror_config.custom_mirrors) - output += '{}\n{}'.format(str(_('Custom mirrors')), table) + mirror_config: MirrorConfiguration = item.value - return output.strip() + output = '' + if mirror_config.regions: + output += '{}: {}\n\n'.format(str(_('Mirror regions')), mirror_config.regions) + if mirror_config.custom_mirrors: + table = FormattedOutput.as_table(mirror_config.custom_mirrors) + output += '{}\n{}'.format(str(_('Custom mirrors')), table) - return None + return output.strip() diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index 963083ab81..e34091462f 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -8,7 +8,6 @@ from .general import SysCommand from .networking import list_interfaces, enrich_iface_types from .output import debug -from .utils.util import format_cols if TYPE_CHECKING: _: Any @@ -78,9 +77,12 @@ def is_nvidia(self) -> bool: return False def packages_text(self) -> str: - text = str(_('Installed packages')) + ':\n' pkg_names = [p.value for p in self.gfx_packages()] - text += format_cols(sorted(pkg_names)) + text = str(_('Installed packages')) + ':\n' + + for p in sorted(pkg_names): + text += f'\t- {p}\n' + return text def gfx_packages(self) -> List[GfxPackage]: diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 7c6bb9294d..e116578cc8 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -24,6 +24,7 @@ from .pacman import Pacman from .plugins import plugins from .storage import storage +from archinstall.tui.curses_menu import Tui if TYPE_CHECKING: _: Any @@ -105,9 +106,9 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> bool: # We avoid printing /mnt/ because that might confuse people if they note it down # and then reboot, and a identical log file will be found in the ISO medium anyway. - print(_("[!] A log file has been created here: {}").format( - os.path.join(storage['LOG_PATH'], storage['LOG_FILE']))) - print(_(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues")) + log_file = os.path.join(storage['LOG_PATH'], storage['LOG_FILE']) + Tui.print(str(_("[!] A log file has been created here: {}").format(log_file))) + Tui.print(str(_('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues'))) raise exc_val if not (missing_steps := self.post_install_check()): diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py index 4b696a78ff..40438e7b7d 100644 --- a/archinstall/lib/interactions/__init__.py +++ b/archinstall/lib/interactions/__init__.py @@ -1,6 +1,5 @@ from .manage_users_conf import UserList, ask_for_additional_users from .network_menu import ManualNetworkConfig, ask_to_configure_network -from .utils import get_password from .disk_conf import ( select_devices, select_disk_config, get_default_partition_layout, diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index a312eeee64..c4f96fcaed 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -7,25 +7,22 @@ from .. import disk from ..disk.device_model import BtrfsMountOption from ..hardware import SysInfo -from ..menu import Menu -from ..menu import TableMenu -from ..menu.menu import MenuSelectionType from ..output import FormattedOutput, debug from ..utils.util import prompt_dir from ..storage import storage +from archinstall.lib.menu.menu_helper import MenuHelper +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + Orientation +) + if TYPE_CHECKING: _: Any -def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: - """ - Asks the user to select one or multiple devices - - :return: List of selected devices - :rtype: list - """ - +def select_devices(preset: Optional[List[disk.BDevice]] = []) -> List[disk.BDevice]: def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]: dev = disk.device_handler.get_device(selection.path) if dev and dev.partition_infos: @@ -35,30 +32,25 @@ def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]: if preset is None: preset = [] - title = str(_('Select one or more devices to use and configure')) - warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?')) - devices = disk.device_handler.devices options = [d.device_info for d in devices] - preset_value = [p.device_info for p in preset] - - choice = TableMenu( - title, - data=options, - multi=True, - preset=preset_value, - preview_command=_preview_device_selection, - preview_title=str(_('Existing Partitions')), - preview_size=0.2, - allow_reset=True, - allow_reset_warning_msg=warning + presets = [p.device_info for p in preset] + + group, header = MenuHelper.create_table(data=options) + group.set_selected_by_value(presets) + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, + search_enabled=False, + multi=True ).run() - match choice.type_: - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: - selected_device_info: List[disk._DeviceInfo] = choice.single_value + match result.type_: + case ResultType.Reset: return [] + case ResultType.Skip: return preset + case ResultType.Selection: + selected_device_info: List[disk._DeviceInfo] = result.get_values() selected_devices = [] for device in devices: @@ -113,35 +105,40 @@ def select_disk_config( manual_mode = disk.DiskLayoutType.Manual.display_msg() pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg() - options = [default_layout, manual_mode, pre_mount_mode] - preset_value = preset.config_type.display_msg() if preset else None - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select a partitioning option'), - options, - allow_reset=True, - allow_reset_warning_msg=warning, - sort=False, - preview_size=0.2, - preset_values=preset_value + items = [ + MenuItem(default_layout, value=default_layout), + MenuItem(manual_mode, value=manual_mode), + MenuItem(pre_mount_mode, value=pre_mount_mode) + ] + group = MenuItemGroup(items, sort_items=False) + + if preset: + group.set_selected_by_value(preset.config_type.display_msg()) + + result = SelectMenu( + group, + allow_skip=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Disk configuration type'))), + allow_reset=True ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - case MenuSelectionType.Selection: - if choice.single_value == pre_mount_mode: + match result.type_: + case ResultType.Skip: return preset + case ResultType.Reset: return None + case ResultType.Selection: + selection = result.get_value() + + if selection == pre_mount_mode: output = 'You will use whatever drive-setup is mounted at the specified directory\n' output += "WARNING: Archinstall won't check the suitability of this setup\n" - try: - path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) - except (KeyboardInterrupt, EOFError): - return preset + path = prompt_dir(str(_('Root mount directory')), output, allow_skip=False) + assert path is not None + mods = disk.device_handler.detect_pre_mounted_mods(path) - storage['MOUNT_POINT'] = Path(path) + storage['MOUNT_POINT'] = path return disk.DiskLayoutConfiguration( config_type=disk.DiskLayoutType.Pre_mount, @@ -155,14 +152,14 @@ def select_disk_config( if not devices: return None - if choice.value == default_layout: + if result.get_value() == default_layout: modifications = get_default_partition_layout(devices, advanced_option=advanced_option) if modifications: return disk.DiskLayoutConfiguration( config_type=disk.DiskLayoutType.Default, device_modifications=modifications ) - elif choice.value == manual_mode: + elif result.get_value() == manual_mode: preset_mods = preset.device_modifications if preset else [] modifications = _manual_partitioning(preset_mods, devices) @@ -179,30 +176,29 @@ def select_lvm_config( disk_config: disk.DiskLayoutConfiguration, preset: Optional[disk.LvmConfiguration] = None, ) -> Optional[disk.LvmConfiguration]: + preset_value = preset.config_type.display_msg() if preset else None default_mode = disk.LvmLayoutType.Default.display_msg() - options = [default_mode] + items = [MenuItem(default_mode, value=default_mode)] + group = MenuItemGroup(items) + group.set_focus_by_value(preset_value) - preset_value = preset.config_type.display_msg() if preset else None - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select a LVM option'), - options, + result = SelectMenu( + group, allow_reset=True, - allow_reset_warning_msg=warning, - sort=False, - preview_size=0.2, - preset_values=preset_value + allow_skip=True, + frame=FrameProperties.min(str(_('LVM configuration type'))), + alignment=Alignment.CENTER ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - case MenuSelectionType.Selection: - if choice.single_value == default_mode: + match result.type_: + case ResultType.Skip: return preset + case ResultType.Reset: return None + case ResultType.Selection: + if result.get_value() == default_mode: return suggest_lvm_layout(disk_config) - return preset + + return None def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.PartitionModification: @@ -227,33 +223,56 @@ def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.Parti def select_main_filesystem_format(advanced_options: bool = False) -> disk.FilesystemType: - options = { - 'btrfs': disk.FilesystemType.Btrfs, - 'ext4': disk.FilesystemType.Ext4, - 'xfs': disk.FilesystemType.Xfs, - 'f2fs': disk.FilesystemType.F2fs - } + items = [ + MenuItem('btrfs', value=disk.FilesystemType.Btrfs), + MenuItem('ext4', value=disk.FilesystemType.Ext4), + MenuItem('xfs', value=disk.FilesystemType.Xfs), + MenuItem('f2fs', value=disk.FilesystemType.F2fs) + ] if advanced_options: - options.update({'ntfs': disk.FilesystemType.Ntfs}) + items.append(MenuItem('ntfs', value=disk.FilesystemType.Ntfs)) + + group = MenuItemGroup(items, sort_items=False) + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min('Filesystem'), + allow_skip=False + ).run() - prompt = _('Select which filesystem your main partition should use') - choice = Menu(prompt, options, skip=False, sort=False).run() - return options[choice.single_value] + match result.type_: + case ResultType.Selection: + return result.get_value() + case _: + raise ValueError('Unhandled result type') def select_mount_options() -> List[str]: - prompt = str(_('Would you like to use compression or disable CoW?')) - options = [str(_('Use compression')), str(_('Disable Copy-on-Write'))] - choice = Menu(prompt, options, sort=False).run() - - if choice.type_ == MenuSelectionType.Selection: - if choice.single_value == options[0]: - return [BtrfsMountOption.compress.value] - else: - return [BtrfsMountOption.nodatacow.value] + prompt = str(_('Would you like to use compression or disable CoW?')) + '\n' + compression = str(_('Use compression')) + disable_cow = str(_('Disable Copy-on-Write')) + + items = [ + MenuItem(compression, value=BtrfsMountOption.compress.value), + MenuItem(disable_cow, value=BtrfsMountOption.nodatacow.value), + ] + group = MenuItemGroup(items, sort_items=False) + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, + search_enabled=False, + allow_skip=False + ).run() - return [] + match result.type_: + case ResultType.Selection: + return [result.get_value()] + case _: + raise ValueError('Unhandled result type') def process_root_partition_size(total_size: disk.Size, sector_size: disk.SectorSize) -> disk.Size: @@ -286,9 +305,19 @@ def suggest_single_disk_layout( min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB, sector_size) if filesystem_type == disk.FilesystemType.Btrfs: - prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_subvolumes = choice.value == Menu.yes() + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + '\n' + group = MenuItemGroup.yes_no() + group.set_focus_by_value(MenuItem.yes().value) + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, + allow_skip=False + ).run() + + using_subvolumes = result.item() == MenuItem.yes() mount_options = select_mount_options() else: using_subvolumes = False @@ -325,9 +354,19 @@ def suggest_single_disk_layout( elif separate_home: using_home_partition = True else: - prompt = str(_('Would you like to create a separate partition for /home?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_home_partition = choice.value == Menu.yes() + prompt = str(_('Would you like to create a separate partition for /home?')) + '\n' + group = MenuItemGroup.yes_no() + group.set_focus_by_value(MenuItem.yes().value) + result = SelectMenu( + group, + header=prompt, + orientation=Orientation.HORIZONTAL, + columns=2, + alignment=Alignment.CENTER, + allow_skip=False + ).run() + + using_home_partition = result.item() == MenuItem.yes() # root partition root_start = boot_partition.start + boot_partition.length @@ -417,10 +456,14 @@ def suggest_multi_disk_layout( root_device: Optional[disk.BDevice] = sorted_delta[0][0] if home_device is None or root_device is None: - text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n') - text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB)) - text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB)) - Menu(str(text), [str(_('Continue'))], skip=False).run() + text = str(_('The selected drives do not have the minimum capacity required for an automatic suggestion\n')) + text += str(_('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB))) + text += str(_('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB))) + + items = [MenuItem(str(_('Continue')))] + group = MenuItemGroup(items) + SelectMenu(group).run() + return [] if filesystem_type == disk.FilesystemType.Btrfs: @@ -503,10 +546,21 @@ def suggest_lvm_layout( filesystem_type = select_main_filesystem_format() if filesystem_type == disk.FilesystemType.Btrfs: - prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_subvolumes = choice.value == Menu.yes() - + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + '\n' + group = MenuItemGroup.yes_no() + group.set_focus_by_value(MenuItem.yes().value) + + result = SelectMenu( + group, + header=prompt, + search_enabled=False, + allow_skip=False, + orientation=Orientation.HORIZONTAL, + columns=2, + alignment=Alignment.CENTER, + ).run() + + using_subvolumes = MenuItem.yes() == result.item() mount_options = select_mount_options() if using_subvolumes: diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index bd3e78446d..f6d71b07b9 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -4,86 +4,114 @@ from typing import List, Any, Optional, TYPE_CHECKING from ..locale import list_timezones -from ..menu import MenuSelectionType, Menu, TextInput from ..models.audio_configuration import Audio, AudioConfiguration from ..output import warn from ..packages.packages import validate_package_list from ..storage import storage from ..translationhandler import Language +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + EditMenu, Orientation, Tui +) if TYPE_CHECKING: _: Any def ask_ntp(preset: bool = True) -> bool: - prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) - prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run() - - return False if choice.value == Menu.no() else True - - -def ask_hostname(preset: str = '') -> str: - hostname = TextInput( - str(_('Desired hostname for the installation: ')), - preset - ).run().strip() - - if not hostname: - return preset + header = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) + '\n' + header += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) + '\n' + + preset_val = MenuItem.yes() if preset else MenuItem.no() + group = MenuItemGroup.yes_no() + group.focus_item = preset_val + + result = SelectMenu( + group, + header=header, + allow_skip=True, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL + ).run() - return hostname + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case _: + raise ValueError('Unhandled return type') + + +def ask_hostname(preset: Optional[str] = None) -> Optional[str]: + result = EditMenu( + str(_('Hostname')), + alignment=Alignment.CENTER, + allow_skip=True, + default_text=preset + ).input() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + hostname = result.text() + if len(hostname) < 1: + return None + return hostname + case ResultType.Reset: + raise ValueError('Unhandled result type') def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: - timezones = list_timezones() default = 'UTC' + timezones = list_timezones() - choice = Menu( - _('Select a timezone'), - timezones, - preset_values=preset, - default_option=default + items = [MenuItem(tz, value=tz) for tz in timezones] + group = MenuItemGroup(items, sort_items=True) + group.set_selected_by_value(preset) + group.set_default_by_value(default) + + result = SelectMenu( + group, + allow_reset=True, + allow_skip=True, + frame=FrameProperties.min(str(_('Timezone'))), + alignment=Alignment.CENTER, ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value - - return None + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return default + case ResultType.Selection: + return result.get_value() -def ask_for_audio_selection( - current: Optional[AudioConfiguration] = None -) -> Optional[AudioConfiguration]: - choices = [ - Audio.Pipewire.name, # pylint: disable=no-member - Audio.Pulseaudio.name, # pylint: disable=no-member - Audio.no_audio_text() - ] +def ask_for_audio_selection(preset: Optional[AudioConfiguration] = None) -> Optional[AudioConfiguration]: + items = [MenuItem(a.value, value=a) for a in Audio] + group = MenuItemGroup(items) - preset = current.audio.name if current else None + if preset: + group.set_focus_by_value(preset.audio) - choice = Menu( - _('Choose an audio server'), - choices, - preset_values=preset + result = SelectMenu( + group, + allow_skip=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Audio'))) ).run() - match choice.type_: - case MenuSelectionType.Skip: return current - case MenuSelectionType.Selection: - value = choice.single_value - if value == Audio.no_audio_text(): - return None - else: - return AudioConfiguration(Audio[value]) - - return None + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return AudioConfiguration(audio=result.get_value()) + case ResultType.Reset: + raise ValueError('Unhandled result type') def select_language(preset: Optional[str] = None) -> Optional[str]: @@ -94,7 +122,8 @@ def select_language(preset: Optional[str] = None) -> Optional[str]: # raise Deprecated("select_language() has been deprecated, use select_kb_layout() instead.") # No need to translate this i feel, as it's a short lived message. - warn("select_language() is deprecated, use select_kb_layout() instead. select_language() will be removed in a future version") + warn( + "select_language() is deprecated, use select_kb_layout() instead. select_language() will be removed in a future version") return select_kb_layout(preset) @@ -103,70 +132,112 @@ def select_archinstall_language(languages: List[Language], preset: Language) -> # these are the displayed language names which can either be # the english name of a language or, if present, the # name of the language in its own language - options = {lang.display_name: lang for lang in languages} + + items = [MenuItem(lang.display_name, lang) for lang in languages] + group = MenuItemGroup(items, sort_items=True) + group.set_focus_by_value(preset) title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n' title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - choice = Menu( - title, - list(options.keys()), - default_option=preset.display_name, - preview_size=0.5 + result = SelectMenu( + group, + header=title, + allow_skip=True, + allow_reset=False, + alignment=Alignment.CENTER, + frame=FrameProperties.min(header=str(_('Select language'))) ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return options[choice.single_value] - - raise ValueError('Language selection not handled') + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Language selection not handled') def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]: # Additional packages (with some light weight error handling for invalid package names) - print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) - print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) - - def read_packages(p: list[str] = []) -> list[str]: - display = ' '.join(p) - input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() - return input_packages.split() if input_packages else [] - - preset = preset if preset else [] - packages = read_packages(preset) - - if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: - while True: - if len(packages): - # Verify packages that were given - print(_("Verifying that additional packages exist (this might take a few seconds)")) - valid, invalid = validate_package_list(packages) - - if invalid: - warn(f"Some packages could not be found in the repository: {invalid}") - packages = read_packages(valid) - continue - break - - return packages - - -def add_number_of_parallel_downloads(input_number: Optional[int] = None) -> Optional[int]: + header = str(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) + '\n' + header += str(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) + '\n' + header += str(_('Write additional packages to install (space separated, leave blank to skip)')) + + def validator(value: str) -> Optional[str]: + packages = value.split() if value else [] + + if len(packages) == 0: + return None + + if storage['arguments']['offline'] or storage['arguments']['no_pkg_lookups']: + return None + + # Verify packages that were given + out = str(_("Verifying that additional packages exist (this might take a few seconds)")) + Tui.print(out, 0) + valid, invalid = validate_package_list(packages) + + if invalid: + return f'{str(_("Some packages could not be found in the repository"))}: {invalid}' + + return None + + result = EditMenu( + str(_('Additional packages')), + alignment=Alignment.CENTER, + allow_skip=True, + allow_reset=True, + edit_width=100, + validator=validator, + default_text=' '.join(preset) + ).input() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return [] + case ResultType.Selection: + packages = result.text() + return packages.split(' ') + + +def add_number_of_parallel_downloads(preset: Optional[int] = None) -> Optional[int]: max_recommended = 5 - print(_("This option enables the number of parallel downloads that can occur during package downloads")) - print(_("Enter the number of parallel downloads to be enabled.\n\nNote:\n")) - print(str(_(" - Maximum recommended value : {} ( Allows {} parallel downloads at a time )")).format(max_recommended, max_recommended)) - print(_(" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n")) - while True: + header = str(_('This option enables the number of parallel downloads that can occur during package downloads')) + '\n' + header += str(_('Enter the number of parallel downloads to be enabled.\n\nNote:\n')) + header += str(_(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )')).format(max_recommended, max_recommended) + '\n' + header += str(_(' - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n')) + + def validator(s: str) -> Optional[str]: try: - input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0) - if input_number <= 0: - input_number = 0 - break - except: - print(str(_("Invalid input! Try again with a valid input [or 0 to disable]")).format(max_recommended)) + value = int(s) + if value >= 0: + return None + except Exception: + pass + + return str(_('Invalid download number')) + + result = EditMenu( + str(_('Number downloads')), + header=header, + allow_skip=True, + allow_reset=True, + validator=validator, + default_text=str(preset) if preset is not None else None + ).input() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return 0 + case ResultType.Selection: + downloads: int = int(result.text()) pacman_conf_path = pathlib.Path("/etc/pacman.conf") with pacman_conf_path.open() as f: @@ -175,11 +246,11 @@ def add_number_of_parallel_downloads(input_number: Optional[int] = None) -> Opti with pacman_conf_path.open("w") as fwrite: for line in pacman_conf: if "ParallelDownloads" in line: - fwrite.write(f"ParallelDownloads = {input_number}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n") + fwrite.write(f"ParallelDownloads = {downloads}\n") else: fwrite.write(f"{line}\n") - return input_number + return downloads def select_additional_repositories(preset: List[str]) -> List[str]: @@ -191,19 +262,55 @@ def select_additional_repositories(preset: List[str]) -> List[str]: """ repositories = ["multilib", "testing"] + items = [MenuItem(r, value=r) for r in repositories] + group = MenuItemGroup(items, sort_items=True) + group.set_selected_by_value(preset) + + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min('Additional repositories'), + allow_reset=True, + allow_skip=True, + multi=True + ).run() - choice = Menu( - _('Choose which optional additional repositories to enable'), - repositories, - sort=False, - multi=True, - preset_values=preset, - allow_reset=True + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return [] + case ResultType.Selection: + return result.get_values() + + +def ask_chroot() -> bool: + prompt = str(_('Would you like to chroot into the newly created installation and perform post-installation configuration?')) + '\n' + group = MenuItemGroup.yes_no() + + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.single_value + return result.item() == MenuItem.yes() + + +def ask_abort() -> None: + prompt = str(_('Do you really want to abort?')) + '\n' + group = MenuItemGroup.yes_no() + + result = SelectMenu( + group, + header=prompt, + allow_skip=False, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL + ).run() - return [] + if result.item() == MenuItem.yes(): + exit(0) diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index 886f85b63f..fef32f5f46 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -3,19 +3,22 @@ import re from typing import Any, TYPE_CHECKING, List, Optional -from .utils import get_password -from ..menu import Menu, ListManager +from ..utils.util import get_password +from ..menu import ListManager from ..models.users import User +from ..general import secret + +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + Alignment, EditMenu, Orientation, + ResultType +) if TYPE_CHECKING: _: Any class UserList(ListManager): - """ - subclass of ListManager for the managing of user accounts - """ - def __init__(self, prompt: str, lusers: List[User]): self._actions = [ str(_('Add a user')), @@ -37,8 +40,9 @@ def handle_action(self, action: str, entry: Optional[User], data: List[User]) -> data = [d for d in data if d.username != new_user.username] data += [new_user] elif action == self._actions[1] and entry: # change password - prompt = str(_('Password for user "{}": ').format(entry.username)) - new_password = get_password(prompt=prompt) + header = f'{str(_("User"))}: {entry.username}\n' + new_password = get_password(str(_('Password')), header=header) + if new_password: user = next(filter(lambda x: x == entry, data)) user.password = new_password @@ -50,42 +54,55 @@ def handle_action(self, action: str, entry: Optional[User], data: List[User]) -> return data - def _check_for_correct_username(self, username: str) -> bool: + def _check_for_correct_username(self, username: str) -> Optional[str]: if re.match(r'^[a-z_][a-z0-9_-]*\$?$', username) and len(username) <= 32: - return True - return False + return None + return str(_("The username you entered is invalid")) def _add_user(self) -> Optional[User]: - prompt = '\n\n' + str(_('Enter username (leave blank to skip): ')) - - while True: - try: - username = input(prompt).strip(' ') - except (KeyboardInterrupt, EOFError): + editResult = EditMenu( + str(_('Username')), + allow_skip=True, + validator=self._check_for_correct_username + ).input() + + match editResult.type_: + case ResultType.Skip: return None + case ResultType.Selection: + username = editResult.text() + case _: + raise ValueError('Unhandled result type') - if not username: - return None - if not self._check_for_correct_username(username): - error_prompt = str(_("The username you entered is invalid. Try again")) - print(error_prompt) - else: - break + header = f'{str(_("Username"))}: {username}\n' - password = get_password(prompt=str(_('Password for user "{}": ').format(username))) + password = get_password(str(_('Password')), header=header, allow_skip=True) if not password: return None - choice = Menu( - str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(), - skip=False, - default_option=Menu.yes(), - clear_screen=False, - show_search_hint=False + header += f'{str(_("Password"))}: {secret(password)}\n\n' + header += str(_('Should "{}" be a superuser (sudo)?\n')).format(username) + + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.yes() + + result = SelectMenu( + group, + header=header, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL, + search_enabled=False, + allow_skip=False ).run() - sudo = True if choice.value == Menu.yes() else False + match result.type_: + case ResultType.Selection: + sudo = result.item() == MenuItem.yes() + case _: + raise ValueError('Unhandled result type') + return User(username, password, sudo) diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index 05386a79f5..c1935542d9 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -1,24 +1,23 @@ from __future__ import annotations import ipaddress -from typing import Any, Optional, TYPE_CHECKING, List, Dict +from typing import Any, Optional, TYPE_CHECKING, List -from ..menu import MenuSelectionType, TextInput from ..models.network_configuration import NetworkConfiguration, NicType, Nic from ..networking import list_interfaces -from ..output import FormattedOutput, warn -from ..menu import ListManager, Menu +from ..menu import ListManager +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + EditMenu +) if TYPE_CHECKING: _: Any class ManualNetworkConfig(ListManager): - """ - subclass of ListManager for the managing of network configurations - """ - def __init__(self, prompt: str, preset: List[Nic]): self._actions = [ str(_('Add interface')), @@ -27,21 +26,6 @@ def __init__(self, prompt: str, preset: List[Nic]): ] super().__init__(prompt, preset, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[Nic]) -> Dict[str, Optional[Nic]]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[Nic]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, iface in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = iface - - return display_data - def selected_action_display(self, nic: Nic) -> str: return nic.iface if nic.iface else '' @@ -69,56 +53,112 @@ def _select_iface(self, data: List[Nic]) -> Optional[str]: if not available: return None - choice = Menu(str(_('Select interface to add')), list(available), skip=True).run() - - if choice.type_ == MenuSelectionType.Skip: + if not available: return None - return choice.single_value + items = [MenuItem(i, value=i) for i in available] + group = MenuItemGroup(items, sort_items=True) + + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Interfaces'))), + allow_skip=True + ).run() + + match result.type_: + case ResultType.Skip: + return None + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + def _get_ip_address( + self, + title: str, + header: str, + allow_skip: bool, + multi: bool, + preset: Optional[str] = None + ) -> Optional[str]: + def validator(ip: str) -> Optional[str]: + if multi: + ips = ip.split(' ') + else: + ips = [ip] + + try: + for ip in ips: + ipaddress.ip_interface(ip) + return None + except ValueError: + return str(_('You need to enter a valid IP in IP-config mode')) + + result = EditMenu( + title, + header=header, + validator=validator, + allow_skip=allow_skip, + default_text=preset + ).input() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.text() + case ResultType.Reset: + raise ValueError('Unhandled result type') def _edit_iface(self, edit_nic: Nic) -> Nic: iface_name = edit_nic.iface modes = ['DHCP (auto detect)', 'IP (static)'] default_mode = 'DHCP (auto detect)' - prompt = _('Select which mode to configure for "{}" or skip to use default mode "{}"').format(iface_name, default_mode) - mode = Menu(prompt, modes, default_option=default_mode, skip=False).run() + header = str(_('Select which mode to configure for "{}" or skip to use default mode "{}"').format(iface_name, default_mode)) + '\n' + items = [MenuItem(m, value=m) for m in modes] + group = MenuItemGroup(items, sort_items=True) + group.set_default_by_value(default_mode) - if mode.value == 'IP (static)': - while 1: - prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) - ip = TextInput(prompt, edit_nic.ip).run().strip() - # Implemented new check for correct IP/subnet input - try: - ipaddress.ip_interface(ip) - break - except ValueError: - warn("You need to enter a valid IP in IP-config mode") - - # Implemented new check for correct gateway IP address - gateway = None - - while 1: - gateway = TextInput( - _('Enter your gateway (router) IP address or leave blank for none: '), - edit_nic.gateway - ).run().strip() - try: - if len(gateway) > 0: - ipaddress.ip_address(gateway) - break - except ValueError: - warn("You need to enter a valid gateway (router) IP address") + result = SelectMenu( + group, + header=header, + allow_skip=False, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Modes'))) + ).run() + + match result.type_: + case ResultType.Selection: + mode = result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + if mode == 'IP (static)': + header = str(_('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name)) + '\n' + ip = self._get_ip_address(str(_('IP address')), header, False, False) + + header = str(_('Enter your gateway (router) IP address (leave blank for none)')) + '\n' + gateway = self._get_ip_address(str(_('Gateway address')), header, True, False) if edit_nic.dns: display_dns = ' '.join(edit_nic.dns) else: display_dns = None - dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip() + + header = str(_('Enter your DNS servers with space separated (leave blank for none)')) + '\n' + dns_servers = self._get_ip_address( + str(_('DNS servers')), + header, + True, + True, + display_dns + ) dns = [] - if len(dns_input): - dns = dns_input.split(' ') + if dns_servers is not None: + dns = dns_servers.split(' ') return Nic(iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False) else: @@ -128,35 +168,40 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: def ask_to_configure_network(preset: Optional[NetworkConfiguration]) -> Optional[NetworkConfiguration]: """ - Configure the network on the newly installed system + Configure the network on the newly installed system """ - options = {n.display_msg(): n for n in NicType} - preset_val = preset.type.display_msg() if preset else None - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select one network interface to configure'), - list(options.keys()), - preset_values=preset_val, - sort=False, + + items = [MenuItem(n.display_msg(), value=n) for n in NicType] + group = MenuItemGroup(items, sort_items=True) + + if preset: + group.set_selected_by_value(preset.type) + + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Network configuration'))), allow_reset=True, - allow_reset_warning_msg=warning + allow_skip=True ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - case MenuSelectionType.Selection: - nic_type = options[choice.single_value] + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return None + case ResultType.Selection: + config = result.get_value() - match nic_type: + match config: case NicType.ISO: return NetworkConfiguration(NicType.ISO) case NicType.NM: return NetworkConfiguration(NicType.NM) case NicType.MANUAL: preset_nics = preset.nics if preset else [] - nics = ManualNetworkConfig('Configure interfaces', preset_nics).run() + nics = ManualNetworkConfig(str(_('Configure interfaces')), preset_nics).run() + if nics: return NetworkConfiguration(NicType.MANUAL, nics) diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 35ba5a8b0a..a3c09ef952 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -3,9 +3,14 @@ from typing import List, Any, TYPE_CHECKING, Optional from ..hardware import SysInfo, GfxDriver -from ..menu import MenuSelectionType, Menu from ..models.bootloader import Bootloader +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, FrameStyle, Alignment, + ResultType, Orientation, PreviewStyle +) + if TYPE_CHECKING: _: Any @@ -17,71 +22,88 @@ def select_kernel(preset: List[str] = []) -> List[str]: :return: The string as a selected kernel :rtype: string """ - kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"] default_kernel = "linux" - warning = str(_('Are you sure you want to reset this setting?')) + items = [MenuItem(k, value=k) for k in kernels] - choice = Menu( - _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel), - kernels, - sort=True, - multi=True, - preset_values=preset, - allow_reset_warning_msg=warning - ).run() + group = MenuItemGroup(items, sort_items=True) + group.set_default_by_value(default_kernel) + group.set_focus_by_value(default_kernel) + group.set_selected_by_value(preset) - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value + result = SelectMenu( + group, + allow_skip=True, + allow_reset=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Kernel'))), + multi=True + ).run() - return [] + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return [] + case ResultType.Selection: + return result.get_values() -def ask_for_bootloader(preset: Bootloader) -> Bootloader: +def ask_for_bootloader(preset: Optional[Bootloader]) -> Optional[Bootloader]: # Systemd is UEFI only if not SysInfo.has_uefi(): - options = [Bootloader.Grub.value, Bootloader.Limine.value] - default = Bootloader.Grub.value + options = [Bootloader.Grub, Bootloader.Limine] + default = Bootloader.Grub else: - options = Bootloader.values() - default = Bootloader.Systemd.value - - preset_value = preset.value if preset else None - - choice = Menu( - _('Choose a bootloader'), - options, - preset_values=preset_value, - sort=False, - default_option=default + options = [b for b in Bootloader] + default = Bootloader.Systemd + + items = [MenuItem(o.value, value=o) for o in options] + group = MenuItemGroup(items) + group.set_default_by_value(default) + group.set_focus_by_value(preset) + + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Bootloader'))), + allow_skip=True ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return Bootloader(choice.value) - - return preset + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') def ask_for_uki(preset: bool = True) -> bool: - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - - prompt = _('Would you like to use unified kernel images?') - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), preset_values=preset_val).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True + prompt = str(_('Would you like to use unified kernel images?')) + '\n' + + group = MenuItemGroup.yes_no() + group.set_focus_by_value(preset) + + result = SelectMenu( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True + ).run() - return preset + match result.type_: + case ResultType.Skip: return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') -def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriver] = None) -> Optional[GfxDriver]: +def select_driver(options: List[GfxDriver] = [], preset: Optional[GfxDriver] = None) -> Optional[GfxDriver]: """ Some what convoluted function, whose job is simple. Select a graphics driver from a pre-defined set of popular options. @@ -92,47 +114,65 @@ def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriv if not options: options = [driver for driver in GfxDriver] - drivers = sorted([o.value for o in options]) - - if drivers: - title = '' - if SysInfo.has_amd_graphics(): - title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n' - if SysInfo.has_intel_graphics(): - title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n')) - if SysInfo.has_nvidia_graphics(): - title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) - - preset = current_value.value if current_value else None - - choice = Menu( - title, - drivers, - preset_values=preset, - default_option=GfxDriver.AllOpenSource.value, - preview_command=lambda x: GfxDriver(x).packages_text(), - preview_size=0.3 - ).run() - - if choice.type_ != MenuSelectionType.Selection: - return current_value - - return GfxDriver(choice.single_value) + items = [MenuItem(o.value, value=o, preview_action=lambda x: x.value.packages_text()) for o in options] + group = MenuItemGroup(items, sort_items=True) + group.set_default_by_value(GfxDriver.AllOpenSource) + + if preset is not None: + group.set_focus_by_value(preset) + + header = '' + if SysInfo.has_amd_graphics(): + header += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n' + if SysInfo.has_intel_graphics(): + header += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n')) + if SysInfo.has_nvidia_graphics(): + header += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) + + result = SelectMenu( + group, + header=header, + allow_skip=True, + allow_reset=True, + preview_size='auto', + preview_style=PreviewStyle.BOTTOM, + preview_frame=FrameProperties(str(_('Info')), h_frame_style=FrameStyle.MIN) + ).run() - return current_value + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return None + case ResultType.Selection: + return result.get_value() def ask_for_swap(preset: bool = True) -> bool: if preset: - preset_val = Menu.yes() + default_item = MenuItem.yes() else: - preset_val = Menu.no() + default_item = MenuItem.no() + + prompt = str(_('Would you like to use swap on zram?')) + '\n' - prompt = _('Would you like to use swap on zram?') - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run() + group = MenuItemGroup.yes_no() + group.set_focus_by_value(default_item) + + result = SelectMenu( + group, + header=prompt, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER, + allow_skip=True + ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True + match result.type_: + case ResultType.Skip: return preset + case ResultType.Selection: + return result.item() == MenuItem.yes() + case ResultType.Reset: + raise ValueError('Unhandled result type') return preset diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py deleted file mode 100644 index fdbb462501..0000000000 --- a/archinstall/lib/interactions/utils.py +++ /dev/null @@ -1,39 +0,0 @@ -from __future__ import annotations - -import getpass -from typing import Any, Optional, TYPE_CHECKING - -from ..models import PasswordStrength -from ..output import log, error - -if TYPE_CHECKING: - _: Any - -# used for signal handler -SIG_TRIGGER = None - - -def get_password(prompt: str = '') -> Optional[str]: - if not prompt: - prompt = _("Enter a password: ") - - while True: - try: - password = getpass.getpass(prompt) - except (KeyboardInterrupt, EOFError): - break - - if len(password.strip()) <= 0: - break - - strength = PasswordStrength.strength(password) - log(f'Password strength: {strength.value}', fg=strength.color()) - - passwd_verification = getpass.getpass(prompt=_('And one more time for verification: ')) - if password != passwd_verification: - error(' * Passwords did not match * ') - continue - - return password - - return None diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 92dc2dabc1..e1e523a120 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,8 +1,13 @@ from dataclasses import dataclass -from typing import Dict, Any, TYPE_CHECKING, Optional +from typing import Dict, Any, TYPE_CHECKING, Optional, List from .utils import list_keyboard_languages, list_locales, set_kb_layout, get_kb_layout -from ..menu import Selector, AbstractSubMenu, MenuSelectionType, Menu +from ..menu import AbstractSubMenu + +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType +) if TYPE_CHECKING: _: Any @@ -28,6 +33,12 @@ def json(self) -> Dict[str, str]: 'sys_enc': self.sys_enc } + def preview(self) -> str: + output = '{}: {}\n'.format(str(_('Keyboard layout')), self.kb_layout) + output += '{}: {}\n'.format(str(_('Locale language')), self.sys_lang) + output += '{}: {}'.format(str(_('Locale encoding')), self.sys_enc) + return output + @classmethod def _load_config(cls, config: 'LocaleConfiguration', args: Dict[str, Any]) -> 'LocaleConfiguration': if 'sys_lang' in args: @@ -54,34 +65,50 @@ def parse_arg(cls, args: Dict[str, Any]) -> 'LocaleConfiguration': class LocaleMenu(AbstractSubMenu): def __init__( self, - data_store: Dict[str, Any], locale_conf: LocaleConfiguration ): - self._preset = locale_conf - super().__init__(data_store=data_store) - - def setup_selection_menu_options(self) -> None: - self._menu_options['keyboard-layout'] = \ - Selector( - _('Keyboard layout'), - lambda preset: self._select_kb_layout(preset), - default=self._preset.kb_layout, - enabled=True) - self._menu_options['sys-language'] = \ - Selector( - _('Locale language'), - lambda preset: select_locale_lang(preset), - default=self._preset.sys_lang, - enabled=True) - self._menu_options['sys-encoding'] = \ - Selector( - _('Locale encoding'), - lambda preset: select_locale_enc(preset), - default=self._preset.sys_enc, - enabled=True) - - def run(self, allow_reset: bool = True) -> LocaleConfiguration: - super().run(allow_reset=allow_reset) + self._locale_conf = locale_conf + self._data_store: Dict[str, Any] = {} + menu_optioons = self._define_menu_options() + + self._item_group = MenuItemGroup(menu_optioons, sort_items=False, checkmarks=True) + super().__init__(self._item_group, data_store=self._data_store, allow_reset=True) + + def _define_menu_options(self) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Keyboard layout')), + action=lambda x: self._select_kb_layout(x), + value=self._locale_conf.kb_layout, + preview_action=self._prev_locale, + key='keyboard-layout' + ), + MenuItem( + text=str(_('Locale language')), + action=lambda x: select_locale_lang(x), + value=self._locale_conf.sys_lang, + preview_action=self._prev_locale, + key='sys-language' + ), + MenuItem( + text=str(_('Locale encoding')), + action=lambda x: select_locale_enc(x), + value=self._locale_conf.sys_enc, + preview_action=self._prev_locale, + key='sys-encoding' + ) + ] + + def _prev_locale(self, item: MenuItem) -> Optional[str]: + temp_locale = LocaleConfiguration( + self._menu_item_group.find_by_key('keyboard-layout').get_value(), + self._menu_item_group.find_by_key('sys-language').get_value(), + self._menu_item_group.find_by_key('sys-encoding').get_value(), + ) + return temp_locale.preview() + + def run(self) -> LocaleConfiguration: + super().run() if not self._data_store: return LocaleConfiguration.default() @@ -103,59 +130,79 @@ def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: locales = list_locales() locale_lang = set([locale.split()[0] for locale in locales]) - choice = Menu( - _('Choose which locale language to use'), - list(locale_lang), - sort=True, - preset_values=preset - ).run() + items = [MenuItem(ll, value=ll) for ll in locale_lang] + group = MenuItemGroup(items, sort_items=True) + group.set_focus_by_value(preset) - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Locale language'))), + allow_skip=True, + ).run() - return None + match result.type_: + case ResultType.Selection: + return result.get_value() + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled return type') def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: locales = list_locales() locale_enc = set([locale.split()[1] for locale in locales]) - choice = Menu( - _('Choose which locale encoding to use'), - list(locale_enc), - sort=True, - preset_values=preset - ).run() + items = [MenuItem(le, value=le) for le in locale_enc] + group = MenuItemGroup(items, sort_items=True) + group.set_focus_by_value(preset) - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Locale encoding'))), + allow_skip=True, + ).run() - return None + match result.type_: + case ResultType.Selection: + return result.get_value() + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled return type') def select_kb_layout(preset: Optional[str] = None) -> Optional[str]: """ - Asks the user to select a language - Usually this is combined with :ref:`archinstall.list_keyboard_languages`. + Select keyboard layout - :return: The language/dictionary key of the selected language + :return: The keyboard layout shortcut for the selected layout :rtype: str """ + kb_lang = list_keyboard_languages() # sort alphabetically and then by length sorted_kb_lang = sorted(kb_lang, key=lambda x: (len(x), x)) - choice = Menu( - _('Select keyboard layout'), - sorted_kb_lang, - preset_values=preset, - sort=False + items = [MenuItem(lang, value=lang) for lang in sorted_kb_lang] + group = MenuItemGroup(items, sort_items=False) + group.set_focus_by_value(preset) + + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Keyboard layout'))), + allow_skip=True, ).run() - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value + match result.type_: + case ResultType.Selection: + return result.get_value() + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled return type') return None diff --git a/archinstall/lib/menu/__init__.py b/archinstall/lib/menu/__init__.py index 9c86faf539..b4c2574d1a 100644 --- a/archinstall/lib/menu/__init__.py +++ b/archinstall/lib/menu/__init__.py @@ -1,9 +1,2 @@ -from .abstract_menu import Selector, AbstractMenu, AbstractSubMenu +from .abstract_menu import AbstractMenu, AbstractSubMenu from .list_manager import ListManager -from .menu import ( - MenuSelectionType, - MenuSelection, - Menu, -) -from .table_selection_menu import TableMenu -from .text_input import TextInput diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 23a27f733a..09500f8278 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -1,11 +1,14 @@ from __future__ import annotations -from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING +from typing import Callable, Any, List, Optional, Dict, TYPE_CHECKING -from .menu import Menu, MenuSelectionType from ..output import error from ..output import unicode_ljust -from ..translationhandler import TranslationHandler, Language +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + PreviewStyle, FrameProperties, FrameStyle, + ResultType, Chars, Tui +) if TYPE_CHECKING: _: Any @@ -144,41 +147,21 @@ def set_mandatory(self, value: bool) -> None: class AbstractMenu: def __init__( self, - data_store: Dict[str, Any] = {}, - auto_cursor: bool = False, - preview_size: float = 0.2 + item_group: MenuItemGroup, + data_store: Dict[str, Any], + auto_cursor: bool = True, + allow_reset: bool = False, + reset_warning: Optional[str] = None ): - """ - Create a new selection menu. - - :param data_store: Area (Dict) where the resulting data will be held. At least an entry for each option. Default area is self._data_store (not preset in the call, due to circular references - :type data_store: Dict - - :param auto_cursor: Boolean which determines if the cursor stays on the first item (false) or steps each invocation of a selection entry (true) - :type auto_cursor: bool - - :param preview_size. Size in fractions of screen size of the preview window - ;type preview_size: float (range 0..1) - - """ - self._enabled_order: List[str] = [] - self._translation_handler = TranslationHandler() - self.is_context_mgr = False + self._menu_item_group = item_group self._data_store = data_store self.auto_cursor = auto_cursor - self._menu_options: Dict[str, Selector] = {} - self.preview_size = preview_size - self._last_choice = None + self._allow_reset = allow_reset + self._reset_warning = reset_warning - self.setup_selection_menu_options() - self._sync_all() - self._populate_default_values() - - self.defined_text = str(_('Defined')) + self.is_context_mgr = False - @property - def last_choice(self): - return self._last_choice + self._sync_all_from_ds() def __enter__(self, *args: Any, **kwargs: Any) -> AbstractMenu: self.is_context_mgr = True @@ -189,263 +172,86 @@ def __exit__(self, *args: Any, **kwargs: Any) -> None: # TODO: skip processing when it comes from a planified exit if len(args) >= 2 and args[1]: error(args[1]) - print(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues") + Tui.print("Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues") raise args[1] - for key in self._menu_options: - selector = self._menu_options[key] - if key and key not in self._data_store: - self._data_store[key] = selector.current_selection - - self.exit_callback() - - @property - def translation_handler(self) -> TranslationHandler: - return self._translation_handler - - def _populate_default_values(self) -> None: - for config_key, selector in self._menu_options.items(): - if selector.default is not None and config_key not in self._data_store: - self._data_store[config_key] = selector.default - - def _sync_all(self) -> None: - for key in self._menu_options.keys(): - self._sync(key) - - def _sync(self, selector_name: str) -> None: - value = self._data_store.get(selector_name, None) - selector = self._menu_options.get(selector_name, None) + self._sync_all_to_ds() - if value is not None: - self._menu_options[selector_name].set_current_selection(value) - elif selector is not None and selector.has_selection(): - self._data_store[selector_name] = selector.current_selection + def _sync_all_from_ds(self) -> None: + for item in self._menu_item_group.menu_items: + if item.key is not None: + if (store_value := self._data_store.get(item.key, None)) is not None: + item.value = store_value - def setup_selection_menu_options(self) -> None: - """ Define the menu options. - Menu options can be defined here in a subclass or done per program calling self.set_option() - """ - return - - def pre_callback(self, selector_name) -> None: - """ will be called before each action in the menu """ - return - - def post_callback(self, selection_name: Optional[str] = None, value: Any = None): - """ will be called after each action in the menu """ - return True + def _sync_all_to_ds(self) -> None: + for item in self._menu_item_group.menu_items: + if item.key: + self._data_store[item.key] = item.value - def exit_callback(self) -> None: - """ will be called at the end of the processing of the menu """ - return - - def _update_enabled_order(self, selector_name: str) -> None: - self._enabled_order.append(selector_name) - - def enable(self, selector_name: str, mandatory: bool = False) -> None: - """ activates menu options """ - if self._menu_options.get(selector_name, None): - self._menu_options[selector_name].set_enabled(True) - self._update_enabled_order(selector_name) - self._menu_options[selector_name].set_mandatory(mandatory) - self._sync(selector_name) - else: - raise ValueError(f'No selector found: {selector_name}') + def _sync(self, item: MenuItem) -> None: + if not item.key: + return - def _preview_display(self, selection_name: str) -> Optional[str]: - config_name, selector = self._find_selection(selection_name) - if preview := selector.preview_func: - return preview() - return None + store_value = self._data_store.get(item.key, None) - def _get_menu_text_padding(self, entries: List[Selector]) -> int: - return max([len(str(selection.description)) for selection in entries]) + if store_value is not None: + item.value = store_value + elif item.value is not None: + self._data_store[item.key] = item.value - def _find_selection(self, selection_name: str) -> Tuple[str, Selector]: - enabled_menus = self._menus_to_enable() - padding = self._get_menu_text_padding(list(enabled_menus.values())) + def set_enabled(self, key: str, enabled: bool) -> None: + if (item := self._menu_item_group.find_by_key(key)) is not None: + item.enabled = enabled + return None - option = [] - for k, v in self._menu_options.items(): - if v.menu_text(padding).strip() == selection_name.strip(): - option.append((k, v)) + raise ValueError(f'No selector found: {key}') - if len(option) != 1: - raise ValueError(f'Selection not found: {selection_name}') - config_name = option[0][0] - selector = option[0][1] - return config_name, selector + def disable_all(self) -> None: + for item in self._menu_item_group.items: + item.enabled = False - def run(self, allow_reset: bool = False): - self._sync_all() - self.post_callback() - cursor_pos = None + def run(self) -> Optional[Any]: + self._sync_all_from_ds() while True: - enabled_menus = self._menus_to_enable() - - padding = self._get_menu_text_padding(list(enabled_menus.values())) - menu_options = [m.menu_text(padding) for m in enabled_menus.values()] - - warning_msg = str(_('All settings will be reset, are you sure?')) - - selection = Menu( - _('Set/Modify the below options'), - menu_options, - sort=False, - cursor_index=cursor_pos, - preview_command=self._preview_display, - preview_size=self.preview_size, - skip_empty_entries=True, - skip=False, - allow_reset=allow_reset, - allow_reset_warning_msg=warning_msg + result = SelectMenu( + self._menu_item_group, + allow_skip=False, + allow_reset=self._allow_reset, + reset_warning_msg=self._reset_warning, + preview_style=PreviewStyle.RIGHT, + preview_size='auto', + preview_frame=FrameProperties('Info', FrameStyle.MAX), ).run() - match selection.type_: - case MenuSelectionType.Reset: - self._data_store = {} - return - case MenuSelectionType.Selection: - value: str = selection.value # type: ignore - - 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 releasing control - if not self._process_selection(value): - break - - # we get the last action key - actions = {str(v.description): k for k, v in self._menu_options.items()} - self._last_choice = actions[selection.value.strip()] # type: ignore - - if not self.is_context_mgr: - self.__exit__() - - def _process_selection(self, selection_name: str) -> bool: - """ determines and executes the selection y - Can / Should be extended to handle specific selection issues - Returns true if the menu shall continue, False if it has ended - """ - # find the selected option in our option list - config_name, selector = self._find_selection(selection_name) - return self.exec_option(config_name, selector) - - def exec_option(self, config_name: str, p_selector: Optional[Selector] = None) -> bool: - """ processes the execution of a given menu entry - - pre process callback - - selection function - - post process callback - - exec action - returns True if the loop has to continue, false if the loop can be closed - """ - if not p_selector: - selector = self.option(config_name) - else: - selector = p_selector - - self.pre_callback(config_name) - - result = None - - if selector.func is not None: - cur_value = self.option(config_name).get_selection() - result = selector.func(cur_value) - self._menu_options[config_name].set_current_selection(result) - - if selector.do_store(): - self._data_store[config_name] = result - - exec_ret_val = selector.exec_func(config_name, result) if selector.exec_func else False - - self.post_callback(config_name, result) - - if exec_ret_val: - return False - - return True - - def _verify_selection_enabled(self, selection_name: str) -> bool: - if selection := self._menu_options.get(selection_name, None): - if not selection.enabled: - return False - - if len(selection.dependencies) > 0: - for dep in selection.dependencies: - if isinstance(dep, str): - if not self._verify_selection_enabled(dep) or self._menu_options[dep].is_empty(): - return False - elif callable(dep): # callable dependency eval - return dep() - else: - raise ValueError(f'Unsupported dependency: {selection_name}') - - if len(selection.dependencies_not) > 0: - for dep in selection.dependencies_not: - if not self._menu_options[dep].is_empty(): - return False - return True - - raise ValueError(f'No selection found: {selection_name}') - - def _menus_to_enable(self) -> dict: - """ general """ - enabled_menus = {} - - for name, selection in self._menu_options.items(): - if self._verify_selection_enabled(name): - enabled_menus[name] = selection - - # sort the enabled menu by the order we enabled them in - # we'll add the entries that have been enabled via the selector constructor at the top - enabled_keys = [i for i in enabled_menus.keys() if i not in self._enabled_order] - # and then we add the ones explicitly enabled by the enable function - enabled_keys += [i for i in self._enabled_order if i in enabled_menus.keys()] - - ordered_menus = {k: enabled_menus[k] for k in enabled_keys} - - return ordered_menus - - def option(self, name: str) -> Selector: - # TODO check inexistent name - return self._menu_options[name] + match result.type_: + case ResultType.Selection: + item: MenuItem = result.item() - def list_enabled_options(self) -> Iterator: - """ Iterator to retrieve the enabled menu options at a given time. - The results are dynamic (if between calls to the iterator some elements -still not retrieved- are (de)activated - """ - for item in self._menu_options: - if item in self._menus_to_enable(): - yield item + if item.action is None: + break + case ResultType.Reset: + self._data_store = {} + return None - def _select_archinstall_language(self, preset: Language) -> Language: - from ..interactions.general_conf import select_archinstall_language - language = select_archinstall_language(self.translation_handler.translated_languages, preset) - self._translation_handler.activate(language) - return language + self._sync_all_to_ds() + return None class AbstractSubMenu(AbstractMenu): - def __init__(self, data_store: Dict[str, Any] = {}, preview_size: float = 0.2): - super().__init__(data_store=data_store, preview_size=preview_size) - - self._menu_options['__separator__'] = Selector('') - self._menu_options['back'] = \ - Selector( - Menu.back(), - no_store=True, - enabled=True, - exec_func=lambda n, v: True, - ) + def __init__( + self, + item_group: MenuItemGroup, + data_store: Dict[str, Any], + auto_cursor: bool = True, + allow_reset: bool = False + ): + back_text = f'{Chars.Right_arrow} ' + str(_('Back')) + item_group.menu_items.append(MenuItem(text=back_text)) + + super().__init__( + item_group, + data_store=data_store, + auto_cursor=auto_cursor, + allow_reset=allow_reset + ) diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index de18791cf6..178e7c9d9e 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -1,10 +1,12 @@ import copy -from os import system from typing import Any, TYPE_CHECKING, Dict, Optional, Tuple, List - -from .menu import Menu from ..output import FormattedOutput +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + Alignment, ResultType +) + if TYPE_CHECKING: _: Any @@ -63,31 +65,34 @@ def run(self) -> List[Any]: data_formatted = self.reformat(self._data) options, header = self._prepare_selection(data_formatted) - system('clear') + items = [MenuItem(o, value=o) for o in options] + group = MenuItemGroup(items, sort_items=False) - choice = Menu( - self._prompt, - options, - sort=False, - clear_screen=False, - clear_menu_on_exit=False, + result = SelectMenu( + group, header=header, - skip_empty_entries=True, - skip=False, - show_search_hint=False + search_enabled=False, + allow_skip=False, + alignment=Alignment.CENTER, ).run() - if choice.value in self._base_actions: - self._data = self.handle_action(choice.value, None, self._data) - elif choice.value in self._terminate_actions: + match result.type_: + case ResultType.Selection: + value = result.get_value() + case _: + raise ValueError('Unhandled return type') + + if value in self._base_actions: + self._data = self.handle_action(value, None, self._data) + elif value in self._terminate_actions: break else: # an entry of the existing selection was chosen - selected_entry = data_formatted[choice.value] # type: ignore + selected_entry = result.get_value() self._run_actions_on_entry(selected_entry) - self._last_choice = choice.value # type: ignore + self._last_choice = value - if choice.value == self._cancel_action: + if result.get_value() == self._cancel_action: return self._original_data # return the original list else: return self._data @@ -110,23 +115,30 @@ def _prepare_selection(self, data_formatted: Dict[str, Any]) -> Tuple[List[str], return options, header - def _run_actions_on_entry(self, entry: Any): + def _run_actions_on_entry(self, entry: Any) -> None: options = self.filter_options(entry, self._sub_menu_actions) + [self._cancel_action] - display_value = self.selected_action_display(entry) - prompt = _("Select an action for '{}'").format(display_value) + items = [MenuItem(o, value=o) for o in options] + group = MenuItemGroup(items, sort_items=False) - choice = Menu( - prompt, - options, - sort=False, - clear_screen=False, - clear_menu_on_exit=False, - show_search_hint=False + header = f'{self.selected_action_display(entry)}\n' + + result = SelectMenu( + group, + header=header, + search_enabled=False, + allow_skip=False, + alignment=Alignment.CENTER ).run() - if choice.value and choice.value != self._cancel_action: - self._data = self.handle_action(choice.value, entry, self._data) + match result.type_: + case ResultType.Selection: + value = result.get_value() + case _: + raise ValueError('Unhandled return type') + + if value != self._cancel_action: + self._data = self.handle_action(value, entry, self._data) def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: """ @@ -139,10 +151,9 @@ def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: # these are the header rows of the table and do not map to any User obviously # we're adding 2 spaces as prefix because the menu selector '> ' will be put before # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[Any]] = {f' {rows[0]}': None, f' {rows[1]}': None} + display_data: Dict[str, Optional[Any]] = {f'{rows[0]}': None, f'{rows[1]}': None} for row, entry in zip(rows[2:], data): - row = row.replace('|', '\\|') display_data[row] = entry return display_data diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py deleted file mode 100644 index dadfd82e1c..0000000000 --- a/archinstall/lib/menu/menu.py +++ /dev/null @@ -1,350 +0,0 @@ -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 - -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): # type: ignore[misc] - _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 diff --git a/archinstall/lib/menu/menu_helper.py b/archinstall/lib/menu/menu_helper.py new file mode 100644 index 0000000000..21c8da77ac --- /dev/null +++ b/archinstall/lib/menu/menu_helper.py @@ -0,0 +1,64 @@ +from typing import Any, Tuple, List, Dict, Optional + +from archinstall.lib.output import FormattedOutput + +from archinstall.tui import ( + MenuItemGroup, MenuItem +) + + +class MenuHelper: + @staticmethod + def create_table( + data: Optional[List[Any]] = None, + table_data: Optional[Tuple[List[Any], str]] = None, + ) -> Tuple[MenuItemGroup, str]: + if data is not None: + table_text = FormattedOutput.as_table(data) + rows = table_text.split('\n') + table = MenuHelper._create_table(data, rows) + elif table_data is not None: + # we assume the table to be + # h1 | h2 + # ----------- + # r1 | r2 + data = table_data[0] + rows = table_data[1].split('\n') + table = MenuHelper._create_table(data, rows) + else: + raise ValueError('Either "data" or "table_data" must be provided') + + table, header = MenuHelper._prepare_selection(table) + + items = [ + MenuItem(text, value=entry) + for text, entry in table.items() + ] + group = MenuItemGroup(items, sort_items=False) + + return group, header + + @staticmethod + def _create_table(data: List[Any], rows: List[str], header_padding: int = 2) -> Dict[str, Any]: + # these are the header rows of the table and do not map to any data obviously + # we're adding 2 spaces as prefix because the menu selector '> ' will be put before + # the selectable rows so the header has to be aligned + padding = ' ' * header_padding + display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None} + + for row, entry in zip(rows[2:], data): + display_data[row] = entry + + return display_data + + @staticmethod + def _prepare_selection(table: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: + # header rows are mapped to None so make sure to exclude those from the selectable data + options = {key: val for key, val in table.items() if val is not None} + header = '' + + if len(options) > 0: + table_header = [key for key, val in table.items() if val is None] + header = '\n'.join(table_header) + + return options, header diff --git a/archinstall/lib/menu/table_selection_menu.py b/archinstall/lib/menu/table_selection_menu.py deleted file mode 100644 index fec6ae59bc..0000000000 --- a/archinstall/lib/menu/table_selection_menu.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Any, Tuple, List, Dict, Optional, Callable - -from .menu import MenuSelectionType, MenuSelection, Menu -from ..output import FormattedOutput - - -class TableMenu(Menu): - def __init__( - self, - title: str, - data: Optional[List[Any]] = None, - table_data: Optional[Tuple[List[Any], str]] = None, - preset: List[Any] = [], - custom_menu_options: List[str] = [], - default: Any = None, - multi: bool = False, - preview_command: Optional[Callable] = None, - preview_title: str = 'Info', - preview_size: float = 0.0, - allow_reset: bool = True, - allow_reset_warning_msg: Optional[str] = None, - skip: bool = True - ): - """ - param title: Text that will be displayed above the menu - :type title: str - - param data: List of objects that will be displayed as rows - :type data: List - - param table_data: Tuple containing a list of objects and the corresponding - Table representation of the data as string; this can be used in case the table - has to be crafted in a more sophisticated manner - :type table_data: Optional[Tuple[List[Any], str]] - - param custom_options: List of custom options that will be displayed under the table - :type custom_menu_options: List - - :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 - """ - self._custom_options = custom_menu_options - self._multi = multi - - if multi: - header_padding = 7 - else: - header_padding = 2 - - if data is not None: - table_text = FormattedOutput.as_table(data) - rows = table_text.split('\n') - table = self._create_table(data, rows, header_padding=header_padding) - elif table_data is not None: - # we assume the table to be - # h1 | h2 - # ----------- - # r1 | r2 - data = table_data[0] - rows = table_data[1].split('\n') - table = self._create_table(data, rows, header_padding=header_padding) - else: - raise ValueError('Either "data" or "table_data" must be provided') - - self._options, header = self._prepare_selection(table) - - preset_values = self._preset_values(preset) - - extra_bottom_space = True if preview_command else False - - super().__init__( - title, - self._options, - preset_values=preset_values, - header=header, - skip_empty_entries=True, - show_search_hint=False, - multi=multi, - default_option=default, - preview_command=lambda x: self._table_show_preview(preview_command, x), - preview_size=preview_size, - preview_title=preview_title, - extra_bottom_space=extra_bottom_space, - allow_reset=allow_reset, - allow_reset_warning_msg=allow_reset_warning_msg, - skip=skip - ) - - def _preset_values(self, preset: List[Any]) -> List[str]: - # when we create the table of just the preset values it will - # be formatted a bit different due to spacing, so to determine - # correct rows lets remove all the spaces and compare apples with apples - preset_table = FormattedOutput.as_table(preset).strip() - data_rows = preset_table.split('\n')[2:] # get all data rows - pure_data_rows = [self._escape_row(row.replace(' ', '')) for row in data_rows] - - # the actual preset value has to be in non-escaped form - pure_option_rows = {o.replace(' ', ''): self._unescape_row(o) for o in self._options.keys()} - preset_rows = [row for pure, row in pure_option_rows.items() if pure in pure_data_rows] - - return preset_rows - - def _table_show_preview(self, preview_command: Optional[Callable], selection: Any) -> Optional[str]: - if preview_command: - row = self._escape_row(selection) - obj = self._options[row] - return preview_command(obj) - return None - - def run(self) -> MenuSelection: - choice = super().run() - - match choice.type_: - case MenuSelectionType.Selection: - if self._multi: - choice.value = [self._options[val] for val in choice.value] # type: ignore - else: - choice.value = self._options[choice.value] # type: ignore - - return choice - - def _escape_row(self, row: str) -> str: - return row.replace('|', '\\|') - - def _unescape_row(self, row: str) -> str: - return row.replace('\\|', '|') - - def _create_table(self, data: List[Any], rows: List[str], header_padding: int = 2) -> Dict[str, Any]: - # these are the header rows of the table and do not map to any data obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - padding = ' ' * header_padding - display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None} - - for row, entry in zip(rows[2:], data): - row = self._escape_row(row) - display_data[row] = entry - - return display_data - - def _prepare_selection(self, table: Dict[str, Any]) -> Tuple[Dict[str, Any], str]: - # header rows are mapped to None so make sure to exclude those from the selectable data - options = {key: val for key, val in table.items() if val is not None} - header = '' - - if len(options) > 0: - table_header = [key for key, val in table.items() if val is None] - header = '\n'.join(table_header) - - custom = {key: None for key in self._custom_options} - options.update(custom) - - return options, header diff --git a/archinstall/lib/menu/text_input.py b/archinstall/lib/menu/text_input.py deleted file mode 100644 index ba672c8c11..0000000000 --- a/archinstall/lib/menu/text_input.py +++ /dev/null @@ -1,26 +0,0 @@ -import readline -import sys - - -class TextInput: - def __init__(self, prompt: str, prefilled_text=''): - self._prompt = prompt - self._prefilled_text = prefilled_text - - def _hook(self) -> None: - readline.insert_text(self._prefilled_text) - readline.redisplay() - - def run(self) -> str: - readline.set_pre_input_hook(self._hook) - try: - result = input(self._prompt) - except (KeyboardInterrupt, EOFError): - # To make sure any output that may follow - # will be on the line after the prompt - sys.stdout.write('\n') - sys.stdout.flush() - - result = '' - readline.set_pre_input_hook() - return result diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index ca92f3f3ee..307e5cbc6e 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -1,16 +1,24 @@ import time import json +import urllib.parse from pathlib import Path from dataclasses import dataclass, field from enum import Enum -from typing import Dict, Any, List, Optional, TYPE_CHECKING +from typing import Dict, Any, List, Optional, TYPE_CHECKING, Tuple -from .menu import AbstractSubMenu, Selector, MenuSelectionType, Menu, ListManager, TextInput +from .menu import AbstractSubMenu, ListManager from .networking import fetch_data_from_url from .output import FormattedOutput, debug from .storage import storage from .models.mirrors import MirrorStatusListV3, MirrorStatusEntryV3 +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + EditMenu +) + + if TYPE_CHECKING: _: Any @@ -67,7 +75,7 @@ def parse_args(cls, args: List[Dict[str, str]]) -> List['CustomMirror']: @dataclass class MirrorConfiguration: - mirror_regions: Dict[str, List[str]] = field(default_factory=dict) + mirror_regions: Dict[str, List[MirrorStatusEntryV3]] = field(default_factory=dict) custom_mirrors: List[CustomMirror] = field(default_factory=list) @property @@ -85,7 +93,7 @@ def mirrorlist_config(self) -> str: for region, mirrors in self.mirror_regions.items(): for mirror in mirrors: - config += f'\n\n## {region}\nServer = {mirror}\n' + config += f'\n\n## {region}\nServer = {mirror.url}$repo/os/$arch\n' for cm in self.custom_mirrors: config += f'\n\n## {cm.name}\nServer = {cm.url}\n' @@ -116,13 +124,18 @@ def parse_args(cls, args: Dict[str, Any]) -> 'MirrorConfiguration': class CustomMirrorList(ListManager): - def __init__(self, prompt: str, custom_mirrors: List[CustomMirror]): + def __init__(self, custom_mirrors: List[CustomMirror]): self._actions = [ str(_('Add a custom mirror')), str(_('Change custom mirror')), str(_('Delete custom mirror')) ] - super().__init__(prompt, custom_mirrors, [self._actions[0]], self._actions[1:]) + super().__init__( + '', + custom_mirrors, + [self._actions[0]], + self._actions[1:] + ) def selected_action_display(self, mirror: CustomMirror) -> str: return mirror.name @@ -148,164 +161,190 @@ def handle_action( return data - def _add_custom_mirror(self, mirror: Optional[CustomMirror] = None) -> Optional[CustomMirror]: - prompt = '\n\n' + str(_('Enter name (leave blank to skip): ')) - existing_name = mirror.name if mirror else '' - - while True: - name = TextInput(prompt, existing_name).run() - if not name: - return mirror - break - - prompt = '\n' + str(_('Enter url (leave blank to skip): ')) - existing_url = mirror.url if mirror else '' - - while True: - url = TextInput(prompt, existing_url).run() - if not url: - return mirror - break - - sign_check_choice = Menu( - str(_('Select signature check option')), - [s.value for s in SignCheck], - skip=False, - clear_screen=False, - preset_values=mirror.sign_check.value if mirror else None + def _add_custom_mirror(self, preset: Optional[CustomMirror] = None) -> Optional[CustomMirror]: + edit_result = EditMenu( + str(_('Mirror name')), + alignment=Alignment.CENTER, + allow_skip=True, + default_text=preset.name if preset else None + ).input() + + match edit_result.type_: + case ResultType.Selection: + name = edit_result.text() + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled return type') + + header = f'{str(_("Name"))}: {name}' + + edit_result = EditMenu( + str(_('Url')), + header=header, + alignment=Alignment.CENTER, + allow_skip=True, + default_text=preset.url if preset else None + ).input() + + match edit_result.type_: + case ResultType.Selection: + url = edit_result.text() + case ResultType.Skip: + return preset + case _: + raise ValueError('Unhandled return type') + + header += f'\n{str(_("Url"))}: {url}\n' + prompt = f'{header}\n' + str(_('Select signature check')) + + sign_chk_items = [MenuItem(s.value, value=s.value) for s in SignCheck] + group = MenuItemGroup(sign_chk_items, sort_items=False) + + if preset is not None: + group.set_selected_by_value(preset.sign_check.value) + + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + allow_skip=False ).run() - sign_option_choice = Menu( - str(_('Select signature option')), - [s.value for s in SignOption], - skip=False, - clear_screen=False, - preset_values=mirror.sign_option.value if mirror else None + match result.type_: + case ResultType.Selection: + sign_check = SignCheck(result.get_value()) + case _: + raise ValueError('Unhandled return type') + + header += f'{str(_("Signature check"))}: {sign_check.value}\n' + prompt = f'{header}\n' + 'Select signature option' + + sign_opt_items = [MenuItem(s.value, value=s.value) for s in SignOption] + group = MenuItemGroup(sign_opt_items, sort_items=False) + + if preset is not None: + group.set_selected_by_value(preset.sign_option.value) + + result = SelectMenu( + group, + header=prompt, + alignment=Alignment.CENTER, + allow_skip=False ).run() - return CustomMirror( - name, - url, - SignCheck(sign_check_choice.single_value), - SignOption(sign_option_choice.single_value) - ) + match result.type_: + case ResultType.Selection: + sign_opt = SignOption(result.get_value()) + case _: + raise ValueError('Unhandled return type') + + return CustomMirror(name, url, sign_check, sign_opt) class MirrorMenu(AbstractSubMenu): def __init__( self, - data_store: Dict[str, Any], preset: Optional[MirrorConfiguration] = None ): if preset: - self._preset = preset + self._mirror_config = preset else: - self._preset = MirrorConfiguration() - - super().__init__(data_store=data_store) - - def setup_selection_menu_options(self) -> None: - self._menu_options['mirror_regions'] = \ - Selector( - _('Mirror region'), - lambda preset: select_mirror_regions(preset), - display_func=lambda x: ', '.join(x.keys()) if x else '', - default=self._preset.mirror_regions, - enabled=True) - self._menu_options['custom_mirrors'] = \ - Selector( - _('Custom mirrors'), - lambda preset: select_custom_mirror(preset=preset), - display_func=lambda x: str(_('Defined')) if x else '', - preview_func=self._prev_custom_mirror, - default=self._preset.custom_mirrors, - enabled=True + self._mirror_config = MirrorConfiguration() + + self._data_store: Dict[str, Any] = {} + + menu_optioons = self._define_menu_options() + self._item_group = MenuItemGroup(menu_optioons, checkmarks=True) + + super().__init__(self._item_group, data_store=self._data_store, allow_reset=True) + + def _define_menu_options(self) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Mirror region')), + action=lambda x: select_mirror_regions(x), + value=self._mirror_config.mirror_regions, + preview_action=self._prev_regions, + key='mirror_regions' + ), + MenuItem( + text=str(_('Custom mirrors')), + action=lambda x: select_custom_mirror(x), + value=self._mirror_config.custom_mirrors, + preview_action=self._prev_custom_mirror, + key='custom_mirrors' ) + ] - def _prev_custom_mirror(self) -> Optional[str]: - selector = self._menu_options['custom_mirrors'] + def _prev_regions(self, item: MenuItem) -> Optional[str]: + mirrors: Dict[str, List[MirrorStatusEntryV3]] = item.get_value() - if selector.has_selection(): - custom_mirrors: List[CustomMirror] = selector.current_selection # type: ignore - output = FormattedOutput.as_table(custom_mirrors) - return output.strip() + output = '' + for name, status_list in mirrors.items(): + output += f'{name}\n' + output += '-' * len(name) + '\n' - return None + for entry in status_list: + output += f'{entry.url}\n' - def run(self, allow_reset: bool = True) -> Optional[MirrorConfiguration]: - super().run(allow_reset=allow_reset) + output += '\n' - if self._data_store.get('mirror_regions', None) or self._data_store.get('custom_mirrors', None): - return MirrorConfiguration( - mirror_regions=self._data_store['mirror_regions'], - custom_mirrors=self._data_store['custom_mirrors'], - ) + return output - return None + def _prev_custom_mirror(self, item: MenuItem) -> Optional[str]: + if not item.value: + return None + custom_mirrors: List[CustomMirror] = item.value + output = FormattedOutput.as_table(custom_mirrors) + return output.strip() -def select_mirror_regions(preset_values: Dict[str, List[str]] = {}) -> Dict[str, List[str]]: - """ - Asks the user to select a mirror or region - Usually this is combined with :ref:`archinstall.list_mirrors`. + def run(self) -> MirrorConfiguration: + super().run() - :return: The dictionary information about a mirror/region. - :rtype: dict - """ - if preset_values is None: - preselected = None - else: - preselected = list(preset_values.keys()) + if not self._data_store: + return MirrorConfiguration() - remote_mirrors = list_mirrors_from_remote() - mirrors: Dict[str, list[str]] = {} + return MirrorConfiguration( + mirror_regions=self._data_store.get('mirror_regions', None), + custom_mirrors=self._data_store.get('custom_mirrors', None), + ) - if remote_mirrors: - choice = Menu( - _('Select one of the regions to download packages from'), - list(remote_mirrors.keys()), - preset_values=preselected, - multi=True, - allow_reset=True - ).run() - match choice.type_: - case MenuSelectionType.Reset: - return {} - case MenuSelectionType.Skip: - return preset_values - case MenuSelectionType.Selection: - for region in choice.multi_value: - mirrors.setdefault(region, []) - for mirror in _sort_mirrors_by_performance(remote_mirrors[region]): - mirrors[region].append(mirror.server_url) - return mirrors - else: - local_mirrors = list_mirrors_from_local() - - choice = Menu( - _('Select one of the regions to download packages from'), - list(local_mirrors.keys()), - preset_values=preselected, - multi=True, - allow_reset=True - ).run() +def select_mirror_regions(preset: Dict[str, List[MirrorStatusEntryV3]]) -> Dict[str, List[MirrorStatusEntryV3]]: + mirrors: Dict[str, List[MirrorStatusEntryV3]] | None = list_mirrors_from_remote() - match choice.type_: - case MenuSelectionType.Reset: - return {} - case MenuSelectionType.Skip: - return preset_values - case MenuSelectionType.Selection: - for region in choice.multi_value: - mirrors[region] = local_mirrors[region] - return mirrors + if not mirrors: + mirrors = list_mirrors_from_local() - return mirrors + items = [MenuItem(name, value=(name, mirrors)) for name, mirrors in mirrors.items()] + group = MenuItemGroup(items, sort_items=True) + preset_values = [(name, mirror) for name, mirror in preset.items()] + group.set_selected_by_value(preset_values) -def select_custom_mirror(prompt: str = '', preset: List[CustomMirror] = []) -> list[CustomMirror]: - custom_mirrors = CustomMirrorList(prompt, preset).run() + result = SelectMenu( + group, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Mirror regions'))), + allow_reset=True, + allow_skip=True, + multi=True, + ).run() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Reset: + return {} + case ResultType.Selection: + selected_mirrors: List[Tuple[str, List[MirrorStatusEntryV3]]] = result.get_values() + return {name: mirror for name, mirror in selected_mirrors} + + +def select_custom_mirror(preset: List[CustomMirror] = []): + custom_mirrors = CustomMirrorList(preset).run() return custom_mirrors @@ -327,7 +366,7 @@ def list_mirrors_from_remote() -> Optional[Dict[str, List[MirrorStatusEntryV3]]] return None -def list_mirrors_from_local() -> Dict[str, list[str]]: +def list_mirrors_from_local() -> Dict[str, List[MirrorStatusEntryV3]]: with Path('/etc/pacman.d/mirrorlist').open('r') as fp: mirrorlist = fp.read() return _parse_locale_mirrors(mirrorlist) @@ -370,13 +409,13 @@ def _parse_remote_mirror_list(mirrorlist: str) -> Dict[str, List[MirrorStatusEnt return sorted_by_regions -def _parse_locale_mirrors(mirrorlist: str) -> Dict[str, List[str]]: +def _parse_locale_mirrors(mirrorlist: str) -> Dict[str, List[MirrorStatusEntryV3]]: lines = mirrorlist.splitlines() # remove empty lines lines = [line for line in lines if line] - mirror_list: Dict[str, List[str]] = {} + mirror_list: Dict[str, List[MirrorStatusEntryV3]] = {} current_region = '' for idx, line in enumerate(lines): @@ -391,6 +430,20 @@ def _parse_locale_mirrors(mirrorlist: str) -> Dict[str, List[str]]: break url = line.removeprefix('Server = ') - mirror_list[current_region].append(url) + mirror_entry = MirrorStatusEntryV3( + url=url.rstrip('$repo/os/$arch'), + protocol=urllib.parse.urlparse(url).scheme, + active=True, + country=current_region or 'Worldwide', + # The following values are normally populated by + # archlinux.org mirror-list endpoint, and can't be known + # from just the local mirror-list file. + country_code='WW', + isos=True, + ipv4=True, + ipv6=True, + details='Locally defined mirror', + ) + mirror_list[current_region].append(mirror_entry) return mirror_list diff --git a/archinstall/lib/models/audio_configuration.py b/archinstall/lib/models/audio_configuration.py index ff307c3be6..e25f93291a 100644 --- a/archinstall/lib/models/audio_configuration.py +++ b/archinstall/lib/models/audio_configuration.py @@ -12,13 +12,10 @@ @dataclass class Audio(Enum): + NoAudio = 'No audio server' Pipewire = 'pipewire' Pulseaudio = 'pulseaudio' - @staticmethod - def no_audio_text() -> str: - return str(_('No audio server')) - @dataclass class AudioConfiguration: @@ -47,8 +44,9 @@ def install_audio_config( case Audio.Pulseaudio: installation.add_additional_packages("pulseaudio") - if SysInfo.requires_sof_fw(): - installation.add_additional_packages('sof-firmware') + if self.audio != Audio.NoAudio: + if SysInfo.requires_sof_fw(): + installation.add_additional_packages('sof-firmware') - if SysInfo.requires_alsa_fw(): - installation.add_additional_packages('alsa-firmware') + if SysInfo.requires_alsa_fw(): + installation.add_additional_packages('alsa-firmware') diff --git a/archinstall/lib/models/mirrors.py b/archinstall/lib/models/mirrors.py index dc95644da4..7b42895341 100644 --- a/archinstall/lib/models/mirrors.py +++ b/archinstall/lib/models/mirrors.py @@ -1,19 +1,20 @@ +from pydantic import BaseModel, field_validator, model_validator import datetime -import pydantic import http.client import urllib.error import urllib.parse import urllib.request from typing import ( Dict, - List + List, + Optional ) from ..networking import ping, DownloadTimer from ..output import debug -class MirrorStatusEntryV3(pydantic.BaseModel): +class MirrorStatusEntryV3(BaseModel): url: str protocol: str active: bool @@ -91,15 +92,15 @@ def latency(self) -> float | None: return self._latency - @pydantic.field_validator('score', mode='before') - def validate_score(cls, value) -> int | None: + @field_validator('score', mode='before') + def validate_score(cls, value: int) -> Optional[int]: if value is not None: value = round(value) debug(f" score: {value}") return value - @pydantic.model_validator(mode='after') + @model_validator(mode='after') def debug_output(self, validation_info) -> 'MirrorStatusEntryV3': self._hostname, *_port = urllib.parse.urlparse(self.url).netloc.split(':', 1) self._port = int(_port[0]) if _port and len(_port) >= 1 else None @@ -108,16 +109,19 @@ def debug_output(self, validation_info) -> 'MirrorStatusEntryV3': return self -class MirrorStatusListV3(pydantic.BaseModel): +class MirrorStatusListV3(BaseModel): cutoff: int last_check: datetime.datetime num_checks: int urls: List[MirrorStatusEntryV3] version: int - @pydantic.model_validator(mode='before') + @model_validator(mode='before') @classmethod - def check_model(cls, data: Dict[str, int | datetime.datetime | List[MirrorStatusEntryV3]]) -> Dict[str, int | datetime.datetime | List[MirrorStatusEntryV3]]: + def check_model( + cls, + data: Dict[str, int | datetime.datetime | List[MirrorStatusEntryV3]] + ) -> Dict[str, int | datetime.datetime | List[MirrorStatusEntryV3]]: if data.get('version') == 3: return data diff --git a/archinstall/lib/networking.py b/archinstall/lib/networking.py index 5ac9f0ecbd..2ae780d82a 100644 --- a/archinstall/lib/networking.py +++ b/archinstall/lib/networking.py @@ -14,6 +14,7 @@ from .exceptions import SysCallError, DownloadTimeout from .output import error, info from .pacman import Pacman +from .output import debug class DownloadTimer(): @@ -191,7 +192,7 @@ def ping(hostname, timeout=5) -> int: latency = round((time.time() - started) * 1000) break except socket.error as error: - print(f"Error: {error}") + debug(f"Error: {error}") break icmp_socket.close() diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 4bf44d6e18..0a491fbee8 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -100,7 +100,7 @@ def as_table( value = record.get(key, '') if '!' in key: - value = '*' * width + value = '*' * len(value) if isinstance(value, (int, float)) or (isinstance(value, str) and value.isnumeric()): obj_data.append(unicode_rjust(str(value), width)) @@ -322,14 +322,9 @@ def log( Journald.log(text, level=level) - from .menu import Menu - if not Menu.is_menu_active(): - # Finally, print the log unless we skipped it based on level. - # We use sys.stdout.write()+flush() instead of print() to try and - # fix issue #94 - if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): - sys.stdout.write(f"{text}\n") - sys.stdout.flush() + if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): + from archinstall.tui import Tui + Tui.print(text) def _count_wchars(string: str) -> int: diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index 980165d634..d0508ee939 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -1,13 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Dict +from typing import TYPE_CHECKING, Any, Optional, Dict, List from archinstall.default_profiles.profile import Profile, GreeterType from .profile_model import ProfileConfiguration -from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector +from ..menu import AbstractSubMenu from ..interactions.system_conf import select_driver from ..hardware import GfxDriver +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + Orientation +) + if TYPE_CHECKING: _: Any @@ -15,7 +21,6 @@ class ProfileMenu(AbstractSubMenu): def __init__( self, - data_store: Dict[str, Any], preset: Optional[ProfileConfiguration] = None ): if preset: @@ -23,45 +28,50 @@ def __init__( else: self._preset = ProfileConfiguration() - super().__init__(data_store=data_store) - - def setup_selection_menu_options(self) -> None: - self._menu_options['profile'] = Selector( - _('Type'), - lambda x: self._select_profile(x), - display_func=lambda x: x.name if x else None, - preview_func=self._preview_profile, - default=self._preset.profile, - enabled=True - ) - - self._menu_options['gfx_driver'] = Selector( - _('Graphics driver'), - lambda preset: self._select_gfx_driver(preset), - display_func=lambda x: x.value if x else None, - dependencies=['profile'], - preview_func=self._preview_gfx, - default=self._preset.gfx_driver if self._preset.profile and self._preset.profile.is_graphic_driver_supported() else None, - enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False - ) - - self._menu_options['greeter'] = Selector( - _('Greeter'), - lambda preset: select_greeter(self._menu_options['profile'].current_selection, preset), - display_func=lambda x: x.value if x else None, - dependencies=['profile'], - default=self._preset.greeter if self._preset.profile and self._preset.profile.is_greeter_supported() else None, - enabled=self._preset.profile.is_greeter_supported() if self._preset.profile else False - ) - - def run(self, allow_reset: bool = True) -> Optional[ProfileConfiguration]: - super().run(allow_reset=allow_reset) + self._data_store: Dict[str, Any] = {} + + menu_optioons = self._define_menu_options() + self._item_group = MenuItemGroup(menu_optioons, checkmarks=True) + + super().__init__(self._item_group, data_store=self._data_store, allow_reset=True) + + def _define_menu_options(self) -> List[MenuItem]: + return [ + MenuItem( + text=str(_('Type')), + action=lambda x: self._select_profile(x), + value=self._preset.profile, + preview_action=self._preview_profile, + key='profile' + ), + MenuItem( + text=str(_('Graphics driver')), + action=lambda x: self._select_gfx_driver(x), + value=self._preset.gfx_driver if self._preset.profile and self._preset.profile.is_graphic_driver_supported() else None, + preview_action=self._prev_gfx, + enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False, + dependencies=['profile'], + key='gfx_driver', + ), + MenuItem( + text=str(_('Greeter')), + action=lambda x: select_greeter(preset=x), + value=self._preset.greeter if self._preset.profile and self._preset.profile.is_greeter_supported() else None, + enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False, + preview_action=self._prev_greeter, + dependencies=['profile'], + key='greeter', + ) + ] + + def run(self) -> Optional[ProfileConfiguration]: + super().run() if self._data_store.get('profile', None): return ProfileConfiguration( - self._menu_options['profile'].current_selection, - self._menu_options['gfx_driver'].current_selection, - self._menu_options['greeter'].current_selection + self._data_store.get('profile', None), + self._data_store.get('gfx_driver', None), + self._data_store.get('greeter', None), ) return None @@ -71,52 +81,69 @@ def _select_profile(self, preset: Optional[Profile]) -> Optional[Profile]: if profile is not None: if not profile.is_graphic_driver_supported(): - self._menu_options['gfx_driver'].set_enabled(False) - self._menu_options['gfx_driver'].set_current_selection(None) + self._item_group.find_by_key('gfx_driver').enabled = False + self._item_group.find_by_key('gfx_driver').value = None else: - self._menu_options['gfx_driver'].set_enabled(True) - self._menu_options['gfx_driver'].set_current_selection(GfxDriver.AllOpenSource) + self._item_group.find_by_key('gfx_driver').enabled = True + self._item_group.find_by_key('gfx_driver').value = GfxDriver.AllOpenSource if not profile.is_greeter_supported(): - self._menu_options['greeter'].set_enabled(False) - self._menu_options['greeter'].set_current_selection(None) + self._item_group.find_by_key('greeter').enabled = False + self._item_group.find_by_key('greeter').value = None else: - self._menu_options['greeter'].set_enabled(True) - self._menu_options['greeter'].set_current_selection(profile.default_greeter_type) + self._item_group.find_by_key('greeter').enabled = True + self._item_group.find_by_key('greeter').value = profile.default_greeter_type else: - self._menu_options['gfx_driver'].set_current_selection(None) - self._menu_options['greeter'].set_current_selection(None) + self._item_group.find_by_key('gfx_driver').value = None + self._item_group.find_by_key('greeter').value = None return profile def _select_gfx_driver(self, preset: Optional[GfxDriver] = None) -> Optional[GfxDriver]: driver = preset - profile: Optional[Profile] = self._menu_options['profile'].current_selection + profile: Optional[Profile] = self._item_group.find_by_key('profile').value if profile: if profile.is_graphic_driver_supported(): - driver = select_driver(current_value=preset) + driver = select_driver(preset=preset) if driver and 'Sway' in profile.current_selection_names(): if driver.is_nvidia(): - prompt = str(_('The proprietary Nvidia driver is not supported by Sway. It is likely that you will run into issues, are you okay with that?')) - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() + header = str(_('The proprietary Nvidia driver is not supported by Sway.')) + '\n' + header += str(_('It is likely that you will run into issues, are you okay with that?')) + '\n' - if choice.value == Menu.no(): - return None + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.no() + group.default_item = MenuItem.no() - return driver + result = SelectMenu( + group, + header=header, + allow_skip=False, + columns=2, + orientation=Orientation.HORIZONTAL, + alignment=Alignment.CENTER + ).run() - def _preview_gfx(self) -> Optional[str]: - driver: Optional[GfxDriver] = self._menu_options['gfx_driver'].current_selection + if result.item() == MenuItem.no(): + return preset - if driver: - return driver.packages_text() + return driver + def _prev_gfx(self, item: MenuItem) -> Optional[str]: + if item.value: + driver = item.get_value().value + packages = item.get_value().packages_text() + return f'Driver: {driver}\n{packages}' return None - def _preview_profile(self) -> Optional[str]: - profile: Optional[Profile] = self._menu_options['profile'].current_selection + def _prev_greeter(self, item: MenuItem) -> Optional[str]: + if item.value: + return f'{str(_("Greeter"))}: {item.value.value}' + return None + + def _preview_profile(self, item: MenuItem) -> Optional[str]: + profile: Optional[Profile] = item.value text = '' if profile: @@ -138,66 +165,71 @@ def select_greeter( preset: Optional[GreeterType] = None ) -> Optional[GreeterType]: if not profile or profile.is_greeter_supported(): - title = str(_('Please chose which greeter to install')) - greeter_options = [greeter.value for greeter in GreeterType] + items = [MenuItem(greeter.value, value=greeter) for greeter in GreeterType] + group = MenuItemGroup(items, sort_items=True) default: Optional[GreeterType] = None - if preset is not None: default = preset elif profile is not None: default_greeter = profile.default_greeter_type default = default_greeter if default_greeter else None - choice = Menu( - title, - greeter_options, - skip=True, - default_option=default.value if default else None - ).run() + group.set_default_by_value(default) - match choice.type_: - case MenuSelectionType.Skip: - return default + result = SelectMenu( + group, + allow_skip=True, + frame=FrameProperties.min(str(_('Greeter'))), + alignment=Alignment.CENTER + ).run() - return GreeterType(choice.single_value) + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') return None def select_profile( current_profile: Optional[Profile] = None, - title: Optional[str] = None, + header: Optional[str] = None, allow_reset: bool = True, - multi: bool = False ) -> Optional[Profile]: from archinstall.lib.profile.profiles_handler import profile_handler top_level_profiles = profile_handler.get_top_level_profiles() - display_title = title - if not display_title: - display_title = str(_('This is a list of pre-programmed default_profiles')) + if header is None: + header = str(_('This is a list of pre-programmed default_profiles')) + '\n' + + items = [MenuItem(p.name, value=p) for p in top_level_profiles] + group = MenuItemGroup(items, sort_items=True) + group.set_selected_by_value(current_profile) - choice = profile_handler.select_profile( - top_level_profiles, - current_profile=current_profile, - title=display_title, + result = SelectMenu( + group, + header=header, allow_reset=allow_reset, - multi=multi - ) + allow_skip=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Main profile'))) + ).run() - match choice.type_: - case MenuSelectionType.Selection: - profile_selection: Profile = choice.single_value + match result.type_: + case ResultType.Reset: + return None + case ResultType.Skip: + return current_profile + case ResultType.Selection: + profile_selection: Profile = result.get_value() select_result = profile_selection.do_on_select() if not select_result: - return select_profile( - current_profile=current_profile, - title=title, - allow_reset=allow_reset, - multi=multi - ) + return None # we're going to reset the currently selected profile(s) to avoid # any stale data laying around @@ -212,7 +244,5 @@ def select_profile( pass return current_profile - case MenuSelectionType.Reset: - return None - case MenuSelectionType.Skip: - return current_profile + + return None diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py index e3a487c3fd..b7325ea5e7 100644 --- a/archinstall/lib/profile/profiles_handler.py +++ b/archinstall/lib/profile/profiles_handler.py @@ -13,11 +13,11 @@ from ...default_profiles.profile import Profile, GreeterType from .profile_model import ProfileConfiguration from ..hardware import GfxDriver -from ..menu import MenuSelectionType, Menu, MenuSelection from ..networking import list_interfaces, fetch_data_from_url from ..output import error, debug, info from ..storage import storage + if TYPE_CHECKING: from ..installer import Installer _: Any @@ -358,58 +358,5 @@ def reset_top_level_profiles(self, exclude: List[Profile] = []): if profile.name not in excluded_profiles: profile.reset() - def select_profile( - self, - selectable_profiles: List[Profile], - current_profile: Optional[Union[Profile, List[Profile]]] = None, - title: str = '', - allow_reset: bool = True, - multi: bool = False, - ) -> MenuSelection: - """ - Helper function to perform a profile selection - """ - options = {p.name: p for p in selectable_profiles} - options = dict((k, v) for k, v in sorted(options.items(), key=lambda x: x[0].upper())) - - warning = str(_('Are you sure you want to reset this setting?')) - - preset_value: Optional[Union[str, List[str]]] = None - if current_profile is not None: - if isinstance(current_profile, list): - preset_value = [p.name for p in current_profile] - else: - preset_value = current_profile.name - - choice = Menu( - title=title, - preset_values=preset_value, - p_options=options, - allow_reset=allow_reset, - allow_reset_warning_msg=warning, - multi=multi, - sort=False, - preview_command=self.preview_text, - preview_size=0.5 - ).run() - - if choice.type_ == MenuSelectionType.Selection: - value = choice.value - if multi: - # this is quite dirty and should eb switched to a - # dedicated return type instead - choice.value = [options[val] for val in value] # type: ignore - else: - choice.value = options[value] # type: ignore - - return choice - - def preview_text(self, selection: str) -> Optional[str]: - """ - Callback for preview display on profile selection - """ - profile = self.get_profile_by_name(selection) - return profile.preview_text() if profile is not None else None - profile_handler = ProfileHandler() diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 5440e5f996..61a05ee105 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -2,22 +2,98 @@ from typing import Any, TYPE_CHECKING, Optional, List from ..output import FormattedOutput -from ..output import info +from ..general import secret + +from archinstall.tui import ( + Alignment, EditMenu +) if TYPE_CHECKING: _: Any -def prompt_dir(text: str, header: Optional[str] = None) -> Path: - if header: - print(header) +def get_password( + text: str, + header: Optional[str] = None, + allow_skip: bool = False, + preset: Optional[str] = None +) -> Optional[str]: + failure: Optional[str] = None while True: - path = input(text).strip(' ') + user_hdr = None + if failure is not None: + user_hdr = f'{header}\n{failure}\n' + elif header is not None: + user_hdr = header + + result = EditMenu( + text, + header=user_hdr, + alignment=Alignment.CENTER, + allow_skip=allow_skip, + default_text=preset, + hide_input=True + ).input() + + if allow_skip and not result.has_item(): + return None + + password = result.text() + hidden = secret(password) + + if header is not None: + confirmation_header = f'{header}{str(_("Pssword"))}: {hidden}\n' + else: + confirmation_header = f'{str(_("Password"))}: {hidden}\n' + + result = EditMenu( + str(_('Confirm password')), + header=confirmation_header, + alignment=Alignment.CENTER, + allow_skip=False, + hide_input=True + ).input() + + if password == result.text(): + return password + + failure = str(_('The confirmation password did not match, please try again')) + + +def prompt_dir( + text: str, + header: Optional[str] = None, + validate: bool = True, + allow_skip: bool = False, + preset: Optional[str] = None +) -> Optional[Path]: + def validate_path(path: str) -> Optional[str]: dest_path = Path(path) + if dest_path.exists() and dest_path.is_dir(): - return dest_path - info(_('Not a valid directory: {}').format(dest_path)) + return None + + return str(_('Not a valid directory')) + + if validate: + validate_func = validate_path + else: + validate_func = None + + result = EditMenu( + text, + header=header, + alignment=Alignment.CENTER, + allow_skip=allow_skip, + validator=validate_func, + default_text=preset + ).input() + + if allow_skip and not result.has_item(): + return None + + return Path(result.text()) def is_subpath(first: Path, second: Path) -> bool: @@ -48,4 +124,6 @@ def format_cols(items: List[str], header: Optional[str] = None) -> str: col = 4 text += FormattedOutput.as_columns(items, col) + # remove whitespaces on each row + text = '\n'.join([t.strip() for t in text.split('\n')]) return text diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 264535f327..e0052dc59a 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -9,10 +9,11 @@ from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.configuration import ConfigurationOutput from archinstall.lib.installer import Installer -from archinstall.lib.menu import Menu from archinstall.lib.models import AudioConfiguration, Bootloader from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.profile.profiles_handler import profile_handler +from archinstall.lib.interactions.general_conf import ask_chroot +from archinstall.tui import Tui if TYPE_CHECKING: _: Any @@ -25,76 +26,18 @@ def ask_user_questions() -> None: """ - First, we'll ask the user for a bunch of user input. - Not until we're satisfied with what we want to install - will we continue with the actual installation steps. + First, we'll ask the user for a bunch of user input. + Not until we're satisfied with what we want to install + will we continue with the actual installation steps. """ - # ref: https://github.com/archlinux/archinstall/pull/831 - # we'll set NTP to true by default since this is also - # the default value specified in the menu options; in - # case it will be changed by the user we'll also update - # the system immediately - global_menu = GlobalMenu(data_store=archinstall.arguments) + with Tui(): + global_menu = GlobalMenu(data_store=archinstall.arguments) - global_menu.enable('archinstall-language') + if not archinstall.arguments.get('advanced', False): + global_menu.set_enabled('parallel downloads', False) - # Set which region to download packages from during the installation - global_menu.enable('mirror_config') - - global_menu.enable('locale_config') - - global_menu.enable('disk_config', mandatory=True) - - # Specify disk encryption options - global_menu.enable('disk_encryption') - - # Ask which boot-loader to use (will only ask if we're in UEFI mode, otherwise will default to GRUB) - global_menu.enable('bootloader') - - global_menu.enable('uki') - - global_menu.enable('swap') - - # Get the hostname for the machine - global_menu.enable('hostname') - - # Ask for a root password (optional, but triggers requirement for super-user if skipped) - global_menu.enable('!root-password', mandatory=True) - - global_menu.enable('!users', mandatory=True) - - # Ask for archinstall-specific profiles_bck (such as desktop environments etc) - global_menu.enable('profile_config') - - # Ask about audio server selection if one is not already set - global_menu.enable('audio_config') - - # Ask for preferred kernel: - global_menu.enable('kernels', mandatory=True) - - global_menu.enable('packages') - - if archinstall.arguments.get('advanced', False): - # Enable parallel downloads - global_menu.enable('parallel downloads') - - # Ask or Call the helper function that asks the user to optionally configure a network. - global_menu.enable('network_config') - - global_menu.enable('timezone') - - global_menu.enable('ntp') - - global_menu.enable('additional-repositories') - - global_menu.enable('__separator__') - - global_menu.enable('save_config') - global_menu.enable('install') - global_menu.enable('abort') - - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -209,9 +152,10 @@ def perform_installation(mountpoint: Path) -> None: info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") if not archinstall.arguments.get('silent'): - prompt = str(_('Would you like to chroot into the newly created installation and perform post-installation configuration?')) - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run() - if choice.value == Menu.yes(): + with Tui(): + chroot = ask_chroot() + + if chroot: try: installation.drop_to_shell() except: @@ -220,27 +164,30 @@ def perform_installation(mountpoint: Path) -> None: debug(f"Disk states after installing: {disk.disk_layouts()}") -if not archinstall.arguments.get('silent'): - ask_user_questions() - -config_output = ConfigurationOutput(archinstall.arguments) +def guided() -> None: + if not archinstall.arguments.get('silent'): + ask_user_questions() -if not archinstall.arguments.get('silent'): - config_output.show() + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() -config_output.save() + if archinstall.arguments.get('dry_run'): + exit(0) -if archinstall.arguments.get('dry_run'): - exit(0) + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + guided() -if not archinstall.arguments.get('silent'): - input(str(_('Press Enter to continue.'))) + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -fs_handler.perform_filesystem_operations() -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) +guided() diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 320e32d3a7..451d31deb6 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -2,13 +2,14 @@ from typing import TYPE_CHECKING, Any, List import archinstall -from archinstall import info +from archinstall import info, debug from archinstall import Installer, ConfigurationOutput from archinstall.default_profiles.minimal import MinimalProfile from archinstall.lib.interactions import suggest_single_disk_layout, select_devices from archinstall.lib.models import Bootloader, User from archinstall.lib.profile import ProfileConfiguration, profile_handler from archinstall.lib import disk +from archinstall.tui import Tui if TYPE_CHECKING: _: Any @@ -88,19 +89,31 @@ def parse_disk_encryption() -> None: ) -prompt_disk_layout() -parse_disk_encryption() +def minimal() -> None: + with Tui(): + prompt_disk_layout() + parse_disk_encryption() -config_output = ConfigurationOutput(archinstall.arguments) -config_output.show() + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() -input(str(_('Press Enter to continue.'))) + if archinstall.arguments.get('dry_run'): + exit(0) -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + minimal() + + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) + + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -fs_handler.perform_filesystem_operations() -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) +minimal() diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index 68b6865437..3e0465de0a 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -5,22 +5,23 @@ from archinstall.lib.installer import Installer from archinstall.lib.configuration import ConfigurationOutput from archinstall.lib import disk +from archinstall.tui import Tui def ask_user_questions() -> None: - global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) + with Tui(): + global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) + global_menu.disable_all() - global_menu.enable('archinstall-language') + global_menu.set_enabled('archinstall-language', True) + global_menu.set_enabled('disk_config', True) + global_menu.set_enabled('disk_encryption', True) + global_menu.set_enabled('swap', True) + global_menu.set_enabled('save_config', True) + global_menu.set_enabled('install', True) + global_menu.set_enabled('abort', True) - global_menu.enable('disk_config', mandatory=True) - global_menu.enable('disk_encryption') - global_menu.enable('swap') - - global_menu.enable('save_config') - global_menu.enable('install') - global_menu.enable('abort') - - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -52,27 +53,30 @@ def perform_installation(mountpoint: Path) -> None: debug(f"Disk states after installing: {disk.disk_layouts()}") -if not archinstall.arguments.get('silent'): - ask_user_questions() - -config_output = ConfigurationOutput(archinstall.arguments) -if not archinstall.arguments.get('silent'): - config_output.show() +def only_hd() -> None: + if not archinstall.arguments.get('silent'): + ask_user_questions() -config_output.save() + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() -if archinstall.arguments.get('dry_run'): - exit(0) + if archinstall.arguments.get('dry_run'): + exit(0) -if not archinstall.arguments.get('silent'): - input('Press Enter to continue.') + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + only_hd() -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) -fs_handler.perform_filesystem_operations() + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) +only_hd() diff --git a/archinstall/scripts/swiss.py b/archinstall/scripts/swiss.py index ccf0e08688..0a0a4c89a8 100644 --- a/archinstall/scripts/swiss.py +++ b/archinstall/scripts/swiss.py @@ -9,158 +9,122 @@ from archinstall.lib import locale from archinstall.lib.models import AudioConfiguration from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.lib import menu from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.installer import Installer from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.interactions.general_conf import ask_chroot +from archinstall.tui import ( + MenuItemGroup, MenuItem, SelectMenu, + FrameProperties, Alignment, ResultType, + Tui +) if TYPE_CHECKING: _: Any class ExecutionMode(Enum): - Full = 'full' + Guided = 'guided' Lineal = 'lineal' Only_HD = 'only-hd' Only_OS = 'only-os' Minimal = 'minimal' -def select_mode() -> ExecutionMode: - options = [str(e.value) for e in ExecutionMode] - choice = menu.Menu( - str(_('Select an execution mode')), - options, - default_option=ExecutionMode.Full.value, - skip=False - ).run() - - return ExecutionMode(choice.single_value) - - -class SetupMenu(GlobalMenu): - def __init__(self, storage_area: Dict[str, Any]): - super().__init__(data_store=storage_area) - - def setup_selection_menu_options(self) -> None: - super().setup_selection_menu_options() - - self._menu_options['mode'] = menu.Selector( - 'Execution mode', - lambda x: select_mode(), - display_func=lambda x: x.value if x else '', - default=ExecutionMode.Full) - - self._menu_options['continue'] = menu.Selector( - 'Continue', - exec_func=lambda n, v: True) - - self.enable('archinstall-language') - self.enable('ntp') - self.enable('mode') - self.enable('continue') - self.enable('abort') - - def exit_callback(self) -> None: - if self._data_store.get('mode', None): - archinstall.arguments['mode'] = self._data_store['mode'] - info(f"Archinstall will execute under {archinstall.arguments['mode']} mode") - - class SwissMainMenu(GlobalMenu): def __init__( self, data_store: Dict[str, Any], - exec_mode: ExecutionMode = ExecutionMode.Full + mode: ExecutionMode = ExecutionMode.Guided, + advanced: bool = False ): - self._execution_mode = exec_mode + self._execution_mode = mode + self._advanced = advanced super().__init__(data_store) - def setup_selection_menu_options(self) -> None: - super().setup_selection_menu_options() - - options_list = [] - mandatory_list = [] + def execute(self) -> None: + ignore = ['install', 'abort'] match self._execution_mode: - case ExecutionMode.Full | ExecutionMode.Lineal: - options_list = [ - 'mirror_config', 'disk_config', - 'disk_encryption', 'swap', 'bootloader', 'hostname', '!root-password', - '!users', 'profile_config', 'audio_config', 'kernels', 'packages', 'additional-repositories', 'network_config', - 'timezone', 'ntp' - ] - - if archinstall.arguments.get('advanced', False): - options_list.extend(['locale_config']) - - mandatory_list = ['disk_config', 'bootloader', 'hostname'] + case ExecutionMode.Guided: + from archinstall.scripts.guided import guided + guided() case ExecutionMode.Only_HD: - options_list = ['disk_config', 'disk_encryption', 'swap'] - mandatory_list = ['disk_config'] + from archinstall.scripts.only_hd import only_hd + only_hd() + case ExecutionMode.Minimal: + from archinstall.scripts.minimal import minimal + minimal() + case ExecutionMode.Lineal: + for item in self._menu_item_group.items: + if self._menu_item_group.should_enable_item(item): + if item.action is not None and item.key is not None: + if item.key not in ignore: + archinstall.arguments[item.key] = item.action(item.value) + + perform_installation( + archinstall.storage.get('MOUNT_POINT', Path('/mnt')), + self._execution_mode + ) case ExecutionMode.Only_OS: - options_list = [ + menu_items = [ 'mirror_config', 'bootloader', 'hostname', - '!root-password', '!users', 'profile_config', 'audio_config', 'kernels', - 'packages', 'additional-repositories', 'network_config', 'timezone', 'ntp' + '!root-password', '!users', 'profile_config', + 'audio_config', 'kernels', 'packages', + 'additional-repositories', 'network_config', 'timezone', 'ntp' ] - mandatory_list = ['hostname'] - if archinstall.arguments.get('advanced', False): - options_list += ['locale_config'] - case ExecutionMode.Minimal: - pass + if self._advanced: + menu_items += ['locale_config'] + + for item in self._menu_item_group.items: + if self._menu_item_group.should_enable_item(item): + if item.action is not None and item.key is not None: + if item.key not in ignore and item.key in menu_items: + while True: + value = item.action(item.value) + if value not in mandatory_list or value is not None: + archinstall.arguments[item.key] = item.action(item.value) + break + + perform_installation( + archinstall.storage.get('MOUNT_POINT', Path('/mnt')), + self._execution_mode + ) case _: info(f' Execution mode {self._execution_mode} not supported') exit(1) - if self._execution_mode != ExecutionMode.Lineal: - options_list += ['save_config', 'install'] - - if not archinstall.arguments.get('advanced', False): - options_list.append('archinstall-language') - options_list += ['abort'] +def ask_user_questions(mode: ExecutionMode = ExecutionMode.Guided) -> None: + advanced = archinstall.arguments.get('advanced', False) - for entry in mandatory_list: - self.enable(entry, mandatory=True) + with Tui(): + if advanced: + header = str(_('Select execution mode')) + items = [MenuItem(ex.name, value=ex) for ex in ExecutionMode] + group = MenuItemGroup(items, sort_items=True) + group.set_default_by_value(ExecutionMode.Guided) - for entry in options_list: - self.enable(entry) + result = SelectMenu( + group, + header=header, + allow_skip=True, + alignment=Alignment.CENTER, + frame=FrameProperties.min(str(_('Modes'))) + ).run() + if result.type_ == ResultType.Skip: + exit(0) -def ask_user_questions(exec_mode: ExecutionMode = ExecutionMode.Full) -> None: - """ - First, we'll ask the user for a bunch of user input. - Not until we're satisfied with what we want to install - will we continue with the actual installation steps. - """ - if archinstall.arguments.get('advanced', None): - setup_area: Dict[str, Any] = {} - setup = SetupMenu(setup_area) + mode = result.get_value() - if exec_mode == ExecutionMode.Lineal: - for entry in setup.list_enabled_options(): - if entry in ('continue', 'abort'): - continue - if not setup.option(entry).enabled: - continue - setup.exec_option(entry) - else: - setup.run() - - archinstall.arguments['archinstall-language'] = setup_area.get('archinstall-language') - - with SwissMainMenu(data_store=archinstall.arguments, exec_mode=exec_mode) as menu: - if mode == ExecutionMode.Lineal: - for entry in menu.list_enabled_options(): - if entry in ('install', 'abort'): - continue - menu.exec_option(entry) - archinstall.arguments[entry] = menu.option(entry).get_selection() - else: - menu.run() + SwissMainMenu( + data_store=archinstall.arguments, + mode=mode, + advanced=advanced + ).execute() def perform_installation(mountpoint: Path, exec_mode: ExecutionMode) -> None: @@ -177,132 +141,134 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode) -> None: disk_encryption=disk_encryption, kernels=archinstall.arguments.get('kernels', ['linux']) ) as installation: - if exec_mode in [ExecutionMode.Full, ExecutionMode.Only_HD]: - installation.mount_ordered_layout() + installation.mount_ordered_layout() - installation.sanity_check() + installation.sanity_check() - if disk_config.config_type != disk.DiskLayoutType.Pre_mount: - if disk_encryption and disk_encryption.encryption_type != disk.EncryptionType.NoEncryption: - # generate encryption key files for the mounted luks devices - installation.generate_key_files() + if disk_config.config_type != disk.DiskLayoutType.Pre_mount: + if disk_encryption and disk_encryption.encryption_type != disk.EncryptionType.NoEncryption: + # generate encryption key files for the mounted luks devices + installation.generate_key_files() - if mirror_config := archinstall.arguments.get('mirror_config', None): - installation.set_mirrors(mirror_config) + if mirror_config := archinstall.arguments.get('mirror_config', None): + installation.set_mirrors(mirror_config) - installation.minimal_installation( - testing=enable_testing, - multilib=enable_multilib, - hostname=archinstall.arguments.get('hostname', 'archlinux'), - locale_config=locale_config - ) + installation.minimal_installation( + testing=enable_testing, + multilib=enable_multilib, + hostname=archinstall.arguments.get('hostname', 'archlinux'), + locale_config=locale_config + ) - if mirror_config := archinstall.arguments.get('mirror_config', None): - installation.set_mirrors(mirror_config, on_target=True) + if mirror_config := archinstall.arguments.get('mirror_config', None): + installation.set_mirrors(mirror_config, on_target=True) - if archinstall.arguments.get('swap'): - installation.setup_swap('zram') + if archinstall.arguments.get('swap'): + installation.setup_swap('zram') - if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and SysInfo.has_uefi(): - installation.add_additional_packages("grub") + if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and SysInfo.has_uefi(): + installation.add_additional_packages("grub") - installation.add_bootloader(archinstall.arguments["bootloader"]) + installation.add_bootloader(archinstall.arguments["bootloader"]) - # If user selected to copy the current ISO network configuration - # Perform a copy of the config - network_config = archinstall.arguments.get('network_config', None) + # If user selected to copy the current ISO network configuration + # Perform a copy of the config + network_config = archinstall.arguments.get('network_config', None) - if network_config: - network_config.install_network_config( - installation, - archinstall.arguments.get('profile_config', None) - ) + if network_config: + network_config.install_network_config( + installation, + archinstall.arguments.get('profile_config', None) + ) - if users := archinstall.arguments.get('!users', None): - installation.create_users(users) + if users := archinstall.arguments.get('!users', None): + installation.create_users(users) - audio_config: Optional[AudioConfiguration] = archinstall.arguments.get('audio_config', None) - if audio_config: - audio_config.install_audio_config(installation) - else: - info("No audio server will be installed") + audio_config: Optional[AudioConfiguration] = archinstall.arguments.get('audio_config', None) + if audio_config: + audio_config.install_audio_config(installation) + else: + info("No audio server will be installed") - if archinstall.arguments.get('packages', None) and archinstall.arguments.get('packages', None)[0] != '': - installation.add_additional_packages(archinstall.arguments.get('packages', [])) + if archinstall.arguments.get('packages', None) and archinstall.arguments.get('packages', None)[0] != '': + installation.add_additional_packages(archinstall.arguments.get('packages', [])) - if profile_config := archinstall.arguments.get('profile_config', None): - profile_handler.install_profile_config(installation, profile_config) + if profile_config := archinstall.arguments.get('profile_config', None): + profile_handler.install_profile_config(installation, profile_config) - if timezone := archinstall.arguments.get('timezone', None): - installation.set_timezone(timezone) + if timezone := archinstall.arguments.get('timezone', None): + installation.set_timezone(timezone) - if archinstall.arguments.get('ntp', False): - installation.activate_time_synchronization() + if archinstall.arguments.get('ntp', False): + installation.activate_time_synchronization() - if archinstall.accessibility_tools_in_use(): - installation.enable_espeakup() + if archinstall.accessibility_tools_in_use(): + installation.enable_espeakup() - if (root_pw := archinstall.arguments.get('!root-password', None)) and len(root_pw): - installation.user_set_pw('root', root_pw) + if (root_pw := archinstall.arguments.get('!root-password', None)) and len(root_pw): + installation.user_set_pw('root', root_pw) - if profile_config := archinstall.arguments.get('profile_config', None): - profile_config.profile.post_install(installation) + if profile_config := archinstall.arguments.get('profile_config', None): + profile_config.profile.post_install(installation) - # If the user provided a list of services to be enabled, pass the list to the enable_service function. - # Note that while it's called enable_service, it can actually take a list of services and iterate it. - if archinstall.arguments.get('services', None): - installation.enable_service(archinstall.arguments.get('services', [])) + # If the user provided a list of services to be enabled, pass the list to the enable_service function. + # Note that while it's called enable_service, it can actually take a list of services and iterate it. + if archinstall.arguments.get('services', None): + installation.enable_service(archinstall.arguments.get('services', [])) - # If the user provided custom commands to be run post-installation, execute them now. - if archinstall.arguments.get('custom-commands', None): - archinstall.run_custom_user_commands(archinstall.arguments['custom-commands'], installation) + # If the user provided custom commands to be run post-installation, execute them now. + if archinstall.arguments.get('custom-commands', None): + archinstall.run_custom_user_commands(archinstall.arguments['custom-commands'], installation) - installation.genfstab() + installation.genfstab() - info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") + info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") - if not archinstall.arguments.get('silent'): - prompt = str( - _('Would you like to chroot into the newly created installation and perform post-installation configuration?')) - choice = menu.Menu(prompt, menu.Menu.yes_no(), default_option=menu.Menu.yes()).run() - if choice.value == menu.Menu.yes(): - try: - installation.drop_to_shell() - except: - pass + if not archinstall.arguments.get('silent'): + with Tui(): + chroot = ask_chroot() - debug(f"Disk states after installing: {disk.disk_layouts()}") + if chroot: + try: + installation.drop_to_shell() + except: + pass + debug(f"Disk states after installing: {disk.disk_layouts()}") -param_mode = archinstall.arguments.get('mode', ExecutionMode.Full.value).lower() -try: - mode = ExecutionMode(param_mode) -except KeyError: - info(f'Mode "{param_mode}" is not supported') - exit(1) +def swiss() -> None: + param_mode = archinstall.arguments.get('mode', ExecutionMode.Guided.value).lower() -if not archinstall.arguments.get('silent'): - ask_user_questions(mode) + try: + mode = ExecutionMode(param_mode) + except KeyError: + info(f'Mode "{param_mode}" is not supported') + exit(1) -config_output = ConfigurationOutput(archinstall.arguments) -if not archinstall.arguments.get('silent'): - config_output.show() + if not archinstall.arguments.get('silent'): + ask_user_questions(mode) -config_output.save() + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() -if archinstall.arguments.get('dry_run'): - exit(0) + if archinstall.arguments.get('dry_run'): + exit(0) -if not archinstall.arguments.get('silent'): - input('Press Enter to continue.') + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + swiss() -if mode in (ExecutionMode.Full, ExecutionMode.Only_HD): fs_handler = disk.FilesystemHandler( archinstall.arguments['disk_config'], archinstall.arguments.get('disk_encryption', None) ) fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt')), mode) + -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt')), mode) +swiss() diff --git a/archinstall/scripts/unattended.py b/archinstall/scripts/unattended.py index 5ae4ae3d87..59050c3f28 100644 --- a/archinstall/scripts/unattended.py +++ b/archinstall/scripts/unattended.py @@ -3,6 +3,8 @@ import archinstall from archinstall import info from archinstall import profile +from archinstall.tui import Tui + for p in profile.profile_handler.get_mac_addr_profiles(): # Tailored means it's a match for this machine @@ -12,7 +14,7 @@ print('Starting install in:') for i in range(10, 0, -1): - print(f'{i}...') + Tui.print(f'{i}...') time.sleep(1) install_session = archinstall.storage['installation_session'] diff --git a/archinstall/tui/__init__.py b/archinstall/tui/__init__.py index e69de29bb2..b63153d35b 100644 --- a/archinstall/tui/__init__.py +++ b/archinstall/tui/__init__.py @@ -0,0 +1,12 @@ +from .curses_menu import ( + SelectMenu, EditMenu, Tui +) + +from .menu_item import ( + MenuItem, MenuItemGroup +) + +from .types import ( + PreviewStyle, FrameProperties, FrameStyle, Alignment, + Result, ResultType, Chars, Orientation +) diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index 8cc4f42522..32815e6001 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -1,13 +1,13 @@ +import sys import curses +import dataclasses import curses.panel import os import signal from abc import ABCMeta, abstractmethod from curses.textpad import Textbox from dataclasses import dataclass -from pprint import pformat -from types import NoneType -from typing import Any, Optional, Tuple, Dict, List, TYPE_CHECKING, Literal +from typing import Any, Optional, Tuple, List, TYPE_CHECKING, Literal from typing import Callable from .help import Help @@ -15,7 +15,7 @@ from .types import ( Result, ResultType, ViewportEntry, STYLE, FrameProperties, FrameStyle, Alignment, - Chars, MenuKeys, MenuOrientation, PreviewStyle, + Chars, MenuKeys, Orientation, PreviewStyle, MenuCell, _FrameDim, SCROLL_INTERVAL ) from ..lib.output import debug @@ -26,94 +26,84 @@ class AbstractCurses(metaclass=ABCMeta): def __init__(self) -> None: - self._help_window: Optional[Viewport] = None - self._set_help_viewport() + self._help_window = self._set_help_viewport() @abstractmethod def resize_win(self) -> None: pass - def clear_help_win(self) -> None: - if self._help_window: - self._help_window.erase() - @abstractmethod def kickoff(self, win: 'curses._CursesWindow') -> Result: pass - def _set_help_viewport(self) -> None: - max_height, max_width = tui.max_yx - width = max_width - 10 + def clear_all(self) -> None: + Tui.t().screen.clear() + Tui.t().screen.refresh() + + def clear_help_win(self) -> None: + self._help_window.erase() + + def _set_help_viewport(self) -> 'Viewport': + max_height, max_width = Tui.t().max_yx height = max_height - 10 - self._help_window = Viewport( - width, + max_help_width = max([len(line) for line in Help.get_help_text().split('\n')]) + x_start = int((max_width / 2) - (max_help_width / 2)) + + return Viewport( + max_help_width + 10, height, - int((max_width / 2) - width / 2), + x_start, int((max_height / 2) - height / 2), - frame=FrameProperties(str(_('Archinstall help')), FrameStyle.MAX) + frame=FrameProperties.min(str(_('Archinstall help'))) ) def _confirm_interrupt(self, screen: Any, warning: str) -> bool: - # when a interrupt signal happens then getchr - # doesn't seem to work anymore so we need to - # call it twice to get it to block and wait for input - screen.getch() - while True: - choice = SelectMenu(MenuItemGroup.default_confirm(), header=warning).single() - - match choice.type_: + result = SelectMenu( + MenuItemGroup.yes_no(), + header=warning, + alignment=Alignment.CENTER, + columns=2, + orientation=Orientation.HORIZONTAL + ).run() + + match result.type_: case ResultType.Selection: - if choice.value == MenuItem.default_yes(): + if result.item() == MenuItem.yes(): return True return False def help_entry(self) -> ViewportEntry: - return ViewportEntry(str(_('Press Ctrl+h for help')), 0, 0, STYLE.NORMAL) + return ViewportEntry(str(_('Press ? for help')), 0, 0, STYLE.NORMAL) def _show_help(self) -> None: if not self._help_window: + debug('no help window set') return help_text = Help.get_help_text() lines = help_text.split('\n') - entries = [ViewportEntry(e, idx, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] + entries = [ViewportEntry('', 0, 0, STYLE.NORMAL)] + entries += [ViewportEntry(f' {e} ', idx + 1, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] self._help_window.update(entries, 0) def get_header_entries( self, header: Optional[str], - alignment: Alignment = Alignment.LEFT + offset: int = 0 ) -> List[ViewportEntry]: cur_row = 0 full_header = [] if header: for header in header.split('\n'): - full_header += [ViewportEntry(header, cur_row, 0, STYLE.NORMAL)] + full_header += [ViewportEntry(header, cur_row, offset, STYLE.NORMAL)] cur_row += 1 - if full_header: - ViewportEntry('', cur_row, 0, STYLE.NORMAL) - cur_row += 1 - - aligned_headers = self._align_headers(alignment, full_header) - return aligned_headers - - def _align_headers( - self, - alignment: Alignment, - headers: List[ViewportEntry] - ) -> List[ViewportEntry]: - if alignment == Alignment.CENTER and headers: - longest_header = max([len(h.text) for h in headers]) - x_offset = int((tui.max_yx[1] / 2) - (longest_header / 2)) - headers = [ViewportEntry(h.text, h.row, x_offset, h.style) for h in headers] - - return headers + return full_header @dataclass @@ -121,11 +111,12 @@ class AbstractViewport: def __init__(self) -> None: pass - def add_str(self, screen: Any, row: int, col: int, text: str, color: STYLE) -> None: + def add_str(self, screen: Any, row: int, col: int, text: str, color: STYLE): try: - screen.addstr(row, col, text, tui.get_color(color)) + screen.addstr(row, col, text, Tui.t().get_color(color)) except curses.error: - debug('Curses error while adding string to viewport') + # debug(f'Curses error while adding string to viewport: {text}') + pass def add_frame( self, @@ -151,9 +142,9 @@ def add_frame( frame_border += self._get_right_frame(dim, scroll_pct) - # adjust the original rows and cols of the entries as they need to be - # shrunk by 1 to make space for the frame - entries = self._adjust_entries(entries) + # adjust the original rows and cols of the entries as + # they need to be shrunk by 1 to make space for the frame + entries = self._adjust_entries(entries, dim) framed_entries = [ top_ve, @@ -162,16 +153,19 @@ def add_frame( *entries ] - debug(pformat(framed_entries)) - return framed_entries + def align_center(self, lines: List[ViewportEntry], width: int) -> int: + max_col = self._max_col(lines) + x_offset = int((width / 2) - (max_col / 2)) + return x_offset + def _get_right_frame( self, dim: _FrameDim, scroll_percentage: Optional[int] = None ) -> List[ViewportEntry]: - right_frame = [] + right_frame = {} scroll_height = int(dim.height * scroll_percentage // 100) if scroll_percentage else 0 if scroll_height <= 0: @@ -179,19 +173,15 @@ def _get_right_frame( elif scroll_height >= dim.height: scroll_height = dim.height - 1 + for i in range(1, dim.height): + right_frame[i] = ViewportEntry(Chars.Vertical, i, dim.x_end - 1, STYLE.NORMAL) + if scroll_percentage is not None: - right_frame = [ - ViewportEntry(Chars.Triangle_up, 0, dim.x_end - 1, STYLE.NORMAL), - ViewportEntry(Chars.Block, scroll_height, dim.x_end - 1, STYLE.NORMAL), - ViewportEntry(Chars.Triangle_down, dim.height, dim.x_end - 1, STYLE.NORMAL) - ] - else: - for i in range(1, dim.height): - right_frame += [ - ViewportEntry(Chars.Vertical, i, dim.x_end - 1, STYLE.NORMAL) - ] + right_frame[0] = ViewportEntry(Chars.Triangle_up, 0, dim.x_end - 1, STYLE.NORMAL) + right_frame[scroll_height] = ViewportEntry(Chars.Block, scroll_height, dim.x_end - 1, STYLE.NORMAL) + right_frame[dim.height] = ViewportEntry(Chars.Triangle_down, dim.height, dim.x_end - 1, STYLE.NORMAL) - return right_frame + return list(right_frame.values()) def _get_top( self, @@ -214,7 +204,7 @@ def _get_bottom( dim: _FrameDim, h_bar: str, scroll_pct: Optional[int] = None - ) -> ViewportEntry: + ): if scroll_pct is None: bottom = Chars.Lower_left + h_bar + Chars.Lower_right else: @@ -235,33 +225,45 @@ def _get_frame_dim( if frame.w_frame_style == FrameStyle.MIN: frame_start = min([e.col for e in entries]) - frame_end = max([len(r) for r in rows] + [header_len + frame_start]) - frame_end += 3 # 2 for frame, 1 for padding - else: - frame_start = 0 - frame_end = max_width + max_row_cols = [(e.col + len(e.text) + 1) for e in entries] + max_row_cols.append(header_len) + frame_end = max(max_row_cols) + + # 2 for frames, 1 for extra space start away from frame + # must align with def _adjust_entries + frame_end += 3 # 2 for frame - if frame.h_frame_style == FrameStyle.MIN: frame_height = len(rows) + 1 if frame_height > max_height: frame_height = max_height else: + frame_start = 0 + frame_end = max_width frame_height = max_height - 1 return _FrameDim(frame_start, frame_end, frame_height) - def _adjust_entries(self, entries: List[ViewportEntry]) -> List[ViewportEntry]: + def _adjust_entries( + self, + entries: List[ViewportEntry], + frame_dim: _FrameDim + ) -> List[ViewportEntry]: for entry in entries: + # top row frame offset entry.row += 1 - entry.col += 1 + # left side frame offset + extra space from frame to start from + entry.col += 2 return entries - def _unique_rows(self, entries: List[ViewportEntry]) -> int: + def _num_unique_rows(self, entries: List[ViewportEntry]) -> int: return len(set([e.row for e in entries])) def _max_col(self, entries: List[ViewportEntry]) -> int: - return max([len(e.text) + e.col for e in entries]) + 1 + values = [len(e.text) + e.col for e in entries] + if not values: + return 0 + return max(values) def _replace_str(self, text: str, index: int = 0, replacement: str = '') -> str: len_replace = len(replacement) @@ -272,12 +274,13 @@ def _assemble_entries(self, entries: List[ViewportEntry]) -> str: return '' max_col = self._max_col(entries) - view = [max_col * ' '] * self._unique_rows(entries) + view = [max_col * ' '] * self._num_unique_rows(entries) for e in entries: view[e.row] = self._replace_str(view[e.row], e.col, e.text) view = [v.rstrip() for v in view] + return '\n'.join(view) @@ -285,22 +288,28 @@ class EditViewport(AbstractViewport): def __init__( self, width: int, - height: int, + edit_width: int, + edit_height: int, x_start: int, y_start: int, - process_key: Callable[[int], int], - frame: FrameProperties + process_key: Callable[[int], Optional[int]], + frame: FrameProperties, + alignment: Alignment = Alignment.CENTER, + hide_input: bool = False ) -> None: super().__init__() - self._max_height, self._max_width = tui.max_yx + self._max_height, self._max_width = Tui.t().max_yx - self.width = width - self.height = height + self._width = width + self._edit_width = edit_width + self._edit_height = edit_height self.x_start = x_start self.y_start = y_start self.process_key = process_key self._frame = frame + self._alignment = alignment + self._hide_input = hide_input self._main_win: Optional['curses._CursesWindow'] = None self._edit_win: Optional['curses._CursesWindow'] = None @@ -309,14 +318,18 @@ def __init__( self._init_wins() def _init_wins(self) -> None: - self._main_win = curses.newwin(self.height, self.width, self.y_start, self.x_start) + self._main_win = curses.newwin(self._edit_height, self._width, self.y_start, 0) self._main_win.nodelay(False) + x_offset = 0 + if self._alignment == Alignment.CENTER: + x_offset = int((self._width / 2) - (self._edit_width / 2)) + self._edit_win = self._main_win.subwin( 1, - self.width - 2, + self._edit_width - 2, self.y_start + 1, - self.x_start + 1 + self.x_start + x_offset + 1 ) def update(self) -> None: @@ -327,13 +340,23 @@ def update(self) -> None: framed = self.add_frame( [ViewportEntry('', 0, 0, STYLE.NORMAL)], - self.width, + self._edit_width, 3, frame=self._frame ) + x_offset = 0 + if self._alignment == Alignment.CENTER: + x_offset = self.align_center(framed, self._width) + for row in framed: - self.add_str(self._main_win, row.row, row.col, row.text, row.style) + self.add_str( + self._main_win, + row.row, + row.col + x_offset, + row.text, + row.style + ) self._main_win.refresh() @@ -342,25 +365,38 @@ def erase(self) -> None: self._main_win.erase() self._main_win.refresh() - def edit(self) -> None: - if not self._edit_win or not self._main_win: - return + def edit(self, default_text: Optional[str] = None) -> None: + assert self._edit_win and self._main_win self._edit_win.erase() + if default_text is not None and len(default_text) > 0: + self._edit_win.addstr(0, 0, default_text) + # if this gets initialized multiple times it will be an overlay # and ENTER has to be pressed multiple times to accept if not self._textbox: self._textbox = curses.textpad.Textbox(self._edit_win) self._main_win.refresh() - self._textbox.edit(self.process_key) + self._textbox.edit(self.process_key) # type: ignore - def gather(self) -> Optional[str]: - if not self._textbox: - return None - return self._textbox.gather().strip() +@dataclass +class ViewportState: + cur_pos: int + displayed_entries: List[ViewportEntry] + scroll_pct: Optional[int] + scroll_pos: Optional[int] = 0 + + def offset(self) -> int: + return min([entry.row for entry in self.displayed_entries], default=0) + + def get_rows(self) -> list[int]: + rows = set() + for entry in self.displayed_entries: + rows.add(entry.row) + return list(rows) @dataclass @@ -372,8 +408,9 @@ def __init__( x_start: int, y_start: int, enable_scroll: bool = False, - frame: Optional[FrameProperties] = None - ) -> None: + frame: Optional[FrameProperties] = None, + alignment: Alignment = Alignment.LEFT + ): super().__init__() self.width = width @@ -382,9 +419,13 @@ def __init__( self.y_start = y_start self._enable_scroll = enable_scroll self._frame = frame + self._alignment = alignment self._main_win = curses.newwin(self.height, self.width, self.y_start, self.x_start) self._main_win.nodelay(False) + self._main_win.standout() + + self._state: Optional[ViewportState] = None def getch(self): return self._main_win.getch() @@ -395,35 +436,40 @@ def erase(self) -> None: def update( self, - entries: List[ViewportEntry], - cursor_pos: int = 0, - scroll_pos: Optional[int] = 0 - ) -> None: - visible_rows, percentage = self._find_visible_rows(entries, cursor_pos, scroll_pos) + lines: List[ViewportEntry], + cur_pos: int = 0, + scroll_pos: Optional[int] = None + ): + self._state = self._get_viewport_state(lines, cur_pos, scroll_pos) + visible_entries = self._adjust_entries_row(self._state.displayed_entries) if self._frame: - visible_rows = self.add_frame( - visible_rows, + visible_entries = self.add_frame( + visible_entries, self.width, self.height, frame=self._frame, - scroll_pct=percentage + scroll_pct=self._state.scroll_pct ) + x_offset = 0 + if self._alignment == Alignment.CENTER: + x_offset = self.align_center(visible_entries, self.width) + self._main_win.erase() - for entry in visible_rows: + for entry in visible_entries: self.add_str( self._main_win, entry.row, - entry.col, + entry.col + x_offset, entry.text, entry.style ) self._main_win.refresh() - def _get_nr_available_rows(self) -> int: + def _get_available_screen_rows(self) -> int: y_offset = 3 if self._frame else 0 return self.height - y_offset @@ -442,48 +488,98 @@ def _calc_scroll_percent( return percentage - def _find_visible_rows( + def _get_viewport_state( self, entries: List[ViewportEntry], - cursor_pos: int, + cur_pos: int, scroll_pos: Optional[int] = 0 - ) -> Tuple[List[ViewportEntry], Optional[int]]: + ) -> ViewportState: if not entries: - return [], 0 + return ViewportState(cur_pos, [], 0) + + # we will be checking if the cursor pos is in the same window + # of rows as the previous selection, in that case we can keep + # the currently shown entries to prevent weird moving in long lists + if self._state is not None and scroll_pos is None: + rows = self._state.get_rows() + + if cur_pos in rows: + same_row_entries = [entry for entry in entries if entry.row in rows] + return ViewportState( + cur_pos, + same_row_entries, + self._state.scroll_pct + ) + + total_rows = max([e.row for e in entries]) + 1 # rows start with 0 so add 1 for the count + screen_rows = self._get_available_screen_rows() + visible_entries = self._get_visible_entries( + entries, + cur_pos, + screen_rows, + scroll_pos, + total_rows + ) - total_rows = max([e.row for e in entries]) + 1 # rows start with 0 and we need the count - available_rows = self._get_nr_available_rows() + if scroll_pos is not None: + percentage = self._calc_scroll_percent(total_rows, screen_rows, scroll_pos) + else: + percentage = None + return ViewportState( + cur_pos, + visible_entries, + percentage, + scroll_pos + ) + + def _get_visible_entries( + self, + entries: List[ViewportEntry], + cur_pos: int, + screen_rows: int, + scroll_pos: Optional[int], + total_rows: int + ) -> list[ViewportEntry]: if scroll_pos is not None: - if total_rows <= available_rows: + if total_rows <= screen_rows: start = 0 end = total_rows else: start = scroll_pos - end = scroll_pos + available_rows + end = scroll_pos + screen_rows else: - if total_rows <= available_rows: + if total_rows <= screen_rows: start = 0 end = total_rows - elif cursor_pos < available_rows: - start = 0 - end = available_rows else: - start = cursor_pos - available_rows + 1 - end = cursor_pos + 1 + if self._state is None: + if cur_pos < screen_rows: + start = 0 + end = screen_rows + else: + start = cur_pos - screen_rows + 1 + end = cur_pos + 1 + else: + if cur_pos < self._state.cur_pos: + start = cur_pos + end = cur_pos + screen_rows + else: + start = cur_pos - screen_rows + 1 + end = cur_pos + 1 - rows = [entry for entry in entries if start <= entry.row < end] - smallest = min([e.row for e in rows]) + return [entry for entry in entries if start <= entry.row < end] - for entry in rows: - entry.row = entry.row - smallest + def _adjust_entries_row(self, entries: List[ViewportEntry]) -> List[ViewportEntry]: + assert self._state is not None + modified = [] - if scroll_pos is not None: - percentage = self._calc_scroll_percent(total_rows, available_rows, scroll_pos) - else: - percentage = None + for entry in entries: + mod = dataclasses.replace(entry) + mod.row = entry.row - self._state.offset() + modified.append(mod) - return rows, percentage + return modified def _replace_str(self, text: str, index: int = 0, replacement: str = '') -> str: len_replace = len(replacement) @@ -497,24 +593,33 @@ class EditMenu(AbstractCurses): def __init__( self, title: str, + edit_width: int = 50, header: Optional[str] = None, validator: Optional[Callable[[str], Optional[str]]] = None, allow_skip: bool = False, allow_reset: bool = False, reset_warning_msg: Optional[str] = None, - alignment: Alignment = Alignment.LEFT + alignment: Alignment = Alignment.CENTER, + default_text: Optional[str] = None, + hide_input: bool = False ): super().__init__() - self._max_height, self._max_width = tui.max_yx + self._max_height, self._max_width = Tui.t().max_yx self._header = header self._validator = validator self._allow_skip = allow_skip self._allow_reset = allow_reset self._interrupt_warning = reset_warning_msg - self._headers = self.get_header_entries(header, alignment=alignment) + self._headers = self.get_header_entries(header, offset=0) self._alignment = alignment + self._edit_width = edit_width + self._default_text = default_text + self._hide_input = hide_input + + if self._interrupt_warning is None: + self._interrupt_warning = str(_('Are you sure you want to reset this setting?')) + '\n' title = f'* {title}' if not self._allow_skip else title self._frame = FrameProperties(title, FrameStyle.MAX) @@ -528,44 +633,43 @@ def __init__( self._last_state: Optional[Result] = None self._help_active = False + self._real_input = "" def _init_viewports(self) -> None: - x_offset = 0 y_offset = 0 - edit_width = 50 - - if self._alignment == Alignment.CENTER: - x_offset = int((self._max_width / 2) - edit_width / 2) self._help_vp = Viewport(self._max_width, 2, 0, y_offset) y_offset += 2 if self._headers: - header_height = len(self._headers) + 1 - self._header_vp = Viewport(self._max_width, header_height, 0, y_offset) + header_height = len(self._headers) + self._header_vp = Viewport(self._max_width, header_height, 0, y_offset, alignment=self._alignment) y_offset += header_height self._input_vp = EditViewport( - edit_width, + self._max_width, + self._edit_width, 3, - x_offset, + 0, y_offset, self._process_edit_key, - frame=self._frame + frame=self._frame, + alignment=self._alignment, + hide_input=self._hide_input ) - y_offset += 3 - self._error_vp = Viewport(self._max_width, 1, x_offset, y_offset) + y_offset += 3 + self._error_vp = Viewport(self._max_width, 1, 0, y_offset, alignment=self._alignment) - def input(self, ) -> Result[str]: - result = tui.run(self) + def input(self) -> Result: + result = Tui.run(self) - assert isinstance(result.value, (str, NoneType)) + assert not result.has_item() or isinstance(result.text(), str) self._clear_all() return result - def resize_win(self) -> None: + def resize_win(self): self._draw() def _clear_all(self) -> None: @@ -582,12 +686,16 @@ def _get_input_text(self) -> Optional[str]: assert self._input_vp assert self._error_vp - text = self._input_vp.gather() + text = self._real_input + + self.clear_all() if text and self._validator: if (err := self._validator(text)) is not None: + self.clear_all() entry = ViewportEntry(err, 0, 0, STYLE.ERROR) self._error_vp.update([entry], 0) + self._real_input = '' return None return text @@ -601,7 +709,7 @@ def _draw(self) -> None: if self._input_vp: self._input_vp.update() - self._input_vp.edit() + self._input_vp.edit(default_text=self._default_text) def kickoff(self, win: 'curses._CursesWindow') -> Result: try: @@ -628,7 +736,7 @@ def kickoff(self, win: 'curses._CursesWindow') -> Result: return self._last_state - def _process_edit_key(self, key: int): + def _process_edit_key(self, key: int) -> Optional[int]: key_handles = MenuKeys.from_ord(key) if self._help_active: @@ -642,27 +750,39 @@ def _process_edit_key(self, key: int): key_handles = [key for key in key_handles if key != MenuKeys.STD_KEYS] # regular key stroke should be passed to the editor - if not key_handles: - return key - - special_key = key_handles[0] - - match special_key: - case MenuKeys.HELP: - self._clear_all() - self._help_active = True - self._show_help() - return None - case MenuKeys.ESC: - if self._help_active: - self._help_active = False - self._draw() - if self._allow_skip: - self._last_state = Result(ResultType.Skip, None) + if key_handles: + special_key = key_handles[0] + + match special_key: + case MenuKeys.HELP: + self._clear_all() + self._help_active = True + self._show_help() + return None + case MenuKeys.ESC: + if self._help_active: + self._help_active = False + self._draw() + if self._allow_skip: + self._last_state = Result(ResultType.Skip, None) + key = 7 + case MenuKeys.ACCEPT: + self._last_state = Result(ResultType.Selection, None) key = 7 - case MenuKeys.ACCEPT: - self._last_state = Result(ResultType.Selection, None) - key = 7 + case MenuKeys.BACKSPACE: + if len(self._real_input) > 0: + self._real_input = self._real_input[:-1] + case _: + self._real_input += chr(key) + if self._hide_input: + key = 42 + else: + try: + self._real_input += chr(key) + if self._hide_input: + key = 42 + except: + pass return key @@ -680,7 +800,8 @@ class SelectMenu(AbstractCurses): def __init__( self, group: MenuItemGroup, - orientation: MenuOrientation = MenuOrientation.VERTICAL, + multi: bool = False, + orientation: Orientation = Orientation.VERTICAL, alignment: Alignment = Alignment.LEFT, columns: int = 1, column_spacing: int = 10, @@ -697,35 +818,41 @@ def __init__( ): super().__init__() + self._multi = multi self._cursor_char = f'{cursor_char} ' self._search_enabled = search_enabled - self._multi = False - self._interrupt_warning = reset_warning_msg self._allow_skip = allow_skip self._allow_reset = allow_reset self._active_search = False self._help_active = False - self._skip_empty_entries = True self._item_group = group self._preview_style = preview_style self._preview_frame = preview_frame self._orientation = orientation self._column_spacing = column_spacing self._alignment = alignment - self._headers = self.get_header_entries(header, alignment) self._footers = self._footer_entries() self._frame = frame + self._interrupt_warning = reset_warning_msg + self._header = header + + header_offset = self._get_header_offset() + self._headers = self.get_header_entries(header, offset=header_offset) + + if self._interrupt_warning is None: + self._interrupt_warning = str(_('Are you sure you want to reset this setting?')) + '\n' - if self._orientation == MenuOrientation.HORIZONTAL: + if self._orientation == Orientation.HORIZONTAL: self._horizontal_cols = columns else: self._horizontal_cols = 1 self._row_entries: List[List[MenuCell]] = [] - self._prev_scroll_pos = 0 + self._prev_scroll_pos: int = 0 + self._cur_pos: Optional[int] = None self._visible_entries: List[ViewportEntry] = [] - self._max_height, self._max_width = tui.max_yx + self._max_height, self._max_width = Tui.t().max_yx self._help_vp: Optional[Viewport] = None self._header_vp: Optional[Viewport] = None @@ -735,21 +862,15 @@ def __init__( self._init_viewports(preview_size) - def single(self) -> Result[MenuItem]: - self._multi = False - result = tui.run(self) - - assert isinstance(result.value, (MenuItem, NoneType)) - - self._clear_all() - return result - - def multi(self) -> Result[List[MenuItem]]: - self._multi = True - result = tui.run(self) - - assert isinstance(result.value, (list, NoneType)) + def _get_header_offset(self) -> int: + # any changes here will impact the list manager table view + offset = len(self._cursor_char) + 1 + if self._multi: + offset += 3 + return offset + def run(self) -> Result: + result = Tui.run(self) self._clear_all() return result @@ -758,7 +879,11 @@ def kickoff(self, win: 'curses._CursesWindow') -> Result: while True: try: + if not self._help_active: + self._draw() + key = win.getch() + ret = self._process_input_key(key) if ret is not None: @@ -766,6 +891,8 @@ def kickoff(self, win: 'curses._CursesWindow') -> Result: except KeyboardInterrupt: if self._handle_interrupt(): return Result(ResultType.Reset, None) + else: + return self.kickoff(win) def resize_win(self) -> None: self._draw() @@ -791,7 +918,7 @@ def _footer_entries(self) -> List[ViewportEntry]: return [] - def _init_viewports(self, arg_prev_size: float | Literal['auto']) -> None: + def _init_viewports(self, arg_prev_size: float | Literal['auto']): footer_height = 2 # possible filter at the bottom y_offset = 0 @@ -799,8 +926,14 @@ def _init_viewports(self, arg_prev_size: float | Literal['auto']) -> None: y_offset += 2 if self._headers: - header_height = len(self._headers) + 1 - self._header_vp = Viewport(self._max_width, header_height, 0, y_offset) + header_height = len(self._headers) + self._header_vp = Viewport( + self._max_width, + header_height, + 0, + y_offset, + alignment=self._alignment + ) y_offset += header_height prev_offset = y_offset + footer_height @@ -811,24 +944,75 @@ def _init_viewports(self, arg_prev_size: float | Literal['auto']) -> None: case PreviewStyle.BOTTOM: menu_height = available_height - prev_size - self._menu_vp = Viewport(self._max_width, menu_height, 0, y_offset, frame=self._frame) - self._preview_vp = Viewport(self._max_width, prev_size, 0, menu_height + y_offset, - frame=self._preview_frame) + self._menu_vp = Viewport( + self._max_width, + menu_height, + 0, + y_offset, + frame=self._frame, + alignment=self._alignment + ) + self._preview_vp = Viewport( + self._max_width, + prev_size, + 0, + menu_height + y_offset, + frame=self._preview_frame + ) case PreviewStyle.RIGHT: menu_width = self._max_width - prev_size - self._menu_vp = Viewport(menu_width, available_height, 0, y_offset, frame=self._frame) - self._preview_vp = Viewport(prev_size, available_height, menu_width, y_offset, - frame=self._preview_frame) + self._menu_vp = Viewport( + menu_width, + available_height, + 0, + y_offset, + frame=self._frame, + alignment=self._alignment + ) + self._preview_vp = Viewport( + prev_size, + available_height, + menu_width, + y_offset, + frame=self._preview_frame, + alignment=self._alignment + ) case PreviewStyle.TOP: menu_height = available_height - prev_size - self._menu_vp = Viewport(self._max_width, menu_height, 0, prev_size + y_offset, frame=self._frame) - self._preview_vp = Viewport(self._max_width, prev_size, 0, y_offset, frame=self._preview_frame) + self._menu_vp = Viewport( + self._max_width, + menu_height, + 0, + prev_size + y_offset, + frame=self._frame, + alignment=self._alignment + ) + self._preview_vp = Viewport( + self._max_width, + prev_size, + 0, + y_offset, + frame=self._preview_frame, + alignment=self._alignment + ) case PreviewStyle.NONE: - self._menu_vp = Viewport(self._max_width, available_height, 0, y_offset, frame=self._frame) - - self._footer_vp = Viewport(self._max_width, footer_height, 0, self._max_height - footer_height) + self._menu_vp = Viewport( + self._max_width, + available_height, + 0, + y_offset, + frame=self._frame, + alignment=self._alignment + ) + + self._footer_vp = Viewport( + self._max_width, + footer_height, + 0, + self._max_height - footer_height + ) def _determine_prev_size( self, @@ -844,6 +1028,8 @@ def _determine_prev_size( match self._preview_style: case PreviewStyle.RIGHT: menu_width = self._item_group.max_width + 5 + if self._multi: + menu_width += 5 prev_size = self._max_width - menu_width case PreviewStyle.BOTTOM: menu_height = len(self._item_group.items) + 1 # leave empty line between menu and preview @@ -866,7 +1052,7 @@ def _draw(self) -> None: footer_entries = self._footer_entries() vp_entries = self._get_row_entries() - cursor_pos = self._get_cursor_pos() + self._cur_pos = self._get_cursor_pos() if self._help_vp: self._update_viewport(self._help_vp, [self.help_entry()]) @@ -875,7 +1061,11 @@ def _draw(self) -> None: self._update_viewport(self._header_vp, self._headers) if self._menu_vp: - self._update_viewport(self._menu_vp, vp_entries, cursor_pos=cursor_pos) + self._update_viewport( + self._menu_vp, + vp_entries, + cur_pos=self._cur_pos + ) if vp_entries: self._update_preview() @@ -889,21 +1079,22 @@ def _update_viewport( self, viewport: Viewport, entries: List[ViewportEntry], - cursor_pos: int = 0 + cur_pos: int = 0 ) -> None: if entries: - viewport.update(entries, cursor_pos=cursor_pos) + viewport.update(entries, cur_pos=cur_pos) else: viewport.update([]) def _get_cursor_pos(self) -> int: - for idx, cell in enumerate(self._row_entries): - if self._item_group.focus_item in cell: - return idx + for idx, cells in enumerate(self._row_entries): + for cell in cells: + if self._item_group.focus_item == cell.item: + return idx return 0 def _get_visible_items(self) -> List[MenuItem]: - return [it for it in self._item_group.items if self._item_group.verify_item_enabled(it)] + return [it for it in self._item_group.items if self._item_group.should_enable_item(it)] def _list_to_cols(self, items: List[MenuItem], cols: int) -> List[List[MenuItem]]: return [items[i:i + cols] for i in range(0, len(items), cols)] @@ -918,27 +1109,15 @@ def _item_distance(self) -> int: else: return self._column_spacing - def _cols_x_align_offset(self) -> int: - assert self._menu_vp - - x_offset = 0 - if self._alignment == Alignment.CENTER: - cols_widths = self._get_col_widths() - total_col_width = sum(cols_widths) - x_offset = int((self._menu_vp.width / 2) - (total_col_width / 2)) - - return x_offset - def _get_row_entries(self) -> List[ViewportEntry]: cells = self._assemble_menu_cells() entries = [] self._row_entries = [cells[x:x + self._horizontal_cols] for x in range(0, len(cells), self._horizontal_cols)] cols_widths = self._get_col_widths() - x_offset = self._cols_x_align_offset() for row_idx, row in enumerate(self._row_entries): - cur_pos = len(self._cursor_char) + x_offset + cur_pos = len(self._cursor_char) for col_idx, cell in enumerate(row): cur_text = '' @@ -987,8 +1166,7 @@ def _assemble_menu_cells(self) -> List[MenuCell]: if self._multi and not item.is_empty(): item_text += self._multi_prefix(item) - suffix = self._default_suffix(item) - item_text += item.get_text(suffix=suffix) + item_text += self._item_group.get_item_text(item) entries += [MenuCell(item, item_text)] @@ -1017,7 +1195,7 @@ def _update_preview(self) -> None: self._preview_vp.update(entries, scroll_pos=self._prev_scroll_pos) - def _calc_prev_scroll_pos(self, entries: List[ViewportEntry]) -> None: + def _calc_prev_scroll_pos(self, entries: List[ViewportEntry]): total_rows = max([e.row for e in entries]) + 1 # rows start with 0 and we need the count if self._prev_scroll_pos >= total_rows: @@ -1025,14 +1203,6 @@ def _calc_prev_scroll_pos(self, entries: List[ViewportEntry]) -> None: elif self._prev_scroll_pos < 0: self._prev_scroll_pos = 0 - def _default_suffix(self, item: MenuItem) -> str: - suffix = '' - - if self._item_group.default_item == item: - suffix = str(_(' (default)')) - - return suffix - def _multi_prefix(self, item: MenuItem) -> str: if self._item_group.is_item_selected(item): return '[x] ' @@ -1040,9 +1210,8 @@ def _multi_prefix(self, item: MenuItem) -> str: return '[ ] ' def _handle_interrupt(self) -> bool: - if self._allow_reset: - if self._interrupt_warning: - return self._confirm_interrupt(self._menu_vp, self._interrupt_warning) + if self._allow_reset and self._interrupt_warning: + return self._confirm_interrupt(self._menu_vp, self._interrupt_warning) else: return False @@ -1089,17 +1258,19 @@ def _process_input_key(self, key: int) -> Optional[Result]: return None case MenuKeys.ACCEPT: if self._multi: - self._item_group.select_current_item() if self._item_group.is_mandatory_fulfilled(): - return Result(ResultType.Selection, self._item_group.selected_items) + if self._item_group.focus_item is not None: + if self._item_group.focus_item not in self._item_group.selected_items: + self._item_group.selected_items.append(self._item_group.focus_item) + return Result(ResultType.Selection, self._item_group.selected_items) else: item = self._item_group.focus_item if item: if item.action: item.value = item.action(item.value) - else: - if self._item_group.is_mandatory_fulfilled(): - return Result(ResultType.Selection, self._item_group.focus_item) + + if self._item_group.is_mandatory_fulfilled(): + return Result(ResultType.Selection, self._item_group.focus_item) return None case MenuKeys.MENU_UP | MenuKeys.MENU_DOWN | MenuKeys.MENU_LEFT | MenuKeys.MENU_RIGHT: @@ -1174,59 +1345,135 @@ def _focus_item(self, key: MenuKeys) -> None: next_row = len(self._row_entries) - 1 if next_row == 0 else next_row - 1 next_col = len(self._row_entries[next_row]) - 1 - self._item_group.focus_item = self._row_entries[next_row][next_col].item + if next_row < len(self._row_entries): + row = self._row_entries[next_row] + if next_col < len(row): + self._item_group.focus_item = row[next_col].item + + if self._item_group.focus_item and self._item_group.focus_item.is_empty(): + self._focus_item(key) class Tui: - def __init__(self) -> None: - self._screen: Any = None - self._colors: Dict[str, int] = {} - self._component: Optional[AbstractCurses] = None + _t: Optional['Tui'] = None - signal.signal(signal.SIGWINCH, self._sig_win_resize) + def __enter__(self): + if Tui._t is None: + tui = self.init() + Tui._t = tui - def init(self) -> None: - self._screen = curses.initscr() + def __exit__(self, exc_type, exc_val, tb): + self.stop() + + @property + def screen(self) -> Any: + return self._screen + @staticmethod + def t() -> 'Tui': + assert Tui._t is not None + return Tui._t + + @staticmethod + def shutdown() -> None: + if Tui._t is None: + return + + Tui.t().stop() + + def init(self) -> 'Tui': + self._screen = curses.initscr() curses.noecho() curses.cbreak() curses.curs_set(0) curses.set_escdelay(25) self._screen.keypad(True) + self._screen.scrollok(True) if curses.has_colors(): curses.start_color() self._set_up_colors() - self._soft_clear_terminal() + signal.signal(signal.SIGWINCH, self._sig_win_resize) + self._screen.refresh() - @property - def screen(self) -> Any: - return self._screen + return self + + def stop(self) -> None: + try: + curses.nocbreak() + + try: + self.screen.keypad(False) + except Exception: + pass + + curses.echo() + curses.curs_set(True) + curses.endwin() + except Exception: + # this may happen when curses has not been initialized + pass + + Tui._t = None + + @staticmethod + def print( + text: str, + row: int = 0, + col: int = 0, + endl: Optional[str] = '\n', + clear_screen: bool = False + ) -> None: + if Tui._t is None: + if clear_screen: + os.system('clear') + + print(text, end=endl) + sys.stdout.flush() + + return + + # will append the row at the very bottom of the screen + # and also scroll the existing text up by 1 line + if row == -1: + last_row = Tui.t().max_yx[0] - 1 + Tui.t().screen.scroll(1) + Tui.t().screen.addstr(last_row, col, text) + else: + Tui.t().screen.addstr(row, col, text) + + Tui.t().screen.refresh() @property def max_yx(self) -> Tuple[int, int]: return self._screen.getmaxyx() - def run(self, component: AbstractCurses) -> Result: - return self._main_loop(component) + @staticmethod + def run(component: AbstractCurses) -> Result: + if Tui._t is None: + tui = Tui().init() + tui.screen.clear() + results = tui._main_loop(component) + Tui().stop() + return results + else: + tui = Tui._t + tui.screen.clear() + return Tui.t()._main_loop(component) def _sig_win_resize(self, signum: int, frame) -> None: - if self._component: - self._component.resize_win() + if hasattr(self, '_component') and self._component is not None: # pylint: disable=E1101 + self._component.resize_win() # pylint: disable=E1101 def _main_loop(self, component: AbstractCurses) -> Result: self._screen.refresh() return component.kickoff(self._screen) - def _reset_terminal(self) -> None: + def _reset_terminal(self): os.system("reset") - def _soft_clear_terminal(self) -> None: - print(chr(27) + "[2J", end="") - print(chr(27) + "[1;1H", end="") - def _set_up_colors(self) -> None: curses.init_pair(STYLE.NORMAL.value, curses.COLOR_WHITE, curses.COLOR_BLACK) curses.init_pair(STYLE.CURSOR_STYLE.value, curses.COLOR_CYAN, curses.COLOR_BLACK) @@ -1237,6 +1484,3 @@ def _set_up_colors(self) -> None: def get_color(self, color: STYLE) -> int: return curses.color_pair(color.value) - - -tui = Tui() diff --git a/archinstall/tui/help.py b/archinstall/tui/help.py index 2b8333368e..b029664232 100644 --- a/archinstall/tui/help.py +++ b/archinstall/tui/help.py @@ -53,6 +53,8 @@ class Help: selection = HelpGroup( group_id=HelpTextGroupId.SELECTION, group_entries=[ + HelpText('Skip selction (if available)', ['Esc']), + HelpText('Reset selection (if available)', ['Ctrl+c']), HelpText('Select on single select', ['Enter']), HelpText('Select on select', ['Space', 'Tab']), HelpText('Reset', ['Ctrl-C']), @@ -75,18 +77,14 @@ def get_help_text() -> str: max_desc_width = max([help.get_desc_width() for help in help_texts]) max_key_width = max([help.get_key_width() for help in help_texts]) - margin = ' ' * 3 - for help in help_texts: - help_output += f'{margin}{help.group_id.value}\n' - divider_len = max_desc_width + max_key_width + len(margin * 2) - help_output += margin + '-' * divider_len + '\n' + help_output += f'{help.group_id.value}\n' + divider_len = max_desc_width + max_key_width + help_output += '-' * divider_len + '\n' for entry in help.group_entries: help_output += ( - margin + entry.description.ljust(max_desc_width, ' ') + - margin + ', '.join(entry.keys) + '\n' ) diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index 5ce6327b96..63d8be9642 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from typing import Any, Self, Optional, List, TYPE_CHECKING -from typing import Callable +from typing import Any, Optional, List, TYPE_CHECKING +from typing import Callable, ClassVar from ..lib.output import unicode_ljust @@ -15,42 +15,51 @@ class MenuItem: action: Optional[Callable[[Any], Any]] = None enabled: bool = True mandatory: bool = False - dependencies: List[Self] = field(default_factory=list) - dependencies_not: List[Self] = field(default_factory=list) + dependencies: List[str | Callable[[], bool]] = field(default_factory=list) + dependencies_not: List[str] = field(default_factory=list) display_action: Optional[Callable[[Any], str]] = None preview_action: Optional[Callable[[Any], Optional[str]]] = None - key: Optional[Any] = None + key: Optional[str] = None + + _yes: ClassVar[Optional['MenuItem']] = None + _no: ClassVar[Optional['MenuItem']] = None + + def get_value(self) -> Any: + assert self.value is not None + return self.value @classmethod - def default_yes(cls) -> Self: - return cls(str(_('Yes'))) + def yes(cls) -> 'MenuItem': + if cls._yes is None: + cls._yes = cls(str(_('Yes')), value=True) + + return cls._yes @classmethod - def default_no(cls) -> Self: - return cls(str(_('No'))) + def no(cls) -> 'MenuItem': + if cls._no is None: + cls._no = cls(str(_('No')), value=True) + + return cls._no def is_empty(self) -> bool: return self.text == '' or self.text is None - def get_text(self, spacing: int = 0, suffix: str = '') -> str: - if self.is_empty(): - return '' - - value_text = '' - - if self.display_action: - value_text = self.display_action(self.value) + def has_value(self) -> bool: + if self.value is None: + return False + elif isinstance(self.value, list) and len(self.value) == 0: + return False + elif isinstance(self.value, dict) and len(self.value) == 0: + return False else: - if self.value is not None: - value_text = str(self.value) + return True - if value_text: - spacing += 2 - text = unicode_ljust(str(self.text), spacing, ' ') - else: - text = self.text + def get_display_value(self) -> Optional[str]: + if self.display_action is not None: + return self.display_action(self.value) - return f'{text} {value_text}{suffix}'.rstrip(' ') + return None @dataclass @@ -59,11 +68,12 @@ class MenuItemGroup: focus_item: Optional[MenuItem] = None default_item: Optional[MenuItem] = None selected_items: List[MenuItem] = field(default_factory=list) - sort_items: bool = True + sort_items: bool = False + checkmarks: bool = False _filter_pattern: str = '' - def __post_init__(self) -> None: + def __post_init__(self): if len(self.menu_items) < 1: raise ValueError('Menu must have at least one item') @@ -79,18 +89,58 @@ def __post_init__(self) -> None: if self.focus_item not in self.menu_items: raise ValueError('Selected item not in menu') + def find_by_key(self, key: str) -> MenuItem: + for item in self.menu_items: + if item.key == key: + return item + + raise ValueError(f'No key found for: {key}') + @staticmethod - def default_confirm() -> 'MenuItemGroup': + def yes_no() -> 'MenuItemGroup': return MenuItemGroup( - [MenuItem.default_yes(), MenuItem.default_no()], - sort_items=False + [MenuItem.yes(), MenuItem.no()], + sort_items=True ) - def index_of(self, item) -> int: + def set_preview_for_all(self, action: Callable[[Any], Optional[str]]) -> None: + for item in self.items: + item.preview_action = action + + def set_focus_by_value(self, value: Any) -> None: + for item in self.menu_items: + if item.value == value: + self.focus_item = item + break + + def set_default_by_value(self, value: Any) -> None: + for item in self.menu_items: + if item.value == value: + self.default_item = item + break + + def set_selected_by_value(self, values: Optional[Any | List[Any]]) -> None: + if values is None: + return + + if not isinstance(values, list): + values = [values] + + for item in self.menu_items: + if item.value in values: + self.selected_items.append(item) + + if values: + self.set_focus_by_value(values[0]) + + def index_of(self, item: MenuItem) -> int: return self.items.index(item) def index_focus(self) -> int: - return self.index_of(self.focus_item) + if self.focus_item: + return self.index_of(self.focus_item) + + raise ValueError('No focus item set') def index_last(self) -> int: return self.index_of(self.items[-1]) @@ -106,8 +156,43 @@ def size(self) -> int: def max_width(self) -> int: # use the menu_items not the items here otherwise the preview # will get resized all the time when a filter is applied + return max([len(self.get_item_text(item)) for item in self.menu_items]) + + def _max_item_width(self) -> int: return max([len(item.text) for item in self.menu_items]) + def get_item_text(self, item: MenuItem) -> str: + if item.is_empty(): + return '' + + max_width = self._max_item_width() + display_text = item.get_display_value() + default_text = self._default_suffix(item) + + text = unicode_ljust(str(item.text), max_width, ' ') + spacing = ' ' * 4 + + if display_text: + text = f'{text}{spacing}{display_text}' + elif self.checkmarks: + from .types import Chars + + if item.has_value(): + if item.get_value() is not False: + text = f'{text}{spacing}{Chars.Check}' + else: + text = item.text + + if default_text: + text = f'{text} {default_text}' + + return text.rstrip(' ') + + def _default_suffix(self, item: MenuItem) -> str: + if self.default_item == item: + return str(_(' (default)')) + return '' + @property def items(self) -> List[MenuItem]: f = self._filter_pattern.lower() @@ -115,14 +200,14 @@ def items(self) -> List[MenuItem]: return list(items) @property - def filter_pattern(self): + def filter_pattern(self) -> str: return self._filter_pattern def set_filter_pattern(self, pattern: str) -> None: self._filter_pattern = pattern self.reload_focus_itme() - def append_filter(self, pattern: str) -> None: + def append_filter(self, pattern: str): self._filter_pattern += pattern self.reload_focus_itme() @@ -189,7 +274,7 @@ def focus_last(self) -> None: if last_item: self.focus_item = last_item - def focus_prev(self, skip_empty: bool = True) -> None: + def focus_prev(self, skip_empty: bool = True): items = self.items if self.focus_item not in items: @@ -203,7 +288,7 @@ def focus_prev(self, skip_empty: bool = True) -> None: if self.focus_item.is_empty() and skip_empty: self.focus_prev(skip_empty) - def focus_next(self, skip_empty: bool = True) -> None: + def focus_next(self, skip_empty: bool = True): items = self.items if self.focus_item not in items: @@ -229,19 +314,21 @@ def max_item_width(self) -> int: return max(spaces) return 0 - def verify_item_enabled(self, item: MenuItem) -> bool: + def should_enable_item(self, item: MenuItem) -> bool: if not item.enabled: return False - if item in self.menu_items: - for dep in item.dependencies: - if not self.verify_item_enabled(dep): - return False - - for dep in item.dependencies_not: - if dep.value is not None: + for dep in item.dependencies: + if isinstance(dep, str): + item = self.find_by_key(dep) + if not item.value or not self.should_enable_item(item): return False + else: + return dep() - return True + for dep_not in item.dependencies_not: + item = self.find_by_key(dep_not) + if item.value is not None: + return False - return False + return True diff --git a/archinstall/tui/types.py b/archinstall/tui/types.py index da69e806de..a1c36b6279 100644 --- a/archinstall/tui/types.py +++ b/archinstall/tui/types.py @@ -1,12 +1,10 @@ import curses from dataclasses import dataclass from enum import Enum, auto -from typing import Optional, List, TypeVar, Generic +from typing import Optional, List, Any from .menu_item import MenuItem -ItemType = TypeVar('ItemType', MenuItem, List[MenuItem], str) - SCROLL_INTERVAL = 10 @@ -46,8 +44,8 @@ class MenuKeys(Enum): ESC = {27} # BACKSPACE (search) BACKSPACE = {127, 263} - # Help view: CTRL+h - HELP = {8} + # Help view: ? + HELP = {63} # Scroll up: CTRL+up, CTRL+k SCROLL_UP = {581} # Scroll down: CTRL+down, CTRL+j @@ -80,6 +78,22 @@ class FrameProperties: w_frame_style: FrameStyle = FrameStyle.MAX h_frame_style: FrameStyle = FrameStyle.MAX + @classmethod + def max(cls, header: str) -> 'FrameProperties': + return FrameProperties( + header, + FrameStyle.MAX, + FrameStyle.MAX, + ) + + @classmethod + def min(cls, header: str) -> 'FrameProperties': + return FrameProperties( + header, + FrameStyle.MIN, + FrameStyle.MIN, + ) + class ResultType(Enum): Selection = auto() @@ -87,7 +101,7 @@ class ResultType(Enum): Reset = auto() -class MenuOrientation(Enum): +class Orientation(Enum): VERTICAL = auto() HORIZONTAL = auto() @@ -106,6 +120,7 @@ class PreviewStyle(Enum): # https://www.compart.com/en/unicode/search?q=box+drawings#characters +# https://en.wikipedia.org/wiki/Box-drawing_characters class Chars: Horizontal = "─" Vertical = "│" @@ -116,12 +131,36 @@ class Chars: Block = "█" Triangle_up = "▲" Triangle_down = "▼" + Check = "+" + Cross = "x" + Right_arrow = "←" @dataclass -class Result(Generic[ItemType]): +class Result: type_: ResultType - value: Optional[ItemType] + _item: Optional[MenuItem | List[MenuItem] | str] + + def has_item(self) -> bool: + return self._item is not None + + def get_value(self) -> Any: + return self.item().get_value() + + def get_values(self) -> List[Any]: + return [i.get_value() for i in self.items()] + + def item(self) -> MenuItem: + assert self._item is not None and isinstance(self._item, MenuItem) + return self._item + + def items(self) -> List[MenuItem]: + assert self._item is not None and isinstance(self._item, list) + return self._item + + def text(self) -> str: + assert self._item is not None and isinstance(self._item, str) + return self._item @dataclass diff --git a/docs/README.md b/docs/README.md index 7ab47d2603..3c6dd64843 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,4 +14,4 @@ For other installation methods refer to the docs of the dependencies. ## Build -In `archinstall/docs`, run `make html` (or specify another target) to build locally. The build files will be in `archinstall/docs/_build`. Open `_build/html/index.html` with your browser to see your changes in action. \ No newline at end of file +In `archinstall/docs`, run `make html` (or specify another target) to build locally. The build files will be in `archinstall/docs/_build`. Open `_build/html/index.html` with your browser to see your changes in action. diff --git a/docs/_static/style.css b/docs/_static/style.css index 579fe077d8..b07bdb1b51 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -1,3 +1,3 @@ .wy-nav-content { max-width: none; -} \ No newline at end of file +} diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html index b0a4480600..3e44f4a314 100644 --- a/docs/_templates/layout.html +++ b/docs/_templates/layout.html @@ -1,4 +1,4 @@ {% extends "!layout.html" %} {% block extrahead %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/docs/archinstall/plugins.rst b/docs/archinstall/plugins.rst index 898f9006cc..1022ab6f6b 100644 --- a/docs/archinstall/plugins.rst +++ b/docs/archinstall/plugins.rst @@ -54,4 +54,4 @@ The simplest way currently is to look at a reference implementation or the commu And search for `plugin.on_ `_ in the code base to find what ``archinstall`` will look for. PR's are welcome to widen the support for this. .. _plugin discovery: https://packaging.python.org/en/latest/specifications/entry-points/ -.. _entry points: https://docs.python.org/3/library/importlib.metadata.html#entry-points \ No newline at end of file +.. _entry points: https://docs.python.org/3/library/importlib.metadata.html#entry-points diff --git a/docs/cli_parameters/config/disk_encryption.rst b/docs/cli_parameters/config/disk_encryption.rst index df2e2fa7b7..9be5e48087 100644 --- a/docs/cli_parameters/config/disk_encryption.rst +++ b/docs/cli_parameters/config/disk_encryption.rst @@ -16,4 +16,4 @@ Disk encryption consists of a top level entry in the user configuration. } } -The ``UID`` in the ``partitions`` list is an internal reference to the ``obj_id`` in the :ref:`disk config` entries. \ No newline at end of file +The ``UID`` in the ``partitions`` list is an internal reference to the ``obj_id`` in the :ref:`disk config` entries. diff --git a/docs/cli_parameters/config/manual_options.csv b/docs/cli_parameters/config/manual_options.csv index 2fcc26f0c9..794c8997c1 100644 --- a/docs/cli_parameters/config/manual_options.csv +++ b/docs/cli_parameters/config/manual_options.csv @@ -1,4 +1,4 @@ Key,Value(s),Description,Required device,``str``,Which block-device to format,yes partitions,[ {key: val} ],The data describing the change/addition in a partition,yes -wipe,``bool``,clear the disk before adding any partitions,No \ No newline at end of file +wipe,``bool``,clear the disk before adding any partitions,No diff --git a/docs/flowcharts/BlockDeviceSelection.svg b/docs/flowcharts/BlockDeviceSelection.svg index 33dd7f709b..7dd2b43979 100644 --- a/docs/flowcharts/BlockDeviceSelection.svg +++ b/docs/flowcharts/BlockDeviceSelection.svg @@ -1 +1 @@ -
Select BlockDevices
Select BlockDevices
No
No
Yes
Yes
Empty Selection
Empty Selection
Yes / No
Yes / No
Encrypt Root
Encrypt Root
No
No
Yes
Yes
Multiple BD's
Multiple BD's
Select /boot, / and optional mounts
Select /boot, / and...
Yes
Yes
No
No
Contains Partitions
Contains Partitions
No
No
Yes
Yes
Old /boot has content
Old /boot has cont...
Select Root FIlesystem
Select Root FIlesyst...
Mount Partitions
Mount Partitions
Install on /mnt
Install on /mnt
Yes
Yes
Wipe /Boot
Wipe /Boot
Clear old
systemd-boot files
Clear old...
Viewer does not support full SVG 1.1
\ No newline at end of file +
Select BlockDevices
Select BlockDevices
No
No
Yes
Yes
Empty Selection
Empty Selection
Yes / No
Yes / No
Encrypt Root
Encrypt Root
No
No
Yes
Yes
Multiple BD's
Multiple BD's
Select /boot, / and optional mounts
Select /boot, / and...
Yes
Yes
No
No
Contains Partitions
Contains Partitions
No
No
Yes
Yes
Old /boot has content
Old /boot has cont...
Select Root FIlesystem
Select Root FIlesyst...
Mount Partitions
Mount Partitions
Install on /mnt
Install on /mnt
Yes
Yes
Wipe /Boot
Wipe /Boot
Clear old
systemd-boot files
Clear old...
Viewer does not support full SVG 1.1
diff --git a/docs/flowcharts/DiskSelectionProcess.drawio b/docs/flowcharts/DiskSelectionProcess.drawio index 7c8a1fcb1d..fb8a225f6f 100644 --- a/docs/flowcharts/DiskSelectionProcess.drawio +++ b/docs/flowcharts/DiskSelectionProcess.drawio @@ -1 +1 @@ -7VvZdqM4EP2anHlKDpsxPMZ20sl0kl7S05meNwVkYCIjt5C3+fqRjFgFNnbwksQv3a5CCKG6dWsROdP7o/knAsb+PXYhOtMUd36mD840TVUsi/3HNYtYY5tC4ZHAFYMyxWPwH0zuFNpJ4MKoMJBijGgwLiodHIbQoQUdIATPisOGGBWfOgYelBSPDkCy9ilwqR9rLa2b6W9g4PnJk1XTjq+MQDJYvEnkAxfPcir96kzvE4xp/Gs070PENy/Zl6fbxRO6ezE//fkt+g3+6n3+8fDzPJ7sepNb0lcgMKRbT/3y2bLtaXf4oFrf7ga/rYfB/dW5IV6NLpL9gi7bPiFiQn3s4RCgq0zbI3gSupDPqjIpG3OH8Vgo/4WULgQWwIRipvLpCImr7C3I4m8mKBedRPzFxUQYzAvSQkhDHNI+RpgsV6obpmF3BkwfUYJfYO5K17IuL3V+R4BQTt/T+vZlbzmve80upetxLznSmOggEEWBs5wUECoGKYmcDAtxyHcieoHU8cWAeCf59pWAt8ZqYlyEJ8SBK8bpwnkA8eCq+cwUmsynIR5BtoXsPgIRoMG0uDggnMtLx2UAYj8EhjaAqljkFKCJeNIjRNytNaWHsPMygNPAYXRQRt3MDyh8HIPlDswYExURwy0v4KSyl+t53EypYbj1E9/mo1NHVSS01gFiBYRqQLeZdaeQUDjP7b1soOSqKehG8K0lxFlGXmrCSH6OuAzl9SatpAhNMukDPrFGBUHskwzMhmSgKtVga50NKqEjs8GvCt8/IHbUA2HnbUScCpBp3mx4N50TtftTuRl3H7/9Y4fnmrGS0c6Vi65qCCw0Bp6Y7isOQpobgofDiC2mjMz0qduHLlMC69VoTPl7xCEswKEEXeLj0fOELa23JoAVQhJH1TUYBYjD7AaiKaSBAyrCHECBF3KMMHtCUh3r2COD0GOSmUk/lm7Bkru3H/7UbsP4Z7UQ/6rRXcNiCp9WU7YKhs6ETJdGkTOUgodvQXWbU1OdieE8oDnOZFJKmex3xphcSAhz2yBd4LpqSizxpiDEjFeVjVGZJ8Bq02sNw2y3koprQa5cqIZmv44QxVRGp+QvZskP4rWLu/Ll4f6YtSsza+iQxZg//Tsvn0/VQB1SOnrJvPahywHlSMqBd5nWJ02ztSlXZ13G9XqCaZUXKqGkSlA6VHmwx0j3YXBbPbCG7vbTnVJl8rqf8FY0g5Gm9AZ/yOg7Zfi7zvC1piFtdxm+Kpn9naTwbbt5coCzLjxZDfPhPbm9HGjSrjSb8HmZgvbTig6ELvsXj3nFD/jkI2ZdeupZN3dpQ99jlloJQLul1KJd723dS+tzw2N20srVqHKVuFVh8arucCGRy/K62qbFG7G0ah6VqTuSpftsv0AQ8q7aV5bGBpx6T7nY7olb7xxbLqbKzL1/GtjskOjgNGC1TQO5gxvdFj3Qo2gjVKfvbQX7nab0FWd5pYq98lCwXOQfvjJ4FQS140o6LAk5X5CbrwoUH/Cg5LBdhKHcqP64EQkQR6xU0/cYoAz14AHKOkWjlVSgNv02pemh2WaRh1EoWOQGjHkcinYRd1T5i4G0rRAfaynXtwhGi4jCkQSaU/eg7oxLb+rjO+seaHKFcmzpRFt5gAsiP11Wy0yQ7FrDD4iOJitI1p0/JuAtwNXV6RqHLgGgnA9sdiQkM0Cd3fO8sS8H3ucnqyvxlDPgbcjS7eWd/DMunuCNKvK57W0o2crSnnXTbGIrtwMt1yjZquCXzA70C0/tKN9hY5e21I0js6Xe1inxqbrbnsiblncJERwLkcv13VMwhrH/96q+PPq4Bd3OCKVUwXWMTjNG2VkFpx9dMve+2aN9Uqj+FNJUS3WEYRenqPkUsq16MHnP/LkGgoDwnIN3lUzEdrP3zGTT47/iwtA9F50mZqWKuHYipNazVaMBIRntEBITs78PjXGW/ZWtfvU/ \ No newline at end of file +7VvZdqM4EP2anHlKDpsxPMZ20sl0kl7S05meNwVkYCIjt5C3+fqRjFgFNnbwksQv3a5CCKG6dWsROdP7o/knAsb+PXYhOtMUd36mD840TVUsi/3HNYtYY5tC4ZHAFYMyxWPwH0zuFNpJ4MKoMJBijGgwLiodHIbQoQUdIATPisOGGBWfOgYelBSPDkCy9ilwqR9rLa2b6W9g4PnJk1XTjq+MQDJYvEnkAxfPcir96kzvE4xp/Gs070PENy/Zl6fbxRO6ezE//fkt+g3+6n3+8fDzPJ7sepNb0lcgMKRbT/3y2bLtaXf4oFrf7ga/rYfB/dW5IV6NLpL9gi7bPiFiQn3s4RCgq0zbI3gSupDPqjIpG3OH8Vgo/4WULgQWwIRipvLpCImr7C3I4m8mKBedRPzFxUQYzAvSQkhDHNI+RpgsV6obpmF3BkwfUYJfYO5K17IuL3V+R4BQTt/T+vZlbzmve80upetxLznSmOggEEWBs5wUECoGKYmcDAtxyHcieoHU8cWAeCf59pWAt8ZqYlyEJ8SBK8bpwnkA8eCq+cwUmsynIR5BtoXsPgIRoMG0uDggnMtLx2UAYj8EhjaAqljkFKCJeNIjRNytNaWHsPMygNPAYXRQRt3MDyh8HIPlDswYExURwy0v4KSyl+t53EypYbj1E9/mo1NHVSS01gFiBYRqQLeZdaeQUDjP7b1soOSqKehG8K0lxFlGXmrCSH6OuAzl9SatpAhNMukDPrFGBUHskwzMhmSgKtVga50NKqEjs8GvCt8/IHbUA2HnbUScCpBp3mx4N50TtftTuRl3H7/9Y4fnmrGS0c6Vi65qCCw0Bp6Y7isOQpobgofDiC2mjMz0qduHLlMC69VoTPl7xCEswKEEXeLj0fOELa23JoAVQhJH1TUYBYjD7AaiKaSBAyrCHECBF3KMMHtCUh3r2COD0GOSmUk/lm7Bkru3H/7UbsP4Z7UQ/6rRXcNiCp9WU7YKhs6ETJdGkTOUgodvQXWbU1OdieE8oDnOZFJKmex3xphcSAhz2yBd4LpqSizxpiDEjFeVjVGZJ8Bq02sNw2y3koprQa5cqIZmv44QxVRGp+QvZskP4rWLu/Ll4f6YtSsza+iQxZg//Tsvn0/VQB1SOnrJvPahywHlSMqBd5nWJ02ztSlXZ13G9XqCaZUXKqGkSlA6VHmwx0j3YXBbPbCG7vbTnVJl8rqf8FY0g5Gm9AZ/yOg7Zfi7zvC1piFtdxm+Kpn9naTwbbt5coCzLjxZDfPhPbm9HGjSrjSb8HmZgvbTig6ELvsXj3nFD/jkI2ZdeupZN3dpQ99jlloJQLul1KJd723dS+tzw2N20srVqHKVuFVh8arucCGRy/K62qbFG7G0ah6VqTuSpftsv0AQ8q7aV5bGBpx6T7nY7olb7xxbLqbKzL1/GtjskOjgNGC1TQO5gxvdFj3Qo2gjVKfvbQX7nab0FWd5pYq98lCwXOQfvjJ4FQS140o6LAk5X5CbrwoUH/Cg5LBdhKHcqP64EQkQR6xU0/cYoAz14AHKOkWjlVSgNv02pemh2WaRh1EoWOQGjHkcinYRd1T5i4G0rRAfaynXtwhGi4jCkQSaU/eg7oxLb+rjO+seaHKFcmzpRFt5gAsiP11Wy0yQ7FrDD4iOJitI1p0/JuAtwNXV6RqHLgGgnA9sdiQkM0Cd3fO8sS8H3ucnqyvxlDPgbcjS7eWd/DMunuCNKvK57W0o2crSnnXTbGIrtwMt1yjZquCXzA70C0/tKN9hY5e21I0js6Xe1inxqbrbnsiblncJERwLkcv13VMwhrH/96q+PPq4Bd3OCKVUwXWMTjNG2VkFpx9dMve+2aN9Uqj+FNJUS3WEYRenqPkUsq16MHnP/LkGgoDwnIN3lUzEdrP3zGTT47/iwtA9F50mZqWKuHYipNazVaMBIRntEBITs78PjXGW/ZWtfvU/ diff --git a/docs/installing/guided.rst b/docs/installing/guided.rst index f82c185f29..ed0e6cdceb 100644 --- a/docs/installing/guided.rst +++ b/docs/installing/guided.rst @@ -269,7 +269,7 @@ Below is an example of how to set the root password and below that are descripti { "username": "", "!password": "", - "sudo": false + "sudo": false } - List of regular user credentials, see configuration for reference - Maybe diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md index 731bed8fdf..366844d7f3 100644 --- a/docs/pull_request_template.md +++ b/docs/pull_request_template.md @@ -6,7 +6,7 @@ ## Tests and Checks - [ ] I have tested the code!
- diff --git a/examples/full_automated_installation.py b/examples/full_automated_installation.py index d25575d4a3..0108babec9 100644 --- a/examples/full_automated_installation.py +++ b/examples/full_automated_installation.py @@ -6,6 +6,7 @@ from archinstall import disk from archinstall import models + # we're creating a new ext4 filesystem installation fs_type = disk.FilesystemType('ext4') device_path = Path('/dev/sda') diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index 93636b1fb4..a738bd3611 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -1,79 +1,40 @@ from pathlib import Path -from typing import TYPE_CHECKING, Callable, Optional +from typing import Optional import archinstall -from archinstall import Installer -from archinstall import profile -from archinstall import SysInfo -from archinstall import disk -from archinstall import menu -from archinstall import models -from archinstall import locale from archinstall import info, debug +from archinstall import SysInfo +from archinstall.lib import locale +from archinstall.lib import disk +from archinstall.lib.global_menu import GlobalMenu +from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib.installer import Installer +from archinstall.lib.models import AudioConfiguration, Bootloader +from archinstall.lib.models.network_configuration import NetworkConfiguration +from archinstall.lib.profile.profiles_handler import profile_handler +from archinstall.lib.interactions.general_conf import ask_chroot +from archinstall.tui import Tui -if TYPE_CHECKING: - _: Callable[[str], str] - - -def ask_user_questions() -> None: - global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) - - global_menu.enable('archinstall-language') - - # Set which region to download packages from during the installation - global_menu.enable('mirror_config') - - global_menu.enable('locale_config') - - global_menu.enable('disk_config', mandatory=True) - - # Specify disk encryption options - global_menu.enable('disk_encryption') - - # Ask which boot-loader to use (will only ask if we're in UEFI mode, otherwise will default to GRUB) - global_menu.enable('bootloader') - - global_menu.enable('swap') - - # Get the hostname for the machine - global_menu.enable('hostname') - - # Ask for a root password (optional, but triggers requirement for super-user if skipped) - global_menu.enable('!root-password', mandatory=True) - - global_menu.enable('!users', mandatory=True) - - # Ask for archinstall-specific profiles_bck (such as desktop environments etc) - global_menu.enable('profile_config') - - # Ask about audio server selection if one is not already set - global_menu.enable('audio_config') - - # Ask for preferred kernel: - global_menu.enable('kernels', mandatory=True) - - global_menu.enable('packages') - - if archinstall.arguments.get('advanced', False): - # Enable parallel downloads - global_menu.enable('parallel downloads') - - # Ask or Call the helper function that asks the user to optionally configure a network. - global_menu.enable('network_config') - global_menu.enable('timezone') +if archinstall.arguments.get('help'): + print("See `man archinstall` for help.") + exit(0) - global_menu.enable('ntp') - global_menu.enable('additional-repositories') +def ask_user_questions() -> None: + """ + First, we'll ask the user for a bunch of user input. + Not until we're satisfied with what we want to install + will we continue with the actual installation steps. + """ - global_menu.enable('__separator__') + with Tui(): + global_menu = GlobalMenu(data_store=archinstall.arguments) - global_menu.enable('save_config') - global_menu.enable('install') - global_menu.enable('abort') + if not archinstall.arguments.get('advanced', False): + global_menu.set_enabled('parallel downloads', False) - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -88,6 +49,7 @@ def perform_installation(mountpoint: Path) -> None: # Retrieve list of additional repositories and set boolean values appropriately enable_testing = 'testing' in archinstall.arguments.get('additional-repositories', []) enable_multilib = 'multilib' in archinstall.arguments.get('additional-repositories', []) + run_mkinitcpio = not archinstall.arguments.get('uki') locale_config: locale.LocaleConfiguration = archinstall.arguments['locale_config'] disk_encryption: disk.DiskEncryption = archinstall.arguments.get('disk_encryption', None) @@ -109,11 +71,12 @@ def perform_installation(mountpoint: Path) -> None: installation.generate_key_files() if mirror_config := archinstall.arguments.get('mirror_config', None): - installation.set_mirrors(mirror_config) + installation.set_mirrors(mirror_config, on_target=False) installation.minimal_installation( testing=enable_testing, multilib=enable_multilib, + mkinitcpio=run_mkinitcpio, hostname=archinstall.arguments.get('hostname', 'archlinux'), locale_config=locale_config ) @@ -124,14 +87,17 @@ def perform_installation(mountpoint: Path) -> None: if archinstall.arguments.get('swap'): installation.setup_swap('zram') - if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and SysInfo.has_uefi(): + if archinstall.arguments.get("bootloader") == Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages("grub") - installation.add_bootloader(archinstall.arguments["bootloader"]) + installation.add_bootloader( + archinstall.arguments["bootloader"], + archinstall.arguments.get('uki', False) + ) # If user selected to copy the current ISO network configuration # Perform a copy of the config - network_config = archinstall.arguments.get('network_config', None) + network_config: Optional[NetworkConfiguration] = archinstall.arguments.get('network_config', None) if network_config: network_config.install_network_config( @@ -142,17 +108,17 @@ def perform_installation(mountpoint: Path) -> None: if users := archinstall.arguments.get('!users', None): installation.create_users(users) - audio_config: Optional[models.AudioConfiguration] = archinstall.arguments.get('audio_config', None) + audio_config: Optional[AudioConfiguration] = archinstall.arguments.get('audio_config', None) if audio_config: audio_config.install_audio_config(installation) else: info("No audio server will be installed") if archinstall.arguments.get('packages', None) and archinstall.arguments.get('packages', None)[0] != '': - installation.add_additional_packages(archinstall.arguments.get('packages', [])) + installation.add_additional_packages(archinstall.arguments.get('packages', None)) if profile_config := archinstall.arguments.get('profile_config', None): - profile.profile_handler.install_profile_config(installation, profile_config) + profile_handler.install_profile_config(installation, profile_config) if timezone := archinstall.arguments.get('timezone', None): installation.set_timezone(timezone) @@ -183,24 +149,42 @@ def perform_installation(mountpoint: Path) -> None: info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") if not archinstall.arguments.get('silent'): - prompt = str(_('Would you like to chroot into the newly created installation and perform post-installation configuration?')) - choice = menu.Menu(prompt, menu.Menu.yes_no(), default_option=menu.Menu.yes()).run() - if choice.value == menu.Menu.yes(): + with Tui(): + chroot = ask_chroot() + + if chroot: try: installation.drop_to_shell() - except Exception: + except: pass debug(f"Disk states after installing: {disk.disk_layouts()}") -ask_user_questions() +def _guided() -> None: + if not archinstall.arguments.get('silent'): + ask_user_questions() + + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() + + if archinstall.arguments.get('dry_run'): + exit(0) + + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + _guided() + + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -fs_handler.perform_filesystem_operations() -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) +_guided() diff --git a/examples/mac_address_installation.py b/examples/mac_address_installation.py index 74a123c7f4..ed999b340a 100644 --- a/examples/mac_address_installation.py +++ b/examples/mac_address_installation.py @@ -2,6 +2,8 @@ import archinstall from archinstall import profile, info +from archinstall.tui import Tui + for _profile in profile.profile_handler.get_mac_addr_profiles(): # Tailored means it's a match for this machine @@ -11,7 +13,7 @@ print('Starting install in:') for i in range(10, 0, -1): - print(f'{i}...') + Tui.print(f'{i}...') time.sleep(1) install_session = archinstall.storage['installation_session'] diff --git a/examples/minimal_installation.py b/examples/minimal_installation.py index c86b1e59e3..d23a0d1854 100644 --- a/examples/minimal_installation.py +++ b/examples/minimal_installation.py @@ -1,16 +1,24 @@ from pathlib import Path -from typing import TYPE_CHECKING, Callable, List +from typing import List import archinstall -from archinstall import disk -from archinstall import Installer -from archinstall import profile -from archinstall import models -from archinstall import interactions +from archinstall import info, debug +from archinstall import Installer, ConfigurationOutput from archinstall.default_profiles.minimal import MinimalProfile +from archinstall.lib.interactions import suggest_single_disk_layout, select_devices +from archinstall.lib.models import Bootloader, User +from archinstall.lib.profile import ProfileConfiguration, profile_handler +from archinstall.lib import disk +from archinstall.tui import Tui -if TYPE_CHECKING: - _: Callable[[str], str] + +info("Minimal only supports:") +info(" * Being installed to a single disk") + +if archinstall.arguments.get('help', None): + info(" - Optional disk encryption via --!encryption-password=") + info(" - Optional filesystem type via --filesystem=") + info(" - Optional systemd network via --network") def perform_installation(mountpoint: Path) -> None: @@ -27,7 +35,7 @@ def perform_installation(mountpoint: Path) -> None: # some other minor details as specified by this profile and user. if installation.minimal_installation(): installation.set_hostname('minimal-arch') - installation.add_bootloader(models.Bootloader.Systemd) + installation.add_bootloader(Bootloader.Systemd) # Optionally enable networking: if archinstall.arguments.get('network', None): @@ -35,20 +43,26 @@ def perform_installation(mountpoint: Path) -> None: installation.add_additional_packages(['nano', 'wget', 'git']) - profile_config = profile.ProfileConfiguration(MinimalProfile()) - profile.profile_handler.install_profile_config(installation, profile_config) + profile_config = ProfileConfiguration(MinimalProfile()) + profile_handler.install_profile_config(installation, profile_config) - user = models.User('devel', 'devel', False) + user = User('devel', 'devel', False) installation.create_users(user) + # Once this is done, we output some useful information to the user + # And the installation is complete. + info("There are two new accounts in your installation after reboot:") + info(" * root (password: airoot)") + info(" * devel (password: devel)") + def prompt_disk_layout() -> None: fs_type = None if filesystem := archinstall.arguments.get('filesystem', None): fs_type = disk.FilesystemType(filesystem) - devices = interactions.select_devices() - modifications = interactions.suggest_single_disk_layout(devices[0], filesystem_type=fs_type) + devices = select_devices() + modifications = suggest_single_disk_layout(devices[0], filesystem_type=fs_type) archinstall.arguments['disk_config'] = disk.DiskLayoutConfiguration( config_type=disk.DiskLayoutType.Default, @@ -72,15 +86,31 @@ def parse_disk_encryption() -> None: ) -prompt_disk_layout() -parse_disk_encryption() +def _minimal() -> None: + with Tui(): + prompt_disk_layout() + parse_disk_encryption() + + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() + + if archinstall.arguments.get('dry_run'): + exit(0) + + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + _minimal() + + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -fs_handler.perform_filesystem_operations() -mount_point = Path('/mnt') -perform_installation(mount_point) +_minimal() diff --git a/examples/only_hd_installation.py b/examples/only_hd_installation.py index 7a71ef10ba..b50455c7bc 100644 --- a/examples/only_hd_installation.py +++ b/examples/only_hd_installation.py @@ -1,23 +1,27 @@ from pathlib import Path import archinstall -from archinstall import Installer, disk, debug +from archinstall import debug +from archinstall.lib.installer import Installer +from archinstall.lib.configuration import ConfigurationOutput +from archinstall.lib import disk +from archinstall.tui import Tui def ask_user_questions() -> None: - global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) + with Tui(): + global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) + global_menu.disable_all() - global_menu.enable('archinstall-language') + global_menu.set_enabled('archinstall-language', True) + global_menu.set_enabled('disk_config', True) + global_menu.set_enabled('disk_encryption', True) + global_menu.set_enabled('swap', True) + global_menu.set_enabled('save_config', True) + global_menu.set_enabled('install', True) + global_menu.set_enabled('abort', True) - global_menu.enable('disk_config', mandatory=True) - global_menu.enable('disk_encryption') - global_menu.enable('swap') - - global_menu.enable('save_config') - global_menu.enable('install') - global_menu.enable('abort') - - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -49,13 +53,30 @@ def perform_installation(mountpoint: Path) -> None: debug(f"Disk states after installing: {disk.disk_layouts()}") -ask_user_questions() +def _only_hd() -> None: + if not archinstall.arguments.get('silent'): + ask_user_questions() + + config = ConfigurationOutput(archinstall.arguments) + config.write_debug() + config.save() + + if archinstall.arguments.get('dry_run'): + exit(0) + + if not archinstall.arguments.get('silent'): + with Tui(): + if not config.confirm_config(): + debug('Installation aborted') + _only_hd() + + fs_handler = disk.FilesystemHandler( + archinstall.arguments['disk_config'], + archinstall.arguments.get('disk_encryption', None) + ) -fs_handler = disk.FilesystemHandler( - archinstall.arguments['disk_config'], - archinstall.arguments.get('disk_encryption', None) -) + fs_handler.perform_filesystem_operations() + perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) -fs_handler.perform_filesystem_operations() -perform_installation(archinstall.storage.get('MOUNT_POINT', Path('/mnt'))) +_only_hd() diff --git a/pyproject.toml b/pyproject.toml index 288c3909ea..e4d9b0d230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,11 @@ module = [ ] ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "archinstall.lib.models.mirrors" +disallow_untyped_decorators = false +disallow_subclassing_any = false + [tool.bandit] targets = ["archinstall"] exclude = ["/tests"]