diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index 55cbabcfe6..08ff387c5f 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -18,7 +18,7 @@ jobs: pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Syyu pacman --noconfirm -Sy python-uv python-setuptools python-pip - pacman --noconfirm -Sy python-pyparted python-pydantic + pacman --noconfirm -Sy python-pyparted python-pydantic python-textual - name: Remove existing archinstall (if any) run: uv pip uninstall archinstall --break-system-packages --system diff --git a/.gitignore b/.gitignore index 9e30144f3b..aa065d1d04 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ requirements.txt /actions-runner /cmd_output.txt uv.lock +pyrightconfig.json diff --git a/archinstall/__init__.py b/archinstall/__init__.py index f96c840980..7325653130 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -11,7 +11,7 @@ from archinstall.lib.network.wifi_handler import wifi_handler from archinstall.lib.networking import ping from archinstall.lib.packages.packages import check_package_upgrade -from archinstall.tui.ui.components import tui as ttui +from archinstall.tui.ui.components import tui as tui from .lib.general import running_from_host from .lib.hardware import SysInfo @@ -19,7 +19,6 @@ from .lib.pacman import Pacman from .lib.plugins import load_plugin, plugins from .lib.translationhandler import Language, tr, translation_handler -from .tui.curses_menu import Tui # @archinstall.plugin decorator hook to programmatically add @@ -97,7 +96,7 @@ def main() -> int: _log_sys_info() - ttui.global_header = 'Archinstall' + tui.global_header = 'Archinstall' if not arch_config_handler.args.offline: _check_online() @@ -107,7 +106,7 @@ def main() -> int: new_version = check_version_upgrade() if new_version: - ttui.global_header = f'{ttui.global_header} {new_version}' + tui.global_header = f'{tui.global_header} {new_version}' info(new_version) time.sleep(3) @@ -135,9 +134,6 @@ def run_as_a_module() -> None: except Exception as e: exc = e finally: - # restore the terminal to the original state - Tui.shutdown() - if exc: err = ''.join(traceback.format_exception(exc)) error(err) @@ -159,7 +155,6 @@ def run_as_a_module() -> None: 'Language', 'Pacman', 'SysInfo', - 'Tui', 'arch_config_handler', 'debug', 'disk_layouts', diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index ab582186e1..b5190c5d67 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -1,12 +1,11 @@ from typing import TYPE_CHECKING, override from archinstall.default_profiles.profile import GreeterType, Profile, ProfileType, SelectResult +from archinstall.lib.menu.helpers import Selection from archinstall.lib.output import info from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import FrameProperties, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -60,7 +59,7 @@ def do_on_select(self) -> SelectResult: MenuItem( p.name, value=p, - preview_action=lambda x: x.value.preview_text(), + preview_action=lambda x: x.value.preview_text() if x.value else None, ) for p in profile_handler.get_desktop_profiles() ] @@ -68,15 +67,13 @@ def do_on_select(self) -> SelectResult: group = MenuItemGroup(items, sort_items=True, sort_case_sensitive=False) group.set_selected_by_value(self.current_selection) - result = SelectMenu[Profile]( + result = Selection[Profile]( group, multi=True, allow_reset=True, allow_skip=True, - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - preview_frame=FrameProperties.max('Info'), - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index 82aa4f6d7b..c565e6d144 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class HyprlandProfile(XorgProfile): @@ -57,13 +56,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index 98f93b04cf..a53dff6c85 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class LabwcProfile(XorgProfile): @@ -54,13 +53,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index ff4edf9945..5258dc50e6 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class NiriProfile(XorgProfile): @@ -62,13 +61,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index d01611b0e2..b7841f2357 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class SwayProfile(XorgProfile): @@ -64,13 +63,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index 5526b9c6f1..2d356dc49c 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -1,12 +1,11 @@ from typing import TYPE_CHECKING, override from archinstall.default_profiles.profile import Profile, ProfileType, SelectResult +from archinstall.lib.menu.helpers import Selection from archinstall.lib.output import info from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import FrameProperties, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -26,7 +25,7 @@ def do_on_select(self) -> SelectResult: MenuItem( p.name, value=p, - preview_action=lambda x: x.value.preview_text(), + preview_action=lambda x: x.value.preview_text() if x.value else None, ) for p in profile_handler.get_server_profiles() ] @@ -34,15 +33,13 @@ def do_on_select(self) -> SelectResult: group = MenuItemGroup(items, sort_items=True) group.set_selected_by_value(self.current_selection) - result = SelectMenu[Profile]( + result = Selection[Profile]( group, allow_reset=True, allow_skip=True, - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - preview_frame=FrameProperties.max('Info'), multi=True, - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index 30ca4dbb84..8612379f11 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -1,12 +1,11 @@ from typing import override from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.application import ApplicationConfiguration, Audio, AudioConfiguration, BluetoothConfiguration, PrintServiceConfiguration from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class ApplicationMenu(AbstractSubMenu[ApplicationConfiguration]): @@ -82,27 +81,18 @@ def _prev_print_service(self, item: MenuItem) -> str | None: def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfiguration | None: - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - - if preset is not None: - group.set_selected_by_value(preset.enabled) - header = tr('Would you like to configure Bluetooth?') + '\n' + preset_val = preset.enabled if preset else False - result = SelectMenu[bool]( - group, + result = Confirmation( header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, allow_skip=True, - ).run() + preset=preset_val, + ).show() match result.type_: case ResultType.Selection: - enabled = result.item() == MenuItem.yes() - return BluetoothConfiguration(enabled) + return BluetoothConfiguration(result.get_value()) case ResultType.Skip: return preset case _: @@ -110,27 +100,19 @@ def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfigur def select_print_service(preset: PrintServiceConfiguration | None) -> PrintServiceConfiguration | None: - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - - if preset is not None: - group.set_selected_by_value(preset.enabled) - header = tr('Would you like to configure the print service?') + '\n' + preset_val = preset.enabled if preset else False - result = SelectMenu[bool]( - group, + result = Confirmation( header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, allow_skip=True, - ).run() + preset=preset_val, + ).show() match result.type_: case ResultType.Selection: - enabled = result.item() == MenuItem.yes() - return PrintServiceConfiguration(enabled) + result.get_value() + return PrintServiceConfiguration(result.get_value()) case ResultType.Skip: return preset case _: @@ -144,12 +126,11 @@ def select_audio(preset: AudioConfiguration | None = None) -> AudioConfiguration if preset: group.set_focus_by_value(preset.audio) - result = SelectMenu[Audio]( + result = Selection[Audio]( group, + header=tr('Select audio configuration'), allow_skip=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Audio')), - ).run() + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 93b7007289..fe011614de 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -27,7 +27,6 @@ from archinstall.lib.plugins import load_plugin from archinstall.lib.translationhandler import Language, tr, translation_handler from archinstall.lib.utils.util import get_password -from archinstall.tui.curses_menu import Tui @p_dataclass @@ -496,31 +495,30 @@ def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None: raise err from err else: incorrect_password = False - - with Tui(): - while True: - header = tr('Incorrect password') if incorrect_password else None - - decryption_pwd = get_password( - text=tr('Credentials file decryption password'), - header=header, - allow_skip=False, - skip_confirmation=True, - ) - - if not decryption_pwd: - return None - - try: - creds_data = decrypt(creds_data, decryption_pwd.plaintext) - break - except ValueError as err: - if 'Invalid password' in str(err): - debug('Incorrect credentials file decryption password') - incorrect_password = True - else: - debug(f'Error decrypting credentials file: {err}') - raise err from err + header = tr('Enter credentials file decryption password') + + while True: + prompt = f'{header}\n\n' + tr('Incorrect password') if incorrect_password else '' + + decryption_pwd = get_password( + header=prompt, + allow_skip=False, + skip_confirmation=True, + ) + + if not decryption_pwd: + return None + + try: + creds_data = decrypt(creds_data, decryption_pwd.plaintext) + break + except ValueError as err: + if 'Invalid password' in str(err): + debug('Incorrect credentials file decryption password') + incorrect_password = True + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err return json.loads(creds_data) diff --git a/archinstall/lib/authentication/authentication_handler.py b/archinstall/lib/authentication/authentication_handler.py index 918b968b78..1fc46b5554 100644 --- a/archinstall/lib/authentication/authentication_handler.py +++ b/archinstall/lib/authentication/authentication_handler.py @@ -5,9 +5,8 @@ from archinstall.lib.general import SysCommandWorker from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod from archinstall.lib.models.users import User -from archinstall.lib.output import debug +from archinstall.lib.output import debug, info from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -82,7 +81,7 @@ def _configure_u2f_mapping( install_session.pacman.strap('pam-u2f') - Tui.print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')) + print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')) # https://developers.yubico.com/pam-u2f/ u2f_auth_file = install_session.target / 'etc/u2f_mappings' @@ -92,9 +91,9 @@ def _configure_u2f_mapping( registered_keys: list[str] = [] for user in users: - Tui.print('') - Tui.print(tr('Setting up U2F device for user: {}').format(user.username)) - Tui.print(tr('You may need to enter the PIN and then touch your U2F device to register it')) + print('') + info(tr('Setting up U2F device for user: {}').format(user.username)) + info(tr('You may need to enter the PIN and then touch your U2F device to register it')) cmd = ' '.join( ['arch-chroot', '-S', str(install_session.target), 'pamu2fcfg', '-u', user.username, '-o', f'pam://{hostname}', '-i', f'pam://{hostname}'] diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py index 15369212e7..937d2002b9 100644 --- a/archinstall/lib/authentication/authentication_menu.py +++ b/archinstall/lib/authentication/authentication_menu.py @@ -3,15 +3,14 @@ from archinstall.lib.disk.fido import Fido2 from archinstall.lib.interactions.manage_users_conf import ask_for_additional_users from archinstall.lib.menu.abstract_menu import AbstractSubMenu +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod from archinstall.lib.models.users import Password, User from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr from archinstall.lib.utils.util import get_password -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]): @@ -31,15 +30,14 @@ def __init__(self, preset: AuthenticationConfiguration | None = None): ) @override - def run(self, additional_title: str | None = None) -> AuthenticationConfiguration: - super().run(additional_title=additional_title) - return self._auth_config + def run(self, additional_title: str | None = None) -> AuthenticationConfiguration | None: + return super().run(additional_title=additional_title) def _define_menu_options(self) -> list[MenuItem]: return [ MenuItem( text=tr('Root password'), - action=select_root_password, + action=lambda x: select_root_password(), preview_action=self._prev_root_pwd, key='root_enc_password', ), @@ -101,12 +99,12 @@ def _prev_u2f_login(self, item: MenuItem) -> str | None: return None -def select_root_password(preset: str | None = None) -> Password | None: - password = get_password(text=tr('Root password'), allow_skip=True) +def select_root_password() -> Password | None: + password = get_password(header=tr('Enter root password'), allow_skip=True) return password -def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | None: +def select_u2f_login(preset: U2FLoginConfiguration | None) -> U2FLoginConfiguration | None: devices = Fido2.get_fido2_devices() if not devices: return None @@ -120,30 +118,22 @@ def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | N if preset is not None: group.set_selected_by_value(preset.u2f_login_method) - result = SelectMenu[U2FLoginMethod]( + result = Selection[U2FLoginMethod]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('U2F Login Method')), allow_skip=True, allow_reset=True, - ).run() + ).show() match result.type_: case ResultType.Selection: u2f_method = result.get_value() - - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() header = tr('Enable passwordless sudo?') - result_sudo = SelectMenu[bool]( - group, + result_sudo = Confirmation( header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, allow_skip=True, - ).run() + preset=False, + ).show() passwordless_sudo = result_sudo.item() == MenuItem.yes() @@ -155,5 +145,3 @@ def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | N return preset case ResultType.Reset: return None - case _: - raise ValueError('Unhandled result type') diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 7b22c564d1..6042e7e3c3 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -1,11 +1,10 @@ import textwrap from typing import override +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..args import arch_config_handler from ..hardware import SysInfo @@ -124,17 +123,7 @@ def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: def _select_uki(self, preset: bool) -> bool: prompt = tr('Would you like to use unified kernel images?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() + result = Confirmation(header=prompt, allow_skip=True, preset=preset).show() match result.type_: case ResultType.Skip: @@ -176,23 +165,17 @@ def _select_removable(self, preset: bool) -> bool: + '\n' ) - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, + result = Confirmation( header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + preset=preset, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - return result.item() == MenuItem.yes() + return result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') @@ -201,7 +184,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options = [] hidden_options = [] default = None - header = None + header = tr('Select bootloader to install') if arch_config_handler.args.skip_boot: default = Bootloader.NO_BOOTLOADER @@ -212,7 +195,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options += [Bootloader.Grub, Bootloader.Limine] if not default: default = Bootloader.Grub - header = tr('UEFI is not detected and some options are disabled') + header += '\n' + tr('UEFI is not detected and some options are disabled') else: options += [b for b in Bootloader if b not in hidden_options] if not default: @@ -223,13 +206,11 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: group.set_default_by_value(default) group.set_focus_by_value(preset) - result = SelectMenu[Bootloader]( + result = Selection[Bootloader]( group, header=header, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Bootloader')), allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 63b68b9ce4..1b5f93b0a7 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -3,11 +3,10 @@ import stat from pathlib import Path +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu, Tui -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from .args import ArchConfig from .crypt import encrypt @@ -56,25 +55,20 @@ def confirm_config(self) -> bool: header = f'{tr("The specified configuration will be applied")}. ' header += tr('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[bool]( - 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(tr('Configuration')), - ).run() - - if result.item() != MenuItem.yes(): - return False + group = MenuItemGroup.yes_no() + group.set_preview_for_all(lambda x: self.user_config_to_json()) + + result = Confirmation( + group=group, + header=header, + allow_skip=False, + preset=True, + preview_location='bottom', + preview_header=tr('Configuration preview'), + ).show() + + if not result.get_value(): + return False return True @@ -160,13 +154,11 @@ def preview(item: MenuItem) -> str | None: ] group = MenuItemGroup(items) - result = SelectMenu[str]( + result = Selection[str]( group, allow_skip=True, - preview_frame=FrameProperties.max(tr('Configuration')), - preview_size='auto', - preview_style=PreviewStyle.RIGHT, - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Skip: @@ -180,8 +172,7 @@ def preview(item: MenuItem) -> str | None: readline.parse_and_bind('tab: complete') dest_path = prompt_dir( - tr('Directory'), - tr('Enter a directory for the configuration(s) to be saved (tab completion enabled)') + '\n', + tr('Enter a directory for the configuration(s) to be saved') + '\n', allow_skip=True, ) @@ -190,50 +181,39 @@ def preview(item: MenuItem) -> str | None: header = tr('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, + save_result = Confirmation( header=header, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=True, + ).show() - match result.type_: + match save_result.type_: case ResultType.Selection: - if result.item() == MenuItem.no(): + if not save_result.get_value(): return + case _: + return debug(f'Saving configuration files to {dest_path.absolute()}') header = tr('Do you want to encrypt the user_credentials.json file?') - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - - result = SelectMenu( - group, + enc_result = Confirmation( header=header, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=False, + ).show() enc_password: str | None = None - match result.type_: - case ResultType.Selection: - if result.item() == MenuItem.yes(): - password = get_password( - text=tr('Credentials file encryption password'), - allow_skip=True, - ) - - if password: - enc_password = password.plaintext + if enc_result.type_ == ResultType.Selection: + if enc_result.get_value(): + password = get_password( + header=tr('Credentials file encryption password'), + allow_skip=True, + ) + + if password: + enc_password = password.plaintext match save_option: case 'user_config': diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 28400b2b47..4f982c224b 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -2,6 +2,7 @@ from typing import override from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu +from archinstall.lib.menu.helpers import Selection from archinstall.lib.models.device import ( DEFAULT_ITER_TIME, BtrfsOptions, @@ -14,10 +15,8 @@ SnapshotType, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..interactions.disk_conf import select_disk_config, select_lvm_config from ..menu.abstract_menu import AbstractSubMenu @@ -32,7 +31,7 @@ class DiskMenuConfig: disk_encryption: DiskEncryption | None -class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]): +class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskMenuConfig]): def __init__(self, disk_layout_config: DiskLayoutConfiguration | None): if not disk_layout_config: self._disk_menu_config = DiskMenuConfig( @@ -95,14 +94,16 @@ def _define_menu_options(self) -> list[MenuItem]: ] @override - def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: - super().run(additional_title=additional_title) + def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: # type: ignore[override] + config: DiskMenuConfig | None = super().run(additional_title=additional_title) + if config is None: + return None - if self._disk_menu_config.disk_config: - self._disk_menu_config.disk_config.lvm_config = self._disk_menu_config.lvm_config - self._disk_menu_config.disk_config.btrfs_options = BtrfsOptions(snapshot_config=self._disk_menu_config.btrfs_snapshot_config) - self._disk_menu_config.disk_config.disk_encryption = self._disk_menu_config.disk_encryption - return self._disk_menu_config.disk_config + if config.disk_config: + config.disk_config.lvm_config = self._disk_menu_config.lvm_config + config.disk_config.btrfs_options = BtrfsOptions(snapshot_config=self._disk_menu_config.btrfs_snapshot_config) + config.disk_config.disk_encryption = self._disk_menu_config.disk_encryption + return config.disk_config return None @@ -169,13 +170,11 @@ def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConf preset=preset_type, ) - result = SelectMenu[SnapshotType]( + result = Selection[SnapshotType]( group, allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('Snapshot type')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -250,9 +249,10 @@ def _prev_btrfs_snapshots(self, item: MenuItem) -> str | None: def _prev_disk_encryption(self, item: MenuItem) -> str | None: disk_config: DiskLayoutConfiguration | None = self._item_group.find_by_key('disk_config').value + lvm_config: LvmConfiguration | None = self._item_group.find_by_key('lvm_config').value enc_config: DiskEncryption | None = item.value - if disk_config and not DiskEncryption.validate_enc(disk_config.device_modifications, disk_config.lvm_config): + if disk_config and not DiskEncryption.validate_enc(disk_config.device_modifications, lvm_config): return tr('LVM disk encryption with more than 2 partitions is currently not supported') if enc_config: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index eeeb897065..6d1958e725 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import override +from archinstall.lib.menu.helpers import Input, Selection, Table from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( DeviceModification, @@ -11,10 +12,8 @@ PartitionModification, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu from ..models.device import DEFAULT_ITER_TIME, Fido2Device @@ -52,9 +51,9 @@ def _define_menu_options(self) -> list[MenuItem]: return [ MenuItem( text=tr('Encryption type'), - action=lambda x: select_encryption_type(self._device_modifications, self._lvm_config, x), + action=lambda x: select_encryption_type(self._lvm_config, x), value=self._enc_config.encryption_type, - preview_action=self._preview, + preview_action=self._prev_type, key='encryption_type', ), MenuItem( @@ -62,7 +61,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=lambda x: select_encrypted_password(), value=self._enc_config.encryption_password, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_password, key='encryption_password', ), MenuItem( @@ -70,7 +69,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=select_iteration_time, value=self._enc_config.iter_time, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_iter_time, key='iter_time', ), MenuItem( @@ -78,7 +77,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=lambda x: select_partitions_to_encrypt(self._device_modifications, x), value=self._enc_config.partitions, dependencies=[self._check_dep_partitions], - preview_action=self._preview, + preview_action=self._prev_partitions, key='partitions', ), MenuItem( @@ -86,7 +85,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=self._select_lvm_vols, value=self._enc_config.lvm_volumes, dependencies=[self._check_dep_lvm_vols], - preview_action=self._preview, + preview_action=self._prev_lvm_vols, key='lvm_volumes', ), MenuItem( @@ -94,7 +93,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=select_hsm, value=self._enc_config.hsm_device, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_hsm, key='hsm_device', ), ] @@ -124,7 +123,9 @@ def _check_dep_lvm_vols(self) -> bool: @override def run(self, additional_title: str | None = None) -> DiskEncryption | None: - super().run(additional_title=additional_title) + enc_config = super().run(additional_title=additional_title) + if enc_config is None: + return None enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value enc_password: Password | None = self._item_group.find_by_key('encryption_password').value @@ -148,97 +149,63 @@ def run(self, additional_title: str | None = None) -> DiskEncryption | None: encryption_type=enc_type, partitions=enc_partitions, lvm_volumes=enc_lvm_vols, - hsm_device=self._enc_config.hsm_device, + hsm_device=enc_config.hsm_device, iter_time=iter_time or DEFAULT_ITER_TIME, ) return None - def _preview(self, item: MenuItem) -> str | None: - 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 (iter_time := self._prev_iter_time()) is not None: - output += f'\n{iter_time}' - - 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) -> str | None: - enc_type = self._item_group.find_by_key('encryption_type').value - - if enc_type: - enc_text = EncryptionType.type_to_text(enc_type) + def _prev_type(self, item: MenuItem) -> str | None: + if item.value: + enc_text = EncryptionType.type_to_text(item.value) return f'{tr("Encryption type")}: {enc_text}' return None - def _prev_password(self) -> str | None: - enc_pwd = self._item_group.find_by_key('encryption_password').value - - if enc_pwd: - return f'{tr("Encryption password")}: {enc_pwd.hidden()}' + def _prev_password(self, item: MenuItem) -> str | None: + if item.value: + return f'{tr("Encryption password")}: {item.value.hidden()}' return None - def _prev_partitions(self) -> str | None: - partitions: list[PartitionModification] | None = self._item_group.find_by_key('partitions').value - - if partitions: + def _prev_partitions(self, item: MenuItem) -> str | None: + if item.value: output = tr('Partitions to be encrypted') + '\n' - output += FormattedOutput.as_table(partitions) + output += FormattedOutput.as_table(item.value) return output.rstrip() return None - def _prev_lvm_vols(self) -> str | None: - volumes: list[PartitionModification] | None = self._item_group.find_by_key('lvm_volumes').value - - if volumes: + def _prev_lvm_vols(self, item: MenuItem) -> str | None: + if item.value: output = tr('LVM volumes to be encrypted') + '\n' - output += FormattedOutput.as_table(volumes) + output += FormattedOutput.as_table(item.value) return output.rstrip() return None - def _prev_hsm(self) -> str | None: - fido_device: Fido2Device | None = self._item_group.find_by_key('hsm_device').value - - if not fido_device: + def _prev_hsm(self, item: MenuItem) -> str | None: + if not item.value: return None + fido_device: Fido2Device = item.value + output = str(fido_device.path) output += f' ({fido_device.manufacturer}, {fido_device.product})' return f'{tr("HSM device")}: {output}' - def _prev_iter_time(self) -> str | None: - iter_time = self._item_group.find_by_key('iter_time').value - enc_type = self._item_group.find_by_key('encryption_type').value + def _prev_iter_time(self, item: MenuItem) -> str | None: + if item.value: + iter_time = item.value + enc_type = self._item_group.find_by_key('encryption_type').value - if iter_time and enc_type != EncryptionType.NoEncryption: - return f'{tr("Iteration time")}: {iter_time}ms' + if iter_time and enc_type != EncryptionType.NoEncryption: + return f'{tr("Iteration time")}: {iter_time}ms' return None def select_encryption_type( - device_modifications: list[DeviceModification], lvm_config: LvmConfiguration | None = None, preset: EncryptionType | None = None, ) -> EncryptionType | None: @@ -258,13 +225,12 @@ def select_encryption_type( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectMenu[EncryptionType]( + result = Selection[EncryptionType]( group, + header=tr('Select encryption type'), allow_skip=True, allow_reset=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Encryption type')), - ).run() + ).show() match result.type_: case ResultType.Reset: @@ -278,7 +244,6 @@ def select_encryption_type( def select_encrypted_password() -> Password | None: header = tr('Enter disk encryption password (leave blank for no encryption)') + '\n' password = get_password( - text=tr('Disk encryption password'), header=header, allow_skip=True, ) @@ -297,12 +262,11 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: if fido_devices: group = MenuHelper(data=fido_devices).create_menu_group() - result = SelectMenu[Fido2Device]( + result = Selection[Fido2Device]( group, header=header, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Reset: @@ -329,15 +293,15 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: - group = MenuHelper(data=avail_partitions).create_menu_group() + group = MenuItemGroup.from_objects(partitions) group.set_selected_by_value(preset) - result = SelectMenu[PartitionModification]( - group, - alignment=Alignment.CENTER, - multi=True, + result = Table[PartitionModification]( + header=tr('Select disks for the installation'), + group=group, allow_skip=True, - ).run() + multi=True, + ).show() match result.type_: case ResultType.Reset: @@ -358,13 +322,15 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: - group = MenuHelper(data=volumes).create_menu_group() + group = MenuItemGroup.from_objects(volumes) + group.set_selected_by_value(preset) - result = SelectMenu[LvmVolume]( - group, - alignment=Alignment.CENTER, + result = Table[LvmVolume]( + header=tr('Select disks for the installation'), + group=group, + allow_skip=True, multi=True, - ).run() + ).show() match result.type_: case ResultType.Reset: @@ -383,10 +349,7 @@ def select_iteration_time(preset: int | None = None) -> int | None: header += tr('Higher values increase security but slow down boot time') + '\n' header += tr(f'Default: {DEFAULT_ITER_TIME}ms, Recommended range: 1000-60000') + '\n' - def validate_iter_time(value: str | None) -> str | None: - if not value: - return None - + def validate_iter_time(value: str) -> str | None: try: iter_time = int(value) if iter_time < 100: @@ -397,21 +360,19 @@ def validate_iter_time(value: str | None) -> str | None: except ValueError: return tr('Please enter a valid number') - result = EditMenu( - tr('Iteration time'), + result = Input( header=header, - alignment=Alignment.CENTER, allow_skip=True, - default_text=str(preset) if preset else str(DEFAULT_ITER_TIME), - validator=validate_iter_time, - ).input() + default_value=str(preset) if preset else str(DEFAULT_ITER_TIME), + validator_callback=validate_iter_time, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - if not result.text(): + if not result.get_value(): return preset - return int(result.text()) + return int(result.get_value()) case ResultType.Reset: return None diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index c397afa5d6..1d961c32a1 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -5,7 +5,6 @@ from pathlib import Path from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui from ..interactions.general_conf import ask_abort from ..luks import Luks2 @@ -339,15 +338,14 @@ def _final_warning(self) -> 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 = tr('Starting device modifications in ') - Tui.print(out, row=0, endl='', clear_screen=True) + print(out, end='', flush=True) try: countdown = '\n5...4...3...2...1\n' for c in countdown: - Tui.print(c, row=0, endl='') + print(c, end='', flush=True) time.sleep(0.25) except KeyboardInterrupt: - with Tui(): - ask_abort() + ask_abort() return True diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 49e69bc20f..be4980e3f6 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import override +from archinstall.lib.menu.helpers import Confirmation, Input, Selection from archinstall.lib.models.device import ( BtrfsMountOption, DeviceModification, @@ -18,10 +19,8 @@ Unit, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..output import FormattedOutput @@ -238,7 +237,7 @@ def filter_options(self, selection: DiskSegment, options: list[str]) -> list[str # was marked as formatting, otherwise we run into issues where # 1. select a new fs -> potentially mark as wipe now # 2. Switch back to old filesystem -> should unmark wipe now, but - # how do we know it was the original one? + # how do we know it was the original one? not_filter += [ self._actions['set_filesystem'], self._actions['mark_bootable'], @@ -404,10 +403,10 @@ def _prompt_formatting(self, partition: PartitionModification) -> None: partition.mountpoint = None def _prompt_mountpoint(self) -> Path: - header = tr('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.') + '\n' - prompt = tr('Mountpoint') + header = tr('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.') + '\n\n' + header += tr('Enter a mountpoint') - mountpoint = prompt_dir(prompt, header, validate=False, allow_skip=False) + mountpoint = prompt_dir(header, validate=False, allow_skip=False) assert mountpoint return mountpoint @@ -417,13 +416,11 @@ def _prompt_partition_fs_type(self, prompt: str | None = None) -> FilesystemType items = [MenuItem(fs.value, value=fs) for fs in fs_types] group = MenuItemGroup(items, sort_items=False) - result = SelectMenu[FilesystemType]( + result = Selection[FilesystemType]( group, header=prompt, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Filesystem')), allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -485,18 +482,16 @@ def validate(value: str | None) -> str | None: prompt += tr('Size: {} / {}').format(max_sectors, max_bytes) + '\n\n' prompt += tr('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...') + '\n' - prompt += tr('If no unit is provided, the value is interpreted as sectors') + '\n' + prompt += tr('If no unit is provided, the value is interpreted as sectors') + '\n\n' max_size = free_space.length + prompt += tr('Enter a size (default: {}): ').format(max_size.format_highest()) - title = tr('Size (default: {}): ').format(max_size.format_highest()) - - result = EditMenu( - title, + result = Input( header=f'{prompt}\b', allow_skip=True, - validator=validate, - ).input() + validator_callback=validate, + ).show() size: Size | None = None @@ -504,12 +499,14 @@ def validate(value: str | None) -> str | None: case ResultType.Skip: size = max_size case ResultType.Selection: - value = result.text() + value = result.get_value() if value: size = self._validate_value(sector_size, max_size, value) else: size = max_size + case _: + raise ValueError('Unhandled result type') assert size return size @@ -546,15 +543,11 @@ def _create_new_partition(self, free_space: FreeSpace) -> PartitionModification: def _reset_confirmation(self) -> bool: prompt = tr('This will remove all newly added partitions, continue?') + '\n' - result = SelectMenu[bool]( - MenuItemGroup.yes_no(), + result = Confirmation( header=prompt, - alignment=Alignment.CENTER, - orientation=Orientation.HORIZONTAL, - columns=2, - reset_warning_msg=prompt, allow_skip=False, - ).run() + allow_reset=False, + ).show() return result.item() == MenuItem.yes() diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index 5b66334cc7..9be6b91f67 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -1,11 +1,10 @@ from pathlib import Path from typing import assert_never, override +from archinstall.lib.menu.helpers import Input from archinstall.lib.models.device import SubvolumeModification from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..utils.util import prompt_dir @@ -40,28 +39,27 @@ def validate(value: str | None) -> str | None: return None return tr('Value cannot be empty') - result = EditMenu( - tr('Subvolume name'), - alignment=Alignment.CENTER, + result = Input( + header=tr('Enter subvolume name'), allow_skip=True, - default_text=str(preset.name) if preset else None, - validator=validate, - ).input() + default_value=str(preset.name) if preset else None, + validator_callback=validate, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - name = result.text() + name = result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') case _: assert_never(result.type_) - header = f'{tr("Subvolume name")}: {name}\n' + header = f'{tr("Subvolume name")}: {name}\n\n' + header += tr('Enter subvolume mountpoint') path = prompt_dir( - tr('Subvolume mountpoint'), header=header, allow_skip=True, validate=True, @@ -80,7 +78,7 @@ def handle_action( entry: SubvolumeModification | None, data: list[SubvolumeModification], ) -> list[SubvolumeModification]: - if action == self._actions[0]: # add + if action == self._actions[0]: new_subvolume = self._add_subvolume() if new_subvolume is not None: @@ -88,15 +86,15 @@ def handle_action( # was created we'll replace the existing one data = [d for d in data if d.name != new_subvolume.name] data += [new_subvolume] - elif entry is not None: # edit - if action == self._actions[1]: # edit subvolume + elif entry is not None: + if action == self._actions[1]: new_subvolume = self._add_subvolume(entry) if new_subvolume is not None: # we'll remove the original subvolume and add the modified version data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name] data += [new_subvolume] - elif action == self._actions[2]: # delete + elif action == self._actions[2]: data = [d for d in data if d != entry] return data diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 9f7ec10398..a7c5c71699 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -7,7 +7,7 @@ from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, EncryptionType, FilesystemType, PartitionModification from archinstall.lib.packages import list_available_packages -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from .applications.application_menu import ApplicationMenu from .args import ArchConfig @@ -61,6 +61,7 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text=tr('Locales'), + value=LocaleConfiguration.default(), action=self._locale_selection, preview_action=self._prev_locale, key='locale_config', @@ -136,7 +137,7 @@ def _get_menu_options(self) -> list[MenuItem]: MenuItem( text=tr('Parallel Downloads'), action=add_number_of_parallel_downloads, - value=0, + value=1, preview_action=self._prev_parallel_dw, key='parallel_downloads', ), @@ -163,6 +164,7 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text='', + read_only=True, ), MenuItem( text=tr('Save configuration'), @@ -186,8 +188,8 @@ def _get_menu_options(self) -> list[MenuItem]: 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 + # if item.key is not None: + # data[item.key] = item.value self.sync_all_to_config() save_config(self._arch_config) @@ -525,10 +527,10 @@ def _select_additional_packages(self, preset: list[str]) -> list[str]: return packages - def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration: + def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration | None: mirror_configuration = MirrorMenu(preset=preset).run() - if mirror_configuration.optional_repositories: + if mirror_configuration and mirror_configuration.optional_repositories: # reset the package list cache in case the repository selection has changed list_available_packages.cache_clear() diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index ca240e3905..ecbd1a21cd 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -32,7 +32,6 @@ from archinstall.lib.models.packages import Repository from archinstall.lib.packages import installed_package from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui from .args import arch_config_handler from .exceptions import DiskError, HardwareIncompatibilityError, RequirementError, ServiceException, SysCallError @@ -133,8 +132,8 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseExceptio # We avoid printing /mnt/ because that might confuse people if they note it down # and then reboot, and an identical log file will be found in the ISO medium anyway. - Tui.print(str(tr('[!] A log file has been created here: {}').format(logger.path))) - Tui.print(tr('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues')) + print(tr('[!] A log file has been created here: {}').format(logger.path)) + print(tr('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues')) # Return None to propagate the exception return None @@ -207,7 +206,7 @@ def _verify_service_stop(self) -> None: # info('Waiting for pacman-init.service to complete.') # while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'): - # time.sleep(1) + # time.sleep(1) if not arch_config_handler.args.skip_wkd: info(tr('Waiting for Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.')) @@ -1079,7 +1078,7 @@ def _get_kernel_params_partition( if root_partition in self._disk_encryption.partitions: # TODO: We need to detect if the encrypted device is a whole disk encryption, - # or simply a partition encryption. Right now we assume it's a partition (and we always have) + # or simply a partition encryption. Right now we assume it's a partition (and we always have) if self._disk_encryption.hsm_device: debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}') @@ -1195,9 +1194,9 @@ def _create_bls_entries( f"""\ # Created by: archinstall # Created on: {self.init_time} - title Arch Linux ({{kernel}}) - linux /vmlinuz-{{kernel}} - initrd /initramfs-{{kernel}}.img + title Arch Linux ({{kernel}}) + linux /vmlinuz-{{kernel}} + initrd /initramfs-{{kernel}}.img options {' '.join(self._get_kernel_params(root))} """, ) @@ -1525,7 +1524,7 @@ def _add_limine_bootloader( f'cmdline: {kernel_params}', ] config_contents += f'\n/Arch Linux ({kernel})\n' - config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' else: entry = [ 'protocol: linux', @@ -1534,7 +1533,7 @@ def _add_limine_bootloader( f'module_path: {path_root}:/initramfs-{kernel}.img', ] config_contents += f'\n/Arch Linux ({kernel})\n' - config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' config_path.write_text(config_contents) diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 43a82529ad..21c859761c 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -3,7 +3,7 @@ from archinstall.lib.args import arch_config_handler from archinstall.lib.disk.device_handler import device_handler from archinstall.lib.disk.partitioning_menu import manual_partitioning -from archinstall.lib.menu.menu_helper import MenuHelper +from archinstall.lib.menu.helpers import Confirmation, Notify, Selection, Table from archinstall.lib.models.device import ( BDevice, BtrfsMountOption, @@ -28,10 +28,8 @@ ) from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..output import FormattedOutput from ..utils.util import prompt_dir @@ -39,7 +37,7 @@ def select_devices(preset: list[BDevice] | None = []) -> list[BDevice]: def _preview_device_selection(item: MenuItem) -> str | None: - device = item.get_value() + device: _DeviceInfo = item.value # type: ignore[assignment] dev = device_handler.get_device(device.path) if dev and dev.partition_infos: @@ -50,23 +48,32 @@ def _preview_device_selection(item: MenuItem) -> str | None: preset = [] devices = device_handler.devices - options = [d.device_info for d in devices] + + items = [ + MenuItem( + str(d.device_info.path), + d.device_info, + preview_action=_preview_device_selection, + ) + for d in devices + ] + presets = [p.device_info for p in preset] - group = MenuHelper(options).create_menu_group() + group = MenuItemGroup(items) group.set_selected_by_value(presets) - group.set_preview_for_all(_preview_device_selection) - result = SelectMenu[_DeviceInfo]( - group, - alignment=Alignment.CENTER, - search_enabled=False, - multi=True, - preview_style=PreviewStyle.BOTTOM, - preview_size='auto', - preview_frame=FrameProperties.max('Partitions'), + result = Table[_DeviceInfo]( + header=tr('Select disks for the installation'), + group=group, + presets=presets, allow_skip=True, - ).run() + multi=True, + preview_location='bottom', + preview_header=tr('Partitions'), + ).show() + + debug(f'Result: {result}') match result.type_: case ResultType.Reset: @@ -81,6 +88,7 @@ def _preview_device_selection(item: MenuItem) -> str | None: if device.device_info in selected_device_info: selected_devices.append(device) + debug(f'Selected devices: {selected_device_info}') return selected_devices @@ -132,13 +140,12 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if preset: group.set_selected_by_value(preset.config_type.display_msg()) - result = SelectMenu[str]( + result = Selection[str]( group, + header=tr('Select a disk configuration'), allow_skip=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Disk configuration type')), allow_reset=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -149,10 +156,11 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay 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" + output = tr('Enter root mount directory') + '\n\n' + output += tr('You will use whatever drive-setup is mounted at the specified directory') + '\n' + output += tr("WARNING: Archinstall won't check the suitability of this setup") - path = prompt_dir(tr('Root mount directory'), output, allow_skip=True) + path = prompt_dir(output, allow_skip=True) if path is None: return None @@ -171,6 +179,9 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if not devices: return None + if devices == preset_devices: + return preset + if result.get_value() == default_layout: modifications = get_default_partition_layout(devices) if modifications: @@ -202,13 +213,11 @@ def select_lvm_config( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectMenu[str]( + result = Selection[str]( group, allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('LVM configuration type')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -253,12 +262,11 @@ def select_main_filesystem_format() -> FilesystemType: items.append(MenuItem('ntfs', value=FilesystemType.Ntfs)) group = MenuItemGroup(items, sort_items=False) - result = SelectMenu[FilesystemType]( + result = Selection[FilesystemType]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min('Filesystem'), + header=tr('Select main filesystem'), allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -277,15 +285,12 @@ def select_mount_options() -> list[str]: MenuItem(disable_cow, value=BtrfsMountOption.nodatacow.value), ] group = MenuItemGroup(items, sort_items=False) - result = SelectMenu[str]( + + result = Selection[str]( group, header=prompt, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - search_enabled=False, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -338,16 +343,12 @@ def suggest_single_disk_layout( if filesystem_type == FilesystemType.Btrfs: prompt = tr('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[bool]( - group, + + result = Confirmation( header=prompt, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, allow_skip=False, - ).run() + preset=True, + ).show() using_subvolumes = result.item() == MenuItem.yes() mount_options = select_mount_options() @@ -375,16 +376,12 @@ def suggest_single_disk_layout( using_home_partition = True else: prompt = tr('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, + + result = Confirmation( header=prompt, - orientation=Orientation.HORIZONTAL, - columns=2, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + preset=True, + ).show() using_home_partition = result.item() == MenuItem.yes() @@ -474,10 +471,7 @@ def suggest_multi_disk_layout( text += tr('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(Unit.GiB)) text += tr('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(Unit.GiB)) - items = [MenuItem(tr('Continue'))] - group = MenuItemGroup(items) - SelectMenu(group).run() - + Notify(text).show() return [] if filesystem_type == FilesystemType.Btrfs: @@ -565,18 +559,7 @@ def suggest_lvm_layout( if filesystem_type == FilesystemType.Btrfs: prompt = tr('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[bool]( - group, - header=prompt, - search_enabled=False, - allow_skip=False, - orientation=Orientation.HORIZONTAL, - columns=2, - alignment=Alignment.CENTER, - ).run() + result = Confirmation(header=prompt, allow_skip=False, preset=True).show() using_subvolumes = MenuItem.yes() == result.item() mount_options = select_mount_options() diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index e0a250ffee..0df2657615 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -2,19 +2,17 @@ from enum import Enum from pathlib import Path -from typing import assert_never +from archinstall.lib.menu.helpers import Confirmation, Input, Loading, Notify, Selection from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu, Tui -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..locale.utils import list_timezones from ..models.packages import AvailablePackage, PackageGroup -from ..output import warn +from ..output import debug, warn from ..translationhandler import Language @@ -33,18 +31,11 @@ def ask_ntp(preset: bool = True) -> bool: + '\n' ) - preset_val = MenuItem.yes() if preset else MenuItem.no() - group = MenuItemGroup.yes_no() - group.focus_item = preset_val - - result = SelectMenu[bool]( - group, + result = Confirmation( header=header, allow_skip=True, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=preset, + ).show() match result.type_: case ResultType.Skip: @@ -56,18 +47,17 @@ def ask_ntp(preset: bool = True) -> bool: def ask_hostname(preset: str | None = None) -> str | None: - result = EditMenu( - tr('Hostname'), - alignment=Alignment.CENTER, + result = Input( + header=tr('Hostname'), allow_skip=True, - default_text=preset, - ).input() + default_value=preset, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - hostname = result.text() + hostname = result.get_value() if len(hostname) < 1: return None return hostname @@ -84,13 +74,13 @@ def ask_for_a_timezone(preset: str | None = None) -> str | None: group.set_selected_by_value(preset) group.set_default_by_value(default) - result = SelectMenu[str]( + result = Selection[str]( group, + header=tr('Select timezone'), allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('Timezone')), - alignment=Alignment.CENTER, - ).run() + enable_filter=True, + ).show() match result.type_: case ResultType.Skip: @@ -127,14 +117,12 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> 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' - result = SelectMenu[Language]( - group, + result = Selection[Language]( header=title, - allow_skip=True, + group=group, allow_reset=False, - alignment=Alignment.CENTER, - frame=FrameProperties.min(header=tr('Select language')), - ).run() + allow_skip=True, + ).show() match result.type_: case ResultType.Skip: @@ -153,11 +141,23 @@ def ask_additional_packages_to_install( respos_text = ', '.join([r.value for r in repositories]) output = tr('Repositories: {}').format(respos_text) + '\n' - output += tr('Loading packages...') - Tui.print(output, clear_screen=True) - packages = list_available_packages(tuple(repositories)) + result = Loading[dict[str, AvailablePackage]]( + header=output, + data_callback=lambda: list_available_packages(tuple(repositories)), + ).show() + + if result.type_ != ResultType.Selection: + debug('Error while loading packages') + return preset + + packages = result.get_value() + + if not packages: + Notify(tr('No packages found')).show() + return [] + package_groups = PackageGroup.from_available_packages(packages) # Additional packages (with some light weight error handling for invalid package names) @@ -177,7 +177,7 @@ def ask_additional_packages_to_install( MenuItem( name, value=pkg, - preview_action=lambda x: x.value.info(), + preview_action=lambda x: x.value.info() if x.value else None, ) for name, pkg in packages.items() ] @@ -186,7 +186,7 @@ def ask_additional_packages_to_install( MenuItem( name, value=group, - preview_action=lambda x: x.value.info(), + preview_action=lambda x: x.value.info() if x.value else None, ) for name, group in package_groups.items() ] @@ -194,65 +194,61 @@ def ask_additional_packages_to_install( menu_group = MenuItemGroup(items, sort_items=True) menu_group.set_selected_by_value(preset_packages) - result = SelectMenu[AvailablePackage | PackageGroup]( + pck_result = Selection[AvailablePackage | PackageGroup]( menu_group, header=header, - alignment=Alignment.LEFT, allow_reset=True, allow_skip=True, multi=True, - preview_frame=FrameProperties.max('Package info'), - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - ).run() + preview_location='right', + enable_filter=True, + ).show() - match result.type_: + match pck_result.type_: case ResultType.Skip: return preset case ResultType.Reset: return [] case ResultType.Selection: - selected_pacakges = result.get_values() + selected_pacakges = pck_result.get_values() return [pkg.name for pkg in selected_pacakges] -def add_number_of_parallel_downloads(preset: int | None = None) -> int | None: +def add_number_of_parallel_downloads(preset: int = 1) -> int | None: max_recommended = 5 header = tr('This option enables the number of parallel downloads that can occur during package downloads') + '\n' - header += tr('Enter the number of parallel downloads to be enabled.\n\nNote:\n') - header += tr(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )').format(max_recommended, max_recommended) + '\n' - header += tr(' - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n') - - def validator(s: str | None) -> str | None: - if s is not None: - try: - value = int(s) - if value >= 0: - return None - except Exception: - pass - - return tr('Invalid download number') - - result = EditMenu( - tr('Number downloads'), + header += tr(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )').format(max_recommended, max_recommended) + '\n\n' + header += tr('Enter the number of parallel downloads to be enabled') + + def validator(s: str) -> str | None: + try: + value = int(s) + + if 1 <= value <= max_recommended: + return None + + return tr('Value must be between 1 and {}').format(max_recommended) + except Exception: + return tr('Please enter a valid number') + + result = Input( header=header, allow_skip=True, allow_reset=True, - validator=validator, - default_text=str(preset) if preset is not None else None, - ).input() + validator_callback=validator, + default_value=str(preset), + ).show() + + downloads = 1 match result.type_: case ResultType.Skip: return preset case ResultType.Reset: - return 0 + return downloads case ResultType.Selection: - downloads: int = int(result.text()) - case _: - assert_never(result.type_) + downloads = int(result.get_value()) pacman_conf_path = Path('/etc/pacman.conf') with pacman_conf_path.open() as f: @@ -279,12 +275,11 @@ def ask_post_installation(elapsed_time: float | None = None) -> PostInstallation items = [MenuItem(action.value, value=action) for action in PostInstallationAction] group = MenuItemGroup(items) - result = SelectMenu[PostInstallationAction]( + result = Selection[PostInstallationAction]( group, header=header, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -295,16 +290,12 @@ def ask_post_installation(elapsed_time: float | None = None) -> PostInstallation def ask_abort() -> None: prompt = tr('Do you really want to abort?') + '\n' - group = MenuItemGroup.yes_no() - result = SelectMenu[bool]( - group, + result = Confirmation( header=prompt, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=False, + ).show() - if result.item() == MenuItem.yes(): + if result.get_value(): exit(0) diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index d1ae13d0a1..458c0e5532 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -3,11 +3,10 @@ import re from typing import override +from archinstall.lib.menu.helpers import Confirmation, Input from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, Orientation +from archinstall.tui.ui.menu_item import MenuItem +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..models.users import User @@ -45,7 +44,8 @@ def handle_action(self, action: str, entry: User | None, data: list[User]) -> li data += [new_user] elif action == self._actions[1] and entry: # change password header = f'{tr("User")}: {entry.username}\n' - new_password = get_password(tr('Password'), header=header) + header += tr('Enter new password') + new_password = get_password(header=header) if new_password: user = next(filter(lambda x: x == entry, data)) @@ -65,17 +65,17 @@ def _check_for_correct_username(self, username: str | None) -> str | None: return tr('The username you entered is invalid') def _add_user(self) -> User | None: - editResult = EditMenu( - tr('Username'), + editResult = Input( + tr('Enter username'), allow_skip=True, - validator=self._check_for_correct_username, - ).input() + validator_callback=self._check_for_correct_username, + ).show() match editResult.type_: case ResultType.Skip: return None case ResultType.Selection: - username = editResult.text() + username = editResult.get_value() case _: raise ValueError('Unhandled result type') @@ -83,27 +83,21 @@ def _add_user(self) -> User | None: return None header = f'{tr("Username")}: {username}\n' + prompt = f'{header}\n' + tr('Enter password') - password = get_password(tr('Password'), header=header, allow_skip=True) + password = get_password(header=prompt, allow_skip=True) if not password: return None - header += f'{tr("Password")}: {password.hidden()}\n\n' - header += str(tr('Should "{}" be a superuser (sudo)?\n')).format(username) + header += f'{tr("Password")}: {password.hidden()}\n' + prompt = f'{header}\n' + tr('Should "{}" be a superuser (sudo)?\n').format(username) - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.yes() - - result = SelectMenu[bool]( - group, - header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - search_enabled=False, + result = Confirmation( + header=prompt, allow_skip=False, - ).run() + preset=True, + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index 7ab2a1f3cd..1cb521ba93 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -3,11 +3,10 @@ import ipaddress from typing import assert_never, override +from archinstall.lib.menu.helpers import Input, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..models.network import NetworkConfiguration, Nic, NicType @@ -64,12 +63,11 @@ def _select_iface(self, data: list[Nic]) -> str | None: items = [MenuItem(i, value=i) for i in available] group = MenuItemGroup(items, sort_items=True) - result = SelectMenu[str]( + result = Selection[str]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Interfaces')), + header=tr('Select an interface'), allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -79,18 +77,13 @@ def _select_iface(self, data: list[Nic]) -> str | None: case ResultType.Reset: raise ValueError('Unhandled result type') - def _get_ip_address( - self, - title: str, - header: str, - allow_skip: bool, - multi: bool, - preset: str | None = None, - ) -> str | None: + def _get_ip_address(self, header: str, allow_skip: bool, multi: bool, preset: str | None = None, allow_empty: bool = False) -> str | None: def validator(ip: str | None) -> str | None: failure = tr('You need to enter a valid IP in IP-config mode') if not ip: + if allow_empty: + return None return failure if multi: @@ -105,19 +98,18 @@ def validator(ip: str | None) -> str | None: except ValueError: return failure - result = EditMenu( - title, + result = Input( header=header, - validator=validator, + validator_callback=validator, allow_skip=allow_skip, - default_text=preset, - ).input() + default_value=preset, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - return result.text() + return result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') @@ -126,18 +118,17 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: modes = ['DHCP (auto detect)', 'IP (static)'] default_mode = 'DHCP (auto detect)' - header = tr('Select which mode to configure for "{}"').format(iface_name) + '\n' + header = tr('Select which mode to configure for "{}"').format(iface_name) + items = [MenuItem(m, value=m) for m in modes] group = MenuItemGroup(items, sort_items=True) group.set_default_by_value(default_mode) - result = SelectMenu[str]( + result = Selection[str]( group, header=header, allow_skip=False, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Modes')), - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -151,10 +142,10 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: if mode == 'IP (static)': header = tr('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) + '\n' - ip = self._get_ip_address(tr('IP address'), header, False, False) + ip = self._get_ip_address(header, False, False) header = tr('Enter your gateway (router) IP address (leave blank for none)') + '\n' - gateway = self._get_ip_address(tr('Gateway address'), header, True, False) + gateway = self._get_ip_address(header, True, False, allow_empty=True) if edit_nic.dns: display_dns = ' '.join(edit_nic.dns) @@ -162,13 +153,7 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: display_dns = None header = tr('Enter your DNS servers with space separated (leave blank for none)') + '\n' - dns_servers = self._get_ip_address( - tr('DNS servers'), - header, - True, - True, - display_dns, - ) + dns_servers = self._get_ip_address(header, True, True, display_dns, allow_empty=True) dns = [] if dns_servers is not None: @@ -191,13 +176,12 @@ def ask_to_configure_network(preset: NetworkConfiguration | None) -> NetworkConf if preset: group.set_selected_by_value(preset.type) - result = SelectMenu[NicType]( + result = Selection[NicType]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Network configuration')), + header=tr('Choose network configuration'), allow_reset=True, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 0c0e54aaee..8b7329e5a0 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -1,10 +1,9 @@ from __future__ import annotations +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, FrameStyle, Orientation, PreviewStyle +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..hardware import GfxDriver, SysInfo @@ -26,14 +25,13 @@ def select_kernel(preset: list[str] = []) -> list[str]: group.set_focus_by_value(default_kernel) group.set_selected_by_value(preset) - result = SelectMenu[str]( + result = Selection[str]( group, + header=tr('Select which kernel(s) to install'), allow_skip=True, allow_reset=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Kernel')), multi=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -44,6 +42,20 @@ def select_kernel(preset: list[str] = []) -> list[str]: return result.get_values() +def ask_for_uki(preset: bool = True) -> bool: + prompt = tr('Would you like to use unified kernel images?') + '\n' + + result = Confirmation(header=prompt, allow_skip=True, preset=preset).show() + + match result.type_: + case ResultType.Skip: + return preset + case ResultType.Selection: + return result.get_value() + case ResultType.Reset: + raise ValueError('Unhandled result type') + + def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None) -> GfxDriver | None: """ Somewhat convoluted function, whose job is simple. @@ -55,7 +67,15 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None if not options: options = [driver for driver in GfxDriver] - items = [MenuItem(o.value, value=o, preview_action=lambda x: x.value.packages_text()) for o in options] + items = [ + MenuItem( + o.value, + value=o, + preview_action=lambda x: x.value.packages_text() if x.value else None, + ) + for o in options + ] + group = MenuItemGroup(items, sort_items=True) group.set_default_by_value(GfxDriver.AllOpenSource) @@ -70,15 +90,13 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None if SysInfo.has_nvidia_graphics(): header += tr('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n') - result = SelectMenu[GfxDriver]( + result = Selection[GfxDriver]( group, header=header, allow_skip=True, allow_reset=True, - preview_size='auto', - preview_style=PreviewStyle.BOTTOM, - preview_frame=FrameProperties(tr('Info'), h_frame_style=FrameStyle.MIN), - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Skip: @@ -90,24 +108,22 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None def ask_for_swap(preset: bool = True) -> bool: - if preset: - default_item = MenuItem.yes() - else: - default_item = MenuItem.no() + prompt = tr('Would you like to use swap on zram?') - prompt = tr('Would you like to use swap on zram?') + '\n' - - group = MenuItemGroup.yes_no() - group.set_focus_by_value(default_item) - - result = SelectMenu[bool]( - group, + result = Selection[GfxDriver]( + MenuItemGroup.yes_no(), header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + allow_reset=True, + preview_location='right', + test=True + ).show() + + # result = Confirmation( + # header=prompt, + # allow_skip=True, + # preset=preset, + # ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 94d34d0097..ee99c55516 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,10 +1,9 @@ from typing import override +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu from ..models.locale import LocaleConfiguration @@ -32,40 +31,33 @@ def _define_menu_options(self) -> list[MenuItem]: text=tr('Keyboard layout'), action=self._select_kb_layout, value=self._locale_conf.kb_layout, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='kb_layout', ), MenuItem( text=tr('Locale language'), action=select_locale_lang, value=self._locale_conf.sys_lang, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='sys_lang', ), MenuItem( text=tr('Locale encoding'), action=select_locale_enc, value=self._locale_conf.sys_enc, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='sys_enc', ), ] - def _prev_locale(self, item: MenuItem) -> str: - temp_locale = LocaleConfiguration( - self._menu_item_group.find_by_key('kb_layout').get_value(), - self._menu_item_group.find_by_key('sys_lang').get_value(), - self._menu_item_group.find_by_key('sys_enc').get_value(), - ) - return temp_locale.preview() - @override - def run( - self, - additional_title: str | None = None, - ) -> LocaleConfiguration: - super().run(additional_title=additional_title) - return self._locale_conf + def run(self, additional_title: str | None = None) -> LocaleConfiguration: + config = super().run(additional_title=additional_title) + + if config is None: + config = LocaleConfiguration.default() + + return config def _select_kb_layout(self, preset: str | None) -> str | None: kb_lang = select_kb_layout(preset) @@ -82,12 +74,11 @@ def select_locale_lang(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Locale language')), - allow_skip=True, - ).run() + result = Selection[str]( + header=tr('Locale language'), + group=group, + enable_filter=True, + ).show() match result.type_: case ResultType.Selection: @@ -106,12 +97,11 @@ def select_locale_enc(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Locale encoding')), - allow_skip=True, - ).run() + result = Selection[str]( + header=tr('Locale encoding'), + group=group, + enable_filter=True, + ).show() match result.type_: case ResultType.Selection: @@ -138,12 +128,11 @@ def select_kb_layout(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=False) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Keyboard layout')), - allow_skip=True, - ).run() + result = Selection[str]( + header=tr('Keyboard layout'), + group=group, + enable_filter=True, + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 209e312007..e79ede8b3d 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -3,11 +3,11 @@ from types import TracebackType from typing import Any, Self +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu, Tui -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Chars, FrameProperties, FrameStyle, PreviewStyle +from archinstall.tui.types import Chars +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..output import error @@ -42,7 +42,7 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseExceptio # TODO: skip processing when it comes from a planified exit if exc_type is not None: error(str(exc_value)) - Tui.print('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues') + print('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues') # Return None to propagate the exception return None @@ -94,37 +94,36 @@ def disable_all(self) -> None: def _is_config_valid(self) -> bool: return True - def run( - self, - additional_title: str | None = None, - ) -> ValueT | None: + def run(self, additional_title: str | None = None) -> ValueT | None: self._sync_from_config() while True: - result = SelectMenu[ValueT]( - self._menu_item_group, + result = Selection[ValueT]( + group=self._menu_item_group, + header=additional_title, 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), - additional_title=additional_title, - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Selection: item: MenuItem = result.item() + self._menu_item_group.focus_item = item if item.action is None: if not self._is_config_valid(): continue break + else: + item.value = item.action(item.value) case ResultType.Reset: return None + case _: + pass self.sync_all_to_config() - return None + return self._config class AbstractSubMenu[ValueT](AbstractMenu[ValueT]): diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py new file mode 100644 index 0000000000..b1d7828545 --- /dev/null +++ b/archinstall/lib/menu/helpers.py @@ -0,0 +1,299 @@ +from collections.abc import Awaitable, Callable +from typing import Any, Literal, TypeVar, override + +from textual.validation import ValidationResult, Validator + +from archinstall.lib.translationhandler import tr +from archinstall.tui.ui.components import ( + ConfirmationScreen, + InputScreen, + ListViewScreen, + LoadingScreen, + NotifyScreen, + OptionListScreen, + SelectListScreen, + TableSelectionScreen, + tui, +) +from archinstall.tui.ui.menu_item import MenuItemGroup +from archinstall.tui.ui.result import Result, ResultType + +ValueT = TypeVar('ValueT') + + +class Selection[ValueT]: + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + multi: bool = False, + enable_filter: bool = False, + test=False, + ): + self._header = header + self._group: MenuItemGroup = group + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._preview_location = preview_location + self._multi = multi + self._enable_filter = enable_filter + self._test = test + + def show(self) -> Result[ValueT]: + result: Result[ValueT] = tui.run(self) + return result + + async def _run(self) -> None: + if self._multi: + result = await SelectListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + enable_filter=self._enable_filter, + ).run() + else: + if self._test: + result = await ListViewScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + enable_filter=self._enable_filter, + ).run() + else: + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + enable_filter=self._enable_filter, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) + + +class Confirmation: + def __init__( + self, + header: str, + group: MenuItemGroup | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + preset: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, + ): + self._header = header + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._preset = preset + self._preview_location = preview_location + self._preview_header = preview_header + + if not group: + self._group = MenuItemGroup.yes_no() + self._group.set_focus_by_value(preset) + else: + self._group = group + + def show(self) -> Result[bool]: + result: Result[bool] = tui.run(self) + return result + + async def _run(self) -> None: + result = await ConfirmationScreen[bool]( + group=self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + preview_header=self._preview_header, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) + + +class Notify: + def __init__(self, header: str): + self._header = header + + def show(self) -> Result[bool]: + result: Result[bool] = tui.run(self) + return result + + async def _run(self) -> None: + await NotifyScreen(header=self._header).run() + tui.exit(Result.true()) + + +class GenericValidator(Validator): + def __init__(self, validator_callback: Callable[[str], str | None]) -> None: + super().__init__() + + self._validator_callback = validator_callback + + @override + def validate(self, value: str) -> ValidationResult: + result = self._validator_callback(value) + + if result is not None: + return self.failure(result) + + return self.success() + + +class Input: + def __init__( + self, + header: str | None = None, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + validator_callback: Callable[[str], str | None] | None = None, + ): + self._header = header + self._placeholder = placeholder + self._password = password + self._default_value = default_value + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._validator_callback = validator_callback + + def show(self) -> Result[str]: + result: Result[str] = tui.run(self) + return result + + async def _run(self) -> None: + validator = GenericValidator(self._validator_callback) if self._validator_callback else None + + result = await InputScreen( + header=self._header, + placeholder=self._placeholder, + password=self._password, + default_value=self._default_value, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + validator=validator, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) + + +class Loading[ValueT]: + def __init__( + self, + header: str | None = None, + timer: int = 3, + data_callback: Callable[[], Any] | None = None, + ): + self._header = header + self._timer = timer + self._data_callback = data_callback + + def show(self) -> Result[ValueT]: + result: Result[ValueT] = tui.run(self) + return result + + async def _run(self) -> None: + if self._data_callback: + result = await LoadingScreen( + header=self._header, + data_callback=self._data_callback, + ).run() + tui.exit(result) + else: + await LoadingScreen( + timer=self._timer, + header=self._header, + ).run() + tui.exit(Result.true()) + + +class Table[ValueT]: + def __init__( + self, + header: str | None = None, + group: MenuItemGroup | None = None, + group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, + presets: list[ValueT] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, + ): + self._header = header + self._group = group + self._data_callback = group_callback + self._loading_header = loading_header + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._multi = multi + self._presets = presets + self._preview_location = preview_location + self._preview_header = preview_header + + if self._group is None and self._data_callback is None: + raise ValueError('Either data or data_callback must be provided') + + def show(self) -> Result[ValueT]: + result: Result[ValueT] = tui.run(self) + return result + + async def _run(self) -> None: + result = await TableSelectionScreen[ValueT]( + header=self._header, + group=self._group, + group_callback=self._data_callback, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + loading_header=self._loading_header, + multi=self._multi, + preview_location=self._preview_location, + preview_header=self._preview_header, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) + + +async def _confirm_reset() -> Result[bool]: + return await ConfirmationScreen[bool]( + MenuItemGroup.yes_no(), + header=tr('Are you sure you want to reset this setting?'), + allow_skip=False, + allow_reset=False, + ).run() diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index d8842d65a2..b495d78843 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -1,12 +1,11 @@ import copy from typing import cast +from archinstall.lib.menu.helpers import Selection from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType class ListManager[ValueT]: @@ -18,7 +17,7 @@ def __init__( prompt: str | None = None, ): """ - :param prompt: Text which will appear at the header + :param prompt: Text which will appear at the header type param: string :param entries: list/dict of option to be shown / manipulated @@ -68,13 +67,12 @@ def run(self) -> list[ValueT]: if self._prompt is not None: prompt = f'{self._prompt}\n\n' - result = SelectMenu[ValueT | str]( + result = Selection[ValueT | str]( group, header=prompt, - search_enabled=False, + enable_filter=False, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -106,15 +104,14 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: items = [MenuItem(o, value=o) for o in options] group = MenuItemGroup(items, sort_items=False) - header = f'{self.selected_action_display(entry)}\n' + header = f'{self.selected_action_display(entry)}' - result = SelectMenu[str]( + result = Selection[str]( group, header=header, - search_enabled=False, + enable_filter=False, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/menu/menu_helper.py b/archinstall/lib/menu/menu_helper.py index 6ca3b9531f..47e04fb88d 100644 --- a/archinstall/lib/menu/menu_helper.py +++ b/archinstall/lib/menu/menu_helper.py @@ -1,5 +1,5 @@ from archinstall.lib.output import FormattedOutput -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup class MenuHelper[ValueT]: diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index d87c5c3af9..d6ee95f6aa 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -3,11 +3,10 @@ from pathlib import Path from typing import override +from archinstall.lib.menu.helpers import Input, Loading, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu, Tui -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from .menu.abstract_menu import AbstractSubMenu from .menu.list_manager import ListManager @@ -68,40 +67,38 @@ def handle_action( return data def _add_custom_repository(self, preset: CustomRepository | None = None) -> CustomRepository | None: - edit_result = EditMenu( - tr('Repository name'), - alignment=Alignment.CENTER, + edit_result = Input( + header=tr('Enter a respository name'), allow_skip=True, - default_text=preset.name if preset else None, - ).input() + default_value=preset.name if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - name = edit_result.text() + name = edit_result.get_value() case ResultType.Skip: return preset case _: raise ValueError('Unhandled return type') - header = f'{tr("Name")}: {name}' + header = f'{tr("Name")}: {name}\n' + prompt = f'{header}\n' + tr('Enter the repository url') - edit_result = EditMenu( - tr('Url'), - header=header, - alignment=Alignment.CENTER, + edit_result = Input( + header=prompt, allow_skip=True, - default_text=preset.url if preset else None, - ).input() + default_value=preset.url if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - url = edit_result.text() + url = edit_result.get_value() case ResultType.Skip: return preset case _: raise ValueError('Unhandled return type') - header += f'\n{tr("Url")}: {url}\n' + header += f'{tr("Url")}: {url}\n' prompt = f'{header}\n' + tr('Select signature check') sign_chk_items = [MenuItem(s.value, value=s.value) for s in SignCheck] @@ -110,12 +107,11 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_check.value) - result = SelectMenu[SignCheck]( + result = Selection[SignCheck]( group, header=prompt, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -132,12 +128,11 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_option.value) - result = SelectMenu( + result = Selection( group, header=prompt, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -190,21 +185,20 @@ def handle_action( return data def _add_custom_server(self, preset: CustomServer | None = None) -> CustomServer | None: - edit_result = EditMenu( - tr('Server url'), - alignment=Alignment.CENTER, + edit_result = Input( + header=tr('Enter server url'), allow_skip=True, - default_text=preset.url if preset else None, - ).input() + default_value=preset.url if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - uri = edit_result.text() + uri = edit_result.get_value() return CustomServer(uri) case ResultType.Skip: return preset - - return None + case _: + return None class MirrorMenu(AbstractSubMenu[MirrorConfiguration]): @@ -296,15 +290,16 @@ def _prev_custom_servers(self, item: MenuItem) -> str | None: return output.strip() @override - def run(self, additional_title: str | None = None) -> MirrorConfiguration: - super().run(additional_title=additional_title) - return self._mirror_config + def run(self, additional_title: str | None = None) -> MirrorConfiguration | None: + return super().run(additional_title=additional_title) def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: - Tui.print(tr('Loading mirror regions...'), clear_screen=True) + Loading[None]( + header=tr('Loading mirror regions...'), + data_callback=mirror_list_handler.load_mirrors, + ).show() - mirror_list_handler.load_mirrors() available_regions = mirror_list_handler.get_mirror_regions() if not available_regions: @@ -317,14 +312,14 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: group.set_selected_by_value(preset_regions) - result = SelectMenu[MirrorRegion]( + result = Selection[MirrorRegion]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Mirror regions')), + header=tr('Select mirror regions to be enabled'), allow_reset=True, allow_skip=True, multi=True, - ).run() + enable_filter=True, + ).show() match result.type_: case ResultType.Skip: @@ -364,14 +359,13 @@ def select_optional_repositories(preset: list[Repository]) -> list[Repository]: group = MenuItemGroup(items, sort_items=False) group.set_selected_by_value(preset) - result = SelectMenu[Repository]( + result = Selection[Repository]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min('Additional repositories'), + header=tr('Select optional repositories to be enabled'), allow_reset=True, allow_skip=True, multi=True, - ).run() + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index 45cf06402e..d1856ffd08 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -581,6 +581,10 @@ class _DeviceInfo: read_only: bool dirty: bool + @override + def __hash__(self) -> int: + return hash(self.path) + def table_data(self) -> dict[str, str | int | bool]: total_free_space = sum([region.get_length(unit=Unit.MiB) for region in self.free_space_regions]) return { @@ -1061,10 +1065,6 @@ def display_msg(self) -> str: match self: case LvmLayoutType.Default: return tr('Default layout') - # case LvmLayoutType.Manual: - # return str(_('Manual configuration')) - - raise ValueError(f'Unknown type: {self}') class _LvmVolumeGroupSerialization(TypedDict): diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 13fc3e4380..ede61ef5c7 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -1,7 +1,7 @@ from asyncio import sleep from dataclasses import dataclass from pathlib import Path -from typing import Any, assert_never +from typing import assert_never from archinstall.lib.exceptions import SysCallError from archinstall.lib.general import SysCommand @@ -9,9 +9,9 @@ from archinstall.lib.network.wpa_supplicant import WpaSupplicantConfig from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItemGroup from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, LoadingScreen, NotifyScreen, TableSelectionScreen, tui -from archinstall.tui.ui.result import ResultType +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import Result, ResultType @dataclass @@ -23,14 +23,13 @@ class WpaCliResult: class WifiHandler: def __init__(self) -> None: - tui.set_main(self) self._wpa_config = WpaSupplicantConfig() - def setup(self) -> Any: - result = tui.run() - return result + def setup(self) -> bool: + result: Result[bool] = tui.run(self) + return result.get_value() - async def run(self) -> None: + async def _run(self) -> None: """ This is the entry point that is called by components.TApp """ @@ -38,7 +37,7 @@ async def run(self) -> None: if not wifi_iface: debug('No wifi interface found') - tui.exit(False) + tui.exit(Result.false()) return None prompt = tr('No network connection found') + '\n\n' @@ -53,17 +52,15 @@ async def run(self) -> None: match result.type_: case ResultType.Selection: - if result.value() is False: - tui.exit(False) + if result.get_value() is False: + tui.exit(Result.false()) return None case ResultType.Skip | ResultType.Reset: - tui.exit(False) + tui.exit(Result.false()) return None - case _: - assert_never(result) setup_result = await self._setup_wifi(wifi_iface) - tui.exit(setup_result) + tui.exit(Result(ResultType.Selection, _data=setup_result)) async def _enable_supplicant(self, wifi_iface: str) -> bool: self._wpa_config.load_config() @@ -118,21 +115,24 @@ async def _setup_wifi(self, wifi_iface: str) -> bool: debug(f'Found wifi interface: {wifi_iface}') - async def get_wifi_networks() -> list[WifiNetwork]: + async def get_wifi_networks() -> MenuItemGroup: debug('Scanning Wifi networks') result = self._wpa_cli('scan', wifi_iface) if not result.success: debug(f'Failed to scan wifi networks: {result.error}') - return [] + return MenuItemGroup([]) await sleep(5) - return self._get_scan_results(wifi_iface) + wifi_networks = self._get_scan_results(wifi_iface) + + items = [MenuItem(network.ssid, value=network) for network in wifi_networks] + return MenuItemGroup(items) result = await TableSelectionScreen[WifiNetwork]( header=tr('Select wifi network to connect to'), loading_header=tr('Scanning wifi networks...'), - data_callback=get_wifi_networks, + group_callback=get_wifi_networks, allow_skip=True, allow_reset=True, ).run() @@ -142,12 +142,12 @@ async def get_wifi_networks() -> list[WifiNetwork]: if not result.has_data(): debug('No networks found') await NotifyScreen(header=tr('No wifi networks found')).run() - tui.exit(False) + tui.exit(Result.false()) return False - network = result.value() + network = result.get_value() case ResultType.Skip | ResultType.Reset: - tui.exit(False) + tui.exit(Result.false()) return False case _: assert_never(result.type_) @@ -170,7 +170,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: await self._notify_failure() return False - await LoadingScreen(3, 'Setting up wifi...').run() + await LoadingScreen(timer=3, header='Setting up wifi...').run() network_id = self._find_network_id(network.ssid, wifi_iface) @@ -186,7 +186,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: await self._notify_failure() return False - await LoadingScreen(5, 'Connecting wifi...').run() + await LoadingScreen(timer=5, header='Connecting wifi...').run() return True @@ -253,7 +253,7 @@ async def _prompt_psk(self, existing: str | None = None) -> str | None: debug('No password provided, aborting connection') return None - return result.value() + return result.get_value() def _get_scan_results(self, iface: str) -> list[WifiNetwork]: debug(f'Retrieving scan results: {iface}') diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 2cac13bc1c..52e718dbcf 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -333,6 +333,4 @@ def log( Journald.log(text, level=level) if level != logging.DEBUG: - from archinstall.tui.curses_menu import Tui - - Tui.print(text) + print(text) diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index d434fc2fb3..9db477bb9d 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -3,11 +3,10 @@ from typing import override from archinstall.default_profiles.profile import GreeterType, Profile +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.result import ResultType from ..hardware import GfxDriver from ..interactions.system_conf import select_driver @@ -65,8 +64,7 @@ def _define_menu_options(self) -> list[MenuItem]: @override def run(self, additional_title: str | None = None) -> ProfileConfiguration | None: - super().run(additional_title=additional_title) - return self._profile_config + return super().run(additional_title=additional_title) def _select_profile(self, preset: Profile | None) -> Profile | None: profile = select_profile(preset) @@ -104,20 +102,13 @@ def _select_gfx_driver(self, preset: GfxDriver | None = None) -> GfxDriver | Non header = tr('The proprietary Nvidia driver is not supported by Sway.') + '\n' header += tr('It is likely that you will run into issues, are you okay with that?') + '\n' - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - group.default_item = MenuItem.no() - - result = SelectMenu[bool]( - group, + result = Confirmation( header=header, allow_skip=False, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - ).run() + preset=False, + ).show() - if result.item() == MenuItem.no(): + if result.get_value(): return preset return driver @@ -169,12 +160,11 @@ def select_greeter( group.set_default_by_value(default) - result = SelectMenu[GreeterType]( + result = Selection[GreeterType]( group, + header=tr('Select which greeter to install'), allow_skip=True, - frame=FrameProperties.min(tr('Greeter')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -197,20 +187,18 @@ def select_profile( top_level_profiles = profile_handler.get_top_level_profiles() if header is None: - header = tr('This is a list of pre-programmed default_profiles') + '\n' + header = tr('Select a profile type') 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) - result = SelectMenu[Profile]( + result = Selection[Profile]( group, header=header, allow_reset=allow_reset, allow_skip=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Main profile')), - ).run() + ).show() match result.type_: case ResultType.Reset: diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index e72d5c77fe..aaece81264 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -1,69 +1,64 @@ from pathlib import Path +from archinstall.lib.menu.helpers import Input from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment +from archinstall.tui.ui.result import ResultType from ..models.users import Password from ..output import FormattedOutput def get_password( - text: str, header: str | None = None, allow_skip: bool = False, preset: str | None = None, skip_confirmation: bool = False, ) -> Password | None: - failure: str | None = None - while True: - 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, + result = Input( + header=header, allow_skip=allow_skip, - default_text=preset, - hide_input=True, - ).input() + default_value=preset, + password=True, + ).show() - if allow_skip: - if not result.has_item() or not result.text(): + if result.type_ == ResultType.Skip: + if allow_skip: return None + else: + continue + elif result.type_ == ResultType.Selection: + if not result.get_value(): + if allow_skip: + return None + else: + continue - password = Password(plaintext=result.text()) + password = Password(plaintext=result.get_value()) + break - if skip_confirmation: - return password + if skip_confirmation: + return password - if header is not None: - confirmation_header = f'{header}{tr("Password")}: {password.hidden()}\n' - else: - confirmation_header = f'{tr("Password")}: {password.hidden()}\n' + confirmation_header = f'{tr("Password")}: {password.hidden()}\n\n' + confirmation_header += tr('Confirm password') - result = EditMenu( - tr('Confirm password'), - header=confirmation_header, - alignment=Alignment.CENTER, - allow_skip=False, - hide_input=True, - ).input() + def _validate(value: str) -> str | None: + if value != password._plaintext: + return tr('The password did not match, please try again') + return None - if password._plaintext == result.text(): - return password + _ = Input( + header=confirmation_header, + allow_skip=False, + password=True, + validator_callback=_validate, + ).show() - failure = tr('The confirmation password did not match, please try again') + return password def prompt_dir( - text: str, header: str | None = None, validate: bool = True, must_exist: bool = True, @@ -87,24 +82,22 @@ def validate_path(path: str | None) -> str | None: else: validate_func = None - result = EditMenu( - text, + result = Input( header=header, - alignment=Alignment.CENTER, allow_skip=allow_skip, - validator=validate_func, - default_text=preset, - ).input() + validator_callback=validate_func, + default_value=preset, + ).show() match result.type_: case ResultType.Skip: return None case ResultType.Selection: - if not result.text(): + if not result.get_value(): return None - return Path(result.text()) - - return None + return Path(result.get_value()) + case _: + return None def is_subpath(first: Path, second: Path) -> bool: diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index a91aa4272d..63d5d68a63 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -22,7 +22,6 @@ from archinstall.lib.packages.packages import check_package_upgrade from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.translationhandler import tr -from archinstall.tui import Tui def ask_user_questions() -> None: @@ -39,13 +38,12 @@ def ask_user_questions() -> None: text = tr('New version available') + f': {upgrade}' title_text = f' ({text})' - with Tui(): - global_menu = GlobalMenu(arch_config_handler.config) + global_menu = GlobalMenu(arch_config_handler.config) - if not arch_config_handler.args.advanced: - global_menu.set_enabled('parallel_downloads', False) + if not arch_config_handler.args.advanced: + global_menu.set_enabled('parallel_downloads', False) - global_menu.run(additional_title=title_text) + global_menu.run(additional_title=title_text) def perform_installation(mountpoint: Path) -> None: @@ -169,9 +167,8 @@ def perform_installation(mountpoint: Path) -> None: debug(f'Disk states after installing:\n{disk_layouts()}') if not arch_config_handler.args.silent: - with Tui(): - elapsed_time = time.time() - start_time - action = ask_post_installation(elapsed_time) + elapsed_time = time.time() - start_time + action = ask_post_installation(elapsed_time) match action: case PostInstallationAction.EXIT: @@ -198,10 +195,9 @@ def guided() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return guided() diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 2c0c26c2f0..e70417c12f 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -11,7 +11,6 @@ from archinstall.lib.models.users import Password, User from archinstall.lib.output import debug, error, info from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.tui import Tui def perform_installation(mountpoint: Path) -> None: @@ -60,9 +59,8 @@ def perform_installation(mountpoint: Path) -> None: def _minimal() -> None: - with Tui(): - disk_config = DiskLayoutConfigurationMenu(disk_layout_config=None).run() - arch_config_handler.config.disk_config = disk_config + disk_config = DiskLayoutConfigurationMenu(disk_layout_config=None).run() + arch_config_handler.config.disk_config = disk_config config = ConfigurationOutput(arch_config_handler.config) config.write_debug() @@ -73,10 +71,9 @@ def _minimal() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return _minimal() diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index b63ee94072..036bfe344f 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -7,20 +7,18 @@ from archinstall.lib.disk.utils import disk_layouts from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.installer import Installer -from archinstall.tui import Tui def ask_user_questions() -> None: - with Tui(): - global_menu = GlobalMenu(arch_config_handler.config) - global_menu.disable_all() + global_menu = GlobalMenu(arch_config_handler.config) + global_menu.disable_all() - global_menu.set_enabled('archinstall_language', True) - global_menu.set_enabled('disk_config', True) - global_menu.set_enabled('swap', True) - global_menu.set_enabled('__config__', True) + global_menu.set_enabled('archinstall_language', True) + global_menu.set_enabled('disk_config', True) + global_menu.set_enabled('swap', True) + global_menu.set_enabled('__config__', True) - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -69,10 +67,9 @@ def _only_hd() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return _only_hd() diff --git a/archinstall/tui/__init__.py b/archinstall/tui/__init__.py index 9bd67f1b73..e69de29bb2 100644 --- a/archinstall/tui/__init__.py +++ b/archinstall/tui/__init__.py @@ -1,20 +0,0 @@ -from .curses_menu import EditMenu, SelectMenu, Tui -from .menu_item import MenuItem, MenuItemGroup -from .result import Result, ResultType -from .types import Alignment, Chars, FrameProperties, FrameStyle, Orientation, PreviewStyle - -__all__ = [ - 'Alignment', - 'Chars', - 'EditMenu', - 'FrameProperties', - 'FrameStyle', - 'MenuItem', - 'MenuItemGroup', - 'Orientation', - 'PreviewStyle', - 'Result', - 'ResultType', - 'SelectMenu', - 'Tui', -] diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index d1ab11e188..c45b594815 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -90,7 +90,7 @@ def _show_help(self) -> None: lines = help_text.split('\n') entries = [ViewportEntry('', 0, 0, STYLE.NORMAL)] - entries += [ViewportEntry(f' {e} ', idx + 1, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] + 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: str) -> list[ViewportEntry]: @@ -227,7 +227,8 @@ def _get_frame_dim( # 2 for frames, 1 for extra space start away from frame # must align with def _adjust_entries - frame_end += 3 # 2 for frame + # 2 for frame + frame_end += 3 frame_height = len(rows) + 1 if frame_height > max_height: @@ -1122,7 +1123,7 @@ def _calc_prev_scroll_pos( def _multi_prefix(self, item: MenuItem) -> str: if item.read_only: - return ' ' + return ' ' elif self._item_group.is_item_selected(item): return '[x] ' else: diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index 52d5ad51a1..81ce94d543 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from enum import Enum from functools import cached_property -from typing import Any, ClassVar +from typing import Any, ClassVar, Self, override from archinstall.lib.translationhandler import tr @@ -22,12 +22,27 @@ class MenuItem: dependencies: list[str | Callable[[], bool]] = field(default_factory=list) dependencies_not: list[str] = field(default_factory=list) display_action: Callable[[Any], str] | None = None - preview_action: Callable[[Any], str | None] | None = None + preview_action: Callable[[Self], str | None] | None = None key: str | None = None + _id: str = '' + _yes: ClassVar[MenuItem | None] = None _no: ClassVar[MenuItem | None] = None + def __post_init__(self) -> None: + if self.key is not None: + self._id = self.key + else: + self._id = str(id(self)) + + @override + def __hash__(self) -> int: + return hash(self._id) + + def get_id(self) -> str: + return self._id + def get_value(self) -> Any: assert self.value is not None return self.value @@ -97,18 +112,25 @@ def __init__( self.focus_first() if self.focus_item not in self.items: - raise ValueError(f'Selected item not in menu: {focus_item}') + raise ValueError(f'Selected item not in menu: {self.focus_item}') def add_item(self, item: MenuItem) -> None: self._menu_items.append(item) delattr(self, 'items') # resetting the cache + def find_by_id(self, item_id: str) -> MenuItem: + for item in self._menu_items: + if item.get_id() == item_id: + return item + + raise ValueError(f'No item found for id: {item_id}') + 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}') + raise ValueError(f'No item found for key: {key}') def get_enabled_items(self) -> list[MenuItem]: return [it for it in self.items if self.is_enabled(it)] @@ -164,6 +186,21 @@ def set_selected_by_value(self, values: Any | list[Any] | None) -> None: if values: self.set_focus_by_value(values[0]) + def get_focused_index(self) -> int | None: + items = self.get_enabled_items() + + if self.focus_item and items: + try: + return items.index(self.focus_item) + except ValueError: + # on large menus (15k+) when filtering very quickly + # the index search is too slow while the items are reduced + # by the filter and it will blow up as it cannot find the + # focus item + pass + + return None + def index_focus(self) -> int | None: if self.focus_item and self.items: try: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 708bae9c36..95df4f1e5c 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -1,19 +1,25 @@ from __future__ import annotations +import sys from collections.abc import Awaitable, Callable -from typing import Any, ClassVar, TypeVar, override +from typing import Any, ClassVar, Literal, TypeVar, override from textual import work from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Center, Horizontal, Vertical +from textual.containers import Center, Horizontal, ScrollableContainer, Vertical from textual.events import Key from textual.screen import Screen -from textual.widgets import Button, DataTable, Input, LoadingIndicator, Static +from textual.validation import Validator +from textual.widgets import Button, DataTable, Footer, Input, Label, ListItem, ListView, LoadingIndicator, OptionList, Rule, SelectionList +from textual.widgets._data_table import RowKey +from textual.widgets.option_list import Option +from textual.widgets.selection_list import Selection +from textual.worker import WorkerCancelled from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') @@ -21,8 +27,8 @@ class BaseScreen(Screen[Result[ValueT]]): BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=True), - Binding('ctrl+c', 'reset_operation', 'Reset', show=True), + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), ] def __init__(self, allow_skip: bool = False, allow_reset: bool = False): @@ -32,34 +38,34 @@ def __init__(self, allow_skip: bool = False, allow_reset: bool = False): def action_cancel_operation(self) -> None: if self._allow_skip: - _ = self.dismiss(Result(ResultType.Skip, None)) + _ = self.dismiss(Result(ResultType.Skip)) - def action_reset_operation(self) -> None: + async def action_reset_operation(self) -> None: if self._allow_reset: - _ = self.dismiss(Result(ResultType.Reset, None)) + _ = self.dismiss(Result(ResultType.Reset)) def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available.""" + """Compose the app header if global header text is available""" if tui.global_header: - yield Static(tui.global_header, classes='app-header') + yield Label(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): CSS = """ LoadingScreen { align: center middle; - } - - .dialog { - align: center middle; - width: 100%; - border: none; background: transparent; } - .header { - text-align: center; - margin-bottom: 1; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + + background: transparent; } LoadingIndicator { @@ -69,71 +75,685 @@ class LoadingScreen(BaseScreen[None]): def __init__( self, - timer: int, + timer: int = 3, + data_callback: Callable[[], Any] | None = None, header: str | None = None, ): super().__init__() self._timer = timer self._header = header + self._data_callback = data_callback async def run(self) -> Result[None]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) # ensures indicator is centered too + with Vertical(classes='content-container'): + if self._header: + with Center(): + yield Label(self._header, classes='header', id='loading_header') + + yield Center(LoadingIndicator()) + + yield Footer() def on_mount(self) -> None: - self.set_timer(self._timer, self.action_pop_screen) + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) def action_pop_screen(self) -> None: _ = self.dismiss() -class ConfirmationScreen(BaseScreen[ValueT]): + +class ListViewScreen(BaseScreen[ValueT]): + """ + List single selection menu + """ + BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=True), - Binding('h', 'focus_left', 'Focus left', show=True), - Binding('right', 'focus_right', 'Focus right', show=True), - Binding('left', 'focus_left', 'Focus left', show=True), + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), ] CSS = """ - ConfirmationScreen { - align: center middle; + ListViewScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; } - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; } - .dialog { - width: 80; - height: 10; - border: none; + .list-container { + width: auto; + height: auto; + max-height: 100%; + + padding-bottom: 3; + background: transparent; } - .dialog-content { - padding: 1; - height: 100%; + ListView { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; } + """ - .message { - text-align: center; - margin-bottom: 1; + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._filter = enable_filter + self._show_frame = False + + self._items = self._get_items() + + def action_cursor_down(self) -> None: + list_view = self.query_one(ListView) + if list_view.has_focus: + list_view.action_cursor_down() + + def action_cursor_up(self) -> None: + list_view = self.query_one(ListView) + if list_view.has_focus: + list_view.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(ListView).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(ListView).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_items(self) -> list[ListItem]: + items = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + items.append( + ListItem( + name=item.text, + id=item.get_id(), + disabled=disabled, + ) + ) + + return items + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + list_view = ListView(id='list_widget') + + list_view = ListView( + ListItem(Label("One")), + ListItem(Label("Two")), + ListItem(Label("Three")), + ) + + if not self._show_frame: + list_view.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield list_view + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield list_view + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_items(self._items) + self.query_one(ListView).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_items = self._get_items() + self._update_items(filtered_items) + + def _update_items(self, items: list[ListItem]) -> None: + list_view = self.query_one(ListView) + # list_view.clear() + # list_view.insert(0, items) + + # list_view.highlighted_child = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) + + # def on_option_list_option_selected(self, event: ListView.Highlighted) -> None: + # item = event.item + # if not item: + # return + # + # if item.id is not None: + # item = self._group.find_by_id(item.id) + # _ = self.dismiss(Result(ResultType.Selection, _item=item)) + # + # def on_option_list_option_highlighted(self, event: ListView.Highlighted) -> None: + # item = event.item + # if not item: + # return + # + # self._set_preview(item.id) + + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None + + preview_widget = self.query_one('#preview_content', Label) + item = self._group.find_by_id(item_id) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +class OptionListScreen(BaseScreen[ValueT]): + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; } - .buttons { - align: center middle; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + padding-bottom: 3; + + background: transparent; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + OptionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._filter = enable_filter + self._show_frame = False + + self._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + option_list = OptionList(id='option_list_widget') + + if not self._show_frame: + option_list.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield option_list + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield option_list + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) + + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) + + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None + + preview_widget = self.query_one('#preview_content', Label) + item = self._group.find_by_id(item_id) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +class SelectListScreen(BaseScreen[ValueT]): + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + SelectionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + selection_list = SelectionList[MenuItem](id='select_list_widget') + + if not self._show_frame: + selection_list.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield selection_list + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield selection_list + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) + + def _update_options(self, options: list[Selection[MenuItem]]) -> None: + selection_list = self.query_one(SelectionList) + selection_list.clear_options() + selection_list.add_options(options) + + selection_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item) + + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None + + item: MenuItem = event.selection.value + self._set_preview(item) + + def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: + item: MenuItem = event.selection.value + + if item not in self._selected_items: + self._selected_items.append(item) + else: + self._selected_items.remove(item) + + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return + + preview_widget = self.query_one('#preview_content', Label) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +class ConfirmationScreen(BaseScreen[ValueT]): + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + border: none; + background: transparent; + } + + .buttons-container { + align: center top; + height: 3; background: transparent; } @@ -145,7 +765,7 @@ class ConfirmationScreen(BaseScreen[ValueT]): } Button.-active { - background: #1793D1; + background: blue; color: white; border: none; text-style: none; @@ -158,30 +778,57 @@ def __init__( header: str, allow_skip: bool = False, allow_reset: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header + self._preview_location = preview_location + self._preview_header = preview_header async def run(self) -> Result[ValueT]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) + yield Label(self._header, classes='header-text', id='header_text') + + if self._preview_location is None: + with Vertical(classes='content-container'): + with Horizontal(classes='buttons-container'): + for item in self._group.items: + yield Button(item.text, id=item.key) + else: + with Vertical(): + with Horizontal(classes='buttons-container'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Rule(orientation='horizontal') + if self._preview_header is not None: + yield Label(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + yield Footer() def on_mount(self) -> None: - self.update_selection() + self._update_selection() + + def action_focus_right(self) -> None: + if self._is_btn_focus(): + self._group.focus_next() + self._update_selection() + + def action_focus_left(self) -> None: + if self._is_btn_focus(): + self._group.focus_prev() + self._update_selection() - def update_selection(self) -> None: + def _update_selection(self) -> None: focused = self._group.focus_item buttons = self.query(Button) @@ -192,23 +839,34 @@ def update_selection(self) -> None: if button.id == focused.key: button.add_class('-active') button.focus() + + if self._preview_header is not None: + preview = self.query_one('#preview_content', Label) + + if focused.preview_action is None: + preview.update('') + else: + text = focused.preview_action(focused) + if text is not None: + preview.update(text) else: button.remove_class('-active') - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() + def _is_btn_focus(self) -> bool: + buttons = self.query(Button) + for button in buttons: + if button.has_focus: + return True - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() + return False def on_key(self, event: Key) -> None: if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - _ = self.dismiss(Result(ResultType.Selection, item.value)) + if self._is_btn_focus(): + item = self._group.focus_item + if not item: + return None + _ = self.dismiss(Result(ResultType.Selection, _item=item)) class NotifyScreen(ConfirmationScreen[ValueT]): @@ -220,141 +878,141 @@ def __init__(self, header: str): class InputScreen(BaseScreen[str]): CSS = """ InputScreen { + align: center middle; } - .dialog-wrapper { - align: center middle; - height: 100%; + .container-wrapper { + align: center top; width: 100%; + height: 1fr; } - .input-dialog { + .input-content { width: 60; height: 10; - border: none; - background: transparent; - } - - .input-content { - padding: 1; - height: 100%; - } - - .input-header { - text-align: center; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; } - .input-prompt { + .input-failure { + color: red; text-align: center; - margin: 0 0 1 0; - background: transparent; - } - - Input { - margin: 1 2; - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; } """ def __init__( self, - header: str, + header: str | None = None, placeholder: str | None = None, password: bool = False, default_value: str | None = None, allow_reset: bool = False, allow_skip: bool = False, + validator: Validator | None = None, ): super().__init__(allow_skip, allow_reset) - self._header = header + self._header = header or '' self._placeholder = placeholder or '' self._password = password self._default_value = default_value or '' self._allow_reset = allow_reset self._allow_skip = allow_skip + self._validator = validator async def run(self) -> Result[str]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(classes='dialog-wrapper'): - with Vertical(classes='input-dialog'): - with Vertical(classes='input-content'): - yield Static(self._header, classes='input-header') - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - ) + yield Label(self._header, classes='header-text', id='header_text') + + with Center(classes='container-wrapper'): + with Vertical(classes='input-content'): + yield Input( + placeholder=self._placeholder, + password=self._password, + value=self._default_value, + id='main_input', + validators=self._validator, + validate_on=['submitted'], + ) + yield Label('', classes='input-failure', id='input-failure') + + yield Footer() def on_mount(self) -> None: input_field = self.query_one('#main_input', Input) input_field.focus() - def on_key(self, event: Key) -> None: - if event.key == 'enter': - input_field = self.query_one('#main_input', Input) - value = input_field.value - _ = self.dismiss(Result(ResultType.Selection, value)) + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.validation_result and not event.validation_result.is_valid: + failures = [failure.description for failure in event.validation_result.failures if failure.description] + failure_out = ', '.join(failures) + + self.query_one('#input-failure', Label).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=True), - Binding('k', 'cursor_up', 'Up', show=True), + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), ] CSS = """ TableSelectionScreen { - align: center middle; + align: center top; background: transparent; } - DataTable { - height: auto; - width: auto; - border: none; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + background: transparent; } - DataTable .datatable--header { + .table-container { + align: center top; + width: 1fr; + height: 1fr; + background: transparent; - border: solid; } - .content-container { + .table-container ScrollableContainer { + align: center top; + height: auto; + + background: transparent; + } + + DataTable { width: auto; - min-height: 10; - min-width: 40; - align: center middle; + height: auto; + + padding-bottom: 2; + + border: none; background: transparent; } - .header { - text-align: center; - margin-bottom: 1; + DataTable .datatable--header { + background: transparent; + border: solid; } LoadingIndicator { height: auto; + padding-top: 2; + background: transparent; } """ @@ -362,87 +1020,133 @@ class TableSelectionScreen(BaseScreen[ValueT]): def __init__( self, header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + group: MenuItemGroup | None = None, + group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, + multi: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, ): super().__init__(allow_skip, allow_reset) self._header = header - self._data = data - self._data_callback = data_callback + self._group = group + self._group_callback = group_callback self._loading_header = loading_header + self._multi = multi + self._preview_location = preview_location + self._preview_header = preview_header + + self._selected_keys: set[RowKey] = set() + self._current_row_key: RowKey | None = None - if self._data is None and self._data_callback is None: + if self._group is None and self._group_callback is None: raise ValueError('Either data or data_callback must be provided') async def run(self) -> Result[ValueT]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) def action_cursor_down(self) -> None: table = self.query_one(DataTable) - if table.cursor_row is not None: - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) def action_cursor_up(self) -> None: table = self.query_one(DataTable) - if table.cursor_row is not None: - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) @override def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + with Center(): + yield Label(self._loading_header, classes='header', id='loading_header') + + yield LoadingIndicator(id='loader') + + if self._preview_location is None: + with Center(): + with Vertical(classes='table-container'): + yield ScrollableContainer(DataTable(id='data_table')) - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') + else: + with Vertical(classes='table-container'): + yield ScrollableContainer(DataTable(id='data_table')) + yield Rule(orientation='horizontal') + if self._preview_header is not None: + yield Label(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content', markup=False)) - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') + yield Footer() def on_mount(self) -> None: self._display_header(True) data_table = self.query_one(DataTable) data_table.cell_padding = 2 - if self._data: - self._put_data_to_table(data_table, self._data) + if self._group: + self._put_data_to_table(data_table, self._group) else: self._load_data(data_table) @work async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) + assert self._group_callback is not None + group = await self._group_callback() + self._put_data_to_table(table, group) def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) + if self._loading_header: + loading_header = self.query_one('#loading_header', Label) loading_header.display = is_loading + + if self._header: + header = self.query_one('#header_text', Label) header.display = not is_loading - except Exception: - pass - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: - _ = self.dismiss(Result(ResultType.Selection, None)) + def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: + items = group.items + selected = group.selected_items + + if not items: + _ = self.dismiss(Result(ResultType.Selection)) return - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + table.add_columns(*cols) - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] - table.add_row(*row_values, key=d) # type: ignore[arg-type] + for item in items: + if not item.value: + continue + + row_values = list(item.value.table_data().values()) + + if self._multi: + if item in selected: + row_values.insert(0, '[X]') + else: + row_values.insert(0, '[ ]') + + row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] + if item in selected: + self._selected_keys.add(row_key) table.cursor_type = 'row' table.display = True @@ -452,51 +1156,179 @@ def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> No self._display_header(False) table.focus() + def action_toggle_selection(self) -> None: + if not self._multi: + return + + if not self._current_row_key: + return + + table = self.query_one(DataTable) + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[ ]') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[X]') + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._current_row_key = event.row_key + item: MenuItem = event.row_key.value # type: ignore[assignment] + + if not item.preview_action: + return + + preview_widget = self.query_one('#preview_content', Label) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - data: ValueT = event.row_key.value # type: ignore[assignment] - _ = self.dismiss(Result(ResultType.Selection, data)) + if self._multi: + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) + + +class _AppInstance(App[ValueT]): + ENABLE_COMMAND_PALETTE = False + + BINDINGS: ClassVar = [ + Binding('f1', 'trigger_help', 'Show/Hide help', show=True), + Binding('ctrl+q', 'quit', 'Quit', show=True, priority=True), + ] -class TApp(App[Any]): CSS = """ + Screen { + color: white; + } + + * { + scrollbar-size: 1 1; + + /* Use high contrast colors */ + scrollbar-color: white; + scrollbar-background: black; + } + .app-header { dock: top; height: auto; width: 100%; content-align: center middle; - background: $primary; + background: blue; color: white; text-style: bold; } - """ - def __init__(self) -> None: - super().__init__(ansi_color=True) - self._main = None - self._global_header: str | None = None + .header-text { + text-align: center; + width: 100%; + height: auto; - @property - def global_header(self) -> str | None: - return self._global_header + padding-top: 2; + padding-bottom: 2; - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + background: transparent; + } - def set_main(self, main: Any) -> None: + .preview-header { + text-align: center; + color: white; + text-style: bold; + width: 100%; + + padding-bottom: 1; + + background: transparent; + } + + .no-border { + border: none; + } + + Input { + border: solid gray 50%; + background: transparent; + height: 3; + color: white; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid blue; + } + + Footer { + dock: bottom; + width: 100%; + background: transparent; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) self._main = main + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + def on_mount(self) -> None: self._run_worker() @work async def _run_worker(self) -> None: try: - if self._main is not None: - await self._main.run() # type: ignore[unreachable] + await self._main._run() + except WorkerCancelled: + debug('Worker was cancelled') except Exception as err: debug(f'Error while running main app: {err}') - raise err from err + # this will terminate the textual app and return the exception + self.exit(err) # type: ignore[arg-type] @work async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: @@ -506,4 +1338,38 @@ async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: return await self._show_async(screen).wait() +class TApp: + app: _AppInstance[Any] | None = None + + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None + + @property + def global_header(self) -> str | None: + return self._global_header + + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value + + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() + + if isinstance(result, Exception): + raise result + + if result is None: + debug('App returned no result, assuming exit') + sys.exit(0) + + return result + + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return + + tui = TApp() diff --git a/archinstall/tui/ui/menu_item.py b/archinstall/tui/ui/menu_item.py new file mode 100644 index 0000000000..f630ea0ef7 --- /dev/null +++ b/archinstall/tui/ui/menu_item.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from functools import cached_property +from typing import Any, ClassVar, Self, override + +from archinstall.lib.translationhandler import tr + + +@dataclass +class MenuItem: + text: str + value: Any | None = None + action: Callable[[Any], Any] | None = None + enabled: bool = True + read_only: bool = False + mandatory: bool = False + dependencies: list[str | Callable[[], bool]] = field(default_factory=list) + dependencies_not: list[str] = field(default_factory=list) + display_action: Callable[[Any], str] | None = None + preview_action: Callable[[Self], str | None] | None = None + key: str | None = None + + _id: str = '' + + _yes: ClassVar[MenuItem | None] = None + _no: ClassVar[MenuItem | None] = None + + def __post_init__(self) -> None: + if self.key is not None: + self._id = self.key + else: + self._id = str(id(self)) + + @override + def __hash__(self) -> int: + return hash(self._id) + + def get_id(self) -> str: + return self._id + + def get_value(self) -> Any: + assert self.value is not None + return self.value + + @classmethod + def yes(cls, action: Callable[[Any], Any] | None = None) -> 'MenuItem': + if cls._yes is None: + cls._yes = cls(tr('Yes'), value=True, key='yes', action=action) + + return cls._yes + + @classmethod + def no(cls, action: Callable[[Any], Any] | None = None) -> 'MenuItem': + if cls._no is None: + cls._no = cls(tr('No'), value=False, key='no', action=action) + + return cls._no + + def is_empty(self) -> bool: + return self.text == '' or self.text is None + + 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: + return True + + def get_display_value(self) -> str | None: + if self.display_action is not None: + return self.display_action(self.value) + + return None + + +class MenuItemGroup: + def __init__( + self, + menu_items: list[MenuItem], + focus_item: MenuItem | None = None, + default_item: MenuItem | None = None, + sort_items: bool = False, + sort_case_sensitive: bool = True, + checkmarks: bool = False, + ) -> None: + if len(menu_items) < 1: + raise ValueError('Menu must have at least one item') + + if sort_items: + if sort_case_sensitive: + menu_items = sorted(menu_items, key=lambda x: x.text) + else: + menu_items = sorted(menu_items, key=lambda x: x.text.lower()) + + self._filter_pattern: str = '' + self._checkmarks: bool = checkmarks + + self._menu_items: list[MenuItem] = menu_items + self.focus_item: MenuItem | None = focus_item + self.selected_items: list[MenuItem] = [] + self.default_item: MenuItem | None = default_item + + if not focus_item: + self.focus_first() + + if self.focus_item not in self.items: + raise ValueError(f'Selected item not in menu: {self.focus_item}') + + @classmethod + def from_objects(cls, items: list[Any]) -> Self: + items = [MenuItem(str(id(item)), value=item) for item in items] + return cls(items) + + def add_item(self, item: MenuItem) -> None: + self._menu_items.append(item) + delattr(self, 'items') # resetting the cache + + def find_by_id(self, item_id: str) -> MenuItem: + for item in self._menu_items: + if item.get_id() == item_id: + return item + + raise ValueError(f'No item found for id: {item_id}') + + def find_by_key(self, key: str) -> MenuItem: + for item in self._menu_items: + if item.key == key: + return item + + raise ValueError(f'No item found for key: {key}') + + def get_enabled_items(self) -> list[MenuItem]: + return [it for it in self.items if self.is_enabled(it)] + + @staticmethod + def yes_no() -> 'MenuItemGroup': + return MenuItemGroup( + [MenuItem.yes(), MenuItem.no()], + sort_items=True, + ) + + @staticmethod + def from_enum( + enum_cls: type[Enum], + sort_items: bool = False, + preset: Enum | None = None, + ) -> 'MenuItemGroup': + items = [MenuItem(elem.value, value=elem) for elem in enum_cls] + group = MenuItemGroup(items, sort_items=sort_items) + + if preset is not None: + group.set_selected_by_value(preset) + + return group + + def set_preview_for_all(self, action: Callable[[Any], str | None]) -> 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: Any | list[Any] | None) -> 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 get_focused_index(self) -> int | None: + items = self.get_enabled_items() + + if self.focus_item and items: + try: + return items.index(self.focus_item) + except ValueError: + # on large menus (15k+) when filtering very quickly + # the index search is too slow while the items are reduced + # by the filter and it will blow up as it cannot find the + # focus item + pass + + return None + + @cached_property + def _max_items_text_width(self) -> int: + return max([len(item.text) for item in self._menu_items]) + + def _default_suffix(self, item: MenuItem) -> str: + if self.default_item == item: + return tr(' (default)') + return '' + + def set_action_for_all(self, action: Callable[[Any], Any]) -> None: + for item in self.items: + item.action = action + + @cached_property + def items(self) -> list[MenuItem]: + pattern = self._filter_pattern.lower() + items = filter(lambda item: item.is_empty() or pattern in item.text.lower(), self._menu_items) + l_items = sorted(items, key=self._items_score) + return l_items + + def _items_score(self, item: MenuItem) -> int: + pattern = self._filter_pattern.lower() + if item.text.lower().startswith(pattern): + return 0 + return 1 + + def set_filter_pattern(self, pattern: str) -> None: + self._filter_pattern = pattern + delattr(self, 'items') # resetting the cache + self.focus_first() + + def focus_index(self, index: int) -> None: + enabled = self.get_enabled_items() + self.focus_item = enabled[index] + + def focus_first(self) -> None: + if len(self.items) == 0: + return + + first_item: MenuItem | None = self.items[0] + + if first_item and not self._is_selectable(first_item): + first_item = self._find_next_selectable_item(self.items, first_item, 1) + + if first_item is not None: + self.focus_item = first_item + + def focus_last(self) -> None: + if len(self.items) == 0: + return + + last_item: MenuItem | None = self.items[-1] + + if last_item and not self._is_selectable(last_item): + last_item = self._find_next_selectable_item(self.items, last_item, -1) + + if last_item is not None: + self.focus_item = last_item + + def focus_prev(self, skip_empty: bool = True) -> None: + # e.g. when filter shows no items + if self.focus_item is None: + return + + item = self._find_next_selectable_item(self.items, self.focus_item, -1) + + if item is not None: + self.focus_item = item + + def focus_next(self, skip_not_enabled: bool = True) -> None: + # e.g. when filter shows no items + if self.focus_item is None: + return + + item = self._find_next_selectable_item(self.items, self.focus_item, 1) + + if item is not None: + self.focus_item = item + + def _find_next_selectable_item( + self, + items: list[MenuItem], + start_item: MenuItem, + direction: int, + ) -> MenuItem | None: + start_index = self.items.index(start_item) + n = len(items) + + current_index = start_index + for _ in range(n): + current_index = (current_index + direction) % n + + if self._is_selectable(items[current_index]): + return items[current_index] + + return None + + def max_item_width(self) -> int: + spaces = [len(str(it.text)) for it in self.items] + if spaces: + return max(spaces) + return 0 + + def _is_selectable(self, item: MenuItem) -> bool: + if item.is_empty(): + return False + elif item.read_only: + return False + + return self.is_enabled(item) + + def is_enabled(self, item: MenuItem) -> bool: + if not item.enabled: + return False + + for dep in item.dependencies: + if isinstance(dep, str): + item = self.find_by_key(dep) + if not item.value or not self.is_enabled(item): + return False + else: + return dep() + + for dep_not in item.dependencies_not: + item = self.find_by_key(dep_not) + if item.value is not None: + return False + + return True diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index c4e92468a5..0d89f23c64 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from enum import Enum, auto -from typing import cast +from typing import Self, cast + +from archinstall.tui.ui.menu_item import MenuItem class ResultType(Enum): @@ -12,15 +14,46 @@ class ResultType(Enum): @dataclass class Result[ValueT]: type_: ResultType - _data: ValueT | list[ValueT] | None + _data: ValueT | list[ValueT] | None = None + _item: MenuItem | list[MenuItem] | None = None + + @classmethod + def true(cls) -> Self: + return cls(ResultType.Selection, _data=True) # type: ignore[arg-type] + + @classmethod + def false(cls) -> Self: + return cls(ResultType.Selection, _data=False) # type: ignore[arg-type] def has_data(self) -> bool: return self._data is not None - def value(self) -> ValueT: - assert type(self._data) is not list and self._data is not None - return cast(ValueT, self._data) + def has_value(self) -> bool: + return self._item is not None + + def item(self) -> MenuItem: + if isinstance(self._item, list) or self._item is None: + raise ValueError('Invalid item type') + return self._item + + def items(self) -> list[MenuItem]: + if isinstance(self._item, list): + return self._item + + raise ValueError('Invalid item type') + + def get_value(self) -> ValueT: + if self._item is not None: + return self.item().get_value() # type: ignore[no-any-return] + + if type(self._data) is not list and self._data is not None: + return cast(ValueT, self._data) + + raise ValueError('No value found') + + def get_values(self) -> list[ValueT]: + if self._item is not None: + return [i.get_value() for i in self.items()] - def values(self) -> list[ValueT]: assert type(self._data) is list return cast(list[ValueT], self._data) diff --git a/examples/mac_address_installation.py b/examples/mac_address_installation.py deleted file mode 100644 index 1c2a3ecb54..0000000000 --- a/examples/mac_address_installation.py +++ /dev/null @@ -1,20 +0,0 @@ -import time - -from archinstall.lib.output import info -from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.lib.storage import storage -from archinstall.tui import Tui - -for _profile in profile_handler.get_mac_addr_profiles(): - # Tailored means it's a match for this machine - # based on its MAC address (or some other criteria - # that fits the requirements for this machine specifically). - info(f'Found a tailored profile for this machine called: "{_profile.name}"') - - print('Starting install in:') - for i in range(10, 0, -1): - Tui.print(f'{i}...') - time.sleep(1) - - install_session = storage['installation_session'] - _profile.install(install_session)