Migrate UI to textual (#3997)

* Footer textual

* Linting

* Revert pre-commit for pylint

* Add missing textual

* Reinstate example

* Linting
This commit is contained in:
Daniel Girtler 2026-01-19 07:01:40 +11:00 committed by GitHub
parent 9a38b73baf
commit 0aca992ac5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 2363 additions and 1190 deletions

View File

@ -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

View File

@ -24,7 +24,7 @@ jobs:
pacman-key --init
pacman --noconfirm -Sy archlinux-keyring
pacman --noconfirm -Syyu
pacman --noconfirm -Sy python python-uv python-setuptools python-pip python-pyparted python-pydantic
pacman --noconfirm -Sy python python-uv python-setuptools python-pip python-pyparted python-pydantic python-textual
- name: Build archinstall
run: |
uv build --no-build-isolation --wheel

View File

@ -43,9 +43,12 @@ repos:
- pytest
- cryptography
- textual
- repo: https://github.com/pycqa/pylint
rev: v4.0.4
- repo: local
hooks:
- id: pylint
fail_fast: true
require_serial: true
- id: pylint
name: pylint
entry: pylint
language: system
types: [python]
fail_fast: true
require_serial: true

View File

@ -8,18 +8,16 @@ import traceback
from archinstall.lib.args import arch_config_handler
from archinstall.lib.disk.utils import disk_layouts
from archinstall.lib.general import running_from_host
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.lib.packages.packages import check_version_upgrade
from .lib.general import running_from_host
from .lib.hardware import SysInfo
from .lib.output import FormattedOutput, debug, error, info, log, warn
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
@ -66,21 +64,6 @@ def _fetch_arch_db() -> None:
sys.exit(1)
def check_version_upgrade() -> str | None:
info('Checking version...')
upgrade = None
upgrade = check_package_upgrade('archinstall')
if upgrade is None:
debug('No archinstall upgrades found')
return None
text = tr('New version available') + f': {upgrade}'
info(text)
return text
def main() -> int:
"""
This can either be run as the compiled and installed application: python setup.py install
@ -97,18 +80,16 @@ def main() -> int:
_log_sys_info()
ttui.global_header = 'Archinstall'
if not arch_config_handler.args.offline:
_check_online()
_fetch_arch_db()
if not arch_config_handler.args.skip_version_check:
new_version = check_version_upgrade()
upgrade = check_version_upgrade()
if new_version:
ttui.global_header = f'{ttui.global_header} {new_version}'
info(new_version)
if upgrade:
text = tr('New version available') + f': {upgrade}'
info(text)
time.sleep(3)
if running_from_host():
@ -135,9 +116,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 +137,6 @@ __all__ = [
'Language',
'Pacman',
'SysInfo',
'Tui',
'arch_config_handler',
'debug',
'disk_layouts',

View File

@ -3,12 +3,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Self, 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
@ -62,7 +61,7 @@ class DesktopProfile(Profile):
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()
]
@ -70,15 +69,13 @@ class DesktopProfile(Profile):
group = MenuItemGroup(items, sort_items=True, sort_case_sensitive=False)
group.set_selected_by_value(self.current_selection)
result = SelectMenu[Self](
result = Selection[Self](
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:

View File

@ -3,11 +3,10 @@ from typing import override
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 @@ class HyprlandProfile(XorgProfile):
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

View File

@ -3,11 +3,10 @@ from typing import override
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 @@ class LabwcProfile(XorgProfile):
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

View File

@ -3,11 +3,10 @@ from typing import override
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 @@ class NiriProfile(XorgProfile):
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

View File

@ -3,11 +3,10 @@ from typing import override
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 @@ class SwayProfile(XorgProfile):
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

View File

@ -3,12 +3,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Self, 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
@ -28,7 +27,7 @@ class ServerProfile(Profile):
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()
]
@ -36,15 +35,13 @@ class ServerProfile(Profile):
group = MenuItemGroup(items, sort_items=True)
group.set_selected_by_value(self.current_selection)
result = SelectMenu[Self](
result = Selection[Self](
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:

View File

@ -2,6 +2,7 @@ from typing import override
from archinstall.lib.hardware import SysInfo
from archinstall.lib.menu.abstract_menu import AbstractSubMenu
from archinstall.lib.menu.helpers import Confirmation, Selection
from archinstall.lib.models.application import (
ApplicationConfiguration,
Audio,
@ -14,10 +15,8 @@ from archinstall.lib.models.application import (
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]):
@ -40,8 +39,8 @@ class ApplicationMenu(AbstractSubMenu[ApplicationConfiguration]):
)
@override
def run(self, additional_title: str | None = None) -> ApplicationConfiguration:
super().run(additional_title=additional_title)
def run(self) -> ApplicationConfiguration:
super().run()
return self._app_config
def _define_menu_options(self) -> list[MenuItem]:
@ -123,13 +122,11 @@ def select_power_management(preset: PowerManagementConfiguration | None = None)
if preset:
group.set_focus_by_value(preset.power_management)
result = SelectMenu[PowerManagement](
result = Selection[PowerManagement](
group,
allow_skip=True,
alignment=Alignment.CENTER,
allow_reset=True,
frame=FrameProperties.min(tr('Power management')),
).run()
).show()
match result.type_:
case ResultType.Skip:
@ -141,27 +138,18 @@ def select_power_management(preset: PowerManagementConfiguration | None = 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 _:
@ -169,27 +157,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 _:
@ -203,12 +183,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:
@ -225,13 +204,11 @@ def select_firewall(preset: FirewallConfiguration | None = None) -> FirewallConf
if preset:
group.set_focus_by_value(preset.firewall)
result = SelectMenu[Firewall](
result = Selection[Firewall](
group,
allow_skip=True,
alignment=Alignment.CENTER,
allow_reset=True,
frame=FrameProperties.min(tr('Firewall')),
).run()
).show()
match result.type_:
case ResultType.Skip:

View File

@ -28,7 +28,6 @@ from archinstall.lib.output import debug, error, logger, warn
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
@ -499,31 +498,30 @@ class ArchConfigHandler:
raise err from err
else:
incorrect_password = False
header = tr('Enter credentials file decryption password')
with Tui():
while True:
header = tr('Incorrect password') if incorrect_password else None
while True:
prompt = f'{header}\n\n' + tr('Incorrect password') if incorrect_password else ''
decryption_pwd = get_password(
text=tr('Credentials file decryption password'),
header=header,
allow_skip=False,
skip_confirmation=True,
)
decryption_pwd = get_password(
header=prompt,
allow_skip=False,
skip_confirmation=True,
)
if not decryption_pwd:
return None
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
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)

View File

@ -7,9 +7,8 @@ from typing import TYPE_CHECKING
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
@ -84,7 +83,7 @@ class AuthenticationHandler:
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'
@ -94,9 +93,9 @@ class AuthenticationHandler:
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}']

View File

@ -3,15 +3,14 @@ from typing import override
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 @@ class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]):
)
@override
def run(self, additional_title: str | None = None) -> AuthenticationConfiguration:
super().run(additional_title=additional_title)
return self._auth_config
def run(self) -> AuthenticationConfiguration | None:
return super().run()
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 @@ class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]):
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')

View File

@ -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
@ -87,11 +86,8 @@ class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]):
return tr('Will install to custom location with NVRAM entry')
@override
def run(
self,
additional_title: str | None = None,
) -> BootloaderConfiguration:
super().run(additional_title=additional_title)
def run(self) -> BootloaderConfiguration:
super().run()
return self._bootloader_conf
def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None:
@ -124,17 +120,7 @@ class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]):
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:
@ -151,7 +137,7 @@ class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]):
+ tr('This installs the bootloader to /EFI/BOOT/BOOTX64.EFI (or similar) which is useful for:')
+ '\n\n'
+ tr('Firmware that does not properly support NVRAM boot entries like most MSI motherboards,')
+ '\n '
+ '\n '
+ tr('most Apple Macs, many laptops...')
+ '\n'
+ tr('USB drives or other portable external media.')
@ -176,23 +162,17 @@ class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]):
+ '\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 +181,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 +192,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 +203,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:

View File

@ -3,11 +3,10 @@ import readline
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 @@ class ConfigurationOutput:
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())
group = MenuItemGroup.yes_no()
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()
result = Confirmation(
group=group,
header=header,
allow_skip=False,
preset=True,
preview_location='bottom',
preview_header=tr('Configuration preview'),
).show()
if result.item() != MenuItem.yes():
return False
if not result.get_value():
return False
return True
@ -160,13 +154,11 @@ def save_config(config: ArchConfig) -> 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 save_config(config: ArchConfig) -> 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 save_config(config: ArchConfig) -> 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 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
if password:
enc_password = password.plaintext
match save_option:
case 'user_config':

View File

@ -2,6 +2,7 @@ from dataclasses import dataclass
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 @@ from archinstall.lib.models.device import (
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 @@ class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]):
]
@override
def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None:
super().run(additional_title=additional_title)
def run(self) -> DiskLayoutConfiguration | None: # type: ignore[override]
config: DiskMenuConfig | None = super().run()
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 @@ class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]):
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 @@ class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]):
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:

View File

@ -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 @@ from archinstall.lib.models.device import (
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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',
),
]
@ -123,8 +122,10 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
return False
@override
def run(self, additional_title: str | None = None) -> DiskEncryption | None:
super().run(additional_title=additional_title)
def run(self) -> DiskEncryption | None:
enc_config = super().run()
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,7 +149,7 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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,
)
@ -157,22 +158,22 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
def _preview(self, item: MenuItem) -> str | None:
output = ''
if (enc_type := self._prev_type()) is not None:
if (enc_type := self._prev_type(item)) is not None:
output += enc_type
if (enc_pwd := self._prev_password()) is not None:
if (enc_pwd := self._prev_password(item)) is not None:
output += f'\n{enc_pwd}'
if (iter_time := self._prev_iter_time()) is not None:
if (iter_time := self._prev_iter_time(item)) is not None:
output += f'\n{iter_time}'
if (fido_device := self._prev_hsm()) is not None:
if (fido_device := self._prev_hsm(item)) is not None:
output += f'\n{fido_device}'
if (partitions := self._prev_partitions()) is not None:
if (partitions := self._prev_partitions(item)) is not None:
output += f'\n\n{partitions}'
if (lvm := self._prev_lvm_vols()) is not None:
if (lvm := self._prev_lvm_vols(item)) is not None:
output += f'\n\n{lvm}'
if not output:
@ -180,7 +181,7 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
return output
def _prev_type(self) -> str | None:
def _prev_type(self, item: MenuItem) -> str | None:
enc_type = self._item_group.find_by_key('encryption_type').value
if enc_type:
@ -189,56 +190,50 @@ class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]):
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 +253,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 +272,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 +290,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 +321,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 +350,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 +377,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 +388,19 @@ def select_iteration_time(preset: int | None = None) -> int | 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

View File

@ -3,7 +3,6 @@ import time
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
@ -328,15 +327,14 @@ class FilesystemHandler:
# Issue a final warning before we continue with something un-revertable.
# We count down 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

View File

@ -2,6 +2,7 @@ import re
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,
@ -16,10 +17,8 @@ from archinstall.lib.models.device import (
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
@ -236,7 +235,7 @@ class PartitioningList(ListManager[DiskSegment]):
# 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'],
@ -402,10 +401,10 @@ class PartitioningList(ListManager[DiskSegment]):
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
@ -415,13 +414,11 @@ class PartitioningList(ListManager[DiskSegment]):
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:
@ -483,18 +480,16 @@ class PartitioningList(ListManager[DiskSegment]):
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
@ -502,12 +497,14 @@ class PartitioningList(ListManager[DiskSegment]):
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
@ -544,15 +541,11 @@ class PartitioningList(ListManager[DiskSegment]):
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()

View File

@ -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 @@ class SubvolumeMenu(ListManager[SubvolumeModification]):
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 @@ class SubvolumeMenu(ListManager[SubvolumeModification]):
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 @@ class SubvolumeMenu(ListManager[SubvolumeModification]):
# 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

View File

@ -287,9 +287,9 @@ class SysCommandWorker:
os.chdir(str(self.working_directory))
# Note: If for any reason, we get a Python exception between here
# and until os.close(), the traceback will get locked inside
# stdout of the child_fd object. `os.read(self.child_fd, 8192)` is the
# only way to get the traceback without losing it.
# and until os.close(), the traceback will get locked inside
# stdout of the child_fd object. `os.read(self.child_fd, 8192)` is the
# only way to get the traceback without losing it.
self.pid, self.child_fd = pty.fork()

View File

@ -6,7 +6,7 @@ from archinstall.lib.models.application import ApplicationConfiguration, ZramCon
from archinstall.lib.models.authentication import AuthenticationConfiguration
from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, 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
@ -38,7 +38,7 @@ from .translationhandler import Language, tr, translation_handler
class GlobalMenu(AbstractMenu[None]):
def __init__(self, arch_config: ArchConfig) -> None:
def __init__(self, arch_config: ArchConfig, title: str | None = None) -> None:
self._arch_config = arch_config
menu_options = self._get_menu_options()
@ -48,7 +48,7 @@ class GlobalMenu(AbstractMenu[None]):
checkmarks=True,
)
super().__init__(self._item_group, config=arch_config)
super().__init__(self._item_group, config=arch_config, title=title)
def _get_menu_options(self) -> list[MenuItem]:
menu_options = [
@ -60,6 +60,7 @@ class GlobalMenu(AbstractMenu[None]):
),
MenuItem(
text=tr('Locales'),
value=LocaleConfiguration.default(),
action=self._locale_selection,
preview_action=self._prev_locale,
key='locale_config',
@ -135,7 +136,7 @@ class GlobalMenu(AbstractMenu[None]):
MenuItem(
text=tr('Parallel Downloads'),
action=add_number_of_parallel_downloads,
value=0,
value=1,
preview_action=self._prev_parallel_dw,
key='parallel_downloads',
),
@ -162,6 +163,7 @@ class GlobalMenu(AbstractMenu[None]):
),
MenuItem(
text='',
read_only=True,
),
MenuItem(
text=tr('Save configuration'),
@ -185,8 +187,8 @@ class GlobalMenu(AbstractMenu[None]):
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)
@ -547,10 +549,10 @@ class GlobalMenu(AbstractMenu[None]):
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()

View File

@ -33,7 +33,6 @@ from archinstall.lib.models.device import (
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 .boot import Boot
@ -132,8 +131,8 @@ class Installer:
# We avoid printing /mnt/<log path> 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
@ -206,7 +205,7 @@ class Installer:
# 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.'))
@ -1100,7 +1099,7 @@ class Installer:
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}')
@ -1216,9 +1215,9 @@ class Installer:
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))}
""",
)

View File

@ -3,7 +3,7 @@ from pathlib import Path
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.models.device import (
)
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 @@ from ..utils.util import prompt_dir
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 select_devices(preset: list[BDevice] | None = []) -> list[BDevice]:
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 select_devices(preset: list[BDevice] | None = []) -> list[BDevice]:
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()

View File

@ -1,19 +1,17 @@
import sys
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
@ -32,18 +30,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:
@ -55,18 +46,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('Enter a 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
@ -83,13 +73,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:
@ -126,14 +116,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:
@ -152,11 +140,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)
@ -176,7 +176,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()
]
@ -185,7 +185,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()
]
@ -193,65 +193,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')
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 | None) -> str | None:
if s is not None:
try:
value = int(s)
if value >= 0:
return None
except Exception:
pass
def validator(s: str) -> str | None:
try:
value = int(s)
return tr('Invalid download number')
if 1 <= value <= max_recommended:
return None
result = EditMenu(
tr('Number downloads'),
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:
@ -278,12 +274,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:
@ -294,16 +289,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():
sys.exit(0)

View File

@ -1,11 +1,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
@ -43,7 +42,8 @@ class UserList(ListManager[User]):
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))
@ -63,17 +63,17 @@ class UserList(ListManager[User]):
return tr('The username you entered is invalid')
def _add_user(self) -> User | None:
editResult = EditMenu(
tr('Username'),
editResult = Input(
tr('Enter a 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')
@ -81,27 +81,21 @@ class UserList(ListManager[User]):
return None
header = f'{tr("Username")}: {username}\n'
prompt = f'{header}\n' + tr('Enter a 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:

View File

@ -1,11 +1,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
@ -62,12 +61,11 @@ class ManualNetworkConfig(ListManager[Nic]):
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:
@ -77,18 +75,13 @@ class ManualNetworkConfig(ListManager[Nic]):
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:
@ -103,19 +96,18 @@ class ManualNetworkConfig(ListManager[Nic]):
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')
@ -124,18 +116,17 @@ class ManualNetworkConfig(ListManager[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:
@ -149,10 +140,10 @@ class ManualNetworkConfig(ListManager[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)
@ -160,13 +151,7 @@ class ManualNetworkConfig(ListManager[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:
@ -189,13 +174,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:

View File

@ -1,11 +1,10 @@
from typing import assert_never
from archinstall.lib.menu.helpers import Confirmation, Selection
from archinstall.lib.models.application import ZramAlgorithm, ZramConfiguration
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
@ -27,14 +26,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:
@ -45,6 +43,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.
@ -56,7 +68,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)
@ -71,15 +91,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:
@ -97,14 +115,11 @@ def ask_for_swap(preset: ZramConfiguration = ZramConfiguration(enabled=True)) ->
group.set_default_by_value(True)
group.set_focus_by_value(preset.enabled)
result = SelectMenu[bool](
group,
result = Confirmation(
header=prompt,
columns=2,
orientation=Orientation.HORIZONTAL,
alignment=Alignment.CENTER,
allow_skip=True,
).run()
preset=preset.enabled,
).show()
match result.type_:
case ResultType.Skip:
@ -119,12 +134,11 @@ def ask_for_swap(preset: ZramConfiguration = ZramConfiguration(enabled=True)) ->
algo_group.set_default_by_value(ZramAlgorithm.ZSTD)
algo_group.set_focus_by_value(preset.algorithm)
algo_result = SelectMenu[ZramAlgorithm](
algo_result = Selection[ZramAlgorithm](
algo_group,
header=tr('Select zram compression algorithm:') + '\n',
alignment=Alignment.CENTER,
allow_skip=True,
).run()
).show()
match algo_result.type_:
case ResultType.Skip:

View File

@ -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 @@ class LocaleMenu(AbstractSubMenu[LocaleConfiguration]):
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) -> LocaleConfiguration:
config = super().run()
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:

View File

@ -1,11 +1,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
@ -17,6 +17,7 @@ class AbstractMenu[ValueT]:
self,
item_group: MenuItemGroup,
config: Any,
title: str | None = None,
auto_cursor: bool = True,
allow_reset: bool = False,
reset_warning: str | None = None,
@ -26,6 +27,7 @@ class AbstractMenu[ValueT]:
self.auto_cursor = auto_cursor
self._allow_reset = allow_reset
self._reset_warning = reset_warning
self._title = title
self.is_context_mgr = False
@ -40,7 +42,7 @@ class AbstractMenu[ValueT]:
# 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
@ -92,37 +94,36 @@ class AbstractMenu[ValueT]:
def _is_config_valid(self) -> bool:
return True
def run(
self,
additional_title: str | None = None,
) -> ValueT | None:
def run(self) -> ValueT | None:
self._sync_from_config()
while True:
result = SelectMenu[ValueT](
self._menu_item_group,
result = Selection[ValueT](
title=self._title,
group=self._menu_item_group,
allow_skip=False,
allow_reset=self._allow_reset,
reset_warning_msg=self._reset_warning,
preview_style=PreviewStyle.RIGHT,
preview_size='auto',
preview_frame=FrameProperties('Info', FrameStyle.MAX),
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]):

View File

@ -0,0 +1,288 @@
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 (
InputScreen,
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,
title: 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,
):
self._header = header
self._title = title
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
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:
result = await OptionListScreen[ValueT](
self._group,
header=self._header,
title=self._title,
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 OptionListScreen[bool](
self._group,
header=self._header,
allow_skip=self._allow_skip,
allow_reset=self._allow_reset,
preview_location=self._preview_location,
enable_filter=False,
).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 OptionListScreen[bool](
MenuItemGroup.yes_no(),
header=tr('Are you sure you want to reset this setting?'),
allow_skip=False,
allow_reset=False,
).run()

View File

@ -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 @@ class ListManager[ValueT]:
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 @@ class ListManager[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 @@ class ListManager[ValueT]:
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:

View File

@ -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]:

View File

@ -3,11 +3,10 @@ import urllib.parse
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 @@ class CustomMirrorRepositoriesList(ListManager[CustomRepository]):
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 @@ class CustomMirrorRepositoriesList(ListManager[CustomRepository]):
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 @@ class CustomMirrorRepositoriesList(ListManager[CustomRepository]):
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 @@ class CustomMirrorServersList(ListManager[CustomServer]):
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 @@ class MirrorMenu(AbstractSubMenu[MirrorConfiguration]):
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) -> MirrorConfiguration | None:
return super().run()
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:

View File

@ -582,6 +582,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 {
@ -1054,10 +1058,6 @@ class LvmLayoutType(Enum):
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):

View File

@ -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.models.network import WifiConfiguredNetwork, WifiNetwork
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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 @@ class WifiHandler:
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}')

View File

@ -332,6 +332,4 @@ def log(
Journald.log(text, level=level)
if level != logging.DEBUG:
from archinstall.tui.curses_menu import Tui
Tui.print(text)
print(text)

View File

@ -16,6 +16,24 @@ BASE_URL_PKG_SEARCH = 'https://archlinux.org/packages/search/json/'
BASE_GROUP_URL = 'https://archlinux.org/groups/search/json/'
# TODO: This shouldn't be living in here but there are too many
# circular dependecies so they will need to be cleanup up first
@lru_cache(maxsize=128)
def check_version_upgrade() -> str | None:
debug('Checking version')
upgrade = None
upgrade = check_package_upgrade('archinstall')
if upgrade is None:
debug('No archinstall upgrades found')
return None
debug(f'Archinstall latest: {upgrade}')
return upgrade
def _make_request(url: str, params: dict[str, str]) -> addinfourl:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False

View File

@ -1,11 +1,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
@ -62,9 +61,8 @@ class ProfileMenu(AbstractSubMenu[ProfileConfiguration]):
]
@override
def run(self, additional_title: str | None = None) -> ProfileConfiguration | None:
super().run(additional_title=additional_title)
return self._profile_config
def run(self) -> ProfileConfiguration | None:
return super().run()
def _select_profile(self, preset: Profile | None) -> Profile | None:
profile = select_profile(preset)
@ -102,20 +100,13 @@ class ProfileMenu(AbstractSubMenu[ProfileConfiguration]):
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
@ -167,12 +158,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:
@ -195,20 +185,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:

View File

@ -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 prompt_dir(
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:

View File

@ -20,10 +20,9 @@ from archinstall.lib.models.device import (
)
from archinstall.lib.models.users import User
from archinstall.lib.output import debug, error, info
from archinstall.lib.packages.packages import check_package_upgrade
from archinstall.lib.packages.packages import check_version_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:
@ -33,20 +32,19 @@ def ask_user_questions() -> None:
will we continue with the actual installation steps.
"""
title_text = None
upgrade = check_version_upgrade()
title_text = 'Archlinux'
upgrade = check_package_upgrade('archinstall')
if upgrade:
text = tr('New version available') + f': {upgrade}'
title_text = f' ({text})'
title_text += f' ({text})'
with Tui():
global_menu = GlobalMenu(arch_config_handler.config)
global_menu = GlobalMenu(arch_config_handler.config, title=title_text)
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()
def perform_installation(mountpoint: Path) -> None:
@ -170,9 +168,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:
@ -199,10 +196,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()

View File

@ -12,7 +12,6 @@ from archinstall.lib.models.profile import ProfileConfiguration
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:
@ -61,9 +60,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()
@ -74,10 +72,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()

View File

@ -8,20 +8,18 @@ from archinstall.lib.disk.filesystem import FilesystemHandler
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:
@ -70,10 +68,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()

View File

@ -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',
]

View File

@ -90,7 +90,7 @@ class AbstractCurses[ValueT](metaclass=ABCMeta):
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 @@ class AbstractViewport:
# 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 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]):
def _multi_prefix(self, item: MenuItem) -> str:
if item.read_only:
return ' '
return ' '
elif self._item_group.is_item_selected(item):
return '[x] '
else:

View File

@ -2,7 +2,7 @@ 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
from typing import Any, ClassVar, Self, override
from archinstall.lib.translationhandler import tr
@ -20,12 +20,26 @@ 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[Self | None] = None
_no: ClassVar[Self | 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
@ -95,18 +109,25 @@ class MenuItemGroup:
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)]
@ -163,6 +184,21 @@ class MenuItemGroup:
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:

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,336 @@
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[Self | None] = None
_no: ClassVar[Self | 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) -> Self:
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) -> Self:
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)]
@classmethod
def yes_no(cls) -> Self:
return cls(
[MenuItem.yes(), MenuItem.no()],
sort_items=True,
)
@classmethod
def from_enum(
cls,
enum_cls: type[Enum],
sort_items: bool = False,
preset: Enum | None = None,
) -> Self:
items = [MenuItem(elem.value, value=elem) for elem in enum_cls]
group = cls(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

View File

@ -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)