From ed1d8853513b4589d8c2650075241de58b15014f Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 3 May 2026 10:38:59 +1000 Subject: [PATCH 1/2] Pull the UI module one level up --- archinstall/default_profiles/desktop.py | 4 +- .../default_profiles/desktops/hyprland.py | 4 +- .../default_profiles/desktops/labwc.py | 4 +- archinstall/default_profiles/desktops/niri.py | 4 +- .../default_profiles/desktops/plasma.py | 4 +- archinstall/default_profiles/desktops/sway.py | 4 +- archinstall/default_profiles/server.py | 4 +- .../lib/applications/application_menu.py | 4 +- archinstall/lib/args.py | 2 +- .../lib/authentication/authentication_menu.py | 4 +- archinstall/lib/bootloader/bootloader_menu.py | 4 +- archinstall/lib/configuration.py | 4 +- archinstall/lib/disk/disk_menu.py | 4 +- archinstall/lib/disk/encryption_menu.py | 4 +- archinstall/lib/disk/partitioning_menu.py | 4 +- archinstall/lib/disk/subvolume_menu.py | 2 +- archinstall/lib/general/general_menu.py | 4 +- archinstall/lib/general/system_menu.py | 4 +- archinstall/lib/global_menu.py | 4 +- archinstall/lib/locale/locale_menu.py | 4 +- archinstall/lib/menu/abstract_menu.py | 6 +- archinstall/lib/menu/helpers.py | 6 +- archinstall/lib/menu/list_manager.py | 4 +- archinstall/lib/menu/menu_helper.py | 2 +- archinstall/lib/menu/util.py | 4 +- archinstall/lib/mirror/mirror_menu.py | 4 +- archinstall/lib/network/network_menu.py | 4 +- archinstall/lib/network/wifi_handler.py | 6 +- archinstall/lib/packages/packages.py | 4 +- archinstall/lib/pacman/pacman_menu.py | 4 +- archinstall/lib/profile/profile_menu.py | 4 +- archinstall/lib/user/user_menu.py | 4 +- archinstall/main.py | 2 +- archinstall/scripts/guided.py | 2 +- archinstall/scripts/minimal.py | 2 +- archinstall/scripts/only_hd.py | 2 +- archinstall/tui/ui/__init__.py | 0 archinstall/tui/ui/components.py | 1347 ----------------- archinstall/tui/ui/menu_item.py | 334 ---- archinstall/tui/ui/result.py | 71 - 40 files changed, 68 insertions(+), 1820 deletions(-) delete mode 100644 archinstall/tui/ui/__init__.py delete mode 100644 archinstall/tui/ui/components.py delete mode 100644 archinstall/tui/ui/menu_item.py delete mode 100644 archinstall/tui/ui/result.py diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 7ffe31a8b0..19681bc7de 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -4,8 +4,8 @@ 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.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index fa0bc5810b..533afce4b6 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -4,8 +4,8 @@ from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class HyprlandProfile(Profile): diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index bf4a32975c..49e8c437fe 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -4,8 +4,8 @@ from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class LabwcProfile(Profile): diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index 347290fed0..8bfda79780 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -4,8 +4,8 @@ from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class NiriProfile(Profile): diff --git a/archinstall/default_profiles/desktops/plasma.py b/archinstall/default_profiles/desktops/plasma.py index e90f8a27e2..66a5ae9a69 100644 --- a/archinstall/default_profiles/desktops/plasma.py +++ b/archinstall/default_profiles/desktops/plasma.py @@ -5,8 +5,8 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.packages.packages import available_package, package_group_info from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class PlasmaFlavor(StrEnum): diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index 5d22230762..d137d03bb7 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -4,8 +4,8 @@ from archinstall.default_profiles.profile import CustomSetting, DisplayServerType, GreeterType, Profile, ProfileType from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class SwayProfile(Profile): diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index 6833731fd2..bdd3d0793c 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -4,8 +4,8 @@ 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.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index 990dcda7cc..99e4872692 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -17,8 +17,8 @@ PrintServiceConfiguration, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class ApplicationMenu(AbstractSubMenu[ApplicationConfiguration]): diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 77545736e0..8d78f7e6d8 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -30,7 +30,7 @@ from archinstall.lib.plugins import load_plugin from archinstall.lib.translationhandler import Language, tr, translation_handler from archinstall.lib.version import get_version -from archinstall.tui.ui.components import tui +from archinstall.tui.components import tui @p_dataclass diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py index 6bead8ca06..5453099fdd 100644 --- a/archinstall/lib/authentication/authentication_menu.py +++ b/archinstall/lib/authentication/authentication_menu.py @@ -9,8 +9,8 @@ from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr from archinstall.lib.user.user_menu import select_users -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]): diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 37eaeb8d21..d32a5a2132 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -5,8 +5,8 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class BootloaderMenu(AbstractSubMenu[BootloaderConfiguration]): diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 3e9135bfb0..aba835d983 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -14,8 +14,8 @@ from archinstall.lib.models.network import NetworkConfiguration from archinstall.lib.output import debug, logger, warn from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class ConfigurationOutput: diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 9743ee783f..26aa145d38 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -37,8 +37,8 @@ ) from archinstall.lib.output import FormattedOutput, debug from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType @dataclass diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index d66ccf92e9..f5d4f0953f 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -19,8 +19,8 @@ from archinstall.lib.models.users import Password from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class DiskEncryptionMenu(AbstractSubMenu[DiskEncryption]): diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 7e07d776cd..a524cf995d 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -21,8 +21,8 @@ ) from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class FreeSpace: diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index 0f8da9e798..94854b3c93 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -6,7 +6,7 @@ from archinstall.lib.menu.util import prompt_dir from archinstall.lib.models.device import SubvolumeModification from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.result import ResultType +from archinstall.tui.result import ResultType class SubvolumeMenu(ListManager[SubvolumeModification]): diff --git a/archinstall/lib/general/general_menu.py b/archinstall/lib/general/general_menu.py index 257b61e5d4..be5498d835 100644 --- a/archinstall/lib/general/general_menu.py +++ b/archinstall/lib/general/general_menu.py @@ -4,8 +4,8 @@ from archinstall.lib.menu.helpers import Confirmation, Input, Selection from archinstall.lib.output import warn from archinstall.lib.translationhandler import Language, tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class PostInstallationAction(Enum): diff --git a/archinstall/lib/general/system_menu.py b/archinstall/lib/general/system_menu.py index 7fa0d1f2e6..82dc4cdb70 100644 --- a/archinstall/lib/general/system_menu.py +++ b/archinstall/lib/general/system_menu.py @@ -5,8 +5,8 @@ from archinstall.lib.models.application import ZramAlgorithm, ZramConfiguration from archinstall.lib.models.package_types import DEFAULT_KERNEL, Kernel from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType async def select_kernel(preset: list[Kernel] = []) -> list[Kernel]: diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index cdd820a943..eac936bdd0 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -32,8 +32,8 @@ from archinstall.lib.pacman.config import PacmanConfig from archinstall.lib.pacman.pacman_menu import PacmanMenu from archinstall.lib.translationhandler import Language, tr, translation_handler -from archinstall.tui.ui.components import tui -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.components import tui +from archinstall.tui.menu_item import MenuItem, MenuItemGroup class GlobalMenu(AbstractMenu[None]): diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index dc743fd358..add9fc2d94 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -5,8 +5,8 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.models.locale import LocaleConfiguration from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class LocaleMenu(AbstractSubMenu[LocaleConfiguration]): diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 8e9cbed7cd..590cabf1df 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -5,9 +5,9 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.output import error from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import InstanceRunnable -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.components import InstanceRunnable +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType CONFIG_KEY = '__config__' diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 9636176598..b512c95625 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -4,9 +4,9 @@ from textual.validation import ValidationResult, Validator from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import InputInfo, InputScreen, LoadingScreen, NotifyScreen, OptionListScreen, SelectListScreen, TableSelectionScreen -from archinstall.tui.ui.menu_item import MenuItemGroup -from archinstall.tui.ui.result import Result, ResultType +from archinstall.tui.components import InputInfo, InputScreen, LoadingScreen, NotifyScreen, OptionListScreen, SelectListScreen, TableSelectionScreen +from archinstall.tui.menu_item import MenuItemGroup +from archinstall.tui.result import Result, ResultType class Selection[ValueT]: diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 54683f5443..3b612162ec 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -4,8 +4,8 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class ListManager[ValueT]: diff --git a/archinstall/lib/menu/menu_helper.py b/archinstall/lib/menu/menu_helper.py index 47e04fb88d..6ca3b9531f 100644 --- a/archinstall/lib/menu/menu_helper.py +++ b/archinstall/lib/menu/menu_helper.py @@ -1,5 +1,5 @@ from archinstall.lib.output import FormattedOutput -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.menu_item import MenuItem, MenuItemGroup class MenuHelper[ValueT]: diff --git a/archinstall/lib/menu/util.py b/archinstall/lib/menu/util.py index ff3dea4703..10edfd490f 100644 --- a/archinstall/lib/menu/util.py +++ b/archinstall/lib/menu/util.py @@ -5,8 +5,8 @@ from archinstall.lib.menu.helpers import Confirmation, Input from archinstall.lib.models.users import Password, PasswordStrength from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import InputInfo, InputInfoType, tui -from archinstall.tui.ui.result import ResultType +from archinstall.tui.components import InputInfo, InputInfoType, tui +from archinstall.tui.result import ResultType async def get_password( diff --git a/archinstall/lib/mirror/mirror_menu.py b/archinstall/lib/mirror/mirror_menu.py index 3e899ccab4..5158bffdec 100644 --- a/archinstall/lib/mirror/mirror_menu.py +++ b/archinstall/lib/mirror/mirror_menu.py @@ -15,8 +15,8 @@ from archinstall.lib.models.packages import Repository from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class CustomMirrorRepositoriesList(ListManager[CustomRepository]): diff --git a/archinstall/lib/network/network_menu.py b/archinstall/lib/network/network_menu.py index 2e95a82ebb..120af19e5c 100644 --- a/archinstall/lib/network/network_menu.py +++ b/archinstall/lib/network/network_menu.py @@ -6,8 +6,8 @@ from archinstall.lib.models.network import NetworkConfiguration, Nic, NicType from archinstall.lib.networking import list_interfaces from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class ManualNetworkConfig(ListManager[Nic]): diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 0546310b67..4c858601da 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -9,9 +9,9 @@ from archinstall.lib.network.wpa_supplicant import WpaSupplicantConfig from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, InstanceRunnable, LoadingScreen, NotifyScreen, TableSelectionScreen, tui -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import Result, ResultType +from archinstall.tui.components import ConfirmationScreen, InputScreen, InstanceRunnable, LoadingScreen, NotifyScreen, TableSelectionScreen, tui +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import Result, ResultType @dataclass diff --git a/archinstall/lib/packages/packages.py b/archinstall/lib/packages/packages.py index 3572c6c445..dcc4da9ebc 100644 --- a/archinstall/lib/packages/packages.py +++ b/archinstall/lib/packages/packages.py @@ -6,8 +6,8 @@ from archinstall.lib.output import debug from archinstall.lib.pacman.pacman import Pacman from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType def installed_package(package: str) -> LocalPackage | None: diff --git a/archinstall/lib/pacman/pacman_menu.py b/archinstall/lib/pacman/pacman_menu.py index 010c3f6d27..16ad6d2b58 100644 --- a/archinstall/lib/pacman/pacman_menu.py +++ b/archinstall/lib/pacman/pacman_menu.py @@ -5,8 +5,8 @@ from archinstall.lib.models.pacman import PacmanConfiguration from archinstall.lib.pathnames import PACMAN_CONF from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class PacmanMenu(AbstractSubMenu[PacmanConfiguration]): diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index 0bdc9b0b0d..d3b35c8283 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -7,8 +7,8 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.profile import ProfileConfiguration from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import ResultType class ProfileMenu(AbstractSubMenu[ProfileConfiguration]): diff --git a/archinstall/lib/user/user_menu.py b/archinstall/lib/user/user_menu.py index e7d77aab1d..8e6e48fd55 100644 --- a/archinstall/lib/user/user_menu.py +++ b/archinstall/lib/user/user_menu.py @@ -6,8 +6,8 @@ from archinstall.lib.menu.util import get_password from archinstall.lib.models.users import User from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem -from archinstall.tui.ui.result import ResultType +from archinstall.tui.menu_item import MenuItem +from archinstall.tui.result import ResultType class UserList(ListManager[User]): diff --git a/archinstall/main.py b/archinstall/main.py index cf0e42f67b..505652a4f7 100644 --- a/archinstall/main.py +++ b/archinstall/main.py @@ -18,7 +18,7 @@ from archinstall.lib.pacman.pacman import Pacman from archinstall.lib.translationhandler import tr, translation_handler from archinstall.lib.utils.util import running_from_iso -from archinstall.tui.ui.components import tui +from archinstall.tui.components import tui def _log_sys_info() -> None: diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 2122848083..899683f26b 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -22,7 +22,7 @@ from archinstall.lib.packages.util import check_version_upgrade from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import tui +from archinstall.tui.components import tui def show_menu( diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 681d4266b0..ee4b49c70d 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -12,7 +12,7 @@ from archinstall.lib.output import debug, error, info from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import tui +from archinstall.tui.components import tui def perform_installation(arch_config_handler: ArchConfigHandler) -> None: diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index afb1f9190b..5df72965c2 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -10,7 +10,7 @@ from archinstall.lib.menu.util import delayed_warning from archinstall.lib.output import debug, error from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.components import tui +from archinstall.tui.components import tui def show_menu(arch_config_handler: ArchConfigHandler) -> None: diff --git a/archinstall/tui/ui/__init__.py b/archinstall/tui/ui/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py deleted file mode 100644 index da6dc66b77..0000000000 --- a/archinstall/tui/ui/components.py +++ /dev/null @@ -1,1347 +0,0 @@ -import sys -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable -from dataclasses import dataclass, replace -from enum import Enum, auto -from typing import Any, ClassVar, Literal, TypeVar, cast, override - -from textual import work -from textual.app import App, ComposeResult -from textual.binding import Binding, BindingsMap -from textual.containers import Center, Horizontal, ScrollableContainer, Vertical -from textual.events import Key -from textual.geometry import Offset -from textual.screen import Screen -from textual.validation import Validator -from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList -from textual.widgets._data_table import RowKey -from textual.widgets.option_list import Option -from textual.widgets.selection_list import Selection -from textual.worker import WorkerCancelled - -from archinstall.lib.output import debug -from archinstall.lib.translationhandler import tr -from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import Result, ResultType - -ValueT = TypeVar('ValueT') - - -def _translate_bindings(source: BindingsMap | None, target: BindingsMap) -> None: - """Translate binding descriptions from source to target. - - Uses source (original, immutable class-level cache) to avoid - double-translation on repeated calls (e.g. language switch). - """ - if source is None: - return - for key, bindings in source.key_to_bindings.items(): - target.key_to_bindings[key] = [replace(b, description=tr(b.description)) if b.description else b for b in bindings] - - -class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=True), - Binding('ctrl+c', 'reset_operation', 'Reset', show=True), - ] - - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset - - def action_cancel_operation(self) -> None: - if self._allow_skip: - _ = self.dismiss(Result(ResultType.Skip)) - - async def action_reset_operation(self) -> None: - if self._allow_reset: - _ = self.dismiss(Result(ResultType.Reset)) - - -class LoadingScreen(BaseScreen[ValueT]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - - background: transparent; - } - - LoadingIndicator { - align: center middle; - } - """ - - def __init__( - self, - timer: int = 3, - data_callback: Callable[[], Any] | None = None, - header: str | None = None, - ): - super().__init__() - self._timer = timer - self._header = header - self._data_callback = data_callback - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - with Vertical(classes='content-container'): - if self._header: - with Center(): - yield Label(self._header, classes='header', id='loading_header') - - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - self._set_cursor() - - def _set_cursor(self) -> None: - label = self.query_one(Label) - self.app.cursor_position = Offset(label.region.x, label.region.y) - self.app.refresh() - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - # cannot call self.dismiss directly from - # background thread (thread=true) as there's no event loop - self.app.call_from_thread(self.dismiss, Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() - - -class _OptionList(OptionList): - BINDINGS: ClassVar = [ - Binding('down', 'cursor_down', 'Down', show=True), - Binding('up', 'cursor_up', 'Up', show=True), - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - ] - - @override - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - - -class OptionListScreen(BaseScreen[ValueT]): - """ - Single selection menu list - """ - - BINDINGS: ClassVar = [ - Binding('/', 'search', 'Search', show=True), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-left: 2; - - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - padding-bottom: 3; - - background: transparent; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-bottom: 3; - - background: transparent; - } - - OptionList > .option-list--option-highlighted { - background: blue; - color: white; - text-style: bold; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - title: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._title = title - self._preview_location = preview_location - self._filter = enable_filter - self._show_frame = False - - self._options = self._get_options() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - if self._title: - yield Label(self._title, classes='app-header') - - with Vertical(classes='content-container'): - if self._header: - yield Label(self._header, classes='header-text', id='header_text') - - option_list = _OptionList(id='option_list_widget') - - if not self._show_frame: - option_list.classes = 'no-border' - - if self._preview_location is None: - with Center(): - with Vertical(classes='list-container'): - yield option_list - else: - Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - yield option_list - yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content', markup=False)) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() - - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) - - def on_input_submitted(self, event: Input.Submitted) -> None: - if self.query_one(Input).has_focus: - self._handle_search_action() - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) - - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) - - self._set_cursor() - - def _set_cursor(self) -> None: - option_list = self.query_one(OptionList) - index = option_list.highlighted - - if index is None: - return - - target_y = sum( - [ - 1 if self._show_frame else 0, # add top buffer for the frame - option_list.region.y, # padding/margin offset of the option list - index, # index of the highlighted option - -option_list.scroll_offset.y, # scroll offset - ] - ) - - # debug(f'Index: {index}') - # debug(f'Region: {option_list.region}') - # debug(f'Scroll offset: {option_list.scroll_offset}') - # debug(f'Target_Y: {target_y}') - - self.app.cursor_position = Offset(option_list.region.x, target_y) - self.app.refresh() - - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return - - preview_widget = self.query_one('#preview_content', Label) - item = self._group.find_by_id(item_id) - - if item.preview_action is not None: - maybe_preview = item.preview_action(item) - - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - -class _SelectionList(SelectionList[ValueT]): - BINDINGS: ClassVar = [ - Binding('down', 'cursor_down', 'Down', show=True), - Binding('up', 'cursor_up', 'Up', show=True), - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'select', 'Toggle', show=True), - ] - - @override - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - - -class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('/', 'search', 'Search', show=True), - Binding('enter', '', 'Confirm', show=True), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-left: 2; - - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-bottom: 3; - - background: transparent; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-bottom: 3; - - background: transparent; - } - - SelectionList > .option-list--option-highlighted { - background: blue; - color: white; - text-style: bold; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = False - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options: list[Selection[MenuItem]] = self._get_selections() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(SelectionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - with Vertical(classes='content-container'): - if self._header: - yield Label(self._header, classes='header-text', id='header_text') - - selection_list = _SelectionList[MenuItem](id='select_list_widget') - - if not self._show_frame: - selection_list.classes = 'no-border' - - if self._preview_location is None: - with Center(): - with Vertical(classes='list-container'): - yield selection_list - else: - Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - yield selection_list - yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content', markup=False)) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_input_submitted(self, event: Input.Submitted) -> None: - if self.query_one(Input).has_focus: - self._handle_search_action() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - selection_list = self.query_one(SelectionList) - - if not selection_list.has_focus or event.key != 'enter': - return - - if len(self._selected_items) < 1: - index = selection_list.highlighted - if index is not None: - selection = selection_list.get_option_at_index(index) - self._selected_items.append(selection.value) - - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) - - def _update_options(self, options: list[Selection[MenuItem]]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) - - selection_list.highlighted = self._group.get_focused_index() - - if focus_item := self._group.focus_item: - self._set_preview(focus_item) - - self._set_cursor() - - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: - if self._preview_location is not None: - item: MenuItem = event.selection.value - self._set_preview(item) - - self._set_cursor() - - def _set_cursor(self) -> None: - selection_list = self.query_one(SelectionList) - index = selection_list.highlighted - - if index is None: - return - - target_y = sum( - [ - 1 if self._show_frame else 0, # add top buffer for the frame - selection_list.region.y, # padding/margin offset of the option list - index, # index of the highlighted option - -selection_list.scroll_offset.y, # scroll offset - ] - ) - - self.app.cursor_position = Offset(selection_list.region.x, target_y) - self.app.refresh() - - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: - item: MenuItem = event.selection.value - - if item not in self._selected_items: - self._selected_items.append(item) - else: - self._selected_items.remove(item) - - def _set_preview(self, item: MenuItem) -> None: - if self._preview_location is None: - return - - preview_widget = self.query_one('#preview_content', Label) - - if item.preview_action is not None: - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - -# DEPRECATED: Removed when switching to async -class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=True), - Binding('left', 'focus_left', 'Focus left', show=True), - ] - - CSS = """ - ConfirmationScreen { - align: center top; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - border: none; - background: transparent; - } - - .buttons-container { - align: center top; - height: 3; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: blue; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['bottom'] | None = None, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._preview_header = preview_header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield Label(self._header, classes='header-text', id='header_text') - - if self._preview_location is None: - with Vertical(classes='content-container'): - with Horizontal(classes='buttons-container'): - for item in self._group.items: - yield Button(item.text, id=item.key) - else: - with Vertical(): - with Horizontal(classes='buttons-container'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - yield Rule(orientation='horizontal') - if self._preview_header is not None: - yield Label(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content', markup=False)) - - yield Footer() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - self._update_selection() - - def action_focus_right(self) -> None: - if self._is_btn_focus(): - self._group.focus_next() - self._update_selection() - - def action_focus_left(self) -> None: - if self._is_btn_focus(): - self._group.focus_prev() - self._update_selection() - - def _update_selection(self) -> None: - focused = self._group.focus_item - buttons = self.query(Button) - - if not focused: - return - - for button in buttons: - if button.id == focused.key: - button.add_class('-active') - button.focus() - - if self._preview_header is not None: - preview = self.query_one('#preview_content', Label) - - if focused.preview_action is None: - preview.update('') - else: - text = focused.preview_action(focused) - if text is not None: - preview.update(text) - else: - button.remove_class('-active') - - def _is_btn_focus(self) -> bool: - buttons = self.query(Button) - for button in buttons: - if button.has_focus: - return True - - return False - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - if self._is_btn_focus(): - item = self._group.focus_item - if not item: - return - _ = self.dismiss(Result(ResultType.Selection, _item=item)) - - -class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) - - -class InputInfoType(Enum): - MsgInfo = auto() - MsgWarning = auto() - MsgError = auto() - - -@dataclass -class InputInfo: - message: str - info_type: InputInfoType - - -class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - align: center middle; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - #input-info { - text-align: center; - } - - .input-hint-msg-error { - color: red; - } - - .input-hint-msg-warning { - color: yellow; - } - - .input-hint-msg-info { - color: green; - } - """ - - def __init__( - self, - header: str | None = None, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - validator: Validator | None = None, - info_callback: Callable[[str], InputInfo | None] | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header or '' - self._placeholder = placeholder or '' - self._password = password - self._default_value = default_value or '' - self._allow_reset = allow_reset - self._allow_skip = allow_skip - self._validator = validator - self._info_callback = info_callback - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield Label(self._header, classes='header-text', id='header_text') - - with Center(classes='container-wrapper'): - with Vertical(classes='input-content'): - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - validators=self._validator, - validate_on=['submitted'], - ) - yield Label('', classes='input-failure', id='input-failure') - yield Label('', id='input-info') - - yield Footer() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_input_submitted(self, event: Input.Submitted) -> None: - if event.validation_result and not event.validation_result.is_valid: - failures = [failure.description for failure in event.validation_result.failures if failure.description] - failure_out = ', '.join(failures) - - self.query_one('#input-failure', Label).update(failure_out) - else: - input_value = event.value - - if not input_value and not self._allow_skip: - self.query_one('#input-failure', Label).update(tr('Input cannot be empty')) - return - - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) - - def on_input_changed(self, event: Input.Changed) -> None: - info_label = self.query_one('#input-info', Label) - if self._info_callback: - result = self._info_callback(event.value) - if result: - css_class = '' - if result.info_type == InputInfoType.MsgError: - css_class = 'input-hint-msg-error' - elif result.info_type == InputInfoType.MsgWarning: - css_class = 'input-hint-msg-warning' - elif result.info_type == InputInfoType.MsgInfo: - css_class = 'input-hint-msg-info' - info_label.update(result.message) - info_label.set_classes(css_class) - else: - info_label.update('') - info_label.set_classes('') - - -class _DataTable(DataTable[ValueT]): - BINDINGS: ClassVar = [ - Binding('down', 'cursor_down', 'Down', show=True), - Binding('up', 'cursor_up', 'Up', show=True), - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'select', 'Toggle', show=True), - Binding('enter', 'select_cursor', 'Confirm', show=True), - ] - - @override - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - - -class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('space', 'toggle_selection', 'Toggle', show=True), # expclit handling of space in multi-selection mode - ] - - CSS = """ - TableSelectionScreen { - align: center top; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - - background: transparent; - } - - .table-container { - align: center top; - width: 1fr; - height: 1fr; - - background: transparent; - } - - .table-container ScrollableContainer { - align: center top; - height: auto; - - background: transparent; - } - - DataTable { - width: auto; - height: auto; - - padding-bottom: 2; - - border: none; - background: transparent; - } - - DataTable .datatable--header { - background: transparent; - border: solid; - } - - LoadingIndicator { - height: auto; - padding-top: 2; - - background: transparent; - } - """ - - def __init__( - self, - header: str | None = None, - group: MenuItemGroup | None = None, - group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - multi: bool = False, - preview_location: Literal['bottom'] | None = None, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._group = group - self._group_callback = group_callback - self._loading_header = loading_header - self._multi = multi - self._preview_location = preview_location - self._preview_header = preview_header - - self._selected_keys: set[RowKey] = set() - self._current_row_key: RowKey | None = None - - if self._group is None and self._group_callback is None: - raise ValueError('Either data or data_callback must be provided') - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - if self._header: - yield Label(self._header, classes='header-text', id='header_text') - - with Vertical(classes='content-container'): - if self._loading_header: - with Center(): - yield Label(self._loading_header, classes='header', id='loading_header') - - yield LoadingIndicator(id='loader') - - if self._preview_location is None: - with Center(): - with Vertical(classes='table-container'): - yield ScrollableContainer(_DataTable(id='data_table')) - - else: - with Vertical(classes='table-container'): - yield ScrollableContainer(_DataTable(id='data_table')) - yield Rule(orientation='horizontal') - if self._preview_header is not None: - yield Label(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content', markup=False)) - - yield Footer() - - def on_mount(self) -> None: - _translate_bindings(self._merged_bindings, self._bindings) - self._display_header(True) - data_table = self.query_one(DataTable) - data_table.cell_padding = 2 - - if self._group: - self._put_data_to_table(data_table, self._group) - else: - self._load_data(data_table) - - @work - async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._group_callback is not None - group = await self._group_callback() - self._put_data_to_table(table, group) - - def _display_header(self, is_loading: bool) -> None: - if self._loading_header: - loading_header = self.query_one('#loading_header', Label) - loading_header.display = is_loading - - if self._header: - header = self.query_one('#header_text', Label) - header.display = not is_loading - - def _get_column_keys(self, items: list[MenuItem]) -> list[str]: - all_keys: list[str] = [] - for item in items: - if item.value: - all_keys.extend(item.value.table_data().keys()) - - # Create unique list while preserving order - unique_keys: list[str] = list(dict.fromkeys(all_keys)) - - if self._multi: - unique_keys.insert(0, ' ') - - return unique_keys - - def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: - items = group.items - selected = group.selected_items - - if not items: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = self._get_column_keys(items) - - table.add_columns(*cols) - - for item in items: - if not item.value: - continue - - row_values = list(item.value.table_data().values()) - - if self._multi: - if item in selected: - row_values.insert(0, '[X]') - else: - row_values.insert(0, '[ ]') - - row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] - if item in selected: - self._selected_keys.add(row_key) - - table.cursor_type = 'row' - table.display = True - - loader = self.query_one('#loader') - loader.display = False - self._display_header(False) - table.focus() - - def action_toggle_selection(self) -> None: - if not self._multi: - return - - if not self._current_row_key: - return - - table = self.query_one(DataTable) - cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) - - if self._current_row_key in self._selected_keys: - self._selected_keys.remove(self._current_row_key) - table.update_cell(self._current_row_key, cell_key.column_key, '[ ]') - else: - self._selected_keys.add(self._current_row_key) - table.update_cell(self._current_row_key, cell_key.column_key, '[X]') - - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - self._set_cursor(event.cursor_row) - - self._current_row_key = event.row_key - item: MenuItem = event.row_key.value # type: ignore[assignment] - - if not item.preview_action: - return - - preview_widget = self.query_one('#preview_content', Label) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def _set_cursor(self, row_index: int) -> None: - data_table = self.query_one(DataTable) - - target_y = sum( - [ - data_table.region.y, # padding/margin offset of the option list - 1, # table header - row_index, # index of the highlighted row - -data_table.scroll_offset.y, # scroll offset - ] - ) - - debug(f'Setting cursor to target_y: {target_y}') - - self.app.cursor_position = Offset(data_table.region.x, target_y) - self.app.refresh() - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - selection = [event.row_key.value] - else: - selection = [row_key.value for row_key in self._selected_keys] - else: - selection = event.row_key.value # type: ignore[assignment] - - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=selection, # type: ignore[arg-type] - ) - ) - - -class InstanceRunnable[ValueT](ABC): - @abstractmethod - async def run(self) -> ValueT | None: - pass - - -class _AppInstance(App[ValueT]): - ENABLE_COMMAND_PALETTE = False - - BINDINGS: ClassVar = [ - Binding('f1', 'trigger_help', 'Show/Hide help', show=True), - Binding('ctrl+q', 'quit', 'Quit', show=True, priority=True), - ] - - CSS = """ - Screen { - color: white; - } - - * { - scrollbar-size: 1 1; - - /* Use high contrast colors */ - scrollbar-color: white; - scrollbar-background: black; - } - - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: blue; - color: white; - text-style: bold; - } - - .header-text { - text-align: center; - width: 100%; - height: auto; - - padding-top: 2; - padding-bottom: 2; - - background: transparent; - } - - .preview-header { - text-align: center; - color: white; - text-style: bold; - width: 100%; - - padding-bottom: 1; - - background: transparent; - } - - .no-border { - border: none; - } - - Input { - border: solid gray 50%; - background: transparent; - height: 3; - color: white; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid blue; - } - - Footer { - dock: bottom; - width: 100%; - background: transparent; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - padding-right: 2; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey white 20%; - } - """ - - def __init__(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> None: - super().__init__(ansi_color=True) - self._main = main - - @override - async def _on_exit_app(self) -> None: - from archinstall.lib.translationhandler import translation_handler - - translation_handler.restore_console_font() - await super()._on_exit_app() - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - _ = self.screen.query('HelpPanel').remove() - else: - _ = self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - from archinstall.lib.translationhandler import translation_handler - - translation_handler.apply_console_font() - _translate_bindings(self._merged_bindings, self._bindings) - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - if isinstance(self._main, InstanceRunnable): - result: ValueT | None = await self._main.run() - else: - result = await self._main() - - tui.exit(result) - except WorkerCancelled: - debug('Worker was cancelled') - except Exception as err: - debug(f'Error while running main app: {err}') - # this will terminate the textual app and return the exception - self.exit(cast(ValueT, err)) - - @work - async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: - return await self.push_screen_wait(screen) - - async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: - return await self._show_async(screen).wait() - - -class TApp: - app: _AppInstance[Any] | None = None - - def run(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> ValueT: - TApp.app = _AppInstance(main) - result: ValueT | Exception | None = TApp.app.run() - - if isinstance(result, Exception): - raise result - - if result is None: - debug('App returned no result, assuming exit') - sys.exit(0) - - return result - - def exit(self, result: Any) -> None: - assert TApp.app - TApp.app.exit(result) - - def translate_bindings(self) -> None: - """Re-translate app-level binding descriptions after language change.""" - if TApp.app is not None: - _translate_bindings(TApp.app._merged_bindings, TApp.app._bindings) - - -tui = TApp() diff --git a/archinstall/tui/ui/menu_item.py b/archinstall/tui/ui/menu_item.py deleted file mode 100644 index 4c10e275ef..0000000000 --- a/archinstall/tui/ui/menu_item.py +++ /dev/null @@ -1,334 +0,0 @@ -from collections.abc import Awaitable, Callable, Iterable -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], Awaitable[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) - del 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: Iterable[Enum] | 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 - del self.items # resetting the cache - self.focus_first() - - def focus_index(self, index: int) -> None: - enabled = self.get_enabled_items() - self.focus_item = enabled[index] - - def focus_first(self) -> None: - if len(self.items) == 0: - return - - first_item: MenuItem | None = self.items[0] - - if first_item and not self._is_selectable(first_item): - first_item = self._find_next_selectable_item(self.items, first_item, 1) - - if first_item is not None: - self.focus_item = first_item - - def focus_last(self) -> None: - if len(self.items) == 0: - return - - last_item: MenuItem | None = self.items[-1] - - if last_item and not self._is_selectable(last_item): - last_item = self._find_next_selectable_item(self.items, last_item, -1) - - if last_item is not None: - self.focus_item = last_item - - def focus_prev(self, skip_empty: bool = True) -> None: - # e.g. when filter shows no items - if self.focus_item is None: - return - - item = self._find_next_selectable_item(self.items, self.focus_item, -1) - - if item is not None: - self.focus_item = item - - def focus_next(self, skip_not_enabled: bool = True) -> None: - # e.g. when filter shows no items - if self.focus_item is None: - return - - item = self._find_next_selectable_item(self.items, self.focus_item, 1) - - if item is not None: - self.focus_item = item - - def _find_next_selectable_item( - self, - items: list[MenuItem], - start_item: MenuItem, - direction: int, - ) -> MenuItem | None: - start_index = self.items.index(start_item) - n = len(items) - - current_index = start_index - for _ in range(n): - current_index = (current_index + direction) % n - - if self._is_selectable(items[current_index]): - return items[current_index] - - return None - - def max_item_width(self) -> int: - spaces = [len(str(it.text)) for it in self.items] - if spaces: - return max(spaces) - return 0 - - def _is_selectable(self, item: MenuItem) -> bool: - if item.is_empty(): - return False - elif item.read_only: - return False - - return self.is_enabled(item) - - def is_enabled(self, item: MenuItem) -> bool: - if not item.enabled: - return False - - for dep in item.dependencies: - if isinstance(dep, str): - item = self.find_by_key(dep) - if not item.value or not self.is_enabled(item): - return False - else: - return dep() - - for dep_not in item.dependencies_not: - item = self.find_by_key(dep_not) - if item.value is not None: - return False - - return True diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py deleted file mode 100644 index 6258bccf1e..0000000000 --- a/archinstall/tui/ui/result.py +++ /dev/null @@ -1,71 +0,0 @@ -from dataclasses import dataclass -from enum import Enum, auto -from typing import Self, cast - -from archinstall.tui.ui.menu_item import MenuItem - - -class ResultType(Enum): - Selection = auto() - Skip = auto() - Reset = auto() - - -@dataclass -class Result[ValueT]: - type_: ResultType - _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] - - @classmethod - def reset(cls) -> Self: - return cls(ResultType.Reset) - - @classmethod - def selection(cls, value: ValueT | list[ValueT] | None) -> Self: - return cls(ResultType.Selection, _data=value) - - @classmethod - def skip(cls) -> Self: - return cls(ResultType.Skip) - - def has_data(self) -> bool: - return self._data is not None - - 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()] - - assert type(self._data) is list - return cast(list[ValueT], self._data) From 5657049b34ba56d1cf87b54a3711a078019de2fe Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 3 May 2026 10:39:30 +1000 Subject: [PATCH 2/2] Update --- archinstall/tui/components.py | 1347 +++++++++++++++++++++++++++++++++ archinstall/tui/menu_item.py | 334 ++++++++ archinstall/tui/result.py | 71 ++ 3 files changed, 1752 insertions(+) create mode 100644 archinstall/tui/components.py create mode 100644 archinstall/tui/menu_item.py create mode 100644 archinstall/tui/result.py diff --git a/archinstall/tui/components.py b/archinstall/tui/components.py new file mode 100644 index 0000000000..f92364bbdd --- /dev/null +++ b/archinstall/tui/components.py @@ -0,0 +1,1347 @@ +import sys +from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, replace +from enum import Enum, auto +from typing import Any, ClassVar, Literal, TypeVar, cast, override + +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding, BindingsMap +from textual.containers import Center, Horizontal, ScrollableContainer, Vertical +from textual.events import Key +from textual.geometry import Offset +from textual.screen import Screen +from textual.validation import Validator +from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList +from textual.widgets._data_table import RowKey +from textual.widgets.option_list import Option +from textual.widgets.selection_list import Selection +from textual.worker import WorkerCancelled + +from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr +from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.result import Result, ResultType + +ValueT = TypeVar('ValueT') + + +def _translate_bindings(source: BindingsMap | None, target: BindingsMap) -> None: + """Translate binding descriptions from source to target. + + Uses source (original, immutable class-level cache) to avoid + double-translation on repeated calls (e.g. language switch). + """ + if source is None: + return + for key, bindings in source.key_to_bindings.items(): + target.key_to_bindings[key] = [replace(b, description=tr(b.description)) if b.description else b for b in bindings] + + +class BaseScreen(Screen[Result[ValueT]]): + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=True), + Binding('ctrl+c', 'reset_operation', 'Reset', show=True), + ] + + def __init__(self, allow_skip: bool = False, allow_reset: bool = False): + super().__init__() + self._allow_skip = allow_skip + self._allow_reset = allow_reset + + def action_cancel_operation(self) -> None: + if self._allow_skip: + _ = self.dismiss(Result(ResultType.Skip)) + + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) + + +class LoadingScreen(BaseScreen[ValueT]): + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + + background: transparent; + } + + LoadingIndicator { + align: center middle; + } + """ + + def __init__( + self, + timer: int = 3, + data_callback: Callable[[], Any] | None = None, + header: str | None = None, + ): + super().__init__() + self._timer = timer + self._header = header + self._data_callback = data_callback + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + with Vertical(classes='content-container'): + if self._header: + with Center(): + yield Label(self._header, classes='header', id='loading_header') + + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + self._set_cursor() + + def _set_cursor(self) -> None: + label = self.query_one(Label) + self.app.cursor_position = Offset(label.region.x, label.region.y) + self.app.refresh() + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + # cannot call self.dismiss directly from + # background thread (thread=true) as there's no event loop + self.app.call_from_thread(self.dismiss, Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() + + +class _OptionList(OptionList): + BINDINGS: ClassVar = [ + Binding('down', 'cursor_down', 'Down', show=True), + Binding('up', 'cursor_up', 'Up', show=True), + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + @override + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + + +class OptionListScreen(BaseScreen[ValueT]): + """ + Single selection menu list + """ + + BINDINGS: ClassVar = [ + Binding('/', 'search', 'Search', show=True), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + padding-bottom: 3; + + background: transparent; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + OptionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + title: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._title = title + self._preview_location = preview_location + self._filter = enable_filter + self._show_frame = False + + self._options = self._get_options() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + if self._title: + yield Label(self._title, classes='app-header') + + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + option_list = _OptionList(id='option_list_widget') + + if not self._show_frame: + option_list.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield option_list + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield option_list + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if self.query_one(Input).has_focus: + self._handle_search_action() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) + + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) + + self._set_cursor() + + def _set_cursor(self) -> None: + option_list = self.query_one(OptionList) + index = option_list.highlighted + + if index is None: + return + + target_y = sum( + [ + 1 if self._show_frame else 0, # add top buffer for the frame + option_list.region.y, # padding/margin offset of the option list + index, # index of the highlighted option + -option_list.scroll_offset.y, # scroll offset + ] + ) + + # debug(f'Index: {index}') + # debug(f'Region: {option_list.region}') + # debug(f'Scroll offset: {option_list.scroll_offset}') + # debug(f'Target_Y: {target_y}') + + self.app.cursor_position = Offset(option_list.region.x, target_y) + self.app.refresh() + + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return + + preview_widget = self.query_one('#preview_content', Label) + item = self._group.find_by_id(item_id) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +class _SelectionList(SelectionList[ValueT]): + BINDINGS: ClassVar = [ + Binding('down', 'cursor_down', 'Down', show=True), + Binding('up', 'cursor_up', 'Up', show=True), + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'select', 'Toggle', show=True), + ] + + @override + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + + +class SelectListScreen(BaseScreen[ValueT]): + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('/', 'search', 'Search', show=True), + Binding('enter', '', 'Confirm', show=True), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + SelectionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(SelectionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + selection_list = _SelectionList[MenuItem](id='select_list_widget') + + if not self._show_frame: + selection_list.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield selection_list + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield selection_list + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if self.query_one(Input).has_focus: + self._handle_search_action() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + selection_list = self.query_one(SelectionList) + + if not selection_list.has_focus or event.key != 'enter': + return + + if len(self._selected_items) < 1: + index = selection_list.highlighted + if index is not None: + selection = selection_list.get_option_at_index(index) + self._selected_items.append(selection.value) + + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) + + def _update_options(self, options: list[Selection[MenuItem]]) -> None: + selection_list = self.query_one(SelectionList) + selection_list.clear_options() + selection_list.add_options(options) + + selection_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item) + + self._set_cursor() + + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is not None: + item: MenuItem = event.selection.value + self._set_preview(item) + + self._set_cursor() + + def _set_cursor(self) -> None: + selection_list = self.query_one(SelectionList) + index = selection_list.highlighted + + if index is None: + return + + target_y = sum( + [ + 1 if self._show_frame else 0, # add top buffer for the frame + selection_list.region.y, # padding/margin offset of the option list + index, # index of the highlighted option + -selection_list.scroll_offset.y, # scroll offset + ] + ) + + self.app.cursor_position = Offset(selection_list.region.x, target_y) + self.app.refresh() + + def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: + item: MenuItem = event.selection.value + + if item not in self._selected_items: + self._selected_items.append(item) + else: + self._selected_items.remove(item) + + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return + + preview_widget = self.query_one('#preview_content', Label) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +# DEPRECATED: Removed when switching to async +class ConfirmationScreen(BaseScreen[ValueT]): + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=True), + Binding('left', 'focus_left', 'Focus left', show=True), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + border: none; + background: transparent; + } + + .buttons-container { + align: center top; + height: 3; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: blue; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._preview_header = preview_header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield Label(self._header, classes='header-text', id='header_text') + + if self._preview_location is None: + with Vertical(classes='content-container'): + with Horizontal(classes='buttons-container'): + for item in self._group.items: + yield Button(item.text, id=item.key) + else: + with Vertical(): + with Horizontal(classes='buttons-container'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Rule(orientation='horizontal') + if self._preview_header is not None: + yield Label(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + yield Footer() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + self._update_selection() + + def action_focus_right(self) -> None: + if self._is_btn_focus(): + self._group.focus_next() + self._update_selection() + + def action_focus_left(self) -> None: + if self._is_btn_focus(): + self._group.focus_prev() + self._update_selection() + + def _update_selection(self) -> None: + focused = self._group.focus_item + buttons = self.query(Button) + + if not focused: + return + + for button in buttons: + if button.id == focused.key: + button.add_class('-active') + button.focus() + + if self._preview_header is not None: + preview = self.query_one('#preview_content', Label) + + if focused.preview_action is None: + preview.update('') + else: + text = focused.preview_action(focused) + if text is not None: + preview.update(text) + else: + button.remove_class('-active') + + def _is_btn_focus(self) -> bool: + buttons = self.query(Button) + for button in buttons: + if button.has_focus: + return True + + return False + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + if self._is_btn_focus(): + item = self._group.focus_item + if not item: + return + _ = self.dismiss(Result(ResultType.Selection, _item=item)) + + +class NotifyScreen(ConfirmationScreen[ValueT]): + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) + + +class InputInfoType(Enum): + MsgInfo = auto() + MsgWarning = auto() + MsgError = auto() + + +@dataclass +class InputInfo: + message: str + info_type: InputInfoType + + +class InputScreen(BaseScreen[str]): + CSS = """ + InputScreen { + align: center middle; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + #input-info { + text-align: center; + } + + .input-hint-msg-error { + color: red; + } + + .input-hint-msg-warning { + color: yellow; + } + + .input-hint-msg-info { + color: green; + } + """ + + def __init__( + self, + header: str | None = None, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + validator: Validator | None = None, + info_callback: Callable[[str], InputInfo | None] | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header or '' + self._placeholder = placeholder or '' + self._password = password + self._default_value = default_value or '' + self._allow_reset = allow_reset + self._allow_skip = allow_skip + self._validator = validator + self._info_callback = info_callback + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield Label(self._header, classes='header-text', id='header_text') + + with Center(classes='container-wrapper'): + with Vertical(classes='input-content'): + yield Input( + placeholder=self._placeholder, + password=self._password, + value=self._default_value, + id='main_input', + validators=self._validator, + validate_on=['submitted'], + ) + yield Label('', classes='input-failure', id='input-failure') + yield Label('', id='input-info') + + yield Footer() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.validation_result and not event.validation_result.is_valid: + failures = [failure.description for failure in event.validation_result.failures if failure.description] + failure_out = ', '.join(failures) + + self.query_one('#input-failure', Label).update(failure_out) + else: + input_value = event.value + + if not input_value and not self._allow_skip: + self.query_one('#input-failure', Label).update(tr('Input cannot be empty')) + return + + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + + def on_input_changed(self, event: Input.Changed) -> None: + info_label = self.query_one('#input-info', Label) + if self._info_callback: + result = self._info_callback(event.value) + if result: + css_class = '' + if result.info_type == InputInfoType.MsgError: + css_class = 'input-hint-msg-error' + elif result.info_type == InputInfoType.MsgWarning: + css_class = 'input-hint-msg-warning' + elif result.info_type == InputInfoType.MsgInfo: + css_class = 'input-hint-msg-info' + info_label.update(result.message) + info_label.set_classes(css_class) + else: + info_label.update('') + info_label.set_classes('') + + +class _DataTable(DataTable[ValueT]): + BINDINGS: ClassVar = [ + Binding('down', 'cursor_down', 'Down', show=True), + Binding('up', 'cursor_up', 'Up', show=True), + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'select', 'Toggle', show=True), + Binding('enter', 'select_cursor', 'Confirm', show=True), + ] + + @override + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + + +class TableSelectionScreen(BaseScreen[ValueT]): + BINDINGS: ClassVar = [ + Binding('space', 'toggle_selection', 'Toggle', show=True), # expclit handling of space in multi-selection mode + ] + + CSS = """ + TableSelectionScreen { + align: center top; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + + background: transparent; + } + + .table-container { + align: center top; + width: 1fr; + height: 1fr; + + background: transparent; + } + + .table-container ScrollableContainer { + align: center top; + height: auto; + + background: transparent; + } + + DataTable { + width: auto; + height: auto; + + padding-bottom: 2; + + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + LoadingIndicator { + height: auto; + padding-top: 2; + + background: transparent; + } + """ + + def __init__( + self, + header: str | None = None, + group: MenuItemGroup | None = None, + group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + preview_location: Literal['bottom'] | None = None, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._group = group + self._group_callback = group_callback + self._loading_header = loading_header + self._multi = multi + self._preview_location = preview_location + self._preview_header = preview_header + + self._selected_keys: set[RowKey] = set() + self._current_row_key: RowKey | None = None + + if self._group is None and self._group_callback is None: + raise ValueError('Either data or data_callback must be provided') + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + with Center(): + yield Label(self._loading_header, classes='header', id='loading_header') + + yield LoadingIndicator(id='loader') + + if self._preview_location is None: + with Center(): + with Vertical(classes='table-container'): + yield ScrollableContainer(_DataTable(id='data_table')) + + else: + with Vertical(classes='table-container'): + yield ScrollableContainer(_DataTable(id='data_table')) + yield Rule(orientation='horizontal') + if self._preview_header is not None: + yield Label(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + yield Footer() + + def on_mount(self) -> None: + _translate_bindings(self._merged_bindings, self._bindings) + self._display_header(True) + data_table = self.query_one(DataTable) + data_table.cell_padding = 2 + + if self._group: + self._put_data_to_table(data_table, self._group) + else: + self._load_data(data_table) + + @work + async def _load_data(self, table: DataTable[ValueT]) -> None: + assert self._group_callback is not None + group = await self._group_callback() + self._put_data_to_table(table, group) + + def _display_header(self, is_loading: bool) -> None: + if self._loading_header: + loading_header = self.query_one('#loading_header', Label) + loading_header.display = is_loading + + if self._header: + header = self.query_one('#header_text', Label) + header.display = not is_loading + + def _get_column_keys(self, items: list[MenuItem]) -> list[str]: + all_keys: list[str] = [] + for item in items: + if item.value: + all_keys.extend(item.value.table_data().keys()) + + # Create unique list while preserving order + unique_keys: list[str] = list(dict.fromkeys(all_keys)) + + if self._multi: + unique_keys.insert(0, ' ') + + return unique_keys + + def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: + items = group.items + selected = group.selected_items + + if not items: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = self._get_column_keys(items) + + table.add_columns(*cols) + + for item in items: + if not item.value: + continue + + row_values = list(item.value.table_data().values()) + + if self._multi: + if item in selected: + row_values.insert(0, '[X]') + else: + row_values.insert(0, '[ ]') + + row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] + if item in selected: + self._selected_keys.add(row_key) + + table.cursor_type = 'row' + table.display = True + + loader = self.query_one('#loader') + loader.display = False + self._display_header(False) + table.focus() + + def action_toggle_selection(self) -> None: + if not self._multi: + return + + if not self._current_row_key: + return + + table = self.query_one(DataTable) + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[ ]') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[X]') + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._set_cursor(event.cursor_row) + + self._current_row_key = event.row_key + item: MenuItem = event.row_key.value # type: ignore[assignment] + + if not item.preview_action: + return + + preview_widget = self.query_one('#preview_content', Label) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def _set_cursor(self, row_index: int) -> None: + data_table = self.query_one(DataTable) + + target_y = sum( + [ + data_table.region.y, # padding/margin offset of the option list + 1, # table header + row_index, # index of the highlighted row + -data_table.scroll_offset.y, # scroll offset + ] + ) + + debug(f'Setting cursor to target_y: {target_y}') + + self.app.cursor_position = Offset(data_table.region.x, target_y) + self.app.refresh() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + selection = [event.row_key.value] + else: + selection = [row_key.value for row_key in self._selected_keys] + else: + selection = event.row_key.value # type: ignore[assignment] + + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=selection, # type: ignore[arg-type] + ) + ) + + +class InstanceRunnable[ValueT](ABC): + @abstractmethod + async def run(self) -> ValueT | None: + pass + + +class _AppInstance(App[ValueT]): + ENABLE_COMMAND_PALETTE = False + + BINDINGS: ClassVar = [ + Binding('f1', 'trigger_help', 'Show/Hide help', show=True), + Binding('ctrl+q', 'quit', 'Quit', show=True, priority=True), + ] + + CSS = """ + Screen { + color: white; + } + + * { + scrollbar-size: 1 1; + + /* Use high contrast colors */ + scrollbar-color: white; + scrollbar-background: black; + } + + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: blue; + color: white; + text-style: bold; + } + + .header-text { + text-align: center; + width: 100%; + height: auto; + + padding-top: 2; + padding-bottom: 2; + + background: transparent; + } + + .preview-header { + text-align: center; + color: white; + text-style: bold; + width: 100%; + + padding-bottom: 1; + + background: transparent; + } + + .no-border { + border: none; + } + + Input { + border: solid gray 50%; + background: transparent; + height: 3; + color: white; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid blue; + } + + Footer { + dock: bottom; + width: 100%; + background: transparent; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + padding-right: 2; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey white 20%; + } + """ + + def __init__(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> None: + super().__init__(ansi_color=True) + self._main = main + + @override + async def _on_exit_app(self) -> None: + from archinstall.lib.translationhandler import translation_handler + + translation_handler.restore_console_font() + await super()._on_exit_app() + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + from archinstall.lib.translationhandler import translation_handler + + translation_handler.apply_console_font() + _translate_bindings(self._merged_bindings, self._bindings) + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + if isinstance(self._main, InstanceRunnable): + result: ValueT | None = await self._main.run() + else: + result = await self._main() + + tui.exit(result) + except WorkerCancelled: + debug('Worker was cancelled') + except Exception as err: + debug(f'Error while running main app: {err}') + # this will terminate the textual app and return the exception + self.exit(cast(ValueT, err)) + + @work + async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: + return await self.push_screen_wait(screen) + + async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: + return await self._show_async(screen).wait() + + +class TApp: + app: _AppInstance[Any] | None = None + + def run(self, main: InstanceRunnable[ValueT] | Callable[[], Awaitable[ValueT]]) -> ValueT: + TApp.app = _AppInstance(main) + result: ValueT | Exception | None = TApp.app.run() + + if isinstance(result, Exception): + raise result + + if result is None: + debug('App returned no result, assuming exit') + sys.exit(0) + + return result + + def exit(self, result: Any) -> None: + assert TApp.app + TApp.app.exit(result) + + def translate_bindings(self) -> None: + """Re-translate app-level binding descriptions after language change.""" + if TApp.app is not None: + _translate_bindings(TApp.app._merged_bindings, TApp.app._bindings) + + +tui = TApp() diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py new file mode 100644 index 0000000000..4c10e275ef --- /dev/null +++ b/archinstall/tui/menu_item.py @@ -0,0 +1,334 @@ +from collections.abc import Awaitable, Callable, Iterable +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], Awaitable[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) + del 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: Iterable[Enum] | 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 + del self.items # resetting the cache + self.focus_first() + + def focus_index(self, index: int) -> None: + enabled = self.get_enabled_items() + self.focus_item = enabled[index] + + def focus_first(self) -> None: + if len(self.items) == 0: + return + + first_item: MenuItem | None = self.items[0] + + if first_item and not self._is_selectable(first_item): + first_item = self._find_next_selectable_item(self.items, first_item, 1) + + if first_item is not None: + self.focus_item = first_item + + def focus_last(self) -> None: + if len(self.items) == 0: + return + + last_item: MenuItem | None = self.items[-1] + + if last_item and not self._is_selectable(last_item): + last_item = self._find_next_selectable_item(self.items, last_item, -1) + + if last_item is not None: + self.focus_item = last_item + + def focus_prev(self, skip_empty: bool = True) -> None: + # e.g. when filter shows no items + if self.focus_item is None: + return + + item = self._find_next_selectable_item(self.items, self.focus_item, -1) + + if item is not None: + self.focus_item = item + + def focus_next(self, skip_not_enabled: bool = True) -> None: + # e.g. when filter shows no items + if self.focus_item is None: + return + + item = self._find_next_selectable_item(self.items, self.focus_item, 1) + + if item is not None: + self.focus_item = item + + def _find_next_selectable_item( + self, + items: list[MenuItem], + start_item: MenuItem, + direction: int, + ) -> MenuItem | None: + start_index = self.items.index(start_item) + n = len(items) + + current_index = start_index + for _ in range(n): + current_index = (current_index + direction) % n + + if self._is_selectable(items[current_index]): + return items[current_index] + + return None + + def max_item_width(self) -> int: + spaces = [len(str(it.text)) for it in self.items] + if spaces: + return max(spaces) + return 0 + + def _is_selectable(self, item: MenuItem) -> bool: + if item.is_empty(): + return False + elif item.read_only: + return False + + return self.is_enabled(item) + + def is_enabled(self, item: MenuItem) -> bool: + if not item.enabled: + return False + + for dep in item.dependencies: + if isinstance(dep, str): + item = self.find_by_key(dep) + if not item.value or not self.is_enabled(item): + return False + else: + return dep() + + for dep_not in item.dependencies_not: + item = self.find_by_key(dep_not) + if item.value is not None: + return False + + return True diff --git a/archinstall/tui/result.py b/archinstall/tui/result.py new file mode 100644 index 0000000000..a2232a4fe8 --- /dev/null +++ b/archinstall/tui/result.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass +from enum import Enum, auto +from typing import Self, cast + +from archinstall.tui.menu_item import MenuItem + + +class ResultType(Enum): + Selection = auto() + Skip = auto() + Reset = auto() + + +@dataclass +class Result[ValueT]: + type_: ResultType + _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] + + @classmethod + def reset(cls) -> Self: + return cls(ResultType.Reset) + + @classmethod + def selection(cls, value: ValueT | list[ValueT] | None) -> Self: + return cls(ResultType.Selection, _data=value) + + @classmethod + def skip(cls) -> Self: + return cls(ResultType.Skip) + + def has_data(self) -> bool: + return self._data is not None + + 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()] + + assert type(self._data) is list + return cast(list[ValueT], self._data)