From cf42da68f4fe974713e6be5c54e1d1152f3cbb72 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sat, 8 Nov 2025 18:04:34 +1100 Subject: [PATCH 01/40] Migrate single selection menus --- archinstall/lib/interactions/general_conf.py | 29 +-- archinstall/lib/locale/locale_menu.py | 28 +-- archinstall/lib/menu/helpers.py | 40 +++++ archinstall/lib/network/wifi_handler.py | 3 +- archinstall/tui/ui/components.py | 177 ++++++++++++++++--- 5 files changed, 214 insertions(+), 63 deletions(-) create mode 100644 archinstall/lib/menu/helpers.py diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index fed77b5e93..23601e8825 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import assert_never +from archinstall.lib.menu.helpers import SingleSelection from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr @@ -11,6 +12,7 @@ from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.result import ResultType as UiResultType from ..locale.utils import list_timezones from ..models.packages import AvailablePackage, PackageGroup @@ -127,21 +129,28 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - result = SelectMenu[Language]( - group, + # result = SelectMenu[Language]( + # group, + # header=title, + # allow_skip=True, + # allow_reset=False, + # alignment=Alignment.CENTER, + # frame=FrameProperties.min(header=tr('Select language')), + # ).run() + + result = SingleSelection[Language]( header=title, - allow_skip=True, + group=group, allow_reset=False, - alignment=Alignment.CENTER, - frame=FrameProperties.min(header=tr('Select language')), - ).run() + allow_skip=True, + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Selection: - return result.get_value() - case ResultType.Reset: + case UiResultType.Selection: + return result.value() + case UiResultType.Reset: raise ValueError('Language selection not handled') diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 1763247307..7c5a48dc54 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,10 +1,9 @@ from typing import override +from archinstall.lib.menu.helpers import SingleSelection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu from ..models.locale import LocaleConfiguration @@ -82,16 +81,11 @@ def select_locale_lang(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Locale language')), - allow_skip=True, - ).run() + result = SingleSelection[str](header=tr('Locale language'), group=group).show() match result.type_: case ResultType.Selection: - return result.get_value() + return result.value() case ResultType.Skip: return preset case _: @@ -106,12 +100,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Locale encoding')), - allow_skip=True, - ).run() + result = SingleSelection[str](header=tr('Locale encoding'), group=group).show() match result.type_: case ResultType.Selection: @@ -138,12 +127,7 @@ def select_kb_layout(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=False) group.set_focus_by_value(preset) - result = SelectMenu[str]( - group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Keyboard layout')), - allow_skip=True, - ).run() + result = SingleSelection[str](header=tr('Keyboard layout'), group=group).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py new file mode 100644 index 0000000000..b4104f21e9 --- /dev/null +++ b/archinstall/lib/menu/helpers.py @@ -0,0 +1,40 @@ +from typing import TypeVar + +from archinstall.lib.output import debug +from archinstall.tui.menu_item import MenuItemGroup +from archinstall.tui.ui.components import OptionListScreen, tui +from archinstall.tui.ui.result import Result, ResultType + +ValueT = TypeVar('ValueT') + + +class SingleSelection[ValueT]: + def __init__( + self, + header: str, + group: MenuItemGroup, + allow_skip: bool = True, + allow_reset: bool = False, + ): + self._header = header + self._group: MenuItemGroup = group + self._allow_skip = allow_skip + self._allow_reset = allow_reset + + def show(self) -> Result[ValueT]: + result = tui.run(self) + if result is None: + return Result(ResultType.Selection, None) + return result + + async def run(self) -> None: + debug('Running single selection menu') + + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + ).run() + + tui.exit(result) diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 13fc3e4380..ab1a0e31ff 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -23,11 +23,10 @@ class WpaCliResult: class WifiHandler: def __init__(self) -> None: - tui.set_main(self) self._wpa_config = WpaSupplicantConfig() def setup(self) -> Any: - result = tui.run() + result = tui.run(self) return result async def run(self) -> None: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 4a7c5d061a..4225afdbc7 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -9,7 +9,8 @@ from textual.containers import Center, Horizontal, Vertical from textual.events import Key from textual.screen import Screen -from textual.widgets import Button, DataTable, Input, LoadingIndicator, Static +from textual.widgets import Button, DataTable, Input, LoadingIndicator, OptionList, Static +from textual.widgets.option_list import Option from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr @@ -48,6 +49,7 @@ class LoadingScreen(BaseScreen[None]): CSS = """ LoadingScreen { align: center middle; + background: transparent; } .dialog { @@ -77,7 +79,8 @@ def __init__( self._header = header async def run(self) -> Result[None]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: @@ -96,6 +99,109 @@ def action_pop_screen(self) -> None: self.dismiss() # type: ignore[unused-awaitable] +class OptionListScreen(BaseScreen[ValueT]): + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=True), + Binding('k', 'cursor_up', 'Up', show=True), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + align: center middle; + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + overflow-x: hidden; + margin-bottom: 0; + width: 100%; + height: auto; + } + + OptionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + def action_cursor_down(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_up() + + 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.items: + options.append(Option(item.text, id=item.text)) + + return options + + @override + def compose(self) -> ComposeResult: + options = self._get_options() + + if self._header: + yield Static(self._header, classes='header', id='header') + + with Center(): + with Vertical(classes='content-container'): + yield OptionList(*options, id='option_list_widget') + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + + for item in self._group.items: + if item.text == selected_option.id: + self.dismiss(Result(ResultType.Selection, item.value)) + return + + debug(f"Error: Selected option ID '{selected_option.id}' not found in group items") + + class ConfirmationScreen(BaseScreen[ValueT]): BINDINGS = [ # noqa: RUF012 Binding('l', 'focus_right', 'Focus right', show=True), @@ -164,7 +270,8 @@ def __init__( self._header = header async def run(self) -> Result[ValueT]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: @@ -220,6 +327,7 @@ def __init__(self, header: str): class InputScreen(BaseScreen[str]): CSS = """ InputScreen { + background: transparent; } .dialog-wrapper { @@ -288,7 +396,8 @@ def __init__( self._allow_skip = allow_skip async def run(self) -> Result[str]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) @override def compose(self) -> ComposeResult: @@ -342,10 +451,9 @@ class TableSelectionScreen(BaseScreen[ValueT]): .content-container { width: auto; - min-height: 10; - min-width: 40; align: center middle; background: transparent; + padding: 2 0; } .header { @@ -378,19 +486,18 @@ def __init__( raise ValueError('Either data or data_callback must be provided') async def run(self) -> Result[ValueT]: - return await tui.show(self) + assert TApp.app + return await TApp.app.show(self) def action_cursor_down(self) -> None: table = self.query_one(DataTable) - if table.cursor_row is not None: - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) def action_cursor_up(self) -> None: table = self.query_one(DataTable) - if table.cursor_row is not None: - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) @override def compose(self) -> ComposeResult: @@ -457,7 +564,7 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: self.dismiss(Result(ResultType.Selection, data)) # type: ignore[unused-awaitable] -class TApp(App[Any]): +class _AppInstance(App[ValueT]): CSS = """ .app-header { dock: top; @@ -470,20 +577,8 @@ class TApp(App[Any]): } """ - def __init__(self) -> None: + def __init__(self, main: Any) -> None: super().__init__(ansi_color=True) - self._main = None - self._global_header: str | None = None - - @property - def global_header(self) -> str | None: - return self._global_header - - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value - - def set_main(self, main: Any) -> None: self._main = main def on_mount(self) -> None: @@ -492,8 +587,7 @@ def on_mount(self) -> None: @work async def _run_worker(self) -> None: try: - if self._main is not None: - await self._main.run() # type: ignore[unreachable] + await self._main.run() # type: ignore[unreachable] except Exception as err: debug(f'Error while running main app: {err}') raise err from err @@ -506,4 +600,29 @@ async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: return await self._show_async(screen).wait() +class TApp: + app: _AppInstance[Any] | None = None + + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None + + @property + def global_header(self) -> str | None: + return self._global_header + + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value + + def run(self, main: Any) -> Result[ValueT] | None: + TApp.app = _AppInstance(main) + return TApp.app.run() + + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return + + tui = TApp() From 275259928c34d59cd166055e675938719b2b7276 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 9 Nov 2025 20:50:42 +1100 Subject: [PATCH 02/40] Update --- .gitignore | 1 + archinstall/lib/global_menu.py | 5 +- archinstall/lib/locale/locale_menu.py | 18 +- archinstall/lib/menu/abstract_menu.py | 23 +- archinstall/lib/menu/helpers.py | 26 +- archinstall/lib/network/wifi_handler.py | 4 +- archinstall/scripts/guided.py | 1 + archinstall/tui/curses_menu.py | 6 +- archinstall/tui/menu_item.py | 32 +- archinstall/tui/ui/components.py | 1194 ++++++++++++----------- archinstall/tui/ui/result.py | 19 +- 11 files changed, 711 insertions(+), 618 deletions(-) diff --git a/.gitignore b/.gitignore index 9e30144f3b..aa065d1d04 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ requirements.txt /actions-runner /cmd_output.txt uv.lock +pyrightconfig.json diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 8acafd8a4c..b166066e7c 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -171,6 +171,7 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text='', + read_only=True, ), MenuItem( text=tr('Save configuration'), @@ -194,8 +195,8 @@ def _get_menu_options(self) -> list[MenuItem]: def _safe_config(self) -> None: # data: dict[str, Any] = {} # for item in self._item_group.items: - # if item.key is not None: - # data[item.key] = item.value + # if item.key is not None: + # data[item.key] = item.value self.sync_all_to_config() save_config(self._arch_config) diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 7c5a48dc54..f0d959319f 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -31,33 +31,25 @@ def _define_menu_options(self) -> list[MenuItem]: text=tr('Keyboard layout'), action=self._select_kb_layout, value=self._locale_conf.kb_layout, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='kb_layout', ), MenuItem( text=tr('Locale language'), action=select_locale_lang, value=self._locale_conf.sys_lang, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='sys_lang', ), MenuItem( text=tr('Locale encoding'), action=select_locale_enc, value=self._locale_conf.sys_enc, - preview_action=self._prev_locale, + preview_action=lambda item: item.get_value(), key='sys_enc', ), ] - def _prev_locale(self, item: MenuItem) -> str: - temp_locale = LocaleConfiguration( - self._menu_item_group.find_by_key('kb_layout').get_value(), - self._menu_item_group.find_by_key('sys_lang').get_value(), - self._menu_item_group.find_by_key('sys_enc').get_value(), - ) - return temp_locale.preview() - @override def run( self, @@ -104,7 +96,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: match result.type_: case ResultType.Selection: - return result.get_value() + return result.value() case ResultType.Skip: return preset case _: @@ -131,7 +123,7 @@ def select_kb_layout(preset: str | None = None) -> str | None: match result.type_: case ResultType.Selection: - return result.get_value() + return result.value() case ResultType.Skip: return preset case _: diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 209e312007..c00bd88fad 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -3,11 +3,12 @@ from types import TracebackType from typing import Any, Self +from archinstall.lib.menu.helpers import SingleSelection from archinstall.lib.translationhandler import tr from archinstall.tui.curses_menu import SelectMenu, Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Chars, FrameProperties, FrameStyle, PreviewStyle +from archinstall.tui.ui.result import ResultType +from archinstall.tui.types import Chars from ..output import error @@ -101,16 +102,15 @@ def run( self._sync_from_config() while True: - result = SelectMenu[ValueT]( - self._menu_item_group, + result = SingleSelection[ValueT]( + group=self._menu_item_group, + header=additional_title, allow_skip=False, allow_reset=self._allow_reset, - reset_warning_msg=self._reset_warning, - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - preview_frame=FrameProperties('Info', FrameStyle.MAX), - additional_title=additional_title, - ).run() + preview_orientation='right', + # reset_warning_msg=self._reset_warning, + # additional_title=additional_title, + ).show() match result.type_: case ResultType.Selection: @@ -120,8 +120,11 @@ def run( if not self._is_config_valid(): continue break + else: + item.value = item.action(item.value) case ResultType.Reset: return None + case _: pass self.sync_all_to_config() return None diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index b4104f21e9..e7c6a79833 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -1,8 +1,9 @@ -from typing import TypeVar +from typing import Literal, TypeVar from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItemGroup -from archinstall.tui.ui.components import OptionListScreen, tui +from archinstall.tui.ui.components import ConfirmationScreen, OptionListScreen, tui from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') @@ -11,23 +12,23 @@ class SingleSelection[ValueT]: def __init__( self, - header: str, group: MenuItemGroup, + header: str | None = None, allow_skip: bool = True, allow_reset: bool = False, + preview_orientation: Literal['right', 'bottom'] | None = None ): self._header = header self._group: MenuItemGroup = group self._allow_skip = allow_skip self._allow_reset = allow_reset + self._preview_orientation = preview_orientation def show(self) -> Result[ValueT]: result = tui.run(self) - if result is None: - return Result(ResultType.Selection, None) return result - async def run(self) -> None: + async def _run(self) -> None: debug('Running single selection menu') result = await OptionListScreen[ValueT]( @@ -35,6 +36,19 @@ async def run(self) -> None: header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, + preview_location=self._preview_orientation ).run() + if result.type_ == ResultType.Reset: + confirmed = await ConfirmationScreen[bool]( + MenuItemGroup.yes_no(), + header=tr('Are you sure you want to reset this setting?'), + allow_skip=False, + allow_reset=False, + ).run() + + if confirmed.value() is False: + return await self._run() + tui.exit(result) + diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index ab1a0e31ff..eee736277c 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -29,7 +29,7 @@ def setup(self) -> Any: result = tui.run(self) return result - async def run(self) -> None: + async def _run(self) -> None: """ This is the entry point that is called by components.TApp """ @@ -58,8 +58,6 @@ async def run(self) -> None: case ResultType.Skip | ResultType.Reset: tui.exit(False) return None - case _: - assert_never(result) setup_result = await self._setup_wifi(wifi_iface) tui.exit(setup_result) diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index c0a252b888..e7eb60347e 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -11,6 +11,7 @@ from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.installer import Installer, accessibility_tools_in_use, run_custom_user_commands from archinstall.lib.interactions.general_conf import PostInstallationAction, ask_post_installation +from archinstall.lib.locale.locale_menu import select_locale_lang from archinstall.lib.models import Bootloader from archinstall.lib.models.device import ( DiskLayoutType, diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index d1ab11e188..7c4a677b54 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -90,7 +90,7 @@ def _show_help(self) -> None: lines = help_text.split('\n') entries = [ViewportEntry('', 0, 0, STYLE.NORMAL)] - entries += [ViewportEntry(f' {e} ', idx + 1, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] + entries += [ViewportEntry(f' {e} ', idx + 1, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] self._help_window.update(entries, 0) def get_header_entries(self, header: str) -> list[ViewportEntry]: @@ -227,7 +227,7 @@ def _get_frame_dim( # 2 for frames, 1 for extra space start away from frame # must align with def _adjust_entries - frame_end += 3 # 2 for frame + frame_end += 3 # 2 for frame frame_height = len(rows) + 1 if frame_height > max_height: @@ -1122,7 +1122,7 @@ def _calc_prev_scroll_pos( def _multi_prefix(self, item: MenuItem) -> str: if item.read_only: - return ' ' + return ' ' elif self._item_group.is_item_selected(item): return '[x] ' else: diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index 741e447d35..5a5e49c5b9 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from enum import Enum from functools import cached_property -from typing import Any, ClassVar +from typing import Any, ClassVar, Self from archinstall.lib.translationhandler import tr @@ -22,12 +22,23 @@ class MenuItem: dependencies: list[str | Callable[[], bool]] = field(default_factory=list) dependencies_not: list[str] = field(default_factory=list) display_action: Callable[[Any], str] | None = None - preview_action: Callable[[Any], str | None] | None = None + preview_action: Callable[[Self], str | None] | None = None key: str | None = None + _id: str = '' + _yes: ClassVar[MenuItem | None] = None _no: ClassVar[MenuItem | None] = None + def __post_init__(self): + if self.key is not None: + self._id = self.key + else: + self._id = str(id(self)) + + def get_id(self) -> str: + return self._id + def get_value(self) -> Any: assert self.value is not None return self.value @@ -101,14 +112,21 @@ def __init__( def add_item(self, item: MenuItem) -> None: self._menu_items.append(item) - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache + + def find_by_id(self, id: str) -> MenuItem: + for item in self._menu_items: + if item.get_id() == id: + return item + + raise ValueError(f'No item found for id: {id}') def find_by_key(self, key: str) -> MenuItem: for item in self._menu_items: if item.key == key: return item - raise ValueError(f'No key found for: {key}') + raise ValueError(f'No item found for key: {key}') def get_enabled_items(self) -> list[MenuItem]: return [it for it in self.items if self.is_enabled(it)] @@ -243,17 +261,17 @@ def has_filter(self) -> bool: def set_filter_pattern(self, pattern: str) -> None: self._filter_pattern = pattern - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def append_filter(self, pattern: str) -> None: self._filter_pattern += pattern - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def reduce_filter(self) -> None: self._filter_pattern = self._filter_pattern[:-1] - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def _reload_focus_item(self) -> None: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 4225afdbc7..71bafc5347 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any, TypeVar, override +from typing import Any, Literal, TypeVar, override from textual import work from textual.app import App, ComposeResult @@ -9,7 +9,7 @@ from textual.containers import Center, Horizontal, Vertical from textual.events import Key from textual.screen import Screen -from textual.widgets import Button, DataTable, Input, LoadingIndicator, OptionList, Static +from textual.widgets import Button, DataTable, Input, LoadingIndicator, OptionList, Rule, Static from textual.widgets.option_list import Option from archinstall.lib.output import debug @@ -21,608 +21,660 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS = [ # noqa: RUF012 - Binding('escape', 'cancel_operation', 'Cancel', show=True), - Binding('ctrl+c', 'reset_operation', 'Reset', show=True), - ] + BINDINGS = [ # noqa: RUF012 + 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 __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, None)) # type: ignore[unused-awaitable] + def action_cancel_operation(self) -> None: + if self._allow_skip: + self.dismiss(Result(ResultType.Skip)) # type: ignore[unused-awaitable] - def action_reset_operation(self) -> None: - if self._allow_reset: - self.dismiss(Result(ResultType.Reset, None)) # type: ignore[unused-awaitable] + async def action_reset_operation(self) -> None: + if self._allow_reset: + self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available.""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available.""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ - - def __init__( - self, - timer: int, - header: str | None = None, - ): - super().__init__() - self._timer = timer - self._header = header - - async def run(self) -> Result[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) # ensures indicator is centered too - - def on_mount(self) -> None: - self.set_timer(self._timer, self.action_pop_screen) - - def action_pop_screen(self) -> None: - self.dismiss() # type: ignore[unused-awaitable] + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ + + def __init__( + self, + timer: int, + header: str | None = None, + ): + super().__init__() + self._timer = timer + self._header = header + + async def run(self) -> Result[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) # ensures indicator is centered too + + def on_mount(self) -> None: + self.set_timer(self._timer, self.action_pop_screen) + + def action_pop_screen(self) -> None: + self.dismiss() # type: ignore[unused-awaitable] class OptionListScreen(BaseScreen[ValueT]): - BINDINGS = [ - Binding('j', 'cursor_down', 'Down', show=True), - Binding('k', 'cursor_up', 'Up', show=True), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - align: center middle; - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - overflow-x: hidden; - margin-bottom: 0; - width: 100%; - height: auto; - } - - OptionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - def action_cursor_down(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_up() - - 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.items: - options.append(Option(item.text, id=item.text)) - - return options - - @override - def compose(self) -> ComposeResult: - options = self._get_options() - - if self._header: - yield Static(self._header, classes='header', id='header') - - with Center(): - with Vertical(classes='content-container'): - yield OptionList(*options, id='option_list_widget') - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - - for item in self._group.items: - if item.text == selected_option.id: - self.dismiss(Result(ResultType.Selection, item.value)) - return - - debug(f"Error: Selected option ID '{selected_option.id}' not found in group items") + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=True), + Binding('k', 'cursor_up', 'Up', 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-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + + def action_cursor_down(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_up() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + options = self._get_options() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield OptionList(*options, id='option_list_widget') + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield OptionList(*options, id='option_list_widget', classes='no-border') + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + 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 self._preview_location is None: + return None + + preview_widget = self.query_one('#preview_content', Static) + highlighted_id = event.option.id + + item = self._group.find_by_id(highlighted_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 ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('l', 'focus_right', 'Focus right', show=True), - Binding('h', 'focus_left', 'Focus left', show=True), - Binding('right', 'focus_right', 'Focus right', show=True), - Binding('left', 'focus_left', 'Focus left', show=True), - ] - - CSS = """ - ConfirmationScreen { - align: center middle; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .dialog { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .dialog-content { - padding: 1; - height: 100%; - } - - .message { - text-align: center; - margin-bottom: 1; - } - - .buttons { - align: center middle; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - self.dismiss(Result(ResultType.Selection, item.value)) # type: ignore[unused-awaitable] + BINDINGS = [ # noqa: RUF012 + Binding('l', 'focus_right', 'Focus right', show=True), + Binding('h', 'focus_left', 'Focus left', show=True), + Binding('right', 'focus_right', 'Focus right', show=True), + Binding('left', 'focus_left', 'Focus left', show=True), + ] + + CSS = """ + ConfirmationScreen { + align: center middle; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .dialog { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .dialog-content { + padding: 1; + height: 100%; + } + + .message { + text-align: center; + margin-bottom: 1; + } + + .buttons { + align: center middle; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='dialog'): + with Vertical(classes='dialog-content'): + yield Static(self._header, classes='message') + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - background: transparent; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .input-dialog { - width: 60; - height: 10; - border: none; - background: transparent; - } - - .input-content { - padding: 1; - height: 100%; - } - - .input-header { - text-align: center; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .input-prompt { - text-align: center; - margin: 0 0 1 0; - background: transparent; - } - - Input { - margin: 1 2; - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ - - def __init__( - self, - header: str, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._placeholder = placeholder or '' - self._password = password - self._default_value = default_value or '' - self._allow_reset = allow_reset - self._allow_skip = allow_skip - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='input-dialog'): - with Vertical(classes='input-content'): - yield Static(self._header, classes='input-header') - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - ) - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - input_field = self.query_one('#main_input', Input) - value = input_field.value - self.dismiss(Result(ResultType.Selection, value)) # type: ignore[unused-awaitable] + CSS = """ + InputScreen { + background: transparent; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .input-dialog { + width: 60; + height: 10; + border: none; + background: transparent; + } + + .input-content { + padding: 1; + height: 100%; + } + + .input-header { + text-align: center; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .input-prompt { + text-align: center; + margin: 0 0 1 0; + background: transparent; + } + + Input { + margin: 1 2; + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + def __init__( + self, + header: str, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._placeholder = placeholder or '' + self._password = password + self._default_value = default_value or '' + self._allow_reset = allow_reset + self._allow_skip = allow_skip + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='input-dialog'): + with Vertical(classes='input-content'): + yield Static(self._header, classes='input-header') + yield Input( + placeholder=self._placeholder, + password=self._password, + value=self._default_value, + id='main_input', + ) + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + input_field = self.query_one('#main_input', Input) + value = input_field.value + self.dismiss(Result(ResultType.Selection, _data=value)) # type: ignore[unused-awaitable] class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('j', 'cursor_down', 'Down', show=True), - Binding('k', 'cursor_up', 'Up', show=True), - ] - - CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } - - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } - - DataTable .datatable--header { - background: transparent; - border: solid; - } - - .content-container { - width: auto; - align: center middle; - background: transparent; - padding: 2 0; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - height: auto; - background: transparent; - } - """ - - def __init__( - self, - header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._data = data - self._data_callback = data_callback - self._loading_header = loading_header - - if self._data is None and self._data_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') - - def on_mount(self) -> None: - self._display_header(True) - data_table = self.query_one(DataTable) - data_table.cell_padding = 2 - - if self._data: - self._put_data_to_table(data_table, self._data) - else: - self._load_data(data_table) - - @work - async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) - - def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: - self.dismiss(Result(ResultType.Selection, None)) # type: ignore[unused-awaitable] - return - - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] - table.add_columns(*cols) - - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] - table.add_row(*row_values, key=d) # type: ignore[arg-type] - - table.cursor_type = 'row' - table.display = True - - loader = self.query_one('#loader') - loader.display = False - self._display_header(False) - table.focus() - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - data: ValueT = event.row_key.value # type: ignore[assignment] - self.dismiss(Result(ResultType.Selection, data)) # type: ignore[unused-awaitable] + BINDINGS = [ # noqa: RUF012 + Binding('j', 'cursor_down', 'Down', show=True), + Binding('k', 'cursor_up', 'Up', show=True), + ] + + CSS = """ + TableSelectionScreen { + align: center middle; + background: transparent; + } + + DataTable { + height: auto; + width: auto; + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + .content-container { + width: auto; + align: center middle; + background: transparent; + padding: 2 0; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + height: auto; + background: transparent; + } + """ + + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + + if self._data is None and self._data_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + yield DataTable(id='data_table') + + def on_mount(self) -> None: + self._display_header(True) + data_table = self.query_one(DataTable) + data_table.cell_padding = 2 + + if self._data: + self._put_data_to_table(data_table, self._data) + else: + self._load_data(data_table) + + @work + async def _load_data(self, table: DataTable[ValueT]) -> None: + assert self._data_callback is not None + data = await self._data_callback() + self._put_data_to_table(table, data) + + def _display_header(self, is_loading: bool) -> None: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: + if not data: + self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] + return + + cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + table.add_columns(*cols) + + for d in data: + row_values = list(d.table_data().values()) # type: ignore[attr-defined] + table.add_row(*row_values, key=d) # type: ignore[arg-type] + + table.cursor_type = 'row' + table.display = True + + loader = self.query_one('#loader') + loader.display = False + self._display_header(False) + table.focus() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + data: ValueT = event.row_key.value # type: ignore[assignment] + self.dismiss(Result(ResultType.Selection, _data=data)) # type: ignore[unused-awaitable] class _AppInstance(App[ValueT]): - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: $primary; - color: white; - text-style: bold; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main.run() # type: ignore[unreachable] - except Exception as err: - debug(f'Error while running main app: {err}') - raise err from 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() + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() # type: ignore[unreachable] + except Exception as err: + debug(f'Error while running main app: {err}') + # this will terminate the textual app and return the exception + self.exit(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 + app: _AppInstance[Any] | None = None + + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None + + @property + def global_header(self) -> str | None: + return self._global_header + + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result = TApp.app.run() - @property - def global_header(self) -> str | None: - return self._global_header + if isinstance(result, Exception): + raise result - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + if result is None: + raise ValueError('No result returned') - def run(self, main: Any) -> Result[ValueT] | None: - TApp.app = _AppInstance(main) - return TApp.app.run() + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index c4e92468a5..ebb668bade 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -2,6 +2,8 @@ from enum import Enum, auto from typing import cast +from archinstall.tui import MenuItem + class ResultType(Enum): Selection = auto() @@ -12,14 +14,25 @@ class ResultType(Enum): @dataclass class Result[ValueT]: type_: ResultType - _data: ValueT | list[ValueT] | None + _data: ValueT | list[ValueT] | None = None + _item: MenuItem | None = None def has_data(self) -> bool: return self._data is not None + def item(self) -> MenuItem: + if self._item is not None: + return self._item + + raise ValueError('No item found') + def value(self) -> ValueT: - assert type(self._data) is not list and self._data is not None - return cast(ValueT, self._data) + if self._item is not None and self._item.value is not None: + return self._item.value + elif type(self._data) is not list and self._data is not None: + return cast(ValueT, self._data) + + raise ValueError('No value found') def values(self) -> list[ValueT]: assert type(self._data) is list From b7022d967d6407239818a2cbc24da05883180a27 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 17 Nov 2025 21:15:41 +1100 Subject: [PATCH 03/40] Update --- .../lib/authentication/authentication_menu.py | 5 +- archinstall/lib/disk/disk_menu.py | 27 +- archinstall/lib/disk/encryption_menu.py | 6 +- archinstall/lib/global_menu.py | 1 + archinstall/lib/interactions/disk_conf.py | 95 +- archinstall/lib/interactions/general_conf.py | 21 +- archinstall/lib/interactions/system_conf.py | 80 +- archinstall/lib/locale/locale_menu.py | 22 +- archinstall/lib/menu/abstract_menu.py | 22 +- archinstall/lib/menu/helpers.py | 198 ++- archinstall/lib/menu/list_manager.py | 28 +- archinstall/lib/mirrors.py | 98 +- archinstall/lib/models/device.py | 6 +- archinstall/lib/profile/profile_menu.py | 3 +- archinstall/tui/menu_item.py | 22 +- archinstall/tui/ui/components.py | 1462 ++++++++++------- archinstall/tui/ui/result.py | 21 +- 17 files changed, 1247 insertions(+), 870 deletions(-) diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py index 2926e9cfed..efc127d1b9 100644 --- a/archinstall/lib/authentication/authentication_menu.py +++ b/archinstall/lib/authentication/authentication_menu.py @@ -31,9 +31,8 @@ def __init__(self, preset: AuthenticationConfiguration | None = None): ) @override - def run(self, additional_title: str | None = None) -> AuthenticationConfiguration: - super().run(additional_title=additional_title) - return self._auth_config + def run(self, additional_title: str | None = None) -> AuthenticationConfiguration | None: + return super().run(additional_title=additional_title) def _define_menu_options(self) -> list[MenuItem]: return [ diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 4a5a84bb24..dd62a800e4 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -2,6 +2,7 @@ from typing import override from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu +from archinstall.lib.menu.helpers import SelectionMenu from archinstall.lib.models.device import ( DEFAULT_ITER_TIME, BtrfsOptions, @@ -14,10 +15,8 @@ SnapshotType, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType from ..interactions.disk_conf import select_disk_config, select_lvm_config from ..menu.abstract_menu import AbstractSubMenu @@ -96,13 +95,15 @@ def _define_menu_options(self) -> list[MenuItem]: @override def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: - super().run(additional_title=additional_title) + config: DiskMenuConfig | None = super().run(additional_title=additional_title) # pyright: ignore[reportAssignmentType] + if config is None: + return None - if self._disk_menu_config.disk_config: - self._disk_menu_config.disk_config.lvm_config = self._disk_menu_config.lvm_config - self._disk_menu_config.disk_config.btrfs_options = BtrfsOptions(snapshot_config=self._disk_menu_config.btrfs_snapshot_config) - self._disk_menu_config.disk_config.disk_encryption = self._disk_menu_config.disk_encryption - return self._disk_menu_config.disk_config + if config.disk_config: + config.disk_config.lvm_config = self._disk_menu_config.lvm_config + config.disk_config.btrfs_options = BtrfsOptions(snapshot_config=self._disk_menu_config.btrfs_snapshot_config) + config.disk_config.disk_encryption = self._disk_menu_config.disk_encryption + return config.disk_config return None @@ -169,13 +170,11 @@ def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConf preset=preset_type, ) - result = SelectMenu[SnapshotType]( + result = SelectionMenu[SnapshotType]( group, allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('Snapshot type')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -183,7 +182,7 @@ def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConf case ResultType.Reset: return None case ResultType.Selection: - return SnapshotConfig(snapshot_type=result.get_value()) + return SnapshotConfig(snapshot_type=result.value()) def _prev_disk_layouts(self, item: MenuItem) -> str | None: if not item.value: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 9375a06b1f..f9023dcc81 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -124,7 +124,9 @@ def _check_dep_lvm_vols(self) -> bool: @override def run(self, additional_title: str | None = None) -> DiskEncryption | None: - super().run(additional_title=additional_title) + enc_config = super().run(additional_title=additional_title) + if enc_config is None: + return None enc_type: EncryptionType | None = self._item_group.find_by_key('encryption_type').value enc_password: Password | None = self._item_group.find_by_key('encryption_password').value @@ -148,7 +150,7 @@ def run(self, additional_title: str | None = None) -> DiskEncryption | None: encryption_type=enc_type, partitions=enc_partitions, lvm_volumes=enc_lvm_vols, - hsm_device=self._enc_config.hsm_device, + hsm_device=enc_config.hsm_device, iter_time=iter_time or DEFAULT_ITER_TIME, ) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b166066e7c..9b0e7c3400 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -60,6 +60,7 @@ def _get_menu_options(self) -> list[MenuItem]: ), MenuItem( text=tr('Locales'), + value=LocaleConfiguration.default(), action=self._locale_selection, preview_action=self._prev_locale, key='locale_config', diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 1f3497869a..6f3b6d39cb 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -3,6 +3,7 @@ from archinstall.lib.args import arch_config_handler from archinstall.lib.disk.device_handler import device_handler from archinstall.lib.disk.partitioning_menu import manual_partitioning +from archinstall.lib.menu.helpers import Confirmation, Notify, SelectionMenu, TableMenu from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( BDevice, @@ -28,10 +29,8 @@ ) from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.result import ResultType from ..output import FormattedOutput from ..utils.util import prompt_dir @@ -53,20 +52,17 @@ def _preview_device_selection(item: MenuItem) -> str | None: options = [d.device_info for d in devices] presets = [p.device_info for p in preset] - group = MenuHelper(options).create_menu_group() - group.set_selected_by_value(presets) - group.set_preview_for_all(_preview_device_selection) + # group = MenuHelper(options).create_menu_group() + # group.set_selected_by_value(presets) + # group.set_preview_for_all(_preview_device_selection) - result = SelectMenu[_DeviceInfo]( - group, - alignment=Alignment.CENTER, - search_enabled=False, - multi=True, - preview_style=PreviewStyle.BOTTOM, - preview_size='auto', - preview_frame=FrameProperties.max('Partitions'), + result = TableMenu[_DeviceInfo]( + header=tr('Select disks for the installation'), + data=options, allow_skip=True, - ).run() + multi=True, + preview_orientation='bottom', + ).show() match result.type_: case ResultType.Reset: @@ -74,7 +70,7 @@ def _preview_device_selection(item: MenuItem) -> str | None: case ResultType.Skip: return preset case ResultType.Selection: - selected_device_info = result.get_values() + selected_device_info = result.values() selected_devices = [] for device in devices: @@ -132,13 +128,13 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if preset: group.set_selected_by_value(preset.config_type.display_msg()) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, + header=tr('Select a disk configuration'), allow_skip=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Disk configuration type')), allow_reset=True, - ).run() + show_frame=False, + ).show() match result.type_: case ResultType.Skip: @@ -146,7 +142,7 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay case ResultType.Reset: return None case ResultType.Selection: - selection = result.get_value() + selection = result.value() if selection == pre_mount_mode: output = 'You will use whatever drive-setup is mounted at the specified directory\n' @@ -171,14 +167,14 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if not devices: return None - if result.get_value() == default_layout: + if result.value() == default_layout: modifications = get_default_partition_layout(devices) if modifications: return DiskLayoutConfiguration( config_type=DiskLayoutType.Default, device_modifications=modifications, ) - elif result.get_value() == manual_mode: + elif result.value() == manual_mode: preset_mods = preset.device_modifications if preset else [] modifications = _manual_partitioning(preset_mods, devices) @@ -202,13 +198,11 @@ def select_lvm_config( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('LVM configuration type')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -216,7 +210,7 @@ def select_lvm_config( case ResultType.Reset: return None case ResultType.Selection: - if result.get_value() == default_mode: + if result.value() == default_mode: return suggest_lvm_layout(disk_config) return None @@ -253,16 +247,14 @@ def select_main_filesystem_format() -> FilesystemType: items.append(MenuItem('ntfs', value=FilesystemType.Ntfs)) group = MenuItemGroup(items, sort_items=False) - result = SelectMenu[FilesystemType]( + result = SelectionMenu[FilesystemType]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min('Filesystem'), allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: - return result.get_value() + return result.value() case _: raise ValueError('Unhandled result type') @@ -277,21 +269,17 @@ def select_mount_options() -> list[str]: MenuItem(disable_cow, value=BtrfsMountOption.nodatacow.value), ] group = MenuItemGroup(items, sort_items=False) - result = SelectMenu[str]( + result = Confirmation[str]( group, header=prompt, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - search_enabled=False, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: return [] case ResultType.Selection: - return [result.get_value()] + return [result.value()] case _: raise ValueError('Unhandled result type') @@ -340,14 +328,11 @@ def suggest_single_disk_layout( prompt = tr('Would you like to use BTRFS subvolumes with a default structure?') + '\n' group = MenuItemGroup.yes_no() group.set_focus_by_value(MenuItem.yes().value) - result = SelectMenu[bool]( + result = Confirmation[bool]( group, header=prompt, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, allow_skip=False, - ).run() + ).show() using_subvolumes = result.item() == MenuItem.yes() mount_options = select_mount_options() @@ -377,14 +362,11 @@ def suggest_single_disk_layout( prompt = tr('Would you like to create a separate partition for /home?') + '\n' group = MenuItemGroup.yes_no() group.set_focus_by_value(MenuItem.yes().value) - result = SelectMenu( + result = Confirmation[str]( group, header=prompt, - orientation=Orientation.HORIZONTAL, - columns=2, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + ).show() using_home_partition = result.item() == MenuItem.yes() @@ -474,10 +456,7 @@ def suggest_multi_disk_layout( text += tr('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(Unit.GiB)) text += tr('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(Unit.GiB)) - items = [MenuItem(tr('Continue'))] - group = MenuItemGroup(items) - SelectMenu(group).run() - + Notify(text).show() return [] if filesystem_type == FilesystemType.Btrfs: @@ -568,15 +547,11 @@ def suggest_lvm_layout( group = MenuItemGroup.yes_no() group.set_focus_by_value(MenuItem.yes().value) - result = SelectMenu[bool]( + result = Confirmation[bool]( group, header=prompt, - search_enabled=False, allow_skip=False, - orientation=Orientation.HORIZONTAL, - columns=2, - alignment=Alignment.CENTER, - ).run() + ).show() using_subvolumes = MenuItem.yes() == result.item() mount_options = select_mount_options() diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 23601e8825..872793bcd4 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import assert_never -from archinstall.lib.menu.helpers import SingleSelection +from archinstall.lib.menu.helpers import Input, SelectionMenu from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr @@ -58,22 +58,21 @@ def ask_ntp(preset: bool = True) -> bool: def ask_hostname(preset: str | None = None) -> str | None: - result = EditMenu( - tr('Hostname'), - alignment=Alignment.CENTER, + result = Input( + header=tr('Hostname'), allow_skip=True, - default_text=preset, - ).input() + default_value=preset, + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Selection: - hostname = result.text() + case UiResultType.Selection: + hostname = result.value() if len(hostname) < 1: return None return hostname - case ResultType.Reset: + case UiResultType.Reset: raise ValueError('Unhandled result type') @@ -138,7 +137,7 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> # frame=FrameProperties.min(header=tr('Select language')), # ).run() - result = SingleSelection[Language]( + result = SelectionMenu[Language]( header=title, group=group, allow_reset=False, diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 99a1d8bd16..7d3d3436c9 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -1,10 +1,9 @@ from __future__ import annotations +from archinstall.lib.menu.helpers import Confirmation, SelectionMenu from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, FrameStyle, Orientation, PreviewStyle +from archinstall.tui.ui.result import ResultType as UiResultType from ..args import arch_config_handler from ..hardware import GfxDriver, SysInfo @@ -28,22 +27,21 @@ def select_kernel(preset: list[str] = []) -> list[str]: group.set_focus_by_value(default_kernel) group.set_selected_by_value(preset) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, + header=tr('Select which kernel(s) to install'), allow_skip=True, allow_reset=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Kernel')), multi=True, - ).run() + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Reset: + case UiResultType.Reset: return [] - case ResultType.Selection: - return result.get_values() + case UiResultType.Selection: + return result.values() def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: @@ -51,7 +49,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options = [] hidden_options = [] default = None - header = None + header = tr('Select which bootloader to install') if arch_config_handler.args.skip_boot: default = Bootloader.NO_BOOTLOADER @@ -62,7 +60,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options += [Bootloader.Grub, Bootloader.Limine] if not default: default = Bootloader.Grub - header = tr('UEFI is not detected and some options are disabled') + header += '\n' + tr('UEFI is not detected and some options are disabled') else: options += [b for b in Bootloader if b not in hidden_options] if not default: @@ -73,20 +71,18 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: group.set_default_by_value(default) group.set_focus_by_value(preset) - result = SelectMenu[Bootloader]( + result = SelectionMenu[Bootloader]( group, header=header, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Bootloader')), allow_skip=True, - ).run() + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Selection: - return result.get_value() - case ResultType.Reset: + case UiResultType.Selection: + return result.value() + case UiResultType.Reset: raise ValueError('Unhandled result type') @@ -96,21 +92,18 @@ def ask_for_uki(preset: bool = True) -> bool: group = MenuItemGroup.yes_no() group.set_focus_by_value(preset) - result = SelectMenu[bool]( + result = Confirmation[bool]( group, header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Selection: + case UiResultType.Selection: return result.item() == MenuItem.yes() - case ResultType.Reset: + case UiResultType.Reset: raise ValueError('Unhandled result type') @@ -140,23 +133,21 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None if SysInfo.has_nvidia_graphics(): header += tr('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n') - result = SelectMenu[GfxDriver]( + result = SelectionMenu[GfxDriver]( group, header=header, allow_skip=True, allow_reset=True, - preview_size='auto', - preview_style=PreviewStyle.BOTTOM, - preview_frame=FrameProperties(tr('Info'), h_frame_style=FrameStyle.MIN), - ).run() + preview_orientation='right', + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Reset: + case UiResultType.Reset: return None - case ResultType.Selection: - return result.get_value() + case UiResultType.Selection: + return result.value() def ask_for_swap(preset: bool = True) -> bool: @@ -170,19 +161,16 @@ def ask_for_swap(preset: bool = True) -> bool: group = MenuItemGroup.yes_no() group.set_focus_by_value(default_item) - result = SelectMenu[bool]( + result = Confirmation[bool]( group, header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + ).show() match result.type_: - case ResultType.Skip: + case UiResultType.Skip: return preset - case ResultType.Selection: + case UiResultType.Selection: return result.item() == MenuItem.yes() - case ResultType.Reset: + case UiResultType.Reset: raise ValueError('Unhandled result type') diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index f0d959319f..60e822a784 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,6 +1,7 @@ from typing import override -from archinstall.lib.menu.helpers import SingleSelection +from archinstall.lib.menu.helpers import SelectionMenu +from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -51,12 +52,13 @@ def _define_menu_options(self) -> list[MenuItem]: ] @override - def run( - self, - additional_title: str | None = None, - ) -> LocaleConfiguration: - super().run(additional_title=additional_title) - return self._locale_conf + def run(self, additional_title: str | None = None) -> LocaleConfiguration: + config = super().run(additional_title=additional_title) + + if config is None: + config = LocaleConfiguration.default() + + return config def _select_kb_layout(self, preset: str | None) -> str | None: kb_lang = select_kb_layout(preset) @@ -73,7 +75,7 @@ def select_locale_lang(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SingleSelection[str](header=tr('Locale language'), group=group).show() + result = SelectionMenu[str](header=tr('Locale language'), group=group).show() match result.type_: case ResultType.Selection: @@ -92,7 +94,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SingleSelection[str](header=tr('Locale encoding'), group=group).show() + result = SelectionMenu[str](header=tr('Locale encoding'), group=group).show() match result.type_: case ResultType.Selection: @@ -119,7 +121,7 @@ def select_kb_layout(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=False) group.set_focus_by_value(preset) - result = SingleSelection[str](header=tr('Keyboard layout'), group=group).show() + result = SelectionMenu[str](header=tr('Keyboard layout'), group=group).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index c00bd88fad..5b796f513e 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -3,14 +3,14 @@ from types import TracebackType from typing import Any, Self -from archinstall.lib.menu.helpers import SingleSelection +from archinstall.lib.menu.helpers import SelectionMenu from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu, Tui +from archinstall.tui.curses_menu import Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.ui.result import ResultType from archinstall.tui.types import Chars +from archinstall.tui.ui.result import ResultType -from ..output import error +from ..output import debug, error CONFIG_KEY = '__config__' @@ -95,21 +95,16 @@ def disable_all(self) -> None: def _is_config_valid(self) -> bool: return True - def run( - self, - additional_title: str | None = None, - ) -> ValueT | None: + def run(self, additional_title: str | None = None) -> ValueT | None: self._sync_from_config() while True: - result = SingleSelection[ValueT]( + result = SelectionMenu[ValueT]( group=self._menu_item_group, header=additional_title, allow_skip=False, allow_reset=self._allow_reset, preview_orientation='right', - # reset_warning_msg=self._reset_warning, - # additional_title=additional_title, ).show() match result.type_: @@ -124,10 +119,11 @@ def run( item.value = item.action(item.value) case ResultType.Reset: return None - case _: pass + case _: + pass self.sync_all_to_config() - return None + return self._config class AbstractSubMenu[ValueT](AbstractMenu[ValueT]): diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index e7c6a79833..ab1de30d35 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -1,54 +1,226 @@ -from typing import Literal, TypeVar +from typing import Awaitable, Callable, Literal, TypeVar from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItemGroup -from archinstall.tui.ui.components import ConfirmationScreen, OptionListScreen, tui +from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, LoadingScreen, NotifyScreen, OptionListScreen, SelectListScreen, TableSelectionScreen, tui from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') -class SingleSelection[ValueT]: +class SelectionMenu[ValueT]: def __init__( self, group: MenuItemGroup, header: str | None = None, allow_skip: bool = True, allow_reset: bool = False, - preview_orientation: Literal['right', 'bottom'] | None = None + preview_orientation: Literal['right', 'bottom'] | None = None, + multi: bool = False, + search_enabled: bool = False, + show_frame: bool = True ): self._header = header self._group: MenuItemGroup = group self._allow_skip = allow_skip self._allow_reset = allow_reset self._preview_orientation = preview_orientation + self._multi = multi + self._search_enabled = search_enabled + self._show_frame = show_frame def show(self) -> Result[ValueT]: result = tui.run(self) return result async def _run(self) -> None: - debug('Running single selection menu') + if not self._multi: + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_orientation, + show_frame=self._show_frame + ).run() + else: + result = await SelectListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_orientation + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.value() is False: + return await self._run() + + tui.exit(result) + + +class Confirmation[ValueT]: + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + ): + self._header = header + self._group: MenuItemGroup = group + self._allow_skip = allow_skip + self._allow_reset = allow_reset - result = await OptionListScreen[ValueT]( + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + result = await ConfirmationScreen[ValueT]( self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, - preview_location=self._preview_orientation ).run() if result.type_ == ResultType.Reset: - confirmed = await ConfirmationScreen[bool]( - MenuItemGroup.yes_no(), - header=tr('Are you sure you want to reset this setting?'), - allow_skip=False, - allow_reset=False, - ).run() + confirmed = await _confirm_reset() + + if confirmed.value() is False: + return await self._run() + + tui.exit(result) + + +class Notify[ValueT]: + def __init__( + self, + header: str | None = None, + ): + self._header = header + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + await NotifyScreen(header=self._header).run() + tui.exit(True) + + +class Input[ValueT]: + def __init__( + self, + header: str | None = None, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + ): + self._header = header + self._placeholder = placeholder + self._password = password + self._default_value = default_value + self._allow_skip = allow_skip + self._allow_reset = allow_reset + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + result = await InputScreen( + header=self._header, + placeholder=self._placeholder, + password=self._password, + default_value=self._default_value, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.value() is False: + return await self._run() + + tui.exit(result) + + +class Loading[ValueT]: + def __init__( + self, + header: str | None = None, + timer: int = 3 + ): + self._header = header + self._timer = timer + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + await LoadingScreen(self._timer, self._header).run() + tui.exit(True) + + +class TableMenu[ValueT]: + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + preview_orientation: str = 'right', + ): + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._multi = multi + self._preview_orientation = preview_orientation + + if self._data is None and self._data_callback is None: + raise ValueError('Either data or data_callback must be provided') + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + result = await TableSelectionScreen[ValueT]( + header=self._header, + data=self._data, + data_callback=self._data_callback, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + loading_header=self._loading_header, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() if confirmed.value() is False: return await self._run() tui.exit(result) + +async def _confirm_reset() -> Result[bool]: + return await ConfirmationScreen[bool]( + MenuItemGroup.yes_no(), + header=tr('Are you sure you want to reset this setting?'), + allow_skip=False, + allow_reset=False, + ).run() diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 7c7b334270..a9a4f9edf6 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -1,12 +1,11 @@ import copy from typing import cast +from archinstall.lib.menu.helpers import SelectionMenu from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment +from archinstall.tui.ui.result import ResultType class ListManager[ValueT]: @@ -18,7 +17,7 @@ def __init__( prompt: str | None = None, ): """ - :param prompt: Text which will appear at the header + :param prompt: Text which will appear at the header type param: string :param entries: list/dict of option to be shown / manipulated @@ -70,17 +69,17 @@ def run(self) -> list[ValueT]: prompt = None - result = SelectMenu[ValueT | str]( + result = SelectionMenu[ValueT | str]( group, header=prompt, search_enabled=False, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + show_frame=False + ).show() match result.type_: case ResultType.Selection: - value = result.get_value() + value = result.value() case _: raise ValueError('Unhandled return type') @@ -90,15 +89,15 @@ def run(self) -> list[ValueT]: elif value in self._terminate_actions: break else: # an entry of the existing selection was chosen - selected_entry = result.get_value() + selected_entry = result.value() selected_entry = cast(ValueT, selected_entry) self._run_actions_on_entry(selected_entry) self._last_choice = value - if result.get_value() == self._cancel_action: - return self._original_data # return the original list + if result.value() == self._cancel_action: + return self._original_data # return the original list else: return self._data @@ -110,17 +109,16 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: header = f'{self.selected_action_display(entry)}\n' - result = SelectMenu[str]( + result = SelectionMenu[str]( group, header=header, search_enabled=False, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Selection: - value = result.get_value() + value = result.value() case _: raise ValueError('Unhandled return type') diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index acc4d9f735..4d06900883 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -3,11 +3,10 @@ from pathlib import Path from typing import override +from archinstall.lib.menu.helpers import Input, Loading, SelectionMenu from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu, Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType from .menu.abstract_menu import AbstractSubMenu from .menu.list_manager import ListManager @@ -52,50 +51,50 @@ def handle_action( entry: CustomRepository | None, data: list[CustomRepository], ) -> list[CustomRepository]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_repo = self._add_custom_repository() if new_repo is not None: data = [d for d in data if d.name != new_repo.name] data += [new_repo] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_repo = self._add_custom_repository(entry) if new_repo is not None: data = [d for d in data if d.name != entry.name] data += [new_repo] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data def _add_custom_repository(self, preset: CustomRepository | None = None) -> CustomRepository | None: - edit_result = EditMenu( - tr('Repository name'), - alignment=Alignment.CENTER, + edit_result = Input( + header=tr('Enter a respository name'), + placeholder=tr('Repository'), allow_skip=True, - default_text=preset.name if preset else None, - ).input() + default_value=preset.name if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - name = edit_result.text() + name = edit_result.value() case ResultType.Skip: return preset case _: raise ValueError('Unhandled return type') - header = f'{tr("Name")}: {name}' + header = f'{tr("Name")}: {name}\n\n' + header += tr('Enter the respository url') - edit_result = EditMenu( - tr('Url'), + edit_result = Input( header=header, - alignment=Alignment.CENTER, + placeholder=tr('Url'), allow_skip=True, - default_text=preset.url if preset else None, - ).input() + default_value=preset.url if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - url = edit_result.text() + url = edit_result.value() case ResultType.Skip: return preset case _: @@ -110,16 +109,15 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_check.value) - result = SelectMenu[SignCheck]( + result = SelectionMenu[SignCheck]( group, header=prompt, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: - sign_check = SignCheck(result.get_value()) + sign_check = SignCheck(result.value()) case _: raise ValueError('Unhandled return type') @@ -132,16 +130,15 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_option.value) - result = SelectMenu( + result = SelectionMenu( group, header=prompt, - alignment=Alignment.CENTER, allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: - sign_opt = SignOption(result.get_value()) + sign_opt = SignOption(result.value()) case _: raise ValueError('Unhandled return type') @@ -174,37 +171,37 @@ def handle_action( entry: CustomServer | None, data: list[CustomServer], ) -> list[CustomServer]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_server = self._add_custom_server() if new_server is not None: data = [d for d in data if d.url != new_server.url] data += [new_server] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_server = self._add_custom_server(entry) if new_server is not None: data = [d for d in data if d.url != entry.url] data += [new_server] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data def _add_custom_server(self, preset: CustomServer | None = None) -> CustomServer | None: - edit_result = EditMenu( - tr('Server url'), - alignment=Alignment.CENTER, + edit_result = Input( + header=tr('Enter server url'), + placeholder=tr('Url'), allow_skip=True, - default_text=preset.url if preset else None, - ).input() + default_value=preset.url if preset else None, + ).show() match edit_result.type_: case ResultType.Selection: - uri = edit_result.text() + uri = edit_result.value() return CustomServer(uri) case ResultType.Skip: return preset - - return None + case _: + return None class MirrorMenu(AbstractSubMenu[MirrorConfiguration]): @@ -296,13 +293,12 @@ def _prev_custom_servers(self, item: MenuItem) -> str | None: return output.strip() @override - def run(self, additional_title: str | None = None) -> MirrorConfiguration: - super().run(additional_title=additional_title) - return self._mirror_config + def run(self, additional_title: str | None = None) -> MirrorConfiguration | None: + return super().run(additional_title=additional_title) def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: - Tui.print(tr('Loading mirror regions...'), clear_screen=True) + Loading(tr('Loading mirror regions...')).show() mirror_list_handler.load_mirrors() available_regions = mirror_list_handler.get_mirror_regions() @@ -317,14 +313,13 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: group.set_selected_by_value(preset_regions) - result = SelectMenu[MirrorRegion]( + result = SelectionMenu[MirrorRegion]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Mirror regions')), + header=tr('Select mirror regions to be enabled'), allow_reset=True, allow_skip=True, multi=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -332,7 +327,7 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: case ResultType.Reset: return [] case ResultType.Selection: - selected_mirrors = result.get_values() + selected_mirrors = result.values() return selected_mirrors @@ -359,14 +354,13 @@ def select_optional_repositories(preset: list[Repository]) -> list[Repository]: group = MenuItemGroup(items, sort_items=True) group.set_selected_by_value(preset) - result = SelectMenu[Repository]( + result = SelectionMenu[Repository]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min('Additional repositories'), + header=tr('Select optional repositories to be enabled'), allow_reset=True, allow_skip=True, multi=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -374,7 +368,7 @@ def select_optional_repositories(preset: list[Repository]) -> list[Repository]: case ResultType.Reset: return [] case ResultType.Selection: - return result.get_values() + return result.values() class MirrorListHandler: diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index 45cf06402e..a094c65fe0 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -581,6 +581,10 @@ class _DeviceInfo: read_only: bool dirty: bool + @override + def __hash__(self) -> int: + return hash(self.path) + def table_data(self) -> dict[str, str | int | bool]: total_free_space = sum([region.get_length(unit=Unit.MiB) for region in self.free_space_regions]) return { @@ -1062,7 +1066,7 @@ def display_msg(self) -> str: case LvmLayoutType.Default: return tr('Default layout') # case LvmLayoutType.Manual: - # return str(_('Manual configuration')) + # return str(_('Manual configuration')) raise ValueError(f'Unknown type: {self}') diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index ca12246cc8..7baae54321 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -65,8 +65,7 @@ def _define_menu_options(self) -> list[MenuItem]: @override def run(self, additional_title: str | None = None) -> ProfileConfiguration | None: - super().run(additional_title=additional_title) - return self._profile_config + return super().run(additional_title=additional_title) def _select_profile(self, preset: Profile | None) -> Profile | None: profile = select_profile(preset) diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index 5a5e49c5b9..d95e0a3795 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -4,7 +4,8 @@ from dataclasses import dataclass, field from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Self +from typing import Any, ClassVar, Self, overload +from typing_extensions import override from archinstall.lib.translationhandler import tr @@ -36,6 +37,10 @@ def __post_init__(self): else: self._id = str(id(self)) + @override + def __hash__(self): + return hash(self._id) + def get_id(self) -> str: return self._id @@ -182,6 +187,21 @@ def set_selected_by_value(self, values: Any | list[Any] | None) -> None: if values: self.set_focus_by_value(values[0]) + def get_focused_index(self) -> int | None: + items = self.get_enabled_items() + + if self.focus_item and items: + try: + return items.index(self.focus_item) + except ValueError: + # on large menus (15k+) when filtering very quickly + # the index search is too slow while the items are reduced + # by the filter and it will blow up as it cannot find the + # focus item + pass + + return None + def index_focus(self) -> int | None: if self.focus_item and self.items: try: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 71bafc5347..632ce6a405 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -3,14 +3,17 @@ from collections.abc import Awaitable, Callable from typing import Any, Literal, TypeVar, override -from textual import work +from textual import on, work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Horizontal, Vertical -from textual.events import Key +from textual.css.query import NoMatches +from textual.events import Key, Mount from textual.screen import Screen -from textual.widgets import Button, DataTable, Input, LoadingIndicator, OptionList, Rule, Static +from textual.widgets.selection_list import Selection +from textual.widgets import Button, DataTable, Footer, Input, LoadingIndicator, OptionList, Rule, SelectionList, Static from textual.widgets.option_list import Option +from textual.worker import WorkerCancelled from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr @@ -21,660 +24,877 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS = [ # noqa: RUF012 - Binding('escape', 'cancel_operation', 'Cancel', show=True), - Binding('ctrl+c', 'reset_operation', 'Reset', show=True), - ] + BINDINGS = [ # noqa: RUF012 + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) # type: ignore[unused-awaitable] + def action_cancel_operation(self) -> None: + if self._allow_skip: + self.dismiss(Result(ResultType.Skip)) # type: ignore[unused-awaitable] - async def action_reset_operation(self) -> None: - if self._allow_reset: - self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] + async def action_reset_operation(self) -> None: + if self._allow_reset: + self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available.""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ - - def __init__( - self, - timer: int, - header: str | None = None, - ): - super().__init__() - self._timer = timer - self._header = header - - async def run(self) -> Result[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) # ensures indicator is centered too - - def on_mount(self) -> None: - self.set_timer(self._timer, self.action_pop_screen) - - def action_pop_screen(self) -> None: - self.dismiss() # type: ignore[unused-awaitable] + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ + + def __init__( + self, + timer: int, + header: str | None = None, + ): + super().__init__() + self._timer = timer + self._header = header + + async def run(self) -> Result[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) # ensures indicator is centered too + + yield Footer() + + def on_mount(self) -> None: + self.set_timer(self._timer, self.action_pop_screen) + + def action_pop_screen(self) -> None: + self.dismiss() # type: ignore[unused-awaitable] class OptionListScreen(BaseScreen[ValueT]): - BINDINGS = [ - Binding('j', 'cursor_down', 'Down', show=True), - Binding('k', 'cursor_up', 'Up', 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-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - - def action_cursor_down(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_up() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - options = self._get_options() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._preview_location is None: - with Center(): - with Vertical(classes='list-container'): - yield OptionList(*options, id='option_list_widget') - else: - Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - yield OptionList(*options, id='option_list_widget', classes='no-border') - yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - 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 self._preview_location is None: - return None - - preview_widget = self.query_one('#preview_content', Static) - highlighted_id = event.option.id - - item = self._group.find_by_id(highlighted_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('') + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 20%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = True + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + + def action_cursor_down(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_up() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + options = self._get_options() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + option_list = OptionList(*options, id='option_list_widget') + option_list.highlighted = self._group.get_focused_index() + + 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 = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + option_list.classes = 'no-border' + + yield option_list + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') + + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + 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 self._preview_location is None: + return None + + preview_widget = self.query_one('#preview_content', Static) + highlighted_id = event.option.id + + item = self._group.find_by_id(highlighted_id) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + +class SelectListScreen(BaseScreen[ValueT]): + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + + def action_cursor_down(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_up() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + items: list[MenuItem] = self.query_one(SelectionList).selected + self.dismiss(Result(ResultType.Selection, _item=items)) + + 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._group.selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + selections = self._get_selections() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield SelectionList[ValueT](*selections, id='select_list_widget') + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield SelectionList[ValueT](*selections, id='select_list_widget') + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') + + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + item = self._group.find_by_id(selected_option.id) + self.dismiss(Result(ResultType.Selection, _item=item)) + + def on_selection_list_selection_highlighted( + self, + event: SelectionList.SelectionHighlighted[ValueT] + ) -> None: + if self._preview_location is None: + return None + + index = event.selection_index + selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) + item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] + + preview_widget = self.query_one('#preview_content', Static) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('l', 'focus_right', 'Focus right', show=True), - Binding('h', 'focus_left', 'Focus left', show=True), - Binding('right', 'focus_right', 'Focus right', show=True), - Binding('left', 'focus_left', 'Focus left', show=True), - ] - - CSS = """ - ConfirmationScreen { - align: center middle; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .dialog { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .dialog-content { - padding: 1; - height: 100%; - } - - .message { - text-align: center; - margin-bottom: 1; - } - - .buttons { - align: center middle; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] + BINDINGS = [ # noqa: RUF012 + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center middle; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .dialog { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .dialog-content { + padding: 1; + height: 100%; + } + + .message { + text-align: center; + margin-bottom: 1; + } + + .buttons { + align: center middle; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='dialog'): + with Vertical(classes='dialog-content'): + yield Static(self._header, classes='message') + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Footer() + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - background: transparent; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .input-dialog { - width: 60; - height: 10; - border: none; - background: transparent; - } - - .input-content { - padding: 1; - height: 100%; - } - - .input-header { - text-align: center; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .input-prompt { - text-align: center; - margin: 0 0 1 0; - background: transparent; - } - - Input { - margin: 1 2; - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ - - def __init__( - self, - header: str, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._placeholder = placeholder or '' - self._password = password - self._default_value = default_value or '' - self._allow_reset = allow_reset - self._allow_skip = allow_skip - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='input-dialog'): - with Vertical(classes='input-content'): - yield Static(self._header, classes='input-header') - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - ) - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - input_field = self.query_one('#main_input', Input) - value = input_field.value - self.dismiss(Result(ResultType.Selection, _data=value)) # type: ignore[unused-awaitable] + CSS = """ + InputScreen { + background: transparent; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .input-dialog { + width: 60; + height: 10; + border: none; + background: transparent; + } + + .input-content { + padding: 1; + height: 100%; + } + + .input-header { + text-align: center; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .input-prompt { + text-align: center; + margin: 0 0 1 0; + background: transparent; + } + + Input { + margin: 1 2; + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + def __init__( + self, + header: str, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='input-dialog'): + with Vertical(classes='input-content'): + yield Static(self._header, classes='input-header') + yield Input( + placeholder=self._placeholder, + password=self._password, + value=self._default_value, + id='main_input', + ) + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + input_field = self.query_one('#main_input', Input) + value = input_field.value + self.dismiss(Result(ResultType.Selection, _data=value)) # type: ignore[unused-awaitable] class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('j', 'cursor_down', 'Down', show=True), - Binding('k', 'cursor_up', 'Up', show=True), - ] - - CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } - - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } - - DataTable .datatable--header { - background: transparent; - border: solid; - } - - .content-container { - width: auto; - align: center middle; - background: transparent; - padding: 2 0; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - height: auto; - background: transparent; - } - """ - - def __init__( - self, - header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._data = data - self._data_callback = data_callback - self._loading_header = loading_header - - if self._data is None and self._data_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') - - def on_mount(self) -> None: - self._display_header(True) - data_table = self.query_one(DataTable) - data_table.cell_padding = 2 - - if self._data: - self._put_data_to_table(data_table, self._data) - else: - self._load_data(data_table) - - @work - async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) - - def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: - self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] - return - - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] - table.add_columns(*cols) - - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] - table.add_row(*row_values, key=d) # type: ignore[arg-type] - - table.cursor_type = 'row' - table.display = True - - loader = self.query_one('#loader') - loader.display = False - self._display_header(False) - table.focus() - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - data: ValueT = event.row_key.value # type: ignore[assignment] - self.dismiss(Result(ResultType.Selection, _data=data)) # type: ignore[unused-awaitable] + BINDINGS = [ # noqa: RUF012 + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center middle; + background: transparent; + } + + DataTable { + height: auto; + width: auto; + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + .content-container { + width: auto; + background: transparent; + padding: 2 0; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + height: auto; + background: transparent; + } + """ + + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + + if self._data is None and self._data_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + yield DataTable(id='data_table') + + yield Footer() + + def on_mount(self) -> None: + self._display_header(True) + data_table = self.query_one(DataTable) + data_table.cell_padding = 2 + + if self._data: + self._put_data_to_table(data_table, self._data) + else: + self._load_data(data_table) + + @work + async def _load_data(self, table: DataTable[ValueT]) -> None: + assert self._data_callback is not None + data = await self._data_callback() + self._put_data_to_table(table, data) + + def _display_header(self, is_loading: bool) -> None: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: + if not data: + self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] + return + + cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + table.add_columns(*cols) + + for d in data: + row_values = list(d.table_data().values()) # type: ignore[attr-defined] + table.add_row(*row_values, key=d) # type: ignore[arg-type] + + table.cursor_type = 'row' + table.display = True + + loader = self.query_one('#loader') + loader.display = False + self._display_header(False) + table.focus() + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + data: ValueT = event.row_key.value # type: ignore[assignment] + self.dismiss(Result(ResultType.Selection, _data=data)) # type: ignore[unused-awaitable] class _AppInstance(App[ValueT]): - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() # type: ignore[unreachable] - except Exception as err: - debug(f'Error while running main app: {err}') - # this will terminate the textual app and return the exception - self.exit(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() + BINDINGS = [ # noqa: RUF012 + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query("HelpPanel"): + self.screen.query("HelpPanel").remove() + else: + self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() # type: ignore[unreachable] + except WorkerCancelled: + debug(f'Worker was cancelled') + pass + except Exception as err: + debug(f'Error while running main app: {err}') + # this will terminate the textual app and return the exception + self.exit(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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() + diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index ebb668bade..c09b9ffc38 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -15,25 +15,34 @@ class ResultType(Enum): class Result[ValueT]: type_: ResultType _data: ValueT | list[ValueT] | None = None - _item: MenuItem | None = None + _item: MenuItem | list[MenuItem] | None = None def has_data(self) -> bool: return self._data is not None def item(self) -> MenuItem: - if self._item is not None: + 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('No item found') + raise ValueError('Invalid item type') def value(self) -> ValueT: - if self._item is not None and self._item.value is not None: - return self._item.value - elif type(self._data) is not list and self._data is not None: + 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 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 d04f44dcebc4e88894f288abc763f09a6d500111 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 19 Nov 2025 20:33:34 +1100 Subject: [PATCH 04/40] Update --- archinstall/lib/disk/disk_menu.py | 4 +- archinstall/lib/disk/encryption_menu.py | 55 ++--- archinstall/lib/interactions/disk_conf.py | 17 +- archinstall/lib/interactions/general_conf.py | 4 +- archinstall/lib/interactions/system_conf.py | 6 +- archinstall/lib/locale/locale_menu.py | 7 +- archinstall/lib/menu/helpers.py | 65 ++++-- archinstall/lib/menu/list_manager.py | 18 +- archinstall/lib/mirrors.py | 26 +-- archinstall/lib/network/wifi_handler.py | 6 +- archinstall/lib/utils/util.py | 28 ++- archinstall/tui/ui/components.py | 202 +++++++++++-------- archinstall/tui/ui/result.py | 6 +- 13 files changed, 243 insertions(+), 201 deletions(-) diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index dd62a800e4..4f6fb72864 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -95,7 +95,7 @@ def _define_menu_options(self) -> list[MenuItem]: @override def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: - config: DiskMenuConfig | None = super().run(additional_title=additional_title) # pyright: ignore[reportAssignmentType] + config: DiskMenuConfig | None = super().run(additional_title=additional_title) # pyright: ignore[reportAssignmentType] if config is None: return None @@ -182,7 +182,7 @@ def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConf case ResultType.Reset: return None case ResultType.Selection: - return SnapshotConfig(snapshot_type=result.value()) + return SnapshotConfig(snapshot_type=result.get_value()) def _prev_disk_layouts(self, item: MenuItem) -> str | None: if not item.value: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index f9023dcc81..d4e8288f17 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import override +from archinstall.lib.menu.helpers import Input, SelectionMenu from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( DeviceModification, @@ -11,10 +12,8 @@ PartitionModification, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu from ..models.device import DEFAULT_ITER_TIME, Fido2Device @@ -52,7 +51,7 @@ def _define_menu_options(self) -> list[MenuItem]: return [ MenuItem( text=tr('Encryption type'), - action=lambda x: select_encryption_type(self._device_modifications, self._lvm_config, x), + action=lambda x: select_encryption_type(self._lvm_config, x), value=self._enc_config.encryption_type, preview_action=self._preview, key='encryption_type', @@ -240,7 +239,6 @@ def _prev_iter_time(self) -> str | None: def select_encryption_type( - device_modifications: list[DeviceModification], lvm_config: LvmConfiguration | None = None, preset: EncryptionType | None = None, ) -> EncryptionType | None: @@ -260,13 +258,7 @@ def select_encryption_type( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectMenu[EncryptionType]( - group, - allow_skip=True, - allow_reset=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Encryption type')), - ).run() + result = SelectionMenu[EncryptionType](group, header=tr('Select encryption type'), allow_skip=True, allow_reset=True, show_frame=False).show() match result.type_: case ResultType.Reset: @@ -280,7 +272,6 @@ def select_encryption_type( def select_encrypted_password() -> Password | None: header = tr('Enter disk encryption password (leave blank for no encryption)') + '\n' password = get_password( - text=tr('Disk encryption password'), header=header, allow_skip=True, ) @@ -299,12 +290,7 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: if fido_devices: group = MenuHelper(data=fido_devices).create_menu_group() - result = SelectMenu[Fido2Device]( - group, - header=header, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() + result = SelectionMenu[Fido2Device](group, header=header, allow_skip=True, show_frame=False).show() match result.type_: case ResultType.Reset: @@ -334,12 +320,12 @@ def select_partitions_to_encrypt( group = MenuHelper(data=avail_partitions).create_menu_group() group.set_selected_by_value(preset) - result = SelectMenu[PartitionModification]( + result = SelectionMenu[PartitionModification]( group, - alignment=Alignment.CENTER, + header=tr('Select partitions to encrypt'), multi=True, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Reset: @@ -362,11 +348,11 @@ def select_lvm_vols_to_encrypt( if volumes: group = MenuHelper(data=volumes).create_menu_group() - result = SelectMenu[LvmVolume]( + result = SelectionMenu[LvmVolume]( group, - alignment=Alignment.CENTER, + header=tr('Select LVM volumes to encrypt'), multi=True, - ).run() + ).show() match result.type_: case ResultType.Reset: @@ -385,10 +371,7 @@ def select_iteration_time(preset: int | None = None) -> int | None: header += tr('Higher values increase security but slow down boot time') + '\n' header += tr(f'Default: {DEFAULT_ITER_TIME}ms, Recommended range: 1000-60000') + '\n' - def validate_iter_time(value: str | None) -> str | None: - if not value: - return None - + def validate_iter_time(value: str) -> str | None: try: iter_time = int(value) if iter_time < 100: @@ -399,21 +382,19 @@ def validate_iter_time(value: str | None) -> str | None: except ValueError: return tr('Please enter a valid number') - result = EditMenu( - tr('Iteration time'), + result = Input( header=header, - alignment=Alignment.CENTER, allow_skip=True, - default_text=str(preset) if preset else str(DEFAULT_ITER_TIME), - validator=validate_iter_time, - ).input() + default_value=str(preset) if preset else str(DEFAULT_ITER_TIME), + validator_callback=validate_iter_time, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - if not result.text(): + if not result.get_value(): return preset - return int(result.text()) + return int(result.get_value()) case ResultType.Reset: return None diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 6f3b6d39cb..9a16ee7343 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -4,7 +4,6 @@ from archinstall.lib.disk.device_handler import device_handler from archinstall.lib.disk.partitioning_menu import manual_partitioning from archinstall.lib.menu.helpers import Confirmation, Notify, SelectionMenu, TableMenu -from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( BDevice, BtrfsMountOption, @@ -70,7 +69,7 @@ def _preview_device_selection(item: MenuItem) -> str | None: case ResultType.Skip: return preset case ResultType.Selection: - selected_device_info = result.values() + selected_device_info = result.get_values() selected_devices = [] for device in devices: @@ -142,7 +141,7 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay case ResultType.Reset: return None case ResultType.Selection: - selection = result.value() + selection = result.get_value() if selection == pre_mount_mode: output = 'You will use whatever drive-setup is mounted at the specified directory\n' @@ -167,14 +166,14 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if not devices: return None - if result.value() == default_layout: + if result.get_value() == default_layout: modifications = get_default_partition_layout(devices) if modifications: return DiskLayoutConfiguration( config_type=DiskLayoutType.Default, device_modifications=modifications, ) - elif result.value() == manual_mode: + elif result.get_value() == manual_mode: preset_mods = preset.device_modifications if preset else [] modifications = _manual_partitioning(preset_mods, devices) @@ -210,7 +209,7 @@ def select_lvm_config( case ResultType.Reset: return None case ResultType.Selection: - if result.value() == default_mode: + if result.get_value() == default_mode: return suggest_lvm_layout(disk_config) return None @@ -249,12 +248,14 @@ def select_main_filesystem_format() -> FilesystemType: group = MenuItemGroup(items, sort_items=False) result = SelectionMenu[FilesystemType]( group, + header=tr('Select main filesystem'), allow_skip=False, + show_frame=False, ).show() match result.type_: case ResultType.Selection: - return result.value() + return result.get_value() case _: raise ValueError('Unhandled result type') @@ -279,7 +280,7 @@ def select_mount_options() -> list[str]: case ResultType.Skip: return [] case ResultType.Selection: - return [result.value()] + return [result.get_value()] case _: raise ValueError('Unhandled result type') diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 872793bcd4..aad2fc79f2 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -68,7 +68,7 @@ def ask_hostname(preset: str | None = None) -> str | None: case UiResultType.Skip: return preset case UiResultType.Selection: - hostname = result.value() + hostname = result.get_value() if len(hostname) < 1: return None return hostname @@ -148,7 +148,7 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> case UiResultType.Skip: return preset case UiResultType.Selection: - return result.value() + return result.get_value() case UiResultType.Reset: raise ValueError('Language selection not handled') diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 7d3d3436c9..a4572d6f89 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -41,7 +41,7 @@ def select_kernel(preset: list[str] = []) -> list[str]: case UiResultType.Reset: return [] case UiResultType.Selection: - return result.values() + return result.get_values() def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: @@ -81,7 +81,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: case UiResultType.Skip: return preset case UiResultType.Selection: - return result.value() + return result.get_value() case UiResultType.Reset: raise ValueError('Unhandled result type') @@ -147,7 +147,7 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None case UiResultType.Reset: return None case UiResultType.Selection: - return result.value() + return result.get_value() def ask_for_swap(preset: bool = True) -> bool: diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 60e822a784..71fa658b9e 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,7 +1,6 @@ from typing import override from archinstall.lib.menu.helpers import SelectionMenu -from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -79,7 +78,7 @@ def select_locale_lang(preset: str | None = None) -> str | None: match result.type_: case ResultType.Selection: - return result.value() + return result.get_value() case ResultType.Skip: return preset case _: @@ -98,7 +97,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: match result.type_: case ResultType.Selection: - return result.value() + return result.get_value() case ResultType.Skip: return preset case _: @@ -125,7 +124,7 @@ def select_kb_layout(preset: str | None = None) -> str | None: match result.type_: case ResultType.Selection: - return result.value() + return result.get_value() case ResultType.Skip: return preset case _: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index ab1de30d35..ee3fa194e1 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -1,9 +1,20 @@ -from typing import Awaitable, Callable, Literal, TypeVar +from collections.abc import Awaitable, Callable +from typing import Literal, TypeVar, override + +from textual.validation import ValidationResult, Validator -from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItemGroup -from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, LoadingScreen, NotifyScreen, OptionListScreen, SelectListScreen, TableSelectionScreen, tui +from archinstall.tui.ui.components import ( + ConfirmationScreen, + InputScreen, + LoadingScreen, + NotifyScreen, + OptionListScreen, + SelectListScreen, + TableSelectionScreen, + tui, +) from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') @@ -19,7 +30,7 @@ def __init__( preview_orientation: Literal['right', 'bottom'] | None = None, multi: bool = False, search_enabled: bool = False, - show_frame: bool = True + show_frame: bool = True, ): self._header = header self._group: MenuItemGroup = group @@ -42,21 +53,17 @@ async def _run(self) -> None: allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_orientation, - show_frame=self._show_frame + show_frame=self._show_frame, ).run() else: result = await SelectListScreen[ValueT]( - self._group, - header=self._header, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - preview_location=self._preview_orientation + self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_orientation ).run() if result.type_ == ResultType.Reset: confirmed = await _confirm_reset() - if confirmed.value() is False: + if confirmed.get_value() is False: return await self._run() tui.exit(result) @@ -90,7 +97,7 @@ async def _run(self) -> None: if result.type_ == ResultType.Reset: confirmed = await _confirm_reset() - if confirmed.value() is False: + if confirmed.get_value() is False: return await self._run() tui.exit(result) @@ -112,7 +119,23 @@ async def _run(self) -> None: tui.exit(True) -class Input[ValueT]: +class GenericValidator(Validator): + def __init__(self, validator_callback: Callable[[str | None], str | None]) -> None: + super().__init__() + + self._validator_callback = validator_callback + + @override + def validate(self, value: str) -> ValidationResult: + result = self._validator_callback(value) + + if result is not None: + return self.failure(result) + + return self.success() + + +class Input: def __init__( self, header: str | None = None, @@ -121,6 +144,7 @@ def __init__( default_value: str | None = None, allow_skip: bool = True, allow_reset: bool = False, + validator_callback: Callable[[str | None], str | None] | None = None, ): self._header = header self._placeholder = placeholder @@ -128,12 +152,15 @@ def __init__( self._default_value = default_value self._allow_skip = allow_skip self._allow_reset = allow_reset + self._validator_callback = validator_callback def show(self) -> Result[ValueT]: result = tui.run(self) return result async def _run(self) -> None: + validator = GenericValidator(self._validator_callback) if self._validator_callback else None + result = await InputScreen( header=self._header, placeholder=self._placeholder, @@ -141,23 +168,20 @@ async def _run(self) -> None: default_value=self._default_value, allow_skip=self._allow_skip, allow_reset=self._allow_reset, + validator=validator, ).run() if result.type_ == ResultType.Reset: confirmed = await _confirm_reset() - if confirmed.value() is False: + if confirmed.get_value() is False: return await self._run() tui.exit(result) class Loading[ValueT]: - def __init__( - self, - header: str | None = None, - timer: int = 3 - ): + def __init__(self, header: str | None = None, timer: int = 3): self._header = header self._timer = timer @@ -206,12 +230,13 @@ async def _run(self) -> None: allow_skip=self._allow_skip, allow_reset=self._allow_reset, loading_header=self._loading_header, + multi=self._multi, ).run() if result.type_ == ResultType.Reset: confirmed = await _confirm_reset() - if confirmed.value() is False: + if confirmed.get_value() is False: return await self._run() tui.exit(result) diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index a9a4f9edf6..0dfdb01cf6 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -69,17 +69,11 @@ def run(self) -> list[ValueT]: prompt = None - result = SelectionMenu[ValueT | str]( - group, - header=prompt, - search_enabled=False, - allow_skip=False, - show_frame=False - ).show() + result = SelectionMenu[ValueT | str](group, header=prompt, search_enabled=False, allow_skip=False, show_frame=False).show() match result.type_: case ResultType.Selection: - value = result.value() + value = result.get_value() case _: raise ValueError('Unhandled return type') @@ -89,15 +83,15 @@ def run(self) -> list[ValueT]: elif value in self._terminate_actions: break else: # an entry of the existing selection was chosen - selected_entry = result.value() + selected_entry = result.get_value() selected_entry = cast(ValueT, selected_entry) self._run_actions_on_entry(selected_entry) self._last_choice = value - if result.value() == self._cancel_action: - return self._original_data # return the original list + if result.get_value() == self._cancel_action: + return self._original_data # return the original list else: return self._data @@ -118,7 +112,7 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: match result.type_: case ResultType.Selection: - value = result.value() + value = result.get_value() case _: raise ValueError('Unhandled return type') diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 4d06900883..833fbbe745 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -51,17 +51,17 @@ def handle_action( entry: CustomRepository | None, data: list[CustomRepository], ) -> list[CustomRepository]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_repo = self._add_custom_repository() if new_repo is not None: data = [d for d in data if d.name != new_repo.name] data += [new_repo] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_repo = self._add_custom_repository(entry) if new_repo is not None: data = [d for d in data if d.name != entry.name] data += [new_repo] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -76,7 +76,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust match edit_result.type_: case ResultType.Selection: - name = edit_result.value() + name = edit_result.get_value() case ResultType.Skip: return preset case _: @@ -94,7 +94,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust match edit_result.type_: case ResultType.Selection: - url = edit_result.value() + url = edit_result.get_value() case ResultType.Skip: return preset case _: @@ -117,7 +117,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust match result.type_: case ResultType.Selection: - sign_check = SignCheck(result.value()) + sign_check = SignCheck(result.get_value()) case _: raise ValueError('Unhandled return type') @@ -138,7 +138,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust match result.type_: case ResultType.Selection: - sign_opt = SignOption(result.value()) + sign_opt = SignOption(result.get_value()) case _: raise ValueError('Unhandled return type') @@ -171,17 +171,17 @@ def handle_action( entry: CustomServer | None, data: list[CustomServer], ) -> list[CustomServer]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_server = self._add_custom_server() if new_server is not None: data = [d for d in data if d.url != new_server.url] data += [new_server] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_server = self._add_custom_server(entry) if new_server is not None: data = [d for d in data if d.url != entry.url] data += [new_server] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -196,7 +196,7 @@ def _add_custom_server(self, preset: CustomServer | None = None) -> CustomServer match edit_result.type_: case ResultType.Selection: - uri = edit_result.value() + uri = edit_result.get_value() return CustomServer(uri) case ResultType.Skip: return preset @@ -327,7 +327,7 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: case ResultType.Reset: return [] case ResultType.Selection: - selected_mirrors = result.values() + selected_mirrors = result.get_values() return selected_mirrors @@ -368,7 +368,7 @@ def select_optional_repositories(preset: list[Repository]) -> list[Repository]: case ResultType.Reset: return [] case ResultType.Selection: - return result.values() + return result.get_values() class MirrorListHandler: diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index eee736277c..15c2ded1ef 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -52,7 +52,7 @@ async def _run(self) -> None: match result.type_: case ResultType.Selection: - if result.value() is False: + if result.get_value() is False: tui.exit(False) return None case ResultType.Skip | ResultType.Reset: @@ -142,7 +142,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: tui.exit(False) return False - network = result.value() + network = result.get_value() case ResultType.Skip | ResultType.Reset: tui.exit(False) return False @@ -250,7 +250,7 @@ async def _prompt_psk(self, existing: str | None = None) -> str | None: debug('No password provided, aborting connection') return None - return result.value() + return result.get_value() def _get_scan_results(self, iface: str) -> list[WifiNetwork]: debug(f'Retrieving scan results: {iface}') diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index e72d5c77fe..5d86c334a4 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -1,5 +1,6 @@ from pathlib import Path +from archinstall.lib.menu.helpers import Input from archinstall.lib.translationhandler import tr from archinstall.tui.curses_menu import EditMenu from archinstall.tui.result import ResultType @@ -10,7 +11,6 @@ def get_password( - text: str, header: str | None = None, allow_skip: bool = False, preset: str | None = None, @@ -25,20 +25,18 @@ def get_password( elif header is not None: user_hdr = header - result = EditMenu( - text, + result = Input( header=user_hdr, - alignment=Alignment.CENTER, allow_skip=allow_skip, - default_text=preset, - hide_input=True, - ).input() + default_value=preset, + password=True, + ).show() if allow_skip: - if not result.has_item() or not result.text(): + if not result.get_value(): return None - password = Password(plaintext=result.text()) + password = Password(plaintext=result.get_value()) if skip_confirmation: return password @@ -48,15 +46,15 @@ def get_password( else: confirmation_header = f'{tr("Password")}: {password.hidden()}\n' - result = EditMenu( - tr('Confirm password'), + confirmation_header += '\n' + tr('Confirm password') + + result = Input( header=confirmation_header, - alignment=Alignment.CENTER, allow_skip=False, - hide_input=True, - ).input() + password=True, + ).show() - if password._plaintext == result.text(): + if password._plaintext == result.get_value(): return password failure = tr('The confirmation password did not match, please try again') diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 632ce6a405..0b5c3542e9 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -3,16 +3,16 @@ from collections.abc import Awaitable, Callable from typing import Any, Literal, TypeVar, override -from textual import on, work +from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Center, Horizontal, Vertical -from textual.css.query import NoMatches -from textual.events import Key, Mount +from textual.events import Key from textual.screen import Screen -from textual.widgets.selection_list import Selection +from textual.validation import Validator from textual.widgets import Button, DataTable, Footer, Input, LoadingIndicator, OptionList, Rule, SelectionList, Static 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 @@ -40,7 +40,7 @@ def action_cancel_operation(self) -> None: async def action_reset_operation(self) -> None: if self._allow_reset: - self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] + self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] def _compose_header(self) -> ComposeResult: """Compose the app header if global header text is available""" @@ -101,7 +101,7 @@ def on_mount(self) -> None: self.set_timer(self._timer, self.action_pop_screen) def action_pop_screen(self) -> None: - self.dismiss() # type: ignore[unused-awaitable] + self.dismiss() # type: ignore[unused-awaitable] class OptionListScreen(BaseScreen[ValueT]): @@ -178,7 +178,7 @@ def __init__( allow_skip: bool = False, allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = True + show_frame: bool = True, ): super().__init__(allow_skip, allow_reset) self._group = group @@ -399,10 +399,7 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No item = self._group.find_by_id(selected_option.id) self.dismiss(Result(ResultType.Selection, _item=item)) - def on_selection_list_selection_highlighted( - self, - event: SelectionList.SelectionHighlighted[ValueT] - ) -> None: + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: if self._preview_location is None: return None @@ -536,7 +533,7 @@ def on_key(self, event: Key) -> None: item = self._group.focus_item if not item: return None - self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] + self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] class NotifyScreen(ConfirmationScreen[ValueT]): @@ -551,22 +548,11 @@ class InputScreen(BaseScreen[str]): background: transparent; } - .dialog-wrapper { - align: center middle; - height: 100%; + .content-container { width: 100%; - } - - .input-dialog { - width: 60; - height: 10; - border: none; + height: 1fr; background: transparent; - } - - .input-content { - padding: 1; - height: 100%; + padding: 2 0; } .input-header { @@ -577,13 +563,17 @@ class InputScreen(BaseScreen[str]): background: transparent; } - .input-prompt { + .input-dialog { + align: center top; text-align: center; - margin: 0 0 1 0; + width: 50%; + height: 100%; background: transparent; } Input { + align: center top; + text-align: center; margin: 1 2; border: solid $accent; background: transparent; @@ -597,6 +587,16 @@ class InputScreen(BaseScreen[str]): Input:focus { border: solid $primary; } + + .input-failure { + height: 3; + width: 100%; + text-align: center; + text-style: bold; + margin: 0 0; + color: red; + background: transparent; + } """ def __init__( @@ -607,6 +607,7 @@ def __init__( default_value: str | None = None, allow_reset: bool = False, allow_skip: bool = False, + validator: Validator | None = None, ): super().__init__(allow_skip, allow_reset) self._header = header or '' @@ -615,6 +616,7 @@ def __init__( self._default_value = default_value or '' self._allow_reset = allow_reset self._allow_skip = allow_skip + self._validator = validator async def run(self) -> Result[str]: assert TApp.app @@ -624,16 +626,18 @@ async def run(self) -> Result[str]: def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(classes='dialog-wrapper'): - with Vertical(classes='input-dialog'): - with Vertical(classes='input-content'): - yield Static(self._header, classes='input-header') - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - ) + with Vertical(classes='content-container'): + yield Static(self._header, classes='input-header') + with Center(classes='input-dialog'): + yield Input( + placeholder=self._placeholder, + password=self._password, + value=self._default_value, + id='main_input', + validators=self._validator, + validate_on=['submitted'], + ) + yield Static('', classes='input-failure', id='input-failure') yield Footer() @@ -641,11 +645,14 @@ def on_mount(self) -> None: input_field = self.query_one('#main_input', Input) input_field.focus() - def on_key(self, event: Key) -> None: - if event.key == 'enter': - input_field = self.query_one('#main_input', Input) - value = input_field.value - self.dismiss(Result(ResultType.Selection, _data=value)) # type: ignore[unused-awaitable] + 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', Static).update(failure_out) + else: + self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): @@ -656,38 +663,38 @@ class TableSelectionScreen(BaseScreen[ValueT]): ] CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } + TableSelectionScreen { + align: center middle; + background: transparent; + } - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } + DataTable { + height: auto; + width: auto; + border: none; + background: transparent; + } - DataTable .datatable--header { - background: transparent; - border: solid; - } + DataTable .datatable--header { + background: transparent; + border: solid; + } - .content-container { - width: auto; - background: transparent; - padding: 2 0; - } + .content-container { + width: auto; + background: transparent; + padding: 2 0; + } - .header { - text-align: center; - margin-bottom: 1; - } + .header { + text-align: center; + margin-bottom: 1; + } - LoadingIndicator { - height: auto; - background: transparent; - } + LoadingIndicator { + height: auto; + background: transparent; + } """ def __init__( @@ -698,12 +705,17 @@ def __init__( allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, + multi: bool = False, ): super().__init__(allow_skip, allow_reset) self._header = header self._data = data self._data_callback = data_callback self._loading_header = loading_header + self._multi = multi + + self._selected_keys: set[int] = set() + self._current_row_key = None if self._data is None and self._data_callback is None: raise ValueError('Either data or data_callback must be provided') @@ -766,14 +778,22 @@ def _display_header(self, is_loading: bool) -> None: def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: if not data: - self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] + self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] return cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + + if self._multi: + cols.insert(0, ' ') + table.add_columns(*cols) for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] + row_values = list(d.table_data().values()) # type: ignore[attr-defined] + + if self._multi: + row_values.insert(0, ' ') + table.add_row(*row_values, key=d) # type: ignore[arg-type] table.cursor_type = 'row' @@ -784,9 +804,35 @@ def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> No self._display_header(False) table.focus() + def action_toggle_selection(self) -> None: + if not self._multi: + return + + if not self._current_row_key: + return + + table = self.query_one(DataTable) + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, ' ') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, 'X') + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._current_row_key = event.row_key + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - data: ValueT = event.row_key.value # type: ignore[assignment] - self.dismiss(Result(ResultType.Selection, _data=data)) # type: ignore[unused-awaitable] + if self._multi: + if len(self._selected_keys) == 0: + self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) + else: + data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] + self.dismiss(Result(ResultType.Selection, _data=data)) + else: + self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) class _AppInstance(App[ValueT]): @@ -835,8 +881,8 @@ def __init__(self, main: Any) -> None: def action_trigger_help(self) -> None: from textual.widgets import HelpPanel - if self.screen.query("HelpPanel"): - self.screen.query("HelpPanel").remove() + if self.screen.query('HelpPanel'): + self.screen.query('HelpPanel').remove() else: self.screen.mount(HelpPanel()) @@ -848,8 +894,7 @@ async def _run_worker(self) -> None: try: await self._main._run() # type: ignore[unreachable] except WorkerCancelled: - debug(f'Worker was cancelled') - pass + 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 @@ -897,4 +942,3 @@ def exit(self, result: Result[ValueT]) -> None: tui = TApp() - diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index c09b9ffc38..9365b7f32f 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -31,16 +31,16 @@ def items(self) -> list[MenuItem]: raise ValueError('Invalid item type') - def value(self) -> ValueT: + def get_value(self) -> ValueT: if self._item is not None: - return self.item().get_value() # type: ignore[no-any-return] + 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 values(self) -> list[ValueT]: + def get_values(self) -> list[ValueT]: if self._item is not None: return [i.get_value() for i in self.items()] From 2cafde10c6715b8ee7065c1e4b983ceb7b1bcfcc Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 23 Nov 2025 10:44:16 +1100 Subject: [PATCH 05/40] Fix input form alignment --- archinstall/tui/ui/components.py | 1718 +++++++++++++++--------------- 1 file changed, 855 insertions(+), 863 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 0b5c3542e9..a9b3420ec8 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -6,7 +6,7 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Center, Horizontal, Vertical +from textual.containers import Center, Container, Horizontal, Vertical from textual.events import Key from textual.screen import Screen from textual.validation import Validator @@ -24,921 +24,913 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS = [ # noqa: RUF012 - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS = [ # noqa: RUF012 + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) # type: ignore[unused-awaitable] + def action_cancel_operation(self) -> None: + if self._allow_skip: + self.dismiss(Result(ResultType.Skip)) # type: ignore[unused-awaitable] - async def action_reset_operation(self) -> None: - if self._allow_reset: - self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] + async def action_reset_operation(self) -> None: + if self._allow_reset: + self.dismiss(Result(ResultType.Reset)) # type: ignore[unused-awaitable] - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ - - def __init__( - self, - timer: int, - header: str | None = None, - ): - super().__init__() - self._timer = timer - self._header = header - - async def run(self) -> Result[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) # ensures indicator is centered too - - yield Footer() - - def on_mount(self) -> None: - self.set_timer(self._timer, self.action_pop_screen) - - def action_pop_screen(self) -> None: - self.dismiss() # type: ignore[unused-awaitable] + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ + + def __init__( + self, + timer: int, + header: str | None = None, + ): + super().__init__() + self._timer = timer + self._header = header + + async def run(self) -> Result[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) # ensures indicator is centered too + + yield Footer() + + def on_mount(self) -> None: + self.set_timer(self._timer, self.action_pop_screen) + + def action_pop_screen(self) -> None: + self.dismiss() # type: ignore[unused-awaitable] class OptionListScreen(BaseScreen[ValueT]): - BINDINGS = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 20%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = True, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - - def action_cursor_down(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_up() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - options = self._get_options() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 20%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = True, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + + def action_cursor_down(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_up() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + options = self._get_options() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') - option_list = OptionList(*options, id='option_list_widget') - option_list.highlighted = self._group.get_focused_index() - - 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 = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - option_list.classes = 'no-border' + option_list = OptionList(*options, id='option_list_widget') + option_list.highlighted = self._group.get_focused_index() + + 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 = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + option_list.classes = 'no-border' - yield option_list - yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') + yield option_list + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') - yield Footer() + yield Footer() - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - item = self._group.find_by_id(selected_option.id) - self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + 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 self._preview_location is None: - return None + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - highlighted_id = event.option.id + preview_widget = self.query_one('#preview_content', Static) + highlighted_id = event.option.id - item = self._group.find_by_id(highlighted_id) + item = self._group.find_by_id(highlighted_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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - BINDINGS = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - - def action_cursor_down(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_up() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - items: list[MenuItem] = self.query_one(SelectionList).selected - self.dismiss(Result(ResultType.Selection, _item=items)) - - 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._group.selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - selections = self._get_selections() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._preview_location is None: - with Center(): - with Vertical(classes='list-container'): - yield SelectionList[ValueT](*selections, id='select_list_widget') - else: - Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - yield SelectionList[ValueT](*selections, id='select_list_widget') - yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') - - yield Footer() - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - item = self._group.find_by_id(selected_option.id) - self.dismiss(Result(ResultType.Selection, _item=item)) - - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: - if self._preview_location is None: - return None - - index = event.selection_index - selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) - item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] - - preview_widget = self.query_one('#preview_content', Static) + BINDINGS = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + + def action_cursor_down(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_up() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + items: list[MenuItem] = self.query_one(SelectionList).selected + self.dismiss(Result(ResultType.Selection, _item=items)) + + 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._group.selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + selections = self._get_selections() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield SelectionList[ValueT](*selections, id='select_list_widget') + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield SelectionList[ValueT](*selections, id='select_list_widget') + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') + + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + item = self._group.find_by_id(selected_option.id) + self.dismiss(Result(ResultType.Selection, _item=item)) + + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: + if self._preview_location is None: + return None + + index = event.selection_index + selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) + item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] + + preview_widget = self.query_one('#preview_content', Static) - 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('') + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center middle; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .dialog { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .dialog-content { - padding: 1; - height: 100%; - } - - .message { - text-align: center; - margin-bottom: 1; - } - - .buttons { - align: center middle; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - yield Footer() - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] + BINDINGS = [ # noqa: RUF012 + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center middle; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .dialog { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .dialog-content { + padding: 1; + height: 100%; + } + + .message { + text-align: center; + margin-bottom: 1; + } + + .buttons { + align: center middle; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='dialog'): + with Vertical(classes='dialog-content'): + yield Static(self._header, classes='message') + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Footer() + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + self.dismiss(Result(ResultType.Selection, _item=item)) # type: ignore[unused-awaitable] class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - background: transparent; - } - - .content-container { - width: 100%; - height: 1fr; - background: transparent; - padding: 2 0; - } - - .input-header { - text-align: center; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .input-dialog { - align: center top; - text-align: center; - width: 50%; - height: 100%; - background: transparent; - } - - Input { - align: center top; - text-align: center; - margin: 1 2; - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - - .input-failure { - height: 3; - width: 100%; - text-align: center; - text-style: bold; - margin: 0 0; - color: red; - background: transparent; - } - """ - - def __init__( - self, - header: str, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - validator: Validator | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header or '' - self._placeholder = placeholder or '' - self._password = password - self._default_value = default_value or '' - self._allow_reset = allow_reset - self._allow_skip = allow_skip - self._validator = validator - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - yield Static(self._header, classes='input-header') - with Center(classes='input-dialog'): - yield Input( - placeholder=self._placeholder, - password=self._password, - value=self._default_value, - id='main_input', - validators=self._validator, - validate_on=['submitted'], - ) - yield Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - self.dismiss(Result(ResultType.Selection, _data=event.value)) + CSS = """ + InputScreen { + align: center middle; + } + + .input-header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + def __init__( + self, + header: str, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + validator: Validator | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header or '' + self._placeholder = placeholder or '' + self._password = password + self._default_value = default_value or '' + self._allow_reset = allow_reset + self._allow_skip = allow_skip + self._validator = validator + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='input-header') + + 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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } - - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } - - DataTable .datatable--header { - background: transparent; - border: solid; - } - - .content-container { - width: auto; - background: transparent; - padding: 2 0; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - height: auto; - background: transparent; - } - """ - - def __init__( - self, - header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - multi: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._data = data - self._data_callback = data_callback - self._loading_header = loading_header - self._multi = multi - - self._selected_keys: set[int] = set() - self._current_row_key = None - - if self._data is None and self._data_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') - - yield Footer() - - def on_mount(self) -> None: - self._display_header(True) - data_table = self.query_one(DataTable) - data_table.cell_padding = 2 - - if self._data: - self._put_data_to_table(data_table, self._data) - else: - self._load_data(data_table) - - @work - async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) - - def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: - self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] - return - - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] - - if self._multi: - row_values.insert(0, ' ') - - table.add_row(*row_values, key=d) # type: ignore[arg-type] - - 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 + BINDINGS = [ # noqa: RUF012 + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center middle; + background: transparent; + } + + DataTable { + height: auto; + width: auto; + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + .content-container { + width: auto; + background: transparent; + padding: 2 0; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + height: auto; + background: transparent; + } + """ + + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + self._multi = multi + + self._selected_keys: set[int] = set() + self._current_row_key = None + + if self._data is None and self._data_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + yield DataTable(id='data_table') + + yield Footer() + + def on_mount(self) -> None: + self._display_header(True) + data_table = self.query_one(DataTable) + data_table.cell_padding = 2 + + if self._data: + self._put_data_to_table(data_table, self._data) + else: + self._load_data(data_table) + + @work + async def _load_data(self, table: DataTable[ValueT]) -> None: + assert self._data_callback is not None + data = await self._data_callback() + self._put_data_to_table(table, data) + + def _display_header(self, is_loading: bool) -> None: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: + if not data: + self.dismiss(Result(ResultType.Selection)) # type: ignore[unused-awaitable] + return + + cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for d in data: + row_values = list(d.table_data().values()) # type: ignore[attr-defined] + + if self._multi: + row_values.insert(0, ' ') + + table.add_row(*row_values, key=d) # type: ignore[arg-type] + + 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) + 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') + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, ' ') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, 'X') - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - self._current_row_key = event.row_key + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._current_row_key = event.row_key - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) - else: - data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] - self.dismiss(Result(ResultType.Selection, _data=data)) - else: - self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) + else: + data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] + self.dismiss(Result(ResultType.Selection, _data=data)) + else: + self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) class _AppInstance(App[ValueT]): - BINDINGS = [ # noqa: RUF012 - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - self.screen.query('HelpPanel').remove() - else: - self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() # type: ignore[unreachable] - 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(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() + BINDINGS = [ # noqa: RUF012 + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + self.screen.query('HelpPanel').remove() + else: + self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() # type: ignore[unreachable] + 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(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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() From 8ddd4f729b62d9412c784014dab2c9dcfebdc903 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 24 Nov 2025 21:52:31 +1100 Subject: [PATCH 06/40] Migrate more menus --- archinstall/lib/disk/partitioning_menu.py | 1094 +++++------ archinstall/lib/disk/subvolume_menu.py | 18 +- archinstall/lib/global_menu.py | 4 +- archinstall/lib/interactions/general_conf.py | 80 +- .../lib/interactions/manage_users_conf.py | 34 +- archinstall/lib/interactions/network_menu.py | 56 +- archinstall/lib/interactions/system_conf.py | 3 - archinstall/lib/menu/abstract_menu.py | 1 + archinstall/lib/menu/helpers.py | 436 +++-- archinstall/lib/menu/list_manager.py | 5 +- archinstall/lib/mirrors.py | 18 +- archinstall/scripts/guided.py | 1 - archinstall/tui/__init__.py | 3 +- archinstall/tui/ui/components.py | 1726 +++++++++-------- archinstall/tui/ui/result.py | 5 +- 15 files changed, 1749 insertions(+), 1735 deletions(-) diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index ff6ed7c12e..dc5e4c938e 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -6,17 +6,17 @@ from archinstall.lib.menu.helpers import Confirmation, Input, SelectionMenu from archinstall.lib.models.device import ( - BtrfsMountOption, - DeviceModification, - FilesystemType, - ModificationStatus, - PartitionFlag, - PartitionModification, - PartitionTable, - PartitionType, - SectorSize, - Size, - Unit, + BtrfsMountOption, + DeviceModification, + FilesystemType, + ModificationStatus, + PartitionFlag, + PartitionModification, + PartitionTable, + PartitionType, + SectorSize, + Size, + Unit, ) from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup @@ -29,556 +29,556 @@ class FreeSpace: - def __init__(self, start: Size, end: Size) -> None: - self.start = start - self.end = end + def __init__(self, start: Size, end: Size) -> None: + self.start = start + self.end = end - @property - def length(self) -> Size: - return self.end - self.start + @property + def length(self) -> Size: + return self.end - self.start - def table_data(self) -> dict[str, str]: - """ - Called for displaying data in table format - """ - return { - 'Start': self.start.format_size(Unit.sectors, self.start.sector_size, include_unit=False), - 'End': self.end.format_size(Unit.sectors, self.start.sector_size, include_unit=False), - 'Size': self.length.format_highest(), - } + def table_data(self) -> dict[str, str]: + """ + Called for displaying data in table format + """ + return { + 'Start': self.start.format_size(Unit.sectors, self.start.sector_size, include_unit=False), + 'End': self.end.format_size(Unit.sectors, self.start.sector_size, include_unit=False), + 'Size': self.length.format_highest(), + } class DiskSegment: - def __init__(self, segment: PartitionModification | FreeSpace) -> None: - self.segment = segment - - def table_data(self) -> dict[str, str]: - """ - Called for displaying data in table format - """ - if isinstance(self.segment, PartitionModification): - return self.segment.table_data() - - part_mod = PartitionModification( - status=ModificationStatus.Create, - type=PartitionType._Unknown, - start=self.segment.start, - length=self.segment.length, - ) - data = part_mod.table_data() - data.update({'Status': 'free', 'Type': '', 'FS type': ''}) - return data + def __init__(self, segment: PartitionModification | FreeSpace) -> None: + self.segment = segment + + def table_data(self) -> dict[str, str]: + """ + Called for displaying data in table format + """ + if isinstance(self.segment, PartitionModification): + return self.segment.table_data() + + part_mod = PartitionModification( + status=ModificationStatus.Create, + type=PartitionType._Unknown, + start=self.segment.start, + length=self.segment.length, + ) + data = part_mod.table_data() + data.update({'Status': 'free', 'Type': '', 'FS type': ''}) + return data class PartitioningList(ListManager[DiskSegment]): - def __init__( - self, - device_mod: DeviceModification, - partition_table: PartitionTable, - ) -> None: - device = device_mod.device - - self._device = device - self._wipe = device_mod.wipe - self._buffer = Size(1, Unit.MiB, device.device_info.sector_size) - self._using_gpt = device_mod.using_gpt(partition_table) - - self._actions = { - 'suggest_partition_layout': tr('Suggest partition layout'), - 'remove_added_partitions': tr('Remove all newly added partitions'), - 'assign_mountpoint': tr('Assign mountpoint'), - 'mark_formatting': tr('Mark/Unmark to be formatted (wipes data)'), - 'mark_bootable': tr('Mark/Unmark as bootable'), - } - if self._using_gpt: - self._actions.update( - { - 'mark_esp': tr('Mark/Unmark as ESP'), - 'mark_xbootldr': tr('Mark/Unmark as XBOOTLDR'), - } - ) - self._actions.update( - { - 'set_filesystem': tr('Change filesystem'), - 'btrfs_mark_compressed': tr('Mark/Unmark as compressed'), # btrfs only - 'btrfs_mark_nodatacow': tr('Mark/Unmark as nodatacow'), # btrfs only - 'btrfs_set_subvolumes': tr('Set subvolumes'), # btrfs only - 'delete_partition': tr('Delete partition'), - } - ) - - device_partitions = [] - - if not device_mod.partitions: - # we'll display the existing partitions of the device - for partition in device.partition_infos: - device_partitions.append( - PartitionModification.from_existing_partition(partition), - ) - else: - device_partitions = device_mod.partitions - - prompt = tr('Partition management: {}').format(device.device_info.path) + '\n' - prompt += tr('Total length: {}').format(device.device_info.total_size.format_size(Unit.MiB)) - self._info = prompt + '\n' - - display_actions = list(self._actions.values()) - super().__init__( - self.as_segments(device_partitions), - display_actions[:1], - display_actions[2:], - self._info + self.wipe_str(), - ) - - def wipe_str(self) -> str: - return '{}: {}'.format(tr('Wipe'), self._wipe) - - def as_segments(self, device_partitions: list[PartitionModification]) -> list[DiskSegment]: - end = self._device.device_info.total_size - - if self._using_gpt: - end = end.gpt_end() - - end = end.align() - - # Reorder device_partitions to move all deleted partitions to the top - device_partitions.sort(key=lambda p: p.is_delete(), reverse=True) - - partitions = [DiskSegment(p) for p in device_partitions if not p.is_delete()] - segments = [DiskSegment(p) for p in device_partitions] - - if not partitions: - free_space = FreeSpace(self._buffer, end) - if free_space.length > self._buffer: - return segments + [DiskSegment(free_space)] - return segments - - first_part_index, first_partition = next( - (i, disk_segment) - for i, disk_segment in enumerate(segments) - if isinstance(disk_segment.segment, PartitionModification) and not disk_segment.segment.is_delete() - ) - - prev_partition = first_partition - index = 0 - - for partition in segments[1:]: - index += 1 - - if isinstance(partition.segment, PartitionModification) and partition.segment.is_delete(): - continue - - if prev_partition.segment.end < partition.segment.start: - free_space = FreeSpace(prev_partition.segment.end, partition.segment.start) - if free_space.length > self._buffer: - segments.insert(index, DiskSegment(free_space)) - index += 1 - - prev_partition = partition - - if first_partition.segment.start > self._buffer: - free_space = FreeSpace(self._buffer, first_partition.segment.start) - if free_space.length > self._buffer: - segments.insert(first_part_index, DiskSegment(free_space)) - - if partitions[-1].segment.end < end: - free_space = FreeSpace(partitions[-1].segment.end, end) - if free_space.length > self._buffer: - segments.append(DiskSegment(free_space)) - - return segments - - @staticmethod - def get_part_mods(disk_segments: list[DiskSegment]) -> list[PartitionModification]: - return [s.segment for s in disk_segments if isinstance(s.segment, PartitionModification)] - - def get_device_mod(self) -> DeviceModification: - disk_segments = super().run() - partitions = self.get_part_mods(disk_segments) - return DeviceModification(self._device, self._wipe, partitions) - - @override - def _run_actions_on_entry(self, entry: DiskSegment) -> None: - # Do not create a menu when the segment is free space - if isinstance(entry.segment, FreeSpace): - self._data = self.handle_action('', entry, self._data) - else: - super()._run_actions_on_entry(entry) - - @override - def selected_action_display(self, selection: DiskSegment) -> str: - if isinstance(selection.segment, PartitionModification): - if selection.segment.status == ModificationStatus.Create: - return tr('Partition - New') - elif selection.segment.is_delete() and selection.segment.dev_path: - title = tr('Partition') + '\n\n' - title += 'status: delete\n' - title += f'device: {selection.segment.dev_path}\n' - for part in self._device.partition_infos: - if part.path == selection.segment.dev_path: - if part.partuuid: - title += f'partuuid: {part.partuuid}' - return title - return str(selection.segment.dev_path) - return '' - - @override - def filter_options(self, selection: DiskSegment, options: list[str]) -> list[str]: - not_filter = [] - - if isinstance(selection.segment, PartitionModification): - if selection.segment.is_delete(): - not_filter = list(self._actions.values()) - # only display formatting if the partition exists already - elif not selection.segment.exists(): - not_filter += [self._actions['mark_formatting']] - else: - # only allow options if the existing partition - # was marked as formatting, otherwise we run into issues where - # 1. select a new fs -> potentially mark as wipe now - # 2. Switch back to old filesystem -> should unmark wipe now, but - # how do we know it was the original one? - not_filter += [ - self._actions['set_filesystem'], - self._actions['mark_bootable'], - ] - if self._using_gpt: - not_filter += [ - self._actions['mark_esp'], - self._actions['mark_xbootldr'], - ] - not_filter += [ - self._actions['btrfs_mark_compressed'], - self._actions['btrfs_mark_nodatacow'], - self._actions['btrfs_set_subvolumes'], - ] - - # non btrfs partitions shouldn't get btrfs options - if selection.segment.fs_type != FilesystemType.Btrfs: - not_filter += [ - self._actions['btrfs_mark_compressed'], - self._actions['btrfs_mark_nodatacow'], - self._actions['btrfs_set_subvolumes'], - ] - else: - not_filter += [self._actions['assign_mountpoint']] - - return [o for o in options if o not in not_filter] - - @override - def handle_action( - self, - action: str, - entry: DiskSegment | None, - data: list[DiskSegment], - ) -> list[DiskSegment]: - if not entry: - action_key = [k for k, v in self._actions.items() if v == action][0] - match action_key: - case 'suggest_partition_layout': - part_mods = self.get_part_mods(data) - device_mod = self._suggest_partition_layout(part_mods) - if device_mod and device_mod.partitions: - data = self.as_segments(device_mod.partitions) - self._wipe = device_mod.wipe - self._prompt = self._info + self.wipe_str() - case 'remove_added_partitions': - if self._reset_confirmation(): - data = [s for s in data if isinstance(s.segment, PartitionModification) and s.segment.is_exists_or_modify()] - elif isinstance(entry.segment, PartitionModification): - partition = entry.segment - action_key = [k for k, v in self._actions.items() if v == action][0] - match action_key: - case 'assign_mountpoint': - new_mountpoint = self._prompt_mountpoint() - if not partition.is_swap(): - if partition.is_home(): - partition.invert_flag(PartitionFlag.LINUX_HOME) - partition.mountpoint = new_mountpoint - if partition.is_root(): - partition.flags = [] - if partition.is_boot(): - partition.flags = [] - partition.set_flag(PartitionFlag.BOOT) - if self._using_gpt: - partition.set_flag(PartitionFlag.ESP) - if partition.is_home(): - partition.flags = [] - partition.set_flag(PartitionFlag.LINUX_HOME) - case 'mark_formatting': - self._prompt_formatting(partition) - case 'mark_bootable': - if not partition.is_swap(): - partition.invert_flag(PartitionFlag.BOOT) - case 'mark_esp': - if not partition.is_root() and not partition.is_home() and not partition.is_swap(): - if PartitionFlag.XBOOTLDR in partition.flags: - partition.invert_flag(PartitionFlag.XBOOTLDR) - partition.invert_flag(PartitionFlag.ESP) - case 'mark_xbootldr': - if not partition.is_root() and not partition.is_home() and not partition.is_swap(): - if PartitionFlag.ESP in partition.flags: - partition.invert_flag(PartitionFlag.ESP) - partition.invert_flag(PartitionFlag.XBOOTLDR) - case 'set_filesystem': - fs_type = self._prompt_partition_fs_type() - - if partition.is_swap(): - partition.invert_flag(PartitionFlag.SWAP) - partition.fs_type = fs_type - if partition.is_swap(): - partition.mountpoint = None - partition.flags = [] - partition.set_flag(PartitionFlag.SWAP) - # btrfs subvolumes will define mountpoints - if fs_type == FilesystemType.Btrfs: - partition.mountpoint = None - case 'btrfs_mark_compressed': - self._toggle_mount_option(partition, BtrfsMountOption.compress) - case 'btrfs_mark_nodatacow': - self._toggle_mount_option(partition, BtrfsMountOption.nodatacow) - case 'btrfs_set_subvolumes': - self._set_btrfs_subvolumes(partition) - case 'delete_partition': - data = self._delete_partition(partition, data) - else: - part_mods = self.get_part_mods(data) - index = data.index(entry) - part_mods.insert(index, self._create_new_partition(entry.segment)) - data = self.as_segments(part_mods) - - return data - - def _delete_partition( - self, - entry: PartitionModification, - data: list[DiskSegment], - ) -> list[DiskSegment]: - if entry.is_exists_or_modify(): - entry.status = ModificationStatus.Delete - part_mods = self.get_part_mods(data) - else: - part_mods = [d.segment for d in data if isinstance(d.segment, PartitionModification) and d.segment != entry] - - return self.as_segments(part_mods) - - def _toggle_mount_option( - self, - partition: PartitionModification, - option: BtrfsMountOption, - ) -> None: - if option.value not in partition.mount_options: - if option == BtrfsMountOption.compress: - partition.mount_options = [o for o in partition.mount_options if o != BtrfsMountOption.nodatacow.value] - - partition.mount_options = [o for o in partition.mount_options if not o.startswith(BtrfsMountOption.compress.name)] - - partition.mount_options.append(option.value) - else: - partition.mount_options = [o for o in partition.mount_options if o != option.value] - - def _set_btrfs_subvolumes(self, partition: PartitionModification) -> None: - partition.btrfs_subvols = SubvolumeMenu( - partition.btrfs_subvols, - None, - ).run() - - def _prompt_formatting(self, partition: PartitionModification) -> None: - # an existing partition can toggle between Exist or Modify - if partition.is_modify(): - partition.status = ModificationStatus.Exist - return - elif partition.exists(): - partition.status = ModificationStatus.Modify - - # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really - # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set, - # it's safe to change the filesystem for this partition. - if partition.fs_type == FilesystemType.Crypto_luks: - prompt = tr('This partition is currently encrypted, to format it a filesystem has to be specified') + '\n' - fs_type = self._prompt_partition_fs_type(prompt) - partition.fs_type = fs_type - - if fs_type == FilesystemType.Btrfs: - partition.mountpoint = None - - def _prompt_mountpoint(self) -> Path: - header = tr('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.') + '\n\n' - header += tr('Enter a mountpoint') - - mountpoint = prompt_dir(header, validate=False, allow_skip=False) - assert mountpoint - - return mountpoint - - def _prompt_partition_fs_type(self, prompt: str | None = None) -> FilesystemType: - fs_types = filter(lambda fs: fs != FilesystemType.Crypto_luks, FilesystemType) - items = [MenuItem(fs.value, value=fs) for fs in fs_types] - group = MenuItemGroup(items, sort_items=False) - - result = SelectionMenu[FilesystemType]( - group, - header=prompt, - allow_skip=False, - show_frame=False, - ).show() - - match result.type_: - case ResultType.Selection: - return result.get_value() - case _: - raise ValueError('Unhandled result type') - - def _validate_value( - self, - sector_size: SectorSize, - max_size: Size, - text: str, - ) -> Size | None: - match = re.match(r'([0-9]+)([a-zA-Z|%]*)', text, re.I) - - if not match: - return None - - str_value, unit = match.groups() - - if unit == '%': - value = int(max_size.value * (int(str_value) / 100)) - unit = max_size.unit.name - else: - value = int(str_value) - - if unit and unit not in Unit.get_all_units(): - return None - - unit = Unit[unit] if unit else Unit.sectors - size = Size(value, unit, sector_size) - - if size.format_highest() == max_size.format_highest(): - return max_size - elif size > max_size or size < self._buffer: - return None - - return size - - def _prompt_size(self, free_space: FreeSpace) -> Size: - def validate(value: str | None) -> str | None: - if not value: - return None - - size = self._validate_value(sector_size, max_size, value) - if not size: - return tr('Invalid size') - return None - - device_info = self._device.device_info - sector_size = device_info.sector_size - - text = tr('Selected free space segment on device {}:').format(device_info.path) + '\n\n' - free_space_table = FormattedOutput.as_table([free_space]) - prompt = text + free_space_table + '\n' - - max_sectors = free_space.length.format_size(Unit.sectors, sector_size) - max_bytes = free_space.length.format_size(Unit.B) - - prompt += tr('Size: {} / {}').format(max_sectors, max_bytes) + '\n\n' - prompt += tr('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...') + '\n' - prompt += tr('If no unit is provided, the value is interpreted as sectors') + '\n\n' - - max_size = free_space.length - prompt += tr('Enter a size (default: {}): ').format(max_size.format_highest()) - - result = Input( - header=f'{prompt}\b', - allow_skip=True, - validator_callback=validate, - ).show() - - size: Size | None = None - - match result.type_: - case ResultType.Skip: - size = max_size - case ResultType.Selection: - value = result.get_value() - - if value: - size = self._validate_value(sector_size, max_size, value) - else: - size = max_size - case _: - raise ValueError('Unhandled result type') - - assert size - return size - - def _create_new_partition(self, free_space: FreeSpace) -> PartitionModification: - length = self._prompt_size(free_space) - - fs_type = self._prompt_partition_fs_type() - - mountpoint = None - if fs_type not in (FilesystemType.Btrfs, FilesystemType.LinuxSwap): - mountpoint = self._prompt_mountpoint() - - partition = PartitionModification( - status=ModificationStatus.Create, - type=PartitionType.Primary, - start=free_space.start, - length=length, - fs_type=fs_type, - mountpoint=mountpoint, - ) - - if partition.mountpoint == Path('/boot'): - partition.set_flag(PartitionFlag.BOOT) - if self._using_gpt: - partition.set_flag(PartitionFlag.ESP) - elif partition.is_swap(): - partition.mountpoint = None - partition.flags = [] - partition.set_flag(PartitionFlag.SWAP) + def __init__( + self, + device_mod: DeviceModification, + partition_table: PartitionTable, + ) -> None: + device = device_mod.device + + self._device = device + self._wipe = device_mod.wipe + self._buffer = Size(1, Unit.MiB, device.device_info.sector_size) + self._using_gpt = device_mod.using_gpt(partition_table) + + self._actions = { + 'suggest_partition_layout': tr('Suggest partition layout'), + 'remove_added_partitions': tr('Remove all newly added partitions'), + 'assign_mountpoint': tr('Assign mountpoint'), + 'mark_formatting': tr('Mark/Unmark to be formatted (wipes data)'), + 'mark_bootable': tr('Mark/Unmark as bootable'), + } + if self._using_gpt: + self._actions.update( + { + 'mark_esp': tr('Mark/Unmark as ESP'), + 'mark_xbootldr': tr('Mark/Unmark as XBOOTLDR'), + } + ) + self._actions.update( + { + 'set_filesystem': tr('Change filesystem'), + 'btrfs_mark_compressed': tr('Mark/Unmark as compressed'), # btrfs only + 'btrfs_mark_nodatacow': tr('Mark/Unmark as nodatacow'), # btrfs only + 'btrfs_set_subvolumes': tr('Set subvolumes'), # btrfs only + 'delete_partition': tr('Delete partition'), + } + ) + + device_partitions = [] + + if not device_mod.partitions: + # we'll display the existing partitions of the device + for partition in device.partition_infos: + device_partitions.append( + PartitionModification.from_existing_partition(partition), + ) + else: + device_partitions = device_mod.partitions + + prompt = tr('Partition management: {}').format(device.device_info.path) + '\n' + prompt += tr('Total length: {}').format(device.device_info.total_size.format_size(Unit.MiB)) + self._info = prompt + '\n' + + display_actions = list(self._actions.values()) + super().__init__( + self.as_segments(device_partitions), + display_actions[:1], + display_actions[2:], + self._info + self.wipe_str(), + ) + + def wipe_str(self) -> str: + return '{}: {}'.format(tr('Wipe'), self._wipe) + + def as_segments(self, device_partitions: list[PartitionModification]) -> list[DiskSegment]: + end = self._device.device_info.total_size + + if self._using_gpt: + end = end.gpt_end() + + end = end.align() + + # Reorder device_partitions to move all deleted partitions to the top + device_partitions.sort(key=lambda p: p.is_delete(), reverse=True) + + partitions = [DiskSegment(p) for p in device_partitions if not p.is_delete()] + segments = [DiskSegment(p) for p in device_partitions] + + if not partitions: + free_space = FreeSpace(self._buffer, end) + if free_space.length > self._buffer: + return segments + [DiskSegment(free_space)] + return segments + + first_part_index, first_partition = next( + (i, disk_segment) + for i, disk_segment in enumerate(segments) + if isinstance(disk_segment.segment, PartitionModification) and not disk_segment.segment.is_delete() + ) + + prev_partition = first_partition + index = 0 + + for partition in segments[1:]: + index += 1 + + if isinstance(partition.segment, PartitionModification) and partition.segment.is_delete(): + continue + + if prev_partition.segment.end < partition.segment.start: + free_space = FreeSpace(prev_partition.segment.end, partition.segment.start) + if free_space.length > self._buffer: + segments.insert(index, DiskSegment(free_space)) + index += 1 + + prev_partition = partition + + if first_partition.segment.start > self._buffer: + free_space = FreeSpace(self._buffer, first_partition.segment.start) + if free_space.length > self._buffer: + segments.insert(first_part_index, DiskSegment(free_space)) + + if partitions[-1].segment.end < end: + free_space = FreeSpace(partitions[-1].segment.end, end) + if free_space.length > self._buffer: + segments.append(DiskSegment(free_space)) + + return segments + + @staticmethod + def get_part_mods(disk_segments: list[DiskSegment]) -> list[PartitionModification]: + return [s.segment for s in disk_segments if isinstance(s.segment, PartitionModification)] + + def get_device_mod(self) -> DeviceModification: + disk_segments = super().run() + partitions = self.get_part_mods(disk_segments) + return DeviceModification(self._device, self._wipe, partitions) + + @override + def _run_actions_on_entry(self, entry: DiskSegment) -> None: + # Do not create a menu when the segment is free space + if isinstance(entry.segment, FreeSpace): + self._data = self.handle_action('', entry, self._data) + else: + super()._run_actions_on_entry(entry) + + @override + def selected_action_display(self, selection: DiskSegment) -> str: + if isinstance(selection.segment, PartitionModification): + if selection.segment.status == ModificationStatus.Create: + return tr('Partition - New') + elif selection.segment.is_delete() and selection.segment.dev_path: + title = tr('Partition') + '\n\n' + title += 'status: delete\n' + title += f'device: {selection.segment.dev_path}\n' + for part in self._device.partition_infos: + if part.path == selection.segment.dev_path: + if part.partuuid: + title += f'partuuid: {part.partuuid}' + return title + return str(selection.segment.dev_path) + return '' + + @override + def filter_options(self, selection: DiskSegment, options: list[str]) -> list[str]: + not_filter = [] + + if isinstance(selection.segment, PartitionModification): + if selection.segment.is_delete(): + not_filter = list(self._actions.values()) + # only display formatting if the partition exists already + elif not selection.segment.exists(): + not_filter += [self._actions['mark_formatting']] + else: + # only allow options if the existing partition + # was marked as formatting, otherwise we run into issues where + # 1. select a new fs -> potentially mark as wipe now + # 2. Switch back to old filesystem -> should unmark wipe now, but + # how do we know it was the original one? + not_filter += [ + self._actions['set_filesystem'], + self._actions['mark_bootable'], + ] + if self._using_gpt: + not_filter += [ + self._actions['mark_esp'], + self._actions['mark_xbootldr'], + ] + not_filter += [ + self._actions['btrfs_mark_compressed'], + self._actions['btrfs_mark_nodatacow'], + self._actions['btrfs_set_subvolumes'], + ] + + # non btrfs partitions shouldn't get btrfs options + if selection.segment.fs_type != FilesystemType.Btrfs: + not_filter += [ + self._actions['btrfs_mark_compressed'], + self._actions['btrfs_mark_nodatacow'], + self._actions['btrfs_set_subvolumes'], + ] + else: + not_filter += [self._actions['assign_mountpoint']] + + return [o for o in options if o not in not_filter] + + @override + def handle_action( + self, + action: str, + entry: DiskSegment | None, + data: list[DiskSegment], + ) -> list[DiskSegment]: + if not entry: + action_key = [k for k, v in self._actions.items() if v == action][0] + match action_key: + case 'suggest_partition_layout': + part_mods = self.get_part_mods(data) + device_mod = self._suggest_partition_layout(part_mods) + if device_mod and device_mod.partitions: + data = self.as_segments(device_mod.partitions) + self._wipe = device_mod.wipe + self._prompt = self._info + self.wipe_str() + case 'remove_added_partitions': + if self._reset_confirmation(): + data = [s for s in data if isinstance(s.segment, PartitionModification) and s.segment.is_exists_or_modify()] + elif isinstance(entry.segment, PartitionModification): + partition = entry.segment + action_key = [k for k, v in self._actions.items() if v == action][0] + match action_key: + case 'assign_mountpoint': + new_mountpoint = self._prompt_mountpoint() + if not partition.is_swap(): + if partition.is_home(): + partition.invert_flag(PartitionFlag.LINUX_HOME) + partition.mountpoint = new_mountpoint + if partition.is_root(): + partition.flags = [] + if partition.is_boot(): + partition.flags = [] + partition.set_flag(PartitionFlag.BOOT) + if self._using_gpt: + partition.set_flag(PartitionFlag.ESP) + if partition.is_home(): + partition.flags = [] + partition.set_flag(PartitionFlag.LINUX_HOME) + case 'mark_formatting': + self._prompt_formatting(partition) + case 'mark_bootable': + if not partition.is_swap(): + partition.invert_flag(PartitionFlag.BOOT) + case 'mark_esp': + if not partition.is_root() and not partition.is_home() and not partition.is_swap(): + if PartitionFlag.XBOOTLDR in partition.flags: + partition.invert_flag(PartitionFlag.XBOOTLDR) + partition.invert_flag(PartitionFlag.ESP) + case 'mark_xbootldr': + if not partition.is_root() and not partition.is_home() and not partition.is_swap(): + if PartitionFlag.ESP in partition.flags: + partition.invert_flag(PartitionFlag.ESP) + partition.invert_flag(PartitionFlag.XBOOTLDR) + case 'set_filesystem': + fs_type = self._prompt_partition_fs_type() + + if partition.is_swap(): + partition.invert_flag(PartitionFlag.SWAP) + partition.fs_type = fs_type + if partition.is_swap(): + partition.mountpoint = None + partition.flags = [] + partition.set_flag(PartitionFlag.SWAP) + # btrfs subvolumes will define mountpoints + if fs_type == FilesystemType.Btrfs: + partition.mountpoint = None + case 'btrfs_mark_compressed': + self._toggle_mount_option(partition, BtrfsMountOption.compress) + case 'btrfs_mark_nodatacow': + self._toggle_mount_option(partition, BtrfsMountOption.nodatacow) + case 'btrfs_set_subvolumes': + self._set_btrfs_subvolumes(partition) + case 'delete_partition': + data = self._delete_partition(partition, data) + else: + part_mods = self.get_part_mods(data) + index = data.index(entry) + part_mods.insert(index, self._create_new_partition(entry.segment)) + data = self.as_segments(part_mods) + + return data + + def _delete_partition( + self, + entry: PartitionModification, + data: list[DiskSegment], + ) -> list[DiskSegment]: + if entry.is_exists_or_modify(): + entry.status = ModificationStatus.Delete + part_mods = self.get_part_mods(data) + else: + part_mods = [d.segment for d in data if isinstance(d.segment, PartitionModification) and d.segment != entry] + + return self.as_segments(part_mods) + + def _toggle_mount_option( + self, + partition: PartitionModification, + option: BtrfsMountOption, + ) -> None: + if option.value not in partition.mount_options: + if option == BtrfsMountOption.compress: + partition.mount_options = [o for o in partition.mount_options if o != BtrfsMountOption.nodatacow.value] + + partition.mount_options = [o for o in partition.mount_options if not o.startswith(BtrfsMountOption.compress.name)] + + partition.mount_options.append(option.value) + else: + partition.mount_options = [o for o in partition.mount_options if o != option.value] + + def _set_btrfs_subvolumes(self, partition: PartitionModification) -> None: + partition.btrfs_subvols = SubvolumeMenu( + partition.btrfs_subvols, + None, + ).run() + + def _prompt_formatting(self, partition: PartitionModification) -> None: + # an existing partition can toggle between Exist or Modify + if partition.is_modify(): + partition.status = ModificationStatus.Exist + return + elif partition.exists(): + partition.status = ModificationStatus.Modify + + # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really + # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set, + # it's safe to change the filesystem for this partition. + if partition.fs_type == FilesystemType.Crypto_luks: + prompt = tr('This partition is currently encrypted, to format it a filesystem has to be specified') + '\n' + fs_type = self._prompt_partition_fs_type(prompt) + partition.fs_type = fs_type + + if fs_type == FilesystemType.Btrfs: + partition.mountpoint = None + + def _prompt_mountpoint(self) -> Path: + header = tr('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.') + '\n\n' + header += tr('Enter a mountpoint') + + mountpoint = prompt_dir(header, validate=False, allow_skip=False) + assert mountpoint + + return mountpoint + + def _prompt_partition_fs_type(self, prompt: str | None = None) -> FilesystemType: + fs_types = filter(lambda fs: fs != FilesystemType.Crypto_luks, FilesystemType) + items = [MenuItem(fs.value, value=fs) for fs in fs_types] + group = MenuItemGroup(items, sort_items=False) + + result = SelectionMenu[FilesystemType]( + group, + header=prompt, + allow_skip=False, + show_frame=False, + ).show() + + match result.type_: + case ResultType.Selection: + return result.get_value() + case _: + raise ValueError('Unhandled result type') + + def _validate_value( + self, + sector_size: SectorSize, + max_size: Size, + text: str, + ) -> Size | None: + match = re.match(r'([0-9]+)([a-zA-Z|%]*)', text, re.I) + + if not match: + return None + + str_value, unit = match.groups() + + if unit == '%': + value = int(max_size.value * (int(str_value) / 100)) + unit = max_size.unit.name + else: + value = int(str_value) + + if unit and unit not in Unit.get_all_units(): + return None + + unit = Unit[unit] if unit else Unit.sectors + size = Size(value, unit, sector_size) + + if size.format_highest() == max_size.format_highest(): + return max_size + elif size > max_size or size < self._buffer: + return None + + return size + + def _prompt_size(self, free_space: FreeSpace) -> Size: + def validate(value: str | None) -> str | None: + if not value: + return None + + size = self._validate_value(sector_size, max_size, value) + if not size: + return tr('Invalid size') + return None + + device_info = self._device.device_info + sector_size = device_info.sector_size + + text = tr('Selected free space segment on device {}:').format(device_info.path) + '\n\n' + free_space_table = FormattedOutput.as_table([free_space]) + prompt = text + free_space_table + '\n' + + max_sectors = free_space.length.format_size(Unit.sectors, sector_size) + max_bytes = free_space.length.format_size(Unit.B) + + prompt += tr('Size: {} / {}').format(max_sectors, max_bytes) + '\n\n' + prompt += tr('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...') + '\n' + prompt += tr('If no unit is provided, the value is interpreted as sectors') + '\n\n' + + max_size = free_space.length + prompt += tr('Enter a size (default: {}): ').format(max_size.format_highest()) + + result = Input( + header=f'{prompt}\b', + allow_skip=True, + validator_callback=validate, + ).show() + + size: Size | None = None + + match result.type_: + case ResultType.Skip: + size = max_size + case ResultType.Selection: + value = result.get_value() + + if value: + size = self._validate_value(sector_size, max_size, value) + else: + size = max_size + case _: + raise ValueError('Unhandled result type') + + assert size + return size + + def _create_new_partition(self, free_space: FreeSpace) -> PartitionModification: + length = self._prompt_size(free_space) + + fs_type = self._prompt_partition_fs_type() + + mountpoint = None + if fs_type not in (FilesystemType.Btrfs, FilesystemType.LinuxSwap): + mountpoint = self._prompt_mountpoint() + + partition = PartitionModification( + status=ModificationStatus.Create, + type=PartitionType.Primary, + start=free_space.start, + length=length, + fs_type=fs_type, + mountpoint=mountpoint, + ) + + if partition.mountpoint == Path('/boot'): + partition.set_flag(PartitionFlag.BOOT) + if self._using_gpt: + partition.set_flag(PartitionFlag.ESP) + elif partition.is_swap(): + partition.mountpoint = None + partition.flags = [] + partition.set_flag(PartitionFlag.SWAP) - return partition - - def _reset_confirmation(self) -> bool: - prompt = tr('This will remove all newly added partitions, continue?') + '\n' - - result = Confirmation[bool]( - MenuItemGroup.yes_no(), - header=prompt, - allow_skip=False, - allow_reset=False, - ).show() + return partition + + def _reset_confirmation(self) -> bool: + prompt = tr('This will remove all newly added partitions, continue?') + '\n' + + result = Confirmation[bool]( + MenuItemGroup.yes_no(), + header=prompt, + allow_skip=False, + allow_reset=False, + ).show() - return result.item() == MenuItem.yes() - - def _suggest_partition_layout( - self, - data: list[PartitionModification], - ) -> DeviceModification | None: - # if modifications have been done already, inform the user - # that this operation will erase those modifications - if any([not entry.exists() for entry in data]): - if not self._reset_confirmation(): - return None - - from ..interactions.disk_conf import suggest_single_disk_layout - - return suggest_single_disk_layout(self._device) + return result.item() == MenuItem.yes() + + def _suggest_partition_layout( + self, + data: list[PartitionModification], + ) -> DeviceModification | None: + # if modifications have been done already, inform the user + # that this operation will erase those modifications + if any([not entry.exists() for entry in data]): + if not self._reset_confirmation(): + return None + + from ..interactions.disk_conf import suggest_single_disk_layout + + return suggest_single_disk_layout(self._device) def manual_partitioning( - device_mod: DeviceModification, - partition_table: PartitionTable, + device_mod: DeviceModification, + partition_table: PartitionTable, ) -> DeviceModification | None: - menu_list = PartitioningList(device_mod, partition_table) - mod = menu_list.get_device_mod() + menu_list = PartitioningList(device_mod, partition_table) + mod = menu_list.get_device_mod() - if menu_list.is_last_choice_cancel(): - return device_mod + if menu_list.is_last_choice_cancel(): + return device_mod - if mod.partitions: - return mod + if mod.partitions: + return mod - return None + return None diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index 1583a58b40..31c5a25289 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -1,11 +1,10 @@ from pathlib import Path from typing import assert_never, override +from archinstall.lib.menu.helpers import Input from archinstall.lib.models.device import SubvolumeModification from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..utils.util import prompt_dir @@ -40,19 +39,18 @@ def validate(value: str | None) -> str | None: return None return tr('Value cannot be empty') - result = EditMenu( - tr('Subvolume name'), - alignment=Alignment.CENTER, + result = Input( + header=tr('Enter subvolume name'), allow_skip=True, - default_text=str(preset.name) if preset else None, - validator=validate, - ).input() + default_value=str(preset.name) if preset else None, + validator_callback=validate, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - name = result.text() + name = result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') case _: diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 1d06a01b7c..550869482e 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -188,8 +188,8 @@ def _get_menu_options(self) -> list[MenuItem]: def _safe_config(self) -> None: # data: dict[str, Any] = {} # for item in self._item_group.items: - # if item.key is not None: - # data[item.key] = item.value + # if item.key is not None: + # data[item.key] = item.value self.sync_all_to_config() save_config(self._arch_config) diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index aad2fc79f2..3a0e89c5d6 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -4,14 +4,12 @@ from pathlib import Path from typing import assert_never -from archinstall.lib.menu.helpers import Input, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Input, Loading, Notify, SelectionMenu from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu, Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.result import ResultType from archinstall.tui.ui.result import ResultType as UiResultType from ..locale.utils import list_timezones @@ -39,14 +37,11 @@ def ask_ntp(preset: bool = True) -> bool: group = MenuItemGroup.yes_no() group.focus_item = preset_val - result = SelectMenu[bool]( + result = SelectionMenu[bool]( group, header=header, allow_skip=True, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -85,13 +80,12 @@ def ask_for_a_timezone(preset: str | None = None) -> str | None: group.set_selected_by_value(preset) group.set_default_by_value(default) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, + header=tr('Select timezone'), allow_reset=True, allow_skip=True, - frame=FrameProperties.min(tr('Timezone')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -128,15 +122,6 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - # result = SelectMenu[Language]( - # group, - # header=title, - # allow_skip=True, - # allow_reset=False, - # alignment=Alignment.CENTER, - # frame=FrameProperties.min(header=tr('Select language')), - # ).run() - result = SelectionMenu[Language]( header=title, group=group, @@ -161,11 +146,17 @@ def ask_additional_packages_to_install( respos_text = ', '.join([r.value for r in repositories]) output = tr('Repositories: {}').format(respos_text) + '\n' - output += tr('Loading packages...') - Tui.print(output, clear_screen=True) - packages = list_available_packages(tuple(repositories)) + packages = Loading[dict[str, AvailablePackage]]( + header=output, + data_callback=lambda: list_available_packages(tuple(repositories)) + ).show() + + if packages is None: + Notify(tr('No packages found')).show() + return [] + package_groups = PackageGroup.from_available_packages(packages) # Additional packages (with some light weight error handling for invalid package names) @@ -184,7 +175,7 @@ def ask_additional_packages_to_install( MenuItem( name, value=pkg, - preview_action=lambda x: x.value.info(), + preview_action=lambda x: x.value.info() if x.value else None, ) for name, pkg in packages.items() ] @@ -193,7 +184,7 @@ def ask_additional_packages_to_install( MenuItem( name, value=group, - preview_action=lambda x: x.value.info(), + preview_action=lambda x: x.value.info() if x.value else None, ) for name, group in package_groups.items() ] @@ -201,17 +192,15 @@ def ask_additional_packages_to_install( menu_group = MenuItemGroup(items, sort_items=True) menu_group.set_selected_by_value(preset_packages) - result = SelectMenu[AvailablePackage | PackageGroup]( + result = SelectionMenu[AvailablePackage | PackageGroup]( menu_group, header=header, - alignment=Alignment.LEFT, allow_reset=True, allow_skip=True, multi=True, - preview_frame=FrameProperties.max('Package info'), - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - ).run() + preview_orientation='right', + show_frame=False, + ).show() match result.type_: case ResultType.Skip: @@ -242,14 +231,15 @@ def validator(s: str | None) -> str | None: return tr('Invalid download number') - result = EditMenu( - tr('Number downloads'), + header += tr('Enter a the number of parallel downloads to be enabled (max recommended: {})').format(max_recommended) + + result = Input( header=header, allow_skip=True, allow_reset=True, - validator=validator, - default_text=str(preset) if preset is not None else None, - ).input() + validator_callback=validator, + default_value=str(preset) if preset is not None else None, + ).show() match result.type_: case ResultType.Skip: @@ -257,7 +247,7 @@ def validator(s: str | None) -> str | None: case ResultType.Reset: return 0 case ResultType.Selection: - downloads: int = int(result.text()) + downloads: int = int(result.get_value()) case _: assert_never(result.type_) @@ -282,12 +272,11 @@ def ask_post_installation() -> PostInstallationAction: items = [MenuItem(action.value, value=action) for action in PostInstallationAction] group = MenuItemGroup(items) - result = SelectMenu[PostInstallationAction]( + result = SelectionMenu[PostInstallationAction]( group, header=header, allow_skip=False, - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Selection: @@ -300,14 +289,11 @@ def ask_abort() -> None: prompt = tr('Do you really want to abort?') + '\n' group = MenuItemGroup.yes_no() - result = SelectMenu[bool]( + result = Confirmation[bool]( group, header=prompt, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + ).show() if result.item() == MenuItem.yes(): exit(0) diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index d1ae13d0a1..207a926b2c 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -3,11 +3,10 @@ import re from typing import override +from archinstall.lib.menu.helpers import Input, SelectionMenu from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, Orientation from ..menu.list_manager import ListManager from ..models.users import User @@ -36,24 +35,25 @@ def selected_action_display(self, selection: User) -> str: @override def handle_action(self, action: str, entry: User | None, data: list[User]) -> list[User]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_user = self._add_user() if new_user is not None: # in case a user with the same username as an existing user # was created we'll replace the existing one data = [d for d in data if d.username != new_user.username] data += [new_user] - elif action == self._actions[1] and entry: # change password + elif action == self._actions[1] and entry: # change password header = f'{tr("User")}: {entry.username}\n' - new_password = get_password(tr('Password'), header=header) + header += tr('Enter new password') + new_password = get_password(header=header) if new_password: user = next(filter(lambda x: x == entry, data)) user.password = new_password - elif action == self._actions[2] and entry: # promote/demote + elif action == self._actions[2] and entry: # promote/demote user = next(filter(lambda x: x == entry, data)) user.sudo = False if user.sudo else True - elif action == self._actions[3] and entry: # delete + elif action == self._actions[3] and entry: # delete data = [d for d in data if d != entry] return data @@ -65,17 +65,17 @@ def _check_for_correct_username(self, username: str | None) -> str | None: return tr('The username you entered is invalid') def _add_user(self) -> User | None: - editResult = EditMenu( - tr('Username'), + editResult = Input( + tr('Enter username'), allow_skip=True, - validator=self._check_for_correct_username, - ).input() + validator_callback=self._check_for_correct_username, + ).show() match editResult.type_: case ResultType.Skip: return None case ResultType.Selection: - username = editResult.text() + username = editResult.get_value() case _: raise ValueError('Unhandled result type') @@ -83,8 +83,9 @@ def _add_user(self) -> User | None: return None header = f'{tr("Username")}: {username}\n' + header += tr('Enter password') - password = get_password(tr('Password'), header=header, allow_skip=True) + password = get_password(header=header, allow_skip=True) if not password: return None @@ -95,15 +96,12 @@ def _add_user(self) -> User | None: group = MenuItemGroup.yes_no() group.focus_item = MenuItem.yes() - result = SelectMenu[bool]( + result = SelectionMenu[bool]( group, header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, search_enabled=False, allow_skip=False, - ).run() + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index 14071ef2d2..f937731ac4 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -3,11 +3,10 @@ import ipaddress from typing import assert_never, override +from archinstall.lib.menu.helpers import Input, SelectionMenu from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import EditMenu, SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..models.network import NetworkConfiguration, Nic, NicType @@ -35,14 +34,14 @@ def selected_action_display(self, selection: Nic) -> str: @override def handle_action(self, action: str, entry: Nic | None, data: list[Nic]) -> list[Nic]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add iface = self._select_iface(data) if iface: nic = Nic(iface=iface) nic = self._edit_iface(nic) data += [nic] elif entry: - if action == self._actions[1]: # edit interface + if action == self._actions[1]: # edit interface data = [d for d in data if d.iface != entry.iface] data.append(self._edit_iface(entry)) elif action == self._actions[2]: # delete @@ -64,12 +63,12 @@ def _select_iface(self, data: list[Nic]) -> str | None: items = [MenuItem(i, value=i) for i in available] group = MenuItemGroup(items, sort_items=True) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Interfaces')), + header=tr('Select an interface'), allow_skip=True, - ).run() + show_frame=False, + ).show() match result.type_: case ResultType.Skip: @@ -81,16 +80,18 @@ def _select_iface(self, data: list[Nic]) -> str | None: def _get_ip_address( self, - title: str, header: str, allow_skip: bool, multi: bool, preset: str | None = None, + allow_empty: bool = False ) -> str | None: def validator(ip: str | None) -> str | None: failure = tr('You need to enter a valid IP in IP-config mode') if not ip: + if allow_empty: + return None return failure if multi: @@ -105,19 +106,18 @@ def validator(ip: str | None) -> str | None: except ValueError: return failure - result = EditMenu( - title, + result = Input( header=header, - validator=validator, + validator_callback=validator, allow_skip=allow_skip, - default_text=preset, - ).input() + default_value=preset, + ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - return result.text() + return result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') @@ -126,18 +126,18 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: modes = ['DHCP (auto detect)', 'IP (static)'] default_mode = 'DHCP (auto detect)' - header = tr('Select which mode to configure for "{}"').format(iface_name) + '\n' + header = tr('Select which mode to configure for "{}"').format(iface_name) + items = [MenuItem(m, value=m) for m in modes] group = MenuItemGroup(items, sort_items=True) group.set_default_by_value(default_mode) - result = SelectMenu[str]( + result = SelectionMenu[str]( group, header=header, allow_skip=False, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Modes')), - ).run() + show_frame=False, + ).show() match result.type_: case ResultType.Selection: @@ -151,10 +151,10 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: if mode == 'IP (static)': header = tr('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) + '\n' - ip = self._get_ip_address(tr('IP address'), header, False, False) + ip = self._get_ip_address(header, False, False) header = tr('Enter your gateway (router) IP address (leave blank for none)') + '\n' - gateway = self._get_ip_address(tr('Gateway address'), header, True, False) + gateway = self._get_ip_address(header, True, False, allow_empty=True) if edit_nic.dns: display_dns = ' '.join(edit_nic.dns) @@ -163,11 +163,11 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: header = tr('Enter your DNS servers with space separated (leave blank for none)') + '\n' dns_servers = self._get_ip_address( - tr('DNS servers'), header, True, True, display_dns, + allow_empty=True ) dns = [] @@ -191,13 +191,13 @@ def ask_to_configure_network(preset: NetworkConfiguration | None) -> NetworkConf if preset: group.set_selected_by_value(preset.type) - result = SelectMenu[NicType]( + result = SelectionMenu[NicType]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Network configuration')), + header=tr('Choose network configuration'), allow_reset=True, allow_skip=True, - ).run() + show_frame=False, + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 0a22f8fb4b..a0d366a77a 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -42,7 +42,6 @@ def select_kernel(preset: list[str] = []) -> list[str]: return result.get_values() -<<<<<<< HEAD def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: # Systemd is UEFI only options = [] @@ -106,8 +105,6 @@ def ask_for_uki(preset: bool = True) -> bool: raise ValueError('Unhandled result type') -======= ->>>>>>> origin/master def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None) -> GfxDriver | None: """ Some what convoluted function, whose job is simple. diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 5b796f513e..0ce1b36907 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -105,6 +105,7 @@ def run(self, additional_title: str | None = None) -> ValueT | None: allow_skip=False, allow_reset=self._allow_reset, preview_orientation='right', + show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 5bef378776..23eaead566 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -2,18 +2,19 @@ from typing import Literal, TypeVar, override from textual.validation import ValidationResult, Validator +from textual.widgets import Select from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItemGroup from archinstall.tui.ui.components import ( - ConfirmationScreen, - InputScreen, - LoadingScreen, - NotifyScreen, - OptionListScreen, - SelectListScreen, - TableSelectionScreen, - tui, + ConfirmationScreen, + InputScreen, + LoadingScreen, + NotifyScreen, + OptionListScreen, + SelectListScreen, + TableSelectionScreen, + tui, ) from archinstall.tui.ui.result import Result, ResultType @@ -21,231 +22,248 @@ class SelectionMenu[ValueT]: - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = True, - allow_reset: bool = False, - preview_orientation: Literal['right', 'bottom'] | None = None, - multi: bool = False, - search_enabled: bool = False, - show_frame: bool = True, - ): - self._header = header - self._group: MenuItemGroup = group - self._allow_skip = allow_skip - self._allow_reset = allow_reset - self._preview_orientation = preview_orientation - self._multi = multi - self._search_enabled = search_enabled - self._show_frame = show_frame - - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result - - async def _run(self) -> None: - if not self._multi: - result = await OptionListScreen[ValueT]( - self._group, - header=self._header, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - preview_location=self._preview_orientation, - show_frame=self._show_frame, - ).run() - else: - result = await SelectListScreen[ValueT]( - self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_orientation - ).run() - - if result.type_ == ResultType.Reset: - confirmed = await _confirm_reset() - - if confirmed.get_value() is False: - return await self._run() - - tui.exit(result) + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + preview_orientation: Literal['right', 'bottom'] | None = None, + multi: bool = False, + search_enabled: bool = False, + show_frame: bool = True, + ): + self._header = header + self._group: MenuItemGroup = group + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._preview_orientation = preview_orientation + self._multi = multi + self._search_enabled = search_enabled + self._show_frame = show_frame + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + if not self._multi: + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_orientation, + show_frame=self._show_frame, + ).run() + else: + result = await SelectListScreen[ValueT]( + self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_orientation + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) class Confirmation[ValueT]: - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = True, - allow_reset: bool = False, - ): - self._header = header - self._group: MenuItemGroup = group - self._allow_skip = allow_skip - self._allow_reset = allow_reset - - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result - - async def _run(self) -> None: - result = await ConfirmationScreen[ValueT]( - self._group, - header=self._header, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - ).run() - - if result.type_ == ResultType.Reset: - confirmed = await _confirm_reset() - - if confirmed.get_value() is False: - return await self._run() - - tui.exit(result) + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + ): + self._header = header + self._group: MenuItemGroup = group + self._allow_skip = allow_skip + self._allow_reset = allow_reset + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + result = await ConfirmationScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) class Notify[ValueT]: - def __init__( - self, - header: str | None = None, - ): - self._header = header + def __init__( + self, + header: str | None = None, + ): + self._header = header - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result - async def _run(self) -> None: - await NotifyScreen(header=self._header).run() - tui.exit(True) + async def _run(self) -> None: + await NotifyScreen(header=self._header).run() + tui.exit(True) class GenericValidator(Validator): - def __init__(self, validator_callback: Callable[[str | None], str | None]) -> None: - super().__init__() + def __init__(self, validator_callback: Callable[[str | None], str | None]) -> None: + super().__init__() - self._validator_callback = validator_callback + self._validator_callback = validator_callback - @override - def validate(self, value: str) -> ValidationResult: - result = self._validator_callback(value) + @override + def validate(self, value: str) -> ValidationResult: + result = self._validator_callback(value) - if result is not None: - return self.failure(result) + if result is not None: + return self.failure(result) - return self.success() + return self.success() class Input: - def __init__( - self, - header: str | None = None, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_skip: bool = True, - allow_reset: bool = False, - validator_callback: Callable[[str | None], str | None] | None = None, - ): - self._header = header - self._placeholder = placeholder - self._password = password - self._default_value = default_value - self._allow_skip = allow_skip - self._allow_reset = allow_reset - self._validator_callback = validator_callback - - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result - - async def _run(self) -> None: - validator = GenericValidator(self._validator_callback) if self._validator_callback else None - - result = await InputScreen( - header=self._header, - placeholder=self._placeholder, - password=self._password, - default_value=self._default_value, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - validator=validator, - ).run() - - if result.type_ == ResultType.Reset: - confirmed = await _confirm_reset() - - if confirmed.get_value() is False: - return await self._run() - - tui.exit(result) + def __init__( + self, + header: str | None = None, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_skip: bool = True, + allow_reset: bool = False, + validator_callback: Callable[[str | None], str | None] | None = None, + ): + self._header = header + self._placeholder = placeholder + self._password = password + self._default_value = default_value + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._validator_callback = validator_callback + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + validator = GenericValidator(self._validator_callback) if self._validator_callback else None + + result = await InputScreen( + header=self._header, + placeholder=self._placeholder, + password=self._password, + default_value=self._default_value, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + validator=validator, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) class Loading[ValueT]: - def __init__(self, header: str | None = None, timer: int = 3): - self._header = header - self._timer = timer - - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result - - async def _run(self) -> None: - await LoadingScreen(self._timer, self._header).run() - tui.exit(True) + def __init__( + self, + header: str | None = None, + timer: int = 3, + data_callback: Callable[[], Awaitable[ValueT]] | None = None, + ): + self._header = header + self._timer = timer + self._data_callback = data_callback + + def show(self) -> ValueT | None: + result = tui.run(self) + + match result.type_: + case ResultType.Selection: + if result.has_value() is False: + return None + return result.get_value() + case _: + return None + + async def _run(self) -> None: + if self._data_callback: + result = await LoadingScreen(header=self._header, data_callback=self._data_callback).run() + tui.exit(result) + else: + await LoadingScreen(self._timer, self._header).run() + tui.exit(True) class TableMenu[ValueT]: - def __init__( - self, - header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - multi: bool = False, - preview_orientation: str = 'right', - ): - self._header = header - self._data = data - self._data_callback = data_callback - self._loading_header = loading_header - self._allow_skip = allow_skip - self._allow_reset = allow_reset - self._multi = multi - self._preview_orientation = preview_orientation - - if self._data is None and self._data_callback is None: - raise ValueError('Either data or data_callback must be provided') - - def show(self) -> Result[ValueT]: - result = tui.run(self) - return result - - async def _run(self) -> None: - result = await TableSelectionScreen[ValueT]( - header=self._header, - data=self._data, - data_callback=self._data_callback, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - loading_header=self._loading_header, - multi=self._multi, - ).run() - - if result.type_ == ResultType.Reset: - confirmed = await _confirm_reset() - - if confirmed.get_value() is False: - return await self._run() - - tui.exit(result) + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + preview_orientation: str = 'right', + ): + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._multi = multi + self._preview_orientation = preview_orientation + + if self._data is None and self._data_callback is None: + raise ValueError('Either data or data_callback must be provided') + + def show(self) -> Result[ValueT]: + result = tui.run(self) + return result + + async def _run(self) -> None: + result = await TableSelectionScreen[ValueT]( + header=self._header, + data=self._data, + data_callback=self._data_callback, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + loading_header=self._loading_header, + multi=self._multi, + ).run() + + if result.type_ == ResultType.Reset: + confirmed = await _confirm_reset() + + if confirmed.get_value() is False: + return await self._run() + + tui.exit(result) async def _confirm_reset() -> Result[bool]: - return await ConfirmationScreen[bool]( - MenuItemGroup.yes_no(), - header=tr('Are you sure you want to reset this setting?'), - allow_skip=False, - allow_reset=False, - ).run() + return await ConfirmationScreen[bool]( + MenuItemGroup.yes_no(), + header=tr('Are you sure you want to reset this setting?'), + allow_skip=False, + allow_reset=False, + ).run() diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 0dfdb01cf6..3878e38e8b 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -91,7 +91,7 @@ def run(self) -> list[ValueT]: self._last_choice = value if result.get_value() == self._cancel_action: - return self._original_data # return the original list + return self._original_data # return the original list else: return self._data @@ -101,13 +101,14 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: items = [MenuItem(o, value=o) for o in options] group = MenuItemGroup(items, sort_items=False) - header = f'{self.selected_action_display(entry)}\n' + header = f'{self.selected_action_display(entry)}' result = SelectionMenu[str]( group, header=header, search_enabled=False, allow_skip=False, + show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 833fbbe745..97c773087b 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -51,17 +51,17 @@ def handle_action( entry: CustomRepository | None, data: list[CustomRepository], ) -> list[CustomRepository]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_repo = self._add_custom_repository() if new_repo is not None: data = [d for d in data if d.name != new_repo.name] data += [new_repo] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_repo = self._add_custom_repository(entry) if new_repo is not None: data = [d for d in data if d.name != entry.name] data += [new_repo] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -171,17 +171,17 @@ def handle_action( entry: CustomServer | None, data: list[CustomServer], ) -> list[CustomServer]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_server = self._add_custom_server() if new_server is not None: data = [d for d in data if d.url != new_server.url] data += [new_server] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_server = self._add_custom_server(entry) if new_server is not None: data = [d for d in data if d.url != entry.url] data += [new_server] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -298,9 +298,11 @@ def run(self, additional_title: str | None = None) -> MirrorConfiguration | None def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: - Loading(tr('Loading mirror regions...')).show() + Loading[None]( + header=tr('Loading mirror regions...'), + data_callback=lambda: mirror_list_handler.load_mirrors() + ).show() - mirror_list_handler.load_mirrors() available_regions = mirror_list_handler.get_mirror_regions() if not available_regions: diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 47867a9917..9fcb22334b 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -11,7 +11,6 @@ from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.installer import Installer, accessibility_tools_in_use, run_custom_user_commands from archinstall.lib.interactions.general_conf import PostInstallationAction, ask_post_installation -from archinstall.lib.locale.locale_menu import select_locale_lang from archinstall.lib.models import Bootloader from archinstall.lib.models.device import ( DiskLayoutType, diff --git a/archinstall/tui/__init__.py b/archinstall/tui/__init__.py index 9bd67f1b73..e739055929 100644 --- a/archinstall/tui/__init__.py +++ b/archinstall/tui/__init__.py @@ -1,4 +1,4 @@ -from .curses_menu import EditMenu, SelectMenu, Tui +from .curses_menu import SelectMenu, Tui from .menu_item import MenuItem, MenuItemGroup from .result import Result, ResultType from .types import Alignment, Chars, FrameProperties, FrameStyle, Orientation, PreviewStyle @@ -6,7 +6,6 @@ __all__ = [ 'Alignment', 'Chars', - 'EditMenu', 'FrameProperties', 'FrameStyle', 'MenuItem', diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index bf602d3a60..2f2a7daca6 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any, Literal, TypeVar, override, ClassVar +from typing import Any, ClassVar, Literal, TypeVar, override from textual import work from textual.app import App, ComposeResult @@ -24,913 +24,925 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ - - def __init__( - self, - timer: int, - header: str | None = None, - ): - super().__init__() - self._timer = timer - self._header = header - - async def run(self) -> Result[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) # ensures indicator is centered too - - yield Footer() - - def on_mount(self) -> None: - self.set_timer(self._timer, self.action_pop_screen) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ + + def __init__( + self, + timer: int = 3, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + header: str | None = None, + ): + super().__init__() + self._timer = timer + self._header = header + self._data_callback = data_callback + + async def run(self) -> Result[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + # def on_mount(self) -> None: + # self.set_timer(self._timer, self.action_pop_screen) + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 20%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = True, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - - def action_cursor_down(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_up() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - options = self._get_options() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 20%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = True, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + + def action_cursor_down(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one('#option_list_widget', OptionList) + option_list.action_cursor_up() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + options = self._get_options() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + option_list = OptionList(*options, id='option_list_widget') + option_list.highlighted = self._group.get_focused_index() + + 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 = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield option_list + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') - option_list = OptionList(*options, id='option_list_widget') - option_list.highlighted = self._group.get_focused_index() - - 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 = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - option_list.classes = 'no-border' + yield Footer() - yield option_list - yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - yield Footer() + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if self._preview_location is None: + return None - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + preview_widget = self.query_one('#preview_content', Static) + highlighted_id = event.option.id - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if self._preview_location is None: - return None + item = self._group.find_by_id(highlighted_id) - preview_widget = self.query_one('#preview_content', Static) - highlighted_id = event.option.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 - item = self._group.find_by_id(highlighted_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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - - def action_cursor_down(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_up() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - items: list[MenuItem] = self.query_one(SelectionList).selected - _ = self.dismiss(Result(ResultType.Selection, _item=items)) - - 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._group.selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - selections = self._get_selections() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._preview_location is None: - with Center(): - with Vertical(classes='list-container'): - yield SelectionList[ValueT](*selections, id='select_list_widget') - else: - Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' - - with Container(): - yield SelectionList[ValueT](*selections, id='select_list_widget') - yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') - - yield Footer() - - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) - - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: - if self._preview_location is None: - return None - - index = event.selection_index - selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) - item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] - - preview_widget = self.query_one('#preview_content', Static) + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + + def action_cursor_down(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one('#select_list_widget', OptionList) + select_list.action_cursor_up() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + items: list[MenuItem] = self.query_one(SelectionList).selected + _ = self.dismiss(Result(ResultType.Selection, _item=items)) + + 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._group.selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + selections = self._get_selections() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield SelectionList[ValueT](*selections, id='select_list_widget') + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield SelectionList[ValueT](*selections, id='select_list_widget') + yield Rule(orientation=rule_orientation) + yield Static('', id='preview_content') + + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) + + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: + if self._preview_location is None: + return None + + index = event.selection_index + selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) + item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] + + preview_widget = self.query_one('#preview_content', Static) - 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('') + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center middle; - } - - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; - } - - .dialog { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .dialog-content { - padding: 1; - height: 100%; - } - - .message { - text-align: center; - margin-bottom: 1; - } - - .buttons { - align: center middle; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - yield Footer() - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center middle; + } + + .dialog-wrapper { + align: center middle; + height: 100%; + width: 100%; + } + + .dialog { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .dialog-content { + padding: 1; + height: 100%; + } + + .message { + text-align: center; + margin-bottom: 1; + } + + .buttons { + align: center middle; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(classes='dialog-wrapper'): + with Vertical(classes='dialog'): + with Vertical(classes='dialog-content'): + yield Static(self._header, classes='message') + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Footer() + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + _ = self.dismiss(Result(ResultType.Selection, _item=item)) class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - align: center middle; - } - - .input-header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ - - def __init__( - self, - header: str, - placeholder: str | None = None, - password: bool = False, - default_value: str | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - validator: Validator | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._header = header or '' - self._placeholder = placeholder or '' - self._password = password - self._default_value = default_value or '' - self._allow_reset = allow_reset - self._allow_skip = allow_skip - self._validator = validator - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(self._header, classes='input-header') - - 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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + CSS = """ + InputScreen { + align: center middle; + } + + .input-header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + def __init__( + self, + header: str, + placeholder: str | None = None, + password: bool = False, + default_value: str | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + validator: Validator | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._header = header or '' + self._placeholder = placeholder or '' + self._password = password + self._default_value = default_value or '' + self._allow_reset = allow_reset + self._allow_skip = allow_skip + self._validator = validator + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='input-header') + + 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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } - - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } - - DataTable .datatable--header { - background: transparent; - border: solid; - } - - .content-container { - width: auto; - background: transparent; - padding: 2 0; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - height: auto; - background: transparent; - } - """ - - def __init__( - self, - header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, - allow_reset: bool = False, - allow_skip: bool = False, - loading_header: str | None = None, - multi: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._header = header - self._data = data - self._data_callback = data_callback - self._loading_header = loading_header - self._multi = multi - - self._selected_keys: set[int] = set() - self._current_row_key = None - - if self._data is None and self._data_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') - - yield Footer() - - def on_mount(self) -> None: - self._display_header(True) - data_table = self.query_one(DataTable) - data_table.cell_padding = 2 - - if self._data: - self._put_data_to_table(data_table, self._data) - else: - self._load_data(data_table) - - @work - async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) - - def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] - - if self._multi: - row_values.insert(0, ' ') - - table.add_row(*row_values, key=d) # type: ignore[arg-type] - - 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 + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center middle; + background: transparent; + } + + DataTable { + height: auto; + width: auto; + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + .content-container { + width: auto; + background: transparent; + padding: 2 0; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + height: auto; + background: transparent; + } + """ + + def __init__( + self, + header: str | None = None, + data: list[ValueT] | None = None, + data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + allow_reset: bool = False, + allow_skip: bool = False, + loading_header: str | None = None, + multi: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._header = header + self._data = data + self._data_callback = data_callback + self._loading_header = loading_header + self._multi = multi + + self._selected_keys: set[int] = set() + self._current_row_key = None + + if self._data is None and self._data_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + yield DataTable(id='data_table') + + yield Footer() + + def on_mount(self) -> None: + self._display_header(True) + data_table = self.query_one(DataTable) + data_table.cell_padding = 2 + + if self._data: + self._put_data_to_table(data_table, self._data) + else: + self._load_data(data_table) + + @work + async def _load_data(self, table: DataTable[ValueT]) -> None: + assert self._data_callback is not None + data = await self._data_callback() + self._put_data_to_table(table, data) + + def _display_header(self, is_loading: bool) -> None: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: + if not data: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for d in data: + row_values = list(d.table_data().values()) # type: ignore[attr-defined] + + if self._multi: + row_values.insert(0, ' ') + + table.add_row(*row_values, key=d) # type: ignore[arg-type] + + 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) + 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') + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, ' ') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, 'X') - def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: - self._current_row_key = event.row_key + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._current_row_key = event.row_key - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - _ = self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) - else: - data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] - _ = self.dismiss(Result(ResultType.Selection, _data=data)) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + _ = self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) + else: + data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] + _ = self.dismiss(Result(ResultType.Selection, _data=data)) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - self.screen.query('HelpPanel').remove() - else: - self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() # type: ignore[unreachable] - 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(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() + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + self.screen.query('HelpPanel').remove() + else: + self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() # type: ignore[unreachable] + 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(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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index 9365b7f32f..16154db546 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -20,6 +20,9 @@ class Result[ValueT]: 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') @@ -33,7 +36,7 @@ def items(self) -> list[MenuItem]: def get_value(self) -> ValueT: if self._item is not None: - return self.item().get_value() # type: ignore[no-any-return] + 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) From ce6b05ea7a8e48ae2428f1138a6e149e5edece27 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 1 Dec 2025 20:16:47 +1100 Subject: [PATCH 07/40] Update --- archinstall/default_profiles/desktop.py | 6 +- .../default_profiles/desktops/hyprland.py | 11 +- .../default_profiles/desktops/labwc.py | 11 +- archinstall/default_profiles/desktops/niri.py | 11 +- archinstall/default_profiles/desktops/sway.py | 11 +- archinstall/default_profiles/server.py | 15 +- .../lib/applications/application_menu.py | 7 +- archinstall/lib/args.py | 50 ++-- .../lib/authentication/authentication_menu.py | 29 +- archinstall/lib/bootloader/bootloader_menu.py | 30 +- archinstall/lib/configuration.py | 96 +++---- archinstall/lib/disk/disk_menu.py | 13 +- archinstall/lib/disk/encryption_menu.py | 117 +++----- archinstall/lib/disk/partitioning_menu.py | 7 +- archinstall/lib/global_menu.py | 6 +- archinstall/lib/interactions/disk_conf.py | 38 +-- archinstall/lib/interactions/general_conf.py | 71 +++-- .../lib/interactions/manage_users_conf.py | 30 +- archinstall/lib/interactions/network_menu.py | 30 +- archinstall/lib/interactions/system_conf.py | 39 ++- archinstall/lib/locale/locale_menu.py | 16 +- archinstall/lib/menu/abstract_menu.py | 9 +- archinstall/lib/menu/helpers.py | 67 +++-- archinstall/lib/menu/list_manager.py | 270 +++++++++--------- archinstall/lib/mirrors.py | 35 ++- archinstall/lib/network/wifi_handler.py | 26 +- archinstall/lib/profile/profile_menu.py | 31 +- archinstall/lib/utils/util.py | 54 ++-- archinstall/scripts/guided.py | 20 +- archinstall/tui/__init__.py | 13 - archinstall/tui/menu_item.py | 21 +- archinstall/tui/ui/components.py | 97 +++++-- archinstall/tui/ui/result.py | 12 +- 33 files changed, 597 insertions(+), 702 deletions(-) diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 0c2b557d6f..11b8ed0f2d 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING, override from archinstall.default_profiles.profile import GreeterType, Profile, ProfileType, SelectResult -from archinstall.lib.menu.helpers import SelectionMenu +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.menu_item import MenuItem, MenuItemGroup @@ -68,12 +68,12 @@ def do_on_select(self) -> SelectResult: group = MenuItemGroup(items, sort_items=True, sort_case_sensitive=False) group.set_selected_by_value(self.current_selection) - result = SelectionMenu[Profile]( + result = Selection[Profile]( group, multi=True, allow_reset=True, allow_skip=True, - preview_orientation='right', + preview_location='right', ).show() match result.type_: diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index 82aa4f6d7b..fbaa87cfe7 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType class HyprlandProfile(XorgProfile): @@ -57,13 +56,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index 98f93b04cf..f515c758b9 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType class LabwcProfile(XorgProfile): @@ -54,13 +53,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index ff4edf9945..f24e67b043 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType class NiriProfile(XorgProfile): @@ -62,13 +61,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index d01611b0e2..f164b7f4fa 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -3,11 +3,10 @@ from archinstall.default_profiles.desktops import SeatAccess from archinstall.default_profiles.profile import GreeterType, ProfileType from archinstall.default_profiles.xorg import XorgProfile +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties +from archinstall.tui.ui.result import ResultType class SwayProfile(XorgProfile): @@ -64,13 +63,11 @@ def _ask_seat_access(self) -> None: default = self.custom_settings.get('seat_access', None) group.set_default_by_value(default) - result = SelectMenu[SeatAccess]( + result = Selection[SeatAccess]( group, header=header, allow_skip=False, - frame=FrameProperties.min(tr('Seat access')), - alignment=Alignment.CENTER, - ).run() + ).show() if result.type_ == ResultType.Selection: self.custom_settings['seat_access'] = result.get_value().value diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index 5526b9c6f1..3c3dd35c60 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -1,12 +1,11 @@ from typing import TYPE_CHECKING, override from archinstall.default_profiles.profile import Profile, ProfileType, SelectResult +from archinstall.lib.menu.helpers import Selection from archinstall.lib.output import info from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import FrameProperties, PreviewStyle +from archinstall.tui.ui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -26,7 +25,7 @@ def do_on_select(self) -> SelectResult: MenuItem( p.name, value=p, - preview_action=lambda x: x.value.preview_text(), + preview_action=lambda x: x.value.preview_text() if x.value else None, ) for p in profile_handler.get_server_profiles() ] @@ -34,15 +33,13 @@ def do_on_select(self) -> SelectResult: group = MenuItemGroup(items, sort_items=True) group.set_selected_by_value(self.current_selection) - result = SelectMenu[Profile]( + result = Selection[Profile]( group, allow_reset=True, allow_skip=True, - preview_style=PreviewStyle.RIGHT, - preview_size='auto', - preview_frame=FrameProperties.max('Info'), multi=True, - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index df7d98c345..aae7638b6f 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -1,7 +1,7 @@ from typing import override from archinstall.lib.menu.abstract_menu import AbstractSubMenu -from archinstall.lib.menu.helpers import Confirmation, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.application import ApplicationConfiguration, Audio, AudioConfiguration, BluetoothConfiguration from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup @@ -74,8 +74,7 @@ def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfigur header = tr('Would you like to configure Bluetooth?') + '\n' - result = Confirmation[bool]( - group, + result = Confirmation( header=header, allow_skip=True, ).show() @@ -97,7 +96,7 @@ def select_audio(preset: AudioConfiguration | None = None) -> AudioConfiguration if preset: group.set_focus_by_value(preset.audio) - result = SelectionMenu[Audio]( + result = Selection[Audio]( group, header=tr('Select audio configuration'), allow_skip=True, diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index e2ab864f89..1544af3c95 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -27,7 +27,6 @@ from archinstall.lib.plugins import load_plugin from archinstall.lib.translationhandler import Language, tr, translation_handler from archinstall.lib.utils.util import get_password -from archinstall.tui.curses_menu import Tui @p_dataclass @@ -496,31 +495,30 @@ def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None: raise err from err else: incorrect_password = False - - with Tui(): - while True: - header = tr('Incorrect password') if incorrect_password else None - - decryption_pwd = get_password( - text=tr('Credentials file decryption password'), - header=header, - allow_skip=False, - skip_confirmation=True, - ) - - if not decryption_pwd: - return None - - try: - creds_data = decrypt(creds_data, decryption_pwd.plaintext) - break - except ValueError as err: - if 'Invalid password' in str(err): - debug('Incorrect credentials file decryption password') - incorrect_password = True - else: - debug(f'Error decrypting credentials file: {err}') - raise err from err + header = tr('Enter credentials file decryption password') + + while True: + prompt = f'{header}\n\n' + tr('Incorrect password') if incorrect_password else '' + + decryption_pwd = get_password( + header=prompt, + allow_skip=False, + skip_confirmation=True, + ) + + if not decryption_pwd: + return None + + try: + creds_data = decrypt(creds_data, decryption_pwd.plaintext) + break + except ValueError as err: + if 'Invalid password' in str(err): + debug('Incorrect credentials file decryption password') + incorrect_password = True + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err return json.loads(creds_data) diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py index b0dde11b5e..0a2000c471 100644 --- a/archinstall/lib/authentication/authentication_menu.py +++ b/archinstall/lib/authentication/authentication_menu.py @@ -3,16 +3,14 @@ from archinstall.lib.disk.fido import Fido2 from archinstall.lib.interactions.manage_users_conf import ask_for_additional_users from archinstall.lib.menu.abstract_menu import AbstractSubMenu -from archinstall.lib.menu.helpers import Confirmation, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod from archinstall.lib.models.users import Password, User from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr from archinstall.lib.utils.util import get_password -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.result import ResultType class AuthenticationMenu(AbstractSubMenu[AuthenticationConfiguration]): @@ -39,7 +37,7 @@ def _define_menu_options(self) -> list[MenuItem]: return [ MenuItem( text=tr('Root password'), - action=select_root_password, + action=lambda x: select_root_password(), preview_action=self._prev_root_pwd, key='root_enc_password', ), @@ -101,12 +99,12 @@ def _prev_u2f_login(self, item: MenuItem) -> str | None: return None -def select_root_password(preset: str | None = None) -> Password | None: - password = get_password(text=tr('Root password'), allow_skip=True) +def select_root_password() -> Password | None: + password = get_password(header=tr('Enter root password'), allow_skip=True) return password -def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | None: +def select_u2f_login(preset: U2FLoginConfiguration | None) -> U2FLoginConfiguration | None: devices = Fido2.get_fido2_devices() if not devices: return None @@ -120,26 +118,21 @@ def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | N if preset is not None: group.set_selected_by_value(preset.u2f_login_method) - result = SelectMenu[U2FLoginMethod]( + result = Selection[U2FLoginMethod]( group, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('U2F Login Method')), allow_skip=True, allow_reset=True, - ).run() + ).show() match result.type_: case ResultType.Selection: u2f_method = result.get_value() - - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() header = tr('Enable passwordless sudo?') - result_sudo = Confirmation[bool]( - group, + result_sudo = Confirmation( header=header, allow_skip=True, + preset=False, ).show() passwordless_sudo = result_sudo.item() == MenuItem.yes() @@ -152,5 +145,3 @@ def select_u2f_login(preset: U2FLoginConfiguration) -> U2FLoginConfiguration | N return preset case ResultType.Reset: return None - case _: - raise ValueError('Unhandled result type') diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index c127186214..6f06c7a2a4 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -1,11 +1,10 @@ import textwrap from typing import override +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.result import ResultType from ..args import arch_config_handler from ..hardware import SysInfo @@ -121,17 +120,7 @@ def _select_bootloader(self, preset: Bootloader | None) -> Bootloader | None: def _select_uki(self, preset: bool) -> bool: prompt = tr('Would you like to use unified kernel images?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = SelectMenu[bool]( - group, - header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - allow_skip=True, - ).run() + result = Confirmation(header=prompt, allow_skip=True, preset=preset).show() match result.type_: case ResultType.Skip: @@ -168,14 +157,11 @@ def _select_removable(self, preset: bool) -> bool: group = MenuItemGroup.yes_no() group.set_focus_by_value(preset) - result = SelectMenu[bool]( + result = Selection[bool]( group, header=prompt, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -212,13 +198,11 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: group.set_default_by_value(default) group.set_focus_by_value(preset) - result = SelectMenu[Bootloader]( + result = Selection[Bootloader]( group, header=header, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Bootloader')), allow_skip=True, - ).run() + ).show() match result.type_: case ResultType.Skip: diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 021ec3369a..bd2b727ac1 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -3,11 +3,10 @@ import stat from pathlib import Path +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu, Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation, PreviewStyle +from archinstall.tui.ui.result import ResultType from .args import ArchConfig from .crypt import encrypt @@ -56,25 +55,19 @@ def confirm_config(self) -> bool: header = f'{tr("The specified configuration will be applied")}. ' header += tr('Would you like to continue?') + '\n' - with Tui(): - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.yes() - group.set_preview_for_all(lambda x: self.user_config_to_json()) - - result = SelectMenu[bool]( - group, - header=header, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - allow_skip=False, - preview_size='auto', - preview_style=PreviewStyle.BOTTOM, - preview_frame=FrameProperties.max(tr('Configuration')), - ).run() - - if result.item() != MenuItem.yes(): - return False + group = MenuItemGroup.yes_no() + group.focus_item = MenuItem.yes() + group.set_preview_for_all(lambda x: self.user_config_to_json()) + + result = Selection[bool]( + group, + header=header, + allow_skip=False, + preview_location='bottom', + ).show() + + if result.item() != MenuItem.yes(): + return False return True @@ -160,13 +153,11 @@ def preview(item: MenuItem) -> str | None: ] group = MenuItemGroup(items) - result = SelectMenu[str]( + result = Selection[str]( group, allow_skip=True, - preview_frame=FrameProperties.max(tr('Configuration')), - preview_size='auto', - preview_style=PreviewStyle.RIGHT, - ).run() + preview_location='right', + ).show() match result.type_: case ResultType.Skip: @@ -180,7 +171,7 @@ def preview(item: MenuItem) -> str | None: readline.parse_and_bind('tab: complete') dest_path = prompt_dir( - tr('Enter a directory for the configuration(s) to be saved (tab completion enabled)') + '\n', + tr('Enter a directory for the configuration(s) to be saved') + '\n', allow_skip=True, ) @@ -189,50 +180,39 @@ def preview(item: MenuItem) -> str | None: header = tr('Do you want to save the configuration file(s) to {}?').format(dest_path) - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.yes() - - result = SelectMenu( - group, + save_result = Confirmation( header=header, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=True, + ).show() - match result.type_: + match save_result.type_: case ResultType.Selection: - if result.item() == MenuItem.no(): + if not save_result.get_value(): return + case _: + return debug(f'Saving configuration files to {dest_path.absolute()}') header = tr('Do you want to encrypt the user_credentials.json file?') - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - - result = SelectMenu( - group, + enc_result = Confirmation( header=header, allow_skip=False, - alignment=Alignment.CENTER, - columns=2, - orientation=Orientation.HORIZONTAL, - ).run() + preset=False, + ).show() enc_password: str | None = None - match result.type_: - case ResultType.Selection: - if result.item() == MenuItem.yes(): - password = get_password( - text=tr('Credentials file encryption password'), - allow_skip=True, - ) - - if password: - enc_password = password.plaintext + if enc_result.type_ == ResultType.Selection: + if enc_result.get_value(): + password = get_password( + header=tr('Credentials file encryption password'), + allow_skip=True, + ) + + if password: + enc_password = password.plaintext match save_option: case 'user_config': diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 4f6fb72864..674af74639 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -2,7 +2,7 @@ from typing import override from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu -from archinstall.lib.menu.helpers import SelectionMenu +from archinstall.lib.menu.helpers import Selection from archinstall.lib.models.device import ( DEFAULT_ITER_TIME, BtrfsOptions, @@ -31,7 +31,7 @@ class DiskMenuConfig: disk_encryption: DiskEncryption | None -class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskLayoutConfiguration]): +class DiskLayoutConfigurationMenu(AbstractSubMenu[DiskMenuConfig]): def __init__(self, disk_layout_config: DiskLayoutConfiguration | None): if not disk_layout_config: self._disk_menu_config = DiskMenuConfig( @@ -94,8 +94,8 @@ def _define_menu_options(self) -> list[MenuItem]: ] @override - def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: - config: DiskMenuConfig | None = super().run(additional_title=additional_title) # pyright: ignore[reportAssignmentType] + def run(self, additional_title: str | None = None) -> DiskLayoutConfiguration | None: # type: ignore[override] + config: DiskMenuConfig | None = super().run(additional_title=additional_title) if config is None: return None @@ -170,7 +170,7 @@ def _select_btrfs_snapshots(self, preset: SnapshotConfig | None) -> SnapshotConf preset=preset_type, ) - result = SelectionMenu[SnapshotType]( + result = Selection[SnapshotType]( group, allow_reset=True, allow_skip=True, @@ -249,9 +249,10 @@ def _prev_btrfs_snapshots(self, item: MenuItem) -> str | None: def _prev_disk_encryption(self, item: MenuItem) -> str | None: disk_config: DiskLayoutConfiguration | None = self._item_group.find_by_key('disk_config').value + lvm_config: LvmConfiguration | None = self._item_group.find_by_key('lvm_config').value enc_config: DiskEncryption | None = item.value - if disk_config and not DiskEncryption.validate_enc(disk_config.device_modifications, disk_config.lvm_config): + if disk_config and not DiskEncryption.validate_enc(disk_config.device_modifications, lvm_config): return tr('LVM disk encryption with more than 2 partitions is currently not supported') if enc_config: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index d4e8288f17..c5236b7761 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import override -from archinstall.lib.menu.helpers import Input, SelectionMenu +from archinstall.lib.menu.helpers import Input, Selection, TableMenu from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( DeviceModification, @@ -53,7 +53,7 @@ def _define_menu_options(self) -> list[MenuItem]: text=tr('Encryption type'), action=lambda x: select_encryption_type(self._lvm_config, x), value=self._enc_config.encryption_type, - preview_action=self._preview, + preview_action=self._prev_type, key='encryption_type', ), MenuItem( @@ -61,7 +61,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=lambda x: select_encrypted_password(), value=self._enc_config.encryption_password, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_password, key='encryption_password', ), MenuItem( @@ -69,7 +69,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=select_iteration_time, value=self._enc_config.iter_time, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_iter_time, key='iter_time', ), MenuItem( @@ -77,7 +77,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=lambda x: select_partitions_to_encrypt(self._device_modifications, x), value=self._enc_config.partitions, dependencies=[self._check_dep_partitions], - preview_action=self._preview, + preview_action=self._prev_partitions, key='partitions', ), MenuItem( @@ -85,7 +85,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=self._select_lvm_vols, value=self._enc_config.lvm_volumes, dependencies=[self._check_dep_lvm_vols], - preview_action=self._preview, + preview_action=self._prev_lvm_vols, key='lvm_volumes', ), MenuItem( @@ -93,7 +93,7 @@ def _define_menu_options(self) -> list[MenuItem]: action=select_hsm, value=self._enc_config.hsm_device, dependencies=[self._check_dep_enc_type], - preview_action=self._preview, + preview_action=self._prev_hsm, key='hsm_device', ), ] @@ -155,85 +155,52 @@ def run(self, additional_title: str | None = None) -> DiskEncryption | None: return None - def _preview(self, item: MenuItem) -> str | None: - output = '' - - if (enc_type := self._prev_type()) is not None: - output += enc_type - - if (enc_pwd := self._prev_password()) is not None: - output += f'\n{enc_pwd}' - - if (iter_time := self._prev_iter_time()) is not None: - output += f'\n{iter_time}' - - if (fido_device := self._prev_hsm()) is not None: - output += f'\n{fido_device}' - - if (partitions := self._prev_partitions()) is not None: - output += f'\n\n{partitions}' - - if (lvm := self._prev_lvm_vols()) is not None: - output += f'\n\n{lvm}' - - if not output: - return None - - return output - - def _prev_type(self) -> str | None: - enc_type = self._item_group.find_by_key('encryption_type').value - - if enc_type: - enc_text = EncryptionType.type_to_text(enc_type) + def _prev_type(self, item: MenuItem) -> str | None: + if item.value: + enc_text = EncryptionType.type_to_text(item.value) return f'{tr("Encryption type")}: {enc_text}' return None - def _prev_password(self) -> str | None: - enc_pwd = self._item_group.find_by_key('encryption_password').value - - if enc_pwd: - return f'{tr("Encryption password")}: {enc_pwd.hidden()}' + def _prev_password(self, item: MenuItem) -> str | None: + if item.value: + return f'{tr("Encryption password")}: {item.value.hidden()}' return None - def _prev_partitions(self) -> str | None: - partitions: list[PartitionModification] | None = self._item_group.find_by_key('partitions').value - - if partitions: + def _prev_partitions(self, item: MenuItem) -> str | None: + if item.value: output = tr('Partitions to be encrypted') + '\n' - output += FormattedOutput.as_table(partitions) + output += FormattedOutput.as_table(item.value) return output.rstrip() return None - def _prev_lvm_vols(self) -> str | None: - volumes: list[PartitionModification] | None = self._item_group.find_by_key('lvm_volumes').value - - if volumes: + def _prev_lvm_vols(self, item: MenuItem) -> str | None: + if item.value: output = tr('LVM volumes to be encrypted') + '\n' - output += FormattedOutput.as_table(volumes) + output += FormattedOutput.as_table(item.value) return output.rstrip() return None - def _prev_hsm(self) -> str | None: - fido_device: Fido2Device | None = self._item_group.find_by_key('hsm_device').value - - if not fido_device: + def _prev_hsm(self, item: MenuItem) -> str | None: + if not item.value: return None + fido_device: Fido2Device = item.value + output = str(fido_device.path) output += f' ({fido_device.manufacturer}, {fido_device.product})' return f'{tr("HSM device")}: {output}' - def _prev_iter_time(self) -> str | None: - iter_time = self._item_group.find_by_key('iter_time').value - enc_type = self._item_group.find_by_key('encryption_type').value + def _prev_iter_time(self, item: MenuItem) -> str | None: + if item.value: + iter_time = item.value + enc_type = self._item_group.find_by_key('encryption_type').value - if iter_time and enc_type != EncryptionType.NoEncryption: - return f'{tr("Iteration time")}: {iter_time}ms' + if iter_time and enc_type != EncryptionType.NoEncryption: + return f'{tr("Iteration time")}: {iter_time}ms' return None @@ -258,7 +225,7 @@ def select_encryption_type( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectionMenu[EncryptionType](group, header=tr('Select encryption type'), allow_skip=True, allow_reset=True, show_frame=False).show() + result = Selection[EncryptionType](group, header=tr('Select encryption type'), allow_skip=True, allow_reset=True, show_frame=False).show() match result.type_: case ResultType.Reset: @@ -290,7 +257,7 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: if fido_devices: group = MenuHelper(data=fido_devices).create_menu_group() - result = SelectionMenu[Fido2Device](group, header=header, allow_skip=True, show_frame=False).show() + result = Selection[Fido2Device](group, header=header, allow_skip=True, show_frame=False).show() match result.type_: case ResultType.Reset: @@ -317,14 +284,12 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: - group = MenuHelper(data=avail_partitions).create_menu_group() - group.set_selected_by_value(preset) - - result = SelectionMenu[PartitionModification]( - group, - header=tr('Select partitions to encrypt'), - multi=True, + result = TableMenu[PartitionModification]( + header=tr('Select disks for the installation'), + data=avail_partitions, allow_skip=True, + multi=True, + preview_orientation='bottom', ).show() match result.type_: @@ -346,12 +311,12 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: - group = MenuHelper(data=volumes).create_menu_group() - - result = SelectionMenu[LvmVolume]( - group, - header=tr('Select LVM volumes to encrypt'), + result = TableMenu[LvmVolume]( + header=tr('Select disks for the installation'), + data=volumes, + allow_skip=True, multi=True, + preview_orientation='bottom', ).show() match result.type_: diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 4a87af146d..24e1abcbeb 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import override -from archinstall.lib.menu.helpers import Confirmation, Input, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Input, Selection from archinstall.lib.models.device import ( BtrfsMountOption, DeviceModification, @@ -416,7 +416,7 @@ def _prompt_partition_fs_type(self, prompt: str | None = None) -> FilesystemType items = [MenuItem(fs.value, value=fs) for fs in fs_types] group = MenuItemGroup(items, sort_items=False) - result = SelectionMenu[FilesystemType]( + result = Selection[FilesystemType]( group, header=prompt, allow_skip=False, @@ -544,8 +544,7 @@ def _create_new_partition(self, free_space: FreeSpace) -> PartitionModification: def _reset_confirmation(self) -> bool: prompt = tr('This will remove all newly added partitions, continue?') + '\n' - result = Confirmation[bool]( - MenuItemGroup.yes_no(), + result = Confirmation( header=prompt, allow_skip=False, allow_reset=False, diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 550869482e..8779484a66 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -137,7 +137,7 @@ def _get_menu_options(self) -> list[MenuItem]: MenuItem( text=tr('Parallel Downloads'), action=add_number_of_parallel_downloads, - value=0, + value=1, preview_action=self._prev_parallel_dw, key='parallel_downloads', ), @@ -522,10 +522,10 @@ def _select_additional_packages(self, preset: list[str]) -> list[str]: return packages - def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration: + def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration | None: mirror_configuration = MirrorMenu(preset=preset).run() - if mirror_configuration.optional_repositories: + if mirror_configuration and mirror_configuration.optional_repositories: # reset the package list cache in case the repository selection has changed list_available_packages.cache_clear() diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 50c80e2691..c90a21b239 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -3,7 +3,7 @@ from archinstall.lib.args import arch_config_handler from archinstall.lib.disk.device_handler import device_handler from archinstall.lib.disk.partitioning_menu import manual_partitioning -from archinstall.lib.menu.helpers import Confirmation, Notify, SelectionMenu, TableMenu +from archinstall.lib.menu.helpers import Confirmation, Notify, Selection, TableMenu from archinstall.lib.models.device import ( BDevice, BtrfsMountOption, @@ -51,13 +51,10 @@ def _preview_device_selection(item: MenuItem) -> str | None: options = [d.device_info for d in devices] presets = [p.device_info for p in preset] - # group = MenuHelper(options).create_menu_group() - # group.set_selected_by_value(presets) - # group.set_preview_for_all(_preview_device_selection) - result = TableMenu[_DeviceInfo]( header=tr('Select disks for the installation'), data=options, + presets=presets, allow_skip=True, multi=True, preview_orientation='bottom', @@ -127,7 +124,7 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if preset: group.set_selected_by_value(preset.config_type.display_msg()) - result = SelectionMenu[str]( + result = Selection[str]( group, header=tr('Select a disk configuration'), allow_skip=True, @@ -198,7 +195,7 @@ def select_lvm_config( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = SelectionMenu[str]( + result = Selection[str]( group, allow_reset=True, allow_skip=True, @@ -247,7 +244,7 @@ def select_main_filesystem_format() -> FilesystemType: items.append(MenuItem('ntfs', value=FilesystemType.Ntfs)) group = MenuItemGroup(items, sort_items=False) - result = SelectionMenu[FilesystemType]( + result = Selection[FilesystemType]( group, header=tr('Select main filesystem'), allow_skip=False, @@ -272,7 +269,7 @@ def select_mount_options() -> list[str]: ] group = MenuItemGroup(items, sort_items=False) - result = SelectionMenu[str]( + result = Selection[str]( group, header=prompt, allow_skip=True, @@ -329,12 +326,11 @@ def suggest_single_disk_layout( if filesystem_type == FilesystemType.Btrfs: prompt = tr('Would you like to use BTRFS subvolumes with a default structure?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(MenuItem.yes().value) - result = Confirmation[bool]( - group, + + result = Confirmation( header=prompt, allow_skip=False, + preset=True, ).show() using_subvolumes = result.item() == MenuItem.yes() @@ -363,12 +359,11 @@ def suggest_single_disk_layout( using_home_partition = True else: prompt = tr('Would you like to create a separate partition for /home?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(MenuItem.yes().value) - result = Confirmation[str]( - group, + + result = Confirmation( header=prompt, allow_skip=False, + preset=True, ).show() using_home_partition = result.item() == MenuItem.yes() @@ -547,14 +542,7 @@ def suggest_lvm_layout( if filesystem_type == FilesystemType.Btrfs: prompt = tr('Would you like to use BTRFS subvolumes with a default structure?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(MenuItem.yes().value) - - result = Confirmation[bool]( - group, - header=prompt, - allow_skip=False, - ).show() + result = Confirmation(header=prompt, allow_skip=False, preset=True).show() using_subvolumes = MenuItem.yes() == result.item() mount_options = select_mount_options() diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 3a0e89c5d6..b08e21aff8 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -2,15 +2,13 @@ from enum import Enum from pathlib import Path -from typing import assert_never -from archinstall.lib.menu.helpers import Confirmation, Input, Loading, Notify, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Input, Loading, Notify, Selection from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType -from archinstall.tui.ui.result import ResultType as UiResultType from ..locale.utils import list_timezones from ..models.packages import AvailablePackage, PackageGroup @@ -37,7 +35,7 @@ def ask_ntp(preset: bool = True) -> bool: group = MenuItemGroup.yes_no() group.focus_item = preset_val - result = SelectionMenu[bool]( + result = Selection[bool]( group, header=header, allow_skip=True, @@ -60,14 +58,14 @@ def ask_hostname(preset: str | None = None) -> str | None: ).show() match result.type_: - case UiResultType.Skip: + case ResultType.Skip: return preset - case UiResultType.Selection: + case ResultType.Selection: hostname = result.get_value() if len(hostname) < 1: return None return hostname - case UiResultType.Reset: + case ResultType.Reset: raise ValueError('Unhandled result type') @@ -80,11 +78,12 @@ def ask_for_a_timezone(preset: str | None = None) -> str | None: group.set_selected_by_value(preset) group.set_default_by_value(default) - result = SelectionMenu[str]( + result = Selection[str]( group, header=tr('Select timezone'), allow_reset=True, allow_skip=True, + show_frame=True, ).show() match result.type_: @@ -122,7 +121,7 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - result = SelectionMenu[Language]( + result = Selection[Language]( header=title, group=group, allow_reset=False, @@ -130,11 +129,11 @@ def select_archinstall_language(languages: list[Language], preset: Language) -> ).show() match result.type_: - case UiResultType.Skip: + case ResultType.Skip: return preset - case UiResultType.Selection: + case ResultType.Selection: return result.get_value() - case UiResultType.Reset: + case ResultType.Reset: raise ValueError('Language selection not handled') @@ -150,7 +149,7 @@ def ask_additional_packages_to_install( packages = Loading[dict[str, AvailablePackage]]( header=output, - data_callback=lambda: list_available_packages(tuple(repositories)) + data_callback=lambda: list_available_packages(tuple(repositories)), ).show() if packages is None: @@ -192,13 +191,13 @@ def ask_additional_packages_to_install( menu_group = MenuItemGroup(items, sort_items=True) menu_group.set_selected_by_value(preset_packages) - result = SelectionMenu[AvailablePackage | PackageGroup]( + result = Selection[AvailablePackage | PackageGroup]( menu_group, header=header, allow_reset=True, allow_skip=True, multi=True, - preview_orientation='right', + preview_location='right', show_frame=False, ).show() @@ -212,44 +211,41 @@ def ask_additional_packages_to_install( return [pkg.name for pkg in selected_pacakges] -def add_number_of_parallel_downloads(preset: int | None = None) -> int | None: +def add_number_of_parallel_downloads(preset: int = 1) -> int | None: max_recommended = 5 header = tr('This option enables the number of parallel downloads that can occur during package downloads') + '\n' - header += tr('Enter the number of parallel downloads to be enabled.\n\nNote:\n') - header += tr(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )').format(max_recommended, max_recommended) + '\n' - header += tr(' - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n') + header += tr(' - Maximum recommended value : {} ( Allows {} parallel downloads at a time )').format(max_recommended, max_recommended) + '\n\n' + header += tr('Enter the number of parallel downloads to be enabled') - def validator(s: str | None) -> str | None: - if s is not None: - try: - value = int(s) - if value >= 0: - return None - except Exception: - pass + def validator(s: str) -> str | None: + try: + value = int(s) - return tr('Invalid download number') + if 1 <= value <= max_recommended: + return None - header += tr('Enter a the number of parallel downloads to be enabled (max recommended: {})').format(max_recommended) + return tr('Value must be between 1 and {}').format(max_recommended) + except Exception: + return tr('Please enter a valid number') result = Input( header=header, allow_skip=True, allow_reset=True, validator_callback=validator, - default_value=str(preset) if preset is not None else None, + default_value=str(preset), ).show() + downloads = 1 + match result.type_: case ResultType.Skip: return preset case ResultType.Reset: - return 0 + return downloads case ResultType.Selection: - downloads: int = int(result.get_value()) - case _: - assert_never(result.type_) + downloads = int(result.get_value()) pacman_conf_path = Path('/etc/pacman.conf') with pacman_conf_path.open() as f: @@ -272,7 +268,7 @@ def ask_post_installation() -> PostInstallationAction: items = [MenuItem(action.value, value=action) for action in PostInstallationAction] group = MenuItemGroup(items) - result = SelectionMenu[PostInstallationAction]( + result = Selection[PostInstallationAction]( group, header=header, allow_skip=False, @@ -287,12 +283,11 @@ def ask_post_installation() -> PostInstallationAction: def ask_abort() -> None: prompt = tr('Do you really want to abort?') + '\n' - group = MenuItemGroup.yes_no() - result = Confirmation[bool]( - group, + result = Confirmation( header=prompt, allow_skip=False, + preset=False, ).show() if result.item() == MenuItem.yes(): diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index 207a926b2c..6471980b7b 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -3,10 +3,10 @@ import re from typing import override -from archinstall.lib.menu.helpers import Input, SelectionMenu +from archinstall.lib.menu.helpers import Confirmation, Input from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType +from archinstall.tui.menu_item import MenuItem +from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager from ..models.users import User @@ -35,14 +35,14 @@ def selected_action_display(self, selection: User) -> str: @override def handle_action(self, action: str, entry: User | None, data: list[User]) -> list[User]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_user = self._add_user() if new_user is not None: # in case a user with the same username as an existing user # was created we'll replace the existing one data = [d for d in data if d.username != new_user.username] data += [new_user] - elif action == self._actions[1] and entry: # change password + elif action == self._actions[1] and entry: # change password header = f'{tr("User")}: {entry.username}\n' header += tr('Enter new password') new_password = get_password(header=header) @@ -50,10 +50,10 @@ def handle_action(self, action: str, entry: User | None, data: list[User]) -> li if new_password: user = next(filter(lambda x: x == entry, data)) user.password = new_password - elif action == self._actions[2] and entry: # promote/demote + elif action == self._actions[2] and entry: # promote/demote user = next(filter(lambda x: x == entry, data)) user.sudo = False if user.sudo else True - elif action == self._actions[3] and entry: # delete + elif action == self._actions[3] and entry: # delete data = [d for d in data if d != entry] return data @@ -83,24 +83,20 @@ def _add_user(self) -> User | None: return None header = f'{tr("Username")}: {username}\n' - header += tr('Enter password') + prompt = f'{header}\n' + tr('Enter password') - password = get_password(header=header, allow_skip=True) + password = get_password(header=prompt, allow_skip=True) if not password: return None - header += f'{tr("Password")}: {password.hidden()}\n\n' - header += str(tr('Should "{}" be a superuser (sudo)?\n')).format(username) + header += f'{tr("Password")}: {password.hidden()}\n' + prompt = f'{header}\n' + tr('Should "{}" be a superuser (sudo)?\n').format(username) - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.yes() - - result = SelectionMenu[bool]( - group, + result = Confirmation( header=header, - search_enabled=False, allow_skip=False, + preset=True, ).show() match result.type_: diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index f937731ac4..d321b2af3e 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -3,7 +3,7 @@ import ipaddress from typing import assert_never, override -from archinstall.lib.menu.helpers import Input, SelectionMenu +from archinstall.lib.menu.helpers import Input, Selection from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -34,14 +34,14 @@ def selected_action_display(self, selection: Nic) -> str: @override def handle_action(self, action: str, entry: Nic | None, data: list[Nic]) -> list[Nic]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add iface = self._select_iface(data) if iface: nic = Nic(iface=iface) nic = self._edit_iface(nic) data += [nic] elif entry: - if action == self._actions[1]: # edit interface + if action == self._actions[1]: # edit interface data = [d for d in data if d.iface != entry.iface] data.append(self._edit_iface(entry)) elif action == self._actions[2]: # delete @@ -63,11 +63,10 @@ def _select_iface(self, data: list[Nic]) -> str | None: items = [MenuItem(i, value=i) for i in available] group = MenuItemGroup(items, sort_items=True) - result = SelectionMenu[str]( + result = Selection[str]( group, header=tr('Select an interface'), allow_skip=True, - show_frame=False, ).show() match result.type_: @@ -78,14 +77,7 @@ def _select_iface(self, data: list[Nic]) -> str | None: case ResultType.Reset: raise ValueError('Unhandled result type') - def _get_ip_address( - self, - header: str, - allow_skip: bool, - multi: bool, - preset: str | None = None, - allow_empty: bool = False - ) -> str | None: + def _get_ip_address(self, header: str, allow_skip: bool, multi: bool, preset: str | None = None, allow_empty: bool = False) -> str | None: def validator(ip: str | None) -> str | None: failure = tr('You need to enter a valid IP in IP-config mode') @@ -132,7 +124,7 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: group = MenuItemGroup(items, sort_items=True) group.set_default_by_value(default_mode) - result = SelectionMenu[str]( + result = Selection[str]( group, header=header, allow_skip=False, @@ -162,13 +154,7 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: display_dns = None header = tr('Enter your DNS servers with space separated (leave blank for none)') + '\n' - dns_servers = self._get_ip_address( - header, - True, - True, - display_dns, - allow_empty=True - ) + dns_servers = self._get_ip_address(header, True, True, display_dns, allow_empty=True) dns = [] if dns_servers is not None: @@ -191,7 +177,7 @@ def ask_to_configure_network(preset: NetworkConfiguration | None) -> NetworkConf if preset: group.set_selected_by_value(preset.type) - result = SelectionMenu[NicType]( + result = Selection[NicType]( group, header=tr('Choose network configuration'), allow_reset=True, diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index df01ac9766..22bc4f1604 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -1,6 +1,7 @@ from __future__ import annotations -from archinstall.lib.menu.helpers import Confirmation, SelectionMenu +from archinstall.lib.args import arch_config_handler +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models import Bootloader from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup @@ -26,12 +27,13 @@ def select_kernel(preset: list[str] = []) -> list[str]: group.set_focus_by_value(default_kernel) group.set_selected_by_value(preset) - result = SelectionMenu[str]( + result = Selection[str]( group, header=tr('Select which kernel(s) to install'), allow_skip=True, allow_reset=True, multi=True, + show_frame=False, ).show() match result.type_: @@ -70,7 +72,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: group.set_default_by_value(default) group.set_focus_by_value(preset) - result = SelectionMenu[Bootloader]( + result = Selection[Bootloader]( group, header=header, allow_skip=True, @@ -88,13 +90,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: def ask_for_uki(preset: bool = True) -> bool: prompt = tr('Would you like to use unified kernel images?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = Confirmation( - header=prompt, - allow_skip=True, - ).show() + result = Confirmation(header=prompt, allow_skip=True, preset=preset).show() match result.type_: case ResultType.Skip: @@ -116,7 +112,15 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None if not options: options = [driver for driver in GfxDriver] - items = [MenuItem(o.value, value=o, preview_action=lambda x: x.value.packages_text()) for o in options] + items = [ + MenuItem( + o.value, + value=o, + preview_action=lambda x: x.value.packages_text() if x.value else None, + ) + for o in options + ] + group = MenuItemGroup(items, sort_items=True) group.set_default_by_value(GfxDriver.AllOpenSource) @@ -131,12 +135,12 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None if SysInfo.has_nvidia_graphics(): header += tr('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n') - result = SelectionMenu[GfxDriver]( + result = Selection[GfxDriver]( group, header=header, allow_skip=True, allow_reset=True, - preview_orientation='right', + preview_location='right', ).show() match result.type_: @@ -149,19 +153,12 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None def ask_for_swap(preset: bool = True) -> bool: - if preset: - default_item = MenuItem.yes() - else: - default_item = MenuItem.no() - prompt = tr('Would you like to use swap on zram?') + '\n' - group = MenuItemGroup.yes_no() - group.set_focus_by_value(default_item) - result = Confirmation( header=prompt, allow_skip=True, + preset=preset, ).show() match result.type_: diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 71fa658b9e..6bef960ff2 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -1,6 +1,6 @@ from typing import override -from archinstall.lib.menu.helpers import SelectionMenu +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -74,7 +74,11 @@ def select_locale_lang(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectionMenu[str](header=tr('Locale language'), group=group).show() + result = Selection[str]( + header=tr('Locale language'), + group=group, + show_frame=True, + ).show() match result.type_: case ResultType.Selection: @@ -93,7 +97,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = SelectionMenu[str](header=tr('Locale encoding'), group=group).show() + result = Selection[str](header=tr('Locale encoding'), group=group).show() match result.type_: case ResultType.Selection: @@ -120,7 +124,11 @@ def select_kb_layout(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=False) group.set_focus_by_value(preset) - result = SelectionMenu[str](header=tr('Keyboard layout'), group=group).show() + result = Selection[str]( + header=tr('Keyboard layout'), + group=group, + show_frame=True, + ).show() match result.type_: case ResultType.Selection: diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 0ce1b36907..f7c60a0038 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -3,14 +3,14 @@ from types import TracebackType from typing import Any, Self -from archinstall.lib.menu.helpers import SelectionMenu +from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr from archinstall.tui.curses_menu import Tui from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.types import Chars from archinstall.tui.ui.result import ResultType -from ..output import debug, error +from ..output import error CONFIG_KEY = '__config__' @@ -99,18 +99,19 @@ def run(self, additional_title: str | None = None) -> ValueT | None: self._sync_from_config() while True: - result = SelectionMenu[ValueT]( + result = Selection[ValueT]( group=self._menu_item_group, header=additional_title, allow_skip=False, allow_reset=self._allow_reset, - preview_orientation='right', + preview_location='right', show_frame=False, ).show() match result.type_: case ResultType.Selection: item: MenuItem = result.item() + self._menu_item_group.focus_item = item if item.action is None: if not self._is_config_valid(): diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 5beb7c41dc..1d9dce2d17 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -1,5 +1,5 @@ from collections.abc import Awaitable, Callable -from typing import Literal, TypeVar, override +from typing import Any, Literal, TypeVar, override from textual.validation import ValidationResult, Validator @@ -20,14 +20,14 @@ ValueT = TypeVar('ValueT') -class SelectionMenu[ValueT]: +class Selection[ValueT]: def __init__( self, group: MenuItemGroup, header: str | None = None, allow_skip: bool = True, allow_reset: bool = False, - preview_orientation: Literal['right', 'bottom'] | None = None, + preview_location: Literal['right', 'bottom'] | None = None, multi: bool = False, search_enabled: bool = False, show_frame: bool = False, @@ -36,28 +36,33 @@ def __init__( self._group: MenuItemGroup = group self._allow_skip = allow_skip self._allow_reset = allow_reset - self._preview_orientation = preview_orientation + self._preview_location = preview_location self._multi = multi self._search_enabled = search_enabled self._show_frame = show_frame def show(self) -> Result[ValueT]: - result = tui.run(self) + result: Result[ValueT] = tui.run(self) return result async def _run(self) -> None: - if not self._multi: - result = await OptionListScreen[ValueT]( + if self._multi: + result = await SelectListScreen[ValueT]( self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, - preview_location=self._preview_orientation, + preview_location=self._preview_location, show_frame=self._show_frame, ).run() else: - result = await SelectListScreen[ValueT]( - self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_orientation + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + show_frame=self._show_frame, ).run() if result.type_ == ResultType.Reset: @@ -72,7 +77,7 @@ async def _run(self) -> None: class Confirmation: def __init__( self, - header: str | None = None, + header: str, allow_skip: bool = True, allow_reset: bool = False, preset: bool = False, @@ -86,7 +91,7 @@ def __init__( self._group.set_focus_by_value(preset) def show(self) -> Result[bool]: - result = tui.run(self) + result: Result[bool] = tui.run(self) return result async def _run(self) -> None: @@ -106,24 +111,21 @@ async def _run(self) -> None: tui.exit(result) -class Notify[ValueT]: - def __init__( - self, - header: str | None = None, - ): +class Notify: + def __init__(self, header: str): self._header = header - def show(self) -> Result[ValueT]: - result = tui.run(self) + def show(self) -> Result[bool]: + result: Result[bool] = tui.run(self) return result async def _run(self) -> None: await NotifyScreen(header=self._header).run() - tui.exit(True) + tui.exit(Result.true()) class GenericValidator(Validator): - def __init__(self, validator_callback: Callable[[str | None], str | None]) -> None: + def __init__(self, validator_callback: Callable[[str], str | None]) -> None: super().__init__() self._validator_callback = validator_callback @@ -147,7 +149,7 @@ def __init__( default_value: str | None = None, allow_skip: bool = True, allow_reset: bool = False, - validator_callback: Callable[[str | None], str | None] | None = None, + validator_callback: Callable[[str], str | None] | None = None, ): self._header = header self._placeholder = placeholder @@ -157,8 +159,8 @@ def __init__( self._allow_reset = allow_reset self._validator_callback = validator_callback - def show(self) -> Result[ValueT]: - result = tui.run(self) + def show(self) -> Result[str]: + result: Result[str] = tui.run(self) return result async def _run(self) -> None: @@ -188,14 +190,14 @@ def __init__( self, header: str | None = None, timer: int = 3, - data_callback: Callable[[], Awaitable[ValueT]] | None = None, + data_callback: Callable[[], Any] | None = None, ): self._header = header self._timer = timer self._data_callback = data_callback def show(self) -> ValueT | None: - result = tui.run(self) + result: Result[ValueT] = tui.run(self) match result.type_: case ResultType.Selection: @@ -210,8 +212,11 @@ async def _run(self) -> None: result = await LoadingScreen(header=self._header, data_callback=self._data_callback).run() tui.exit(result) else: - await LoadingScreen(self._timer, self._header).run() - tui.exit(True) + await LoadingScreen( + timer=self._timer, + header=self._header, + ).run() + tui.exit(Result.true()) class TableMenu[ValueT]: @@ -220,11 +225,12 @@ def __init__( header: str | None = None, data: list[ValueT] | None = None, data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + presets: list[ValueT] | None = None, allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, - preview_orientation: str = 'right', + preview_orientation: str | None = None, ): self._header = header self._data = data @@ -234,12 +240,13 @@ def __init__( self._allow_reset = allow_reset self._multi = multi self._preview_orientation = preview_orientation + self._presets = presets if self._data is None and self._data_callback is None: raise ValueError('Either data or data_callback must be provided') def show(self) -> Result[ValueT]: - result = tui.run(self) + result: Result[ValueT] = tui.run(self) return result async def _run(self) -> None: diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index c344c1378a..cb310b883a 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -1,7 +1,7 @@ import copy from typing import cast -from archinstall.lib.menu.helpers import SelectionMenu +from archinstall.lib.menu.helpers import Selection from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup @@ -9,137 +9,137 @@ class ListManager[ValueT]: - def __init__( - self, - entries: list[ValueT], - base_actions: list[str], - sub_menu_actions: list[str], - prompt: str | None = None, - ): - """ - :param prompt: Text which will appear at the header - type param: string - - :param entries: list/dict of option to be shown / manipulated - type param: list - - :param base_actions: list of actions that is displayed in the main list manager, - usually global actions such as 'Add...' - type param: list - - :param sub_menu_actions: list of actions available for a chosen entry - type param: list - """ - self._original_data = copy.deepcopy(entries) - self._data = copy.deepcopy(entries) - - self._prompt = prompt - - self._separator = '' - self._confirm_action = tr('Confirm and exit') - self._cancel_action = tr('Cancel') - - self._terminate_actions = [self._confirm_action, self._cancel_action] - self._base_actions = base_actions - self._sub_menu_actions = sub_menu_actions - - self._last_choice: ValueT | str | None = None - - @property - def last_choice(self) -> ValueT | str | None: - return self._last_choice - - def is_last_choice_cancel(self) -> bool: - if self._last_choice is not None: - return self._last_choice == self._cancel_action - return False - - def run(self) -> list[ValueT]: - additional_options = self._base_actions + self._terminate_actions - - while True: - group = MenuHelper( - data=self._data, - additional_options=additional_options, - ).create_menu_group() - - prompt = None - if self._prompt is not None: - prompt = f'{self._prompt}\n\n' - - result = SelectionMenu[ValueT | str]( - group, - header=prompt, - search_enabled=False, - allow_skip=False, - show_frame=False, - ).show() - - match result.type_: - case ResultType.Selection: - value = result.get_value() - case _: - raise ValueError('Unhandled return type') - - if value in self._base_actions: - value = cast(str, value) - self._data = self.handle_action(value, None, self._data) - elif value in self._terminate_actions: - break - else: # an entry of the existing selection was chosen - selected_entry = result.get_value() - selected_entry = cast(ValueT, selected_entry) - - self._run_actions_on_entry(selected_entry) - - self._last_choice = value - - if result.get_value() == self._cancel_action: - return self._original_data # return the original list - else: - return self._data - - def _run_actions_on_entry(self, entry: ValueT) -> None: - options = self.filter_options(entry, self._sub_menu_actions) + [self._cancel_action] - - items = [MenuItem(o, value=o) for o in options] - group = MenuItemGroup(items, sort_items=False) - - header = f'{self.selected_action_display(entry)}' - - result = SelectionMenu[str]( - group, - header=header, - search_enabled=False, - allow_skip=False, - show_frame=False, - ).show() - - match result.type_: - case ResultType.Selection: - value = result.get_value() - case _: - raise ValueError('Unhandled return type') - - if value != self._cancel_action: - self._data = self.handle_action(value, entry, self._data) - - def selected_action_display(self, selection: ValueT) -> str: - """ - this will return the value to be displayed in the - "Select an action for '{}'" string - """ - raise NotImplementedError('Please implement me in the child class') - - def handle_action(self, action: str, entry: ValueT | None, data: list[ValueT]) -> list[ValueT]: - """ - this function is called when a base action or - a specific action for an entry is triggered - """ - raise NotImplementedError('Please implement me in the child class') - - def filter_options(self, selection: ValueT, options: list[str]) -> list[str]: - """ - filter which actions to show for an specific selection - """ - return options + def __init__( + self, + entries: list[ValueT], + base_actions: list[str], + sub_menu_actions: list[str], + prompt: str | None = None, + ): + """ + :param prompt: Text which will appear at the header + type param: string + + :param entries: list/dict of option to be shown / manipulated + type param: list + + :param base_actions: list of actions that is displayed in the main list manager, + usually global actions such as 'Add...' + type param: list + + :param sub_menu_actions: list of actions available for a chosen entry + type param: list + """ + self._original_data = copy.deepcopy(entries) + self._data = copy.deepcopy(entries) + + self._prompt = prompt + + self._separator = '' + self._confirm_action = tr('Confirm and exit') + self._cancel_action = tr('Cancel') + + self._terminate_actions = [self._confirm_action, self._cancel_action] + self._base_actions = base_actions + self._sub_menu_actions = sub_menu_actions + + self._last_choice: ValueT | str | None = None + + @property + def last_choice(self) -> ValueT | str | None: + return self._last_choice + + def is_last_choice_cancel(self) -> bool: + if self._last_choice is not None: + return self._last_choice == self._cancel_action + return False + + def run(self) -> list[ValueT]: + additional_options = self._base_actions + self._terminate_actions + + while True: + group = MenuHelper( + data=self._data, + additional_options=additional_options, + ).create_menu_group() + + prompt = None + if self._prompt is not None: + prompt = f'{self._prompt}\n\n' + + result = Selection[ValueT | str]( + group, + header=prompt, + search_enabled=False, + allow_skip=False, + show_frame=False, + ).show() + + match result.type_: + case ResultType.Selection: + value = result.get_value() + case _: + raise ValueError('Unhandled return type') + + if value in self._base_actions: + value = cast(str, value) + self._data = self.handle_action(value, None, self._data) + elif value in self._terminate_actions: + break + else: # an entry of the existing selection was chosen + selected_entry = result.get_value() + selected_entry = cast(ValueT, selected_entry) + + self._run_actions_on_entry(selected_entry) + + self._last_choice = value + + if result.get_value() == self._cancel_action: + return self._original_data # return the original list + else: + return self._data + + def _run_actions_on_entry(self, entry: ValueT) -> None: + options = self.filter_options(entry, self._sub_menu_actions) + [self._cancel_action] + + items = [MenuItem(o, value=o) for o in options] + group = MenuItemGroup(items, sort_items=False) + + header = f'{self.selected_action_display(entry)}' + + result = Selection[str]( + group, + header=header, + search_enabled=False, + allow_skip=False, + show_frame=False, + ).show() + + match result.type_: + case ResultType.Selection: + value = result.get_value() + case _: + raise ValueError('Unhandled return type') + + if value != self._cancel_action: + self._data = self.handle_action(value, entry, self._data) + + def selected_action_display(self, selection: ValueT) -> str: + """ + this will return the value to be displayed in the + "Select an action for '{}'" string + """ + raise NotImplementedError('Please implement me in the child class') + + def handle_action(self, action: str, entry: ValueT | None, data: list[ValueT]) -> list[ValueT]: + """ + this function is called when a base action or + a specific action for an entry is triggered + """ + raise NotImplementedError('Please implement me in the child class') + + def filter_options(self, selection: ValueT, options: list[str]) -> list[str]: + """ + filter which actions to show for an specific selection + """ + return options diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 97c773087b..f442f2dfb8 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import override -from archinstall.lib.menu.helpers import Input, Loading, SelectionMenu +from archinstall.lib.menu.helpers import Input, Loading, Selection from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -51,17 +51,17 @@ def handle_action( entry: CustomRepository | None, data: list[CustomRepository], ) -> list[CustomRepository]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_repo = self._add_custom_repository() if new_repo is not None: data = [d for d in data if d.name != new_repo.name] data += [new_repo] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_repo = self._add_custom_repository(entry) if new_repo is not None: data = [d for d in data if d.name != entry.name] data += [new_repo] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -69,7 +69,6 @@ def handle_action( def _add_custom_repository(self, preset: CustomRepository | None = None) -> CustomRepository | None: edit_result = Input( header=tr('Enter a respository name'), - placeholder=tr('Repository'), allow_skip=True, default_value=preset.name if preset else None, ).show() @@ -82,12 +81,11 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust case _: raise ValueError('Unhandled return type') - header = f'{tr("Name")}: {name}\n\n' - header += tr('Enter the respository url') + header = f'{tr("Name")}: {name}\n' + prompt = f'{header}\n' + tr('Enter the repository url') edit_result = Input( - header=header, - placeholder=tr('Url'), + header=prompt, allow_skip=True, default_value=preset.url if preset else None, ).show() @@ -100,7 +98,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust case _: raise ValueError('Unhandled return type') - header += f'\n{tr("Url")}: {url}\n' + header += f'{tr("Url")}: {url}\n' prompt = f'{header}\n' + tr('Select signature check') sign_chk_items = [MenuItem(s.value, value=s.value) for s in SignCheck] @@ -109,7 +107,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_check.value) - result = SelectionMenu[SignCheck]( + result = Selection[SignCheck]( group, header=prompt, allow_skip=False, @@ -130,7 +128,7 @@ def _add_custom_repository(self, preset: CustomRepository | None = None) -> Cust if preset is not None: group.set_selected_by_value(preset.sign_option.value) - result = SelectionMenu( + result = Selection( group, header=prompt, allow_skip=False, @@ -171,17 +169,17 @@ def handle_action( entry: CustomServer | None, data: list[CustomServer], ) -> list[CustomServer]: - if action == self._actions[0]: # add + if action == self._actions[0]: # add new_server = self._add_custom_server() if new_server is not None: data = [d for d in data if d.url != new_server.url] data += [new_server] - elif action == self._actions[1] and entry: # modify repo + elif action == self._actions[1] and entry: # modify repo new_server = self._add_custom_server(entry) if new_server is not None: data = [d for d in data if d.url != entry.url] data += [new_server] - elif action == self._actions[2] and entry: # delete + elif action == self._actions[2] and entry: # delete data = [d for d in data if d != entry] return data @@ -189,7 +187,6 @@ def handle_action( def _add_custom_server(self, preset: CustomServer | None = None) -> CustomServer | None: edit_result = Input( header=tr('Enter server url'), - placeholder=tr('Url'), allow_skip=True, default_value=preset.url if preset else None, ).show() @@ -300,7 +297,7 @@ def run(self, additional_title: str | None = None) -> MirrorConfiguration | None def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: Loading[None]( header=tr('Loading mirror regions...'), - data_callback=lambda: mirror_list_handler.load_mirrors() + data_callback=mirror_list_handler.load_mirrors, ).show() available_regions = mirror_list_handler.get_mirror_regions() @@ -315,7 +312,7 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: group.set_selected_by_value(preset_regions) - result = SelectionMenu[MirrorRegion]( + result = Selection[MirrorRegion]( group, header=tr('Select mirror regions to be enabled'), allow_reset=True, @@ -356,7 +353,7 @@ def select_optional_repositories(preset: list[Repository]) -> list[Repository]: group = MenuItemGroup(items, sort_items=True) group.set_selected_by_value(preset) - result = SelectionMenu[Repository]( + result = Selection[Repository]( group, header=tr('Select optional repositories to be enabled'), allow_reset=True, diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 15c2ded1ef..68396abd99 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -1,7 +1,7 @@ from asyncio import sleep from dataclasses import dataclass from pathlib import Path -from typing import Any, assert_never +from typing import assert_never from archinstall.lib.exceptions import SysCallError from archinstall.lib.general import SysCommand @@ -11,7 +11,7 @@ from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItemGroup from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, LoadingScreen, NotifyScreen, TableSelectionScreen, tui -from archinstall.tui.ui.result import ResultType +from archinstall.tui.ui.result import Result, ResultType @dataclass @@ -25,9 +25,9 @@ class WifiHandler: def __init__(self) -> None: self._wpa_config = WpaSupplicantConfig() - def setup(self) -> Any: - result = tui.run(self) - return result + def setup(self) -> bool: + result: Result[bool] = tui.run(self) + return result.get_value() async def _run(self) -> None: """ @@ -37,7 +37,7 @@ async def _run(self) -> None: if not wifi_iface: debug('No wifi interface found') - tui.exit(False) + tui.exit(Result.false()) return None prompt = tr('No network connection found') + '\n\n' @@ -53,14 +53,14 @@ async def _run(self) -> None: match result.type_: case ResultType.Selection: if result.get_value() is False: - tui.exit(False) + tui.exit(Result.false()) return None case ResultType.Skip | ResultType.Reset: - tui.exit(False) + tui.exit(Result.false()) return None setup_result = await self._setup_wifi(wifi_iface) - tui.exit(setup_result) + tui.exit(Result(ResultType.Selection, _data=setup_result)) async def _enable_supplicant(self, wifi_iface: str) -> bool: self._wpa_config.load_config() @@ -139,12 +139,12 @@ async def get_wifi_networks() -> list[WifiNetwork]: if not result.has_data(): debug('No networks found') await NotifyScreen(header=tr('No wifi networks found')).run() - tui.exit(False) + tui.exit(Result.false()) return False network = result.get_value() case ResultType.Skip | ResultType.Reset: - tui.exit(False) + tui.exit(Result.false()) return False case _: assert_never(result.type_) @@ -167,7 +167,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: await self._notify_failure() return False - await LoadingScreen(3, 'Setting up wifi...').run() + await LoadingScreen(timer=3, header='Setting up wifi...').run() network_id = self._find_network_id(network.ssid, wifi_iface) @@ -183,7 +183,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: await self._notify_failure() return False - await LoadingScreen(5, 'Connecting wifi...').run() + await LoadingScreen(timer=5, header='Connecting wifi...').run() return True diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index 7baae54321..3b8b55de36 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -3,11 +3,10 @@ from typing import override from archinstall.default_profiles.profile import GreeterType, Profile +from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import SelectMenu from archinstall.tui.menu_item import MenuItem, MenuItemGroup -from archinstall.tui.result import ResultType -from archinstall.tui.types import Alignment, FrameProperties, Orientation +from archinstall.tui.ui.result import ResultType from ..hardware import GfxDriver from ..interactions.system_conf import select_driver @@ -103,18 +102,11 @@ def _select_gfx_driver(self, preset: GfxDriver | None = None) -> GfxDriver | Non header = tr('The proprietary Nvidia driver is not supported by Sway.') + '\n' header += tr('It is likely that you will run into issues, are you okay with that?') + '\n' - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - group.default_item = MenuItem.no() - - result = SelectMenu[bool]( - group, + result = Confirmation( header=header, allow_skip=False, - columns=2, - orientation=Orientation.HORIZONTAL, - alignment=Alignment.CENTER, - ).run() + preset=False, + ).show() if result.item() == MenuItem.no(): return preset @@ -168,12 +160,11 @@ def select_greeter( group.set_default_by_value(default) - result = SelectMenu[GreeterType]( + result = Selection[GreeterType]( group, + header=tr('Select which greeter to install'), allow_skip=True, - frame=FrameProperties.min(tr('Greeter')), - alignment=Alignment.CENTER, - ).run() + ).show() match result.type_: case ResultType.Skip: @@ -202,14 +193,12 @@ def select_profile( group = MenuItemGroup(items, sort_items=True) group.set_selected_by_value(current_profile) - result = SelectMenu[Profile]( + result = Selection[Profile]( group, header=header, allow_reset=allow_reset, allow_skip=True, - alignment=Alignment.CENTER, - frame=FrameProperties.min(tr('Main profile')), - ).run() + ).show() match result.type_: case ResultType.Reset: diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 5a93c67e72..aaece81264 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -14,48 +14,48 @@ def get_password( preset: str | None = None, skip_confirmation: bool = False, ) -> Password | None: - failure: str | None = None - while True: - user_hdr = None - if failure is not None: - user_hdr = f'{header}\n{failure}\n' - elif header is not None: - user_hdr = header - result = Input( - header=user_hdr, + header=header, allow_skip=allow_skip, default_value=preset, password=True, ).show() - if allow_skip: - if not result.get_value(): + if result.type_ == ResultType.Skip: + if allow_skip: return None + else: + continue + elif result.type_ == ResultType.Selection: + if not result.get_value(): + if allow_skip: + return None + else: + continue password = Password(plaintext=result.get_value()) + break - if skip_confirmation: - return password + if skip_confirmation: + return password - if header is not None: - confirmation_header = f'{header}{tr("Password")}: {password.hidden()}\n' - else: - confirmation_header = f'{tr("Password")}: {password.hidden()}\n' + confirmation_header = f'{tr("Password")}: {password.hidden()}\n\n' + confirmation_header += tr('Confirm password') - confirmation_header += '\n' + tr('Confirm password') + def _validate(value: str) -> str | None: + if value != password._plaintext: + return tr('The password did not match, please try again') + return None - result = Input( - header=confirmation_header, - allow_skip=False, - password=True, - ).show() - - if password._plaintext == result.get_value(): - return password + _ = Input( + header=confirmation_header, + allow_skip=False, + password=True, + validator_callback=_validate, + ).show() - failure = tr('The confirmation password did not match, please try again') + return password def prompt_dir( diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 9fcb22334b..151179e432 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -21,7 +21,6 @@ from archinstall.lib.packages.packages import check_package_upgrade from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib.translationhandler import tr -from archinstall.tui import Tui def ask_user_questions() -> None: @@ -38,13 +37,12 @@ def ask_user_questions() -> None: text = tr('New version available') + f': {upgrade}' title_text = f' ({text})' - with Tui(): - global_menu = GlobalMenu(arch_config_handler.config) + global_menu = GlobalMenu(arch_config_handler.config) - if not arch_config_handler.args.advanced: - global_menu.set_enabled('parallel_downloads', False) + if not arch_config_handler.args.advanced: + global_menu.set_enabled('parallel_downloads', False) - global_menu.run(additional_title=title_text) + global_menu.run(additional_title=title_text) def perform_installation(mountpoint: Path) -> None: @@ -167,8 +165,7 @@ def perform_installation(mountpoint: Path) -> None: debug(f'Disk states after installing:\n{disk_layouts()}') if not arch_config_handler.args.silent: - with Tui(): - action = ask_post_installation() + action = ask_post_installation() match action: case PostInstallationAction.EXIT: @@ -195,10 +192,9 @@ def guided() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return guided() diff --git a/archinstall/tui/__init__.py b/archinstall/tui/__init__.py index e739055929..092d93b078 100644 --- a/archinstall/tui/__init__.py +++ b/archinstall/tui/__init__.py @@ -1,19 +1,6 @@ -from .curses_menu import SelectMenu, Tui from .menu_item import MenuItem, MenuItemGroup -from .result import Result, ResultType -from .types import Alignment, Chars, FrameProperties, FrameStyle, Orientation, PreviewStyle __all__ = [ - 'Alignment', - 'Chars', - 'FrameProperties', - 'FrameStyle', 'MenuItem', 'MenuItemGroup', - 'Orientation', - 'PreviewStyle', - 'Result', - 'ResultType', - 'SelectMenu', - 'Tui', ] diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index d95e0a3795..b7939b6cf1 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -4,8 +4,7 @@ from dataclasses import dataclass, field from enum import Enum from functools import cached_property -from typing import Any, ClassVar, Self, overload -from typing_extensions import override +from typing import Any, ClassVar, Self, override from archinstall.lib.translationhandler import tr @@ -31,14 +30,14 @@ class MenuItem: _yes: ClassVar[MenuItem | None] = None _no: ClassVar[MenuItem | None] = None - def __post_init__(self): + 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): + def __hash__(self) -> int: return hash(self._id) def get_id(self) -> str: @@ -117,14 +116,14 @@ def __init__( def add_item(self, item: MenuItem) -> None: self._menu_items.append(item) - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache - def find_by_id(self, id: str) -> MenuItem: + def find_by_id(self, item_id: str) -> MenuItem: for item in self._menu_items: - if item.get_id() == id: + if item.get_id() == item_id: return item - raise ValueError(f'No item found for id: {id}') + raise ValueError(f'No item found for id: {item_id}') def find_by_key(self, key: str) -> MenuItem: for item in self._menu_items: @@ -281,17 +280,17 @@ def has_filter(self) -> bool: def set_filter_pattern(self, pattern: str) -> None: self._filter_pattern = pattern - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def append_filter(self, pattern: str) -> None: self._filter_pattern += pattern - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def reduce_filter(self) -> None: self._filter_pattern = self._filter_pattern[:-1] - delattr(self, 'items') # resetting the cache + delattr(self, 'items') # resetting the cache self._reload_focus_item() def _reload_focus_item(self) -> None: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 2f2a7daca6..dc7c995ea8 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -11,6 +11,7 @@ from textual.screen import Screen from textual.validation import Validator from textual.widgets import Button, DataTable, Footer, Input, LoadingIndicator, OptionList, Rule, SelectionList, Static +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 @@ -75,7 +76,7 @@ class LoadingScreen(BaseScreen[None]): def __init__( self, timer: int = 3, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + data_callback: Callable[[], Any] | None = None, header: str | None = None, ): super().__init__() @@ -100,7 +101,7 @@ def compose(self) -> ComposeResult: yield Footer() # def on_mount(self) -> None: - # self.set_timer(self._timer, self.action_pop_screen) + # self.set_timer(self._timer, self.action_pop_screen) def on_mount(self) -> None: if self._data_callback: @@ -119,6 +120,10 @@ def action_pop_screen(self) -> None: class OptionListScreen(BaseScreen[ValueT]): + """ + List single selection menu + """ + BINDINGS: ClassVar = [ Binding('j', 'cursor_down', 'Down', show=False), Binding('k', 'cursor_up', 'Up', show=False), @@ -192,7 +197,7 @@ def __init__( allow_skip: bool = False, allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = True, + show_frame: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group @@ -243,7 +248,7 @@ def compose(self) -> ComposeResult: yield option_list else: Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' with Container(): yield option_list @@ -252,19 +257,27 @@ def compose(self) -> ComposeResult: yield Footer() + def on_mount(self) -> None: + focused_item = self._group.focus_item + if focused_item: + self._set_preview(focused_item.get_id()) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: selected_option = event.option - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) + + def _set_preview(self, item_id: str) -> None: if self._preview_location is None: return None preview_widget = self.query_one('#preview_content', Static) - highlighted_id = event.option.id - - item = self._group.find_by_id(highlighted_id) + item = self._group.find_by_id(item_id) if item.preview_action is not None: maybe_preview = item.preview_action(item) @@ -276,6 +289,10 @@ def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) class SelectListScreen(BaseScreen[ValueT]): + """ + Multi selection menu + """ + BINDINGS: ClassVar = [ Binding('j', 'cursor_down', 'Down', show=False), Binding('k', 'cursor_up', 'Up', show=False), @@ -348,11 +365,13 @@ def __init__( allow_skip: bool = False, allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header self._preview_location = preview_location + self._show_frame = show_frame def action_cursor_down(self) -> None: select_list = self.query_one('#select_list_widget', OptionList) @@ -391,16 +410,21 @@ def compose(self) -> ComposeResult: if self._header: yield Static(self._header, classes='header', id='header') + selection_list = SelectionList[MenuItem](*selections, 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 SelectionList[ValueT](*selections, id='select_list_widget') + yield selection_list else: Container = Horizontal if self._preview_location == 'right' else Vertical - rule_orientation = 'vertical' if self._preview_location == 'right' else 'horizontal' + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' with Container(): - yield SelectionList[ValueT](*selections, id='select_list_widget') + yield selection_list yield Rule(orientation=rule_orientation) yield Static('', id='preview_content') @@ -408,16 +432,17 @@ def compose(self) -> ComposeResult: def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: selected_option = event.option - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + if selected_option.id: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: if self._preview_location is None: return None index = event.selection_index - selection: Selection[ValueT] = self.query_one(SelectionList).get_option_at_index(index) - item: MenuItem = selection.value # pyright: ignore[reportAssignmentType] + selection: Selection[MenuItem] = self.query_one(SelectionList).get_option_at_index(index) + item: MenuItem = selection.value preview_widget = self.query_one('#preview_content', Static) @@ -604,7 +629,7 @@ class InputScreen(BaseScreen[str]): def __init__( self, - header: str, + header: str | None = None, placeholder: str | None = None, password: bool = False, default_value: str | None = None, @@ -718,8 +743,8 @@ def __init__( self._loading_header = loading_header self._multi = multi - self._selected_keys: set[int] = set() - self._current_row_key = None + self._selected_keys: set[RowKey] = set() + self._current_row_key: RowKey | None = None if self._data is None and self._data_callback is None: raise ValueError('Either data or data_callback must be provided') @@ -793,7 +818,7 @@ def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> No table.add_columns(*cols) for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] + row_values = list(d.table_data().values()) # type: ignore[attr-defined] if self._multi: row_values.insert(0, ' ') @@ -831,12 +856,26 @@ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: if self._multi: if len(self._selected_keys) == 0: - _ = self.dismiss(Result(ResultType.Selection, _data=[event.row_key.value])) + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _data=[event.row_key.value], # type: ignore[list-item] + ) + ) else: - data = [row_key.value for row_key in self._selected_keys] # type: ignore[unused-awaitable] - _ = self.dismiss(Result(ResultType.Selection, _data=data)) + _ = self.dismiss( + Result( + ResultType.Selection, + _data=[row_key.value for row_key in self._selected_keys], # type: ignore[misc] + ) + ) else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.row_key.value)) + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _data=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): @@ -886,9 +925,9 @@ def action_trigger_help(self) -> None: from textual.widgets import HelpPanel if self.screen.query('HelpPanel'): - self.screen.query('HelpPanel').remove() + _ = self.screen.query('HelpPanel').remove() else: - self.screen.mount(HelpPanel()) + _ = self.screen.mount(HelpPanel()) def on_mount(self) -> None: self._run_worker() @@ -896,13 +935,13 @@ def on_mount(self) -> None: @work async def _run_worker(self) -> None: try: - await self._main._run() # type: ignore[unreachable] + await self._main._run() except WorkerCancelled: debug('Worker was cancelled') except Exception as err: debug(f'Error while running main app: {err}') # this will terminate the textual app and return the exception - self.exit(err) + self.exit(err) # type: ignore[arg-type] @work async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: @@ -929,7 +968,7 @@ def global_header(self, value: str | None) -> None: def run(self, main: Any) -> Result[ValueT]: TApp.app = _AppInstance(main) - result = TApp.app.run() + result: Result[ValueT] | Exception | None = TApp.app.run() if isinstance(result, Exception): raise result diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index 16154db546..ad6e121470 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from enum import Enum, auto -from typing import cast +from typing import Self, cast from archinstall.tui import MenuItem @@ -17,6 +17,14 @@ class Result[ValueT]: _data: ValueT | list[ValueT] | None = None _item: MenuItem | list[MenuItem] | None = None + @classmethod + def true(cls) -> Self: + return cls(ResultType.Selection, _data=True) # type: ignore[arg-type] + + @classmethod + def false(cls) -> Self: + return cls(ResultType.Selection, _data=False) # type: ignore[arg-type] + def has_data(self) -> bool: return self._data is not None @@ -36,7 +44,7 @@ def items(self) -> list[MenuItem]: def get_value(self) -> ValueT: if self._item is not None: - return self.item().get_value() # type: ignore[no-any-return] + 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) From 1c9597b37aaf7adf2c4977d12bc15e50b51e9e48 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 8 Dec 2025 22:04:50 +1100 Subject: [PATCH 08/40] Update --- archinstall/lib/disk/encryption_menu.py | 8 +- archinstall/lib/interactions/disk_conf.py | 11 +- archinstall/lib/interactions/general_conf.py | 13 +- archinstall/lib/locale/locale_menu.py | 7 +- archinstall/lib/menu/helpers.py | 36 +-- archinstall/lib/menu/list_manager.py | 4 +- archinstall/lib/mirrors.py | 2 + archinstall/lib/network/wifi_handler.py | 2 +- archinstall/tui/ui/components.py | 298 +++++++++++++------ 9 files changed, 262 insertions(+), 119 deletions(-) diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index ba2884bdb4..5ae72ea2bb 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import override -from archinstall.lib.menu.helpers import Input, Selection, TableMenu +from archinstall.lib.menu.helpers import Input, Selection, Table from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.models.device import ( DeviceModification, @@ -284,12 +284,11 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: - result = TableMenu[PartitionModification]( + result = Table[PartitionModification]( header=tr('Select disks for the installation'), data=avail_partitions, allow_skip=True, multi=True, - preview_orientation='bottom', ).show() match result.type_: @@ -311,12 +310,11 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: - result = TableMenu[LvmVolume]( + result = Table[LvmVolume]( header=tr('Select disks for the installation'), data=volumes, allow_skip=True, multi=True, - preview_orientation='bottom', ).show() match result.type_: diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index c90a21b239..a82dbd8611 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -3,7 +3,7 @@ from archinstall.lib.args import arch_config_handler from archinstall.lib.disk.device_handler import device_handler from archinstall.lib.disk.partitioning_menu import manual_partitioning -from archinstall.lib.menu.helpers import Confirmation, Notify, Selection, TableMenu +from archinstall.lib.menu.helpers import Confirmation, Notify, Selection, Table from archinstall.lib.models.device import ( BDevice, BtrfsMountOption, @@ -36,8 +36,7 @@ def select_devices(preset: list[BDevice] | None = []) -> list[BDevice]: - def _preview_device_selection(item: MenuItem) -> str | None: - device = item.get_value() + def _preview_device_selection(device: _DeviceInfo) -> str | None: dev = device_handler.get_device(device.path) if dev and dev.partition_infos: @@ -48,16 +47,18 @@ def _preview_device_selection(item: MenuItem) -> str | None: preset = [] devices = device_handler.devices + options = [d.device_info for d in devices] presets = [p.device_info for p in preset] - result = TableMenu[_DeviceInfo]( + result = Table[_DeviceInfo]( header=tr('Select disks for the installation'), data=options, presets=presets, allow_skip=True, multi=True, - preview_orientation='bottom', + preview_header=tr('Partitions'), + preview_callback=_preview_device_selection, ).show() match result.type_: diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index b08e21aff8..aa4c63cbee 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -12,7 +12,7 @@ from ..locale.utils import list_timezones from ..models.packages import AvailablePackage, PackageGroup -from ..output import warn +from ..output import debug, warn from ..translationhandler import Language @@ -147,12 +147,18 @@ def ask_additional_packages_to_install( output = tr('Repositories: {}').format(respos_text) + '\n' output += tr('Loading packages...') - packages = Loading[dict[str, AvailablePackage]]( + result = Loading[dict[str, AvailablePackage]]( header=output, data_callback=lambda: list_available_packages(tuple(repositories)), ).show() - if packages is None: + if result.type_ != ResultType.Selection: + debug('Error while loading packages') + return preset + + packages = result.get_value() + + if not packages: Notify(tr('No packages found')).show() return [] @@ -199,6 +205,7 @@ def ask_additional_packages_to_install( multi=True, preview_location='right', show_frame=False, + enable_filter=True, ).show() match result.type_: diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 5ac91e921b..afb0964495 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -78,6 +78,7 @@ def select_locale_lang(preset: str | None = None) -> str | None: header=tr('Locale language'), group=group, show_frame=True, + enable_filter=True, ).show() match result.type_: @@ -97,7 +98,10 @@ def select_locale_enc(preset: str | None = None) -> str | None: group = MenuItemGroup(items, sort_items=True) group.set_focus_by_value(preset) - result = Selection[str](header=tr('Locale encoding'), group=group).show() + result = Selection[str]( + header=tr('Locale encoding'), + group=group, + ).show() match result.type_: case ResultType.Selection: @@ -128,6 +132,7 @@ def select_kb_layout(preset: str | None = None) -> str | None: header=tr('Keyboard layout'), group=group, show_frame=True, + enable_filter=True, ).show() match result.type_: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 1d9dce2d17..73248bcb5e 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -29,7 +29,7 @@ def __init__( allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, multi: bool = False, - search_enabled: bool = False, + enable_filter: bool = False, show_frame: bool = False, ): self._header = header @@ -38,7 +38,7 @@ def __init__( self._allow_reset = allow_reset self._preview_location = preview_location self._multi = multi - self._search_enabled = search_enabled + self._enable_filter = enable_filter self._show_frame = show_frame def show(self) -> Result[ValueT]: @@ -54,6 +54,7 @@ async def _run(self) -> None: allow_reset=self._allow_reset, preview_location=self._preview_location, show_frame=self._show_frame, + enable_filter=self._enable_filter, ).run() else: result = await OptionListScreen[ValueT]( @@ -63,6 +64,7 @@ async def _run(self) -> None: allow_reset=self._allow_reset, preview_location=self._preview_location, show_frame=self._show_frame, + enable_filter=self._enable_filter, ).run() if result.type_ == ResultType.Reset: @@ -196,20 +198,16 @@ def __init__( self._timer = timer self._data_callback = data_callback - def show(self) -> ValueT | None: + def show(self) -> Result[ValueT]: result: Result[ValueT] = tui.run(self) - - match result.type_: - case ResultType.Selection: - if result.has_value() is False: - return None - return result.get_value() - case _: - return None + return result async def _run(self) -> None: if self._data_callback: - result = await LoadingScreen(header=self._header, data_callback=self._data_callback).run() + result = await LoadingScreen( + header=self._header, + data_callback=self._data_callback, + ).run() tui.exit(result) else: await LoadingScreen( @@ -219,18 +217,19 @@ async def _run(self) -> None: tui.exit(Result.true()) -class TableMenu[ValueT]: +class Table[ValueT]: def __init__( self, header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + data: MenuItemGroup | None = None, + data_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, presets: list[ValueT] | None = None, allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, - preview_orientation: str | None = None, + preview_header: str | None = None, + preview_callback: Callable[[ValueT], str | None] | None = None, ): self._header = header self._data = data @@ -239,8 +238,9 @@ def __init__( self._allow_skip = allow_skip self._allow_reset = allow_reset self._multi = multi - self._preview_orientation = preview_orientation self._presets = presets + self._preview_header = preview_header + self._preview_callback = preview_callback if self._data is None and self._data_callback is None: raise ValueError('Either data or data_callback must be provided') @@ -258,6 +258,8 @@ async def _run(self) -> None: allow_reset=self._allow_reset, loading_header=self._loading_header, multi=self._multi, + preview_header=self._preview_header, + preview_callback=self._preview_callback, ).run() if result.type_ == ResultType.Reset: diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 76481fe1ab..2cd276dad7 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -70,7 +70,7 @@ def run(self) -> list[ValueT]: result = Selection[ValueT | str]( group, header=prompt, - search_enabled=False, + enable_filter=False, allow_skip=False, show_frame=False, ).show() @@ -110,7 +110,7 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: result = Selection[str]( group, header=header, - search_enabled=False, + enable_filter=False, allow_skip=False, show_frame=False, ).show() diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index b187c4b17d..25b814d83b 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -318,6 +318,8 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: allow_reset=True, allow_skip=True, multi=True, + show_frame=True, + enable_filter=True, ).show() match result.type_: diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 68396abd99..6e5d09d5b0 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -129,7 +129,7 @@ async def get_wifi_networks() -> list[WifiNetwork]: result = await TableSelectionScreen[WifiNetwork]( header=tr('Select wifi network to connect to'), loading_header=tr('Scanning wifi networks...'), - data_callback=get_wifi_networks, + group_callback=get_wifi_networks, allow_skip=True, allow_reset=True, ).run() diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index dc7c995ea8..3b22c0320b 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -100,9 +100,6 @@ def compose(self) -> ComposeResult: yield Footer() - # def on_mount(self) -> None: - # self.set_timer(self._timer, self.action_pop_screen) - def on_mount(self) -> None: if self._data_callback: self._exec_callback() @@ -127,6 +124,7 @@ class OptionListScreen(BaseScreen[ValueT]): BINDINGS: ClassVar = [ Binding('j', 'cursor_down', 'Down', show=False), Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), ] CSS = """ @@ -198,12 +196,16 @@ def __init__( allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, show_frame: bool = False, + enable_filter: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header self._preview_location = preview_location self._show_frame = show_frame + self._filter = enable_filter + + self._options = self._get_options() def action_cursor_down(self) -> None: option_list = self.query_one('#option_list_widget', OptionList) @@ -213,6 +215,25 @@ def action_cursor_up(self) -> None: option_list = self.query_one('#option_list_widget', OptionList) option_list.action_cursor_up() + def action_search(self) -> None: + 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) @@ -230,14 +251,11 @@ def _get_options(self) -> list[Option]: def compose(self) -> ComposeResult: yield from self._compose_header() - options = self._get_options() - with Vertical(classes='content-container'): if self._header: yield Static(self._header, classes='header', id='header') - option_list = OptionList(*options, id='option_list_widget') - option_list.highlighted = self._group.get_focused_index() + option_list = OptionList(id='option_list_widget') if not self._show_frame: option_list.classes = 'no-border' @@ -255,12 +273,29 @@ def compose(self) -> ComposeResult: yield Rule(orientation=rule_orientation) yield Static('', id='preview_content') + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + yield Footer() def on_mount(self) -> None: - focused_item = self._group.focus_item - if focused_item: - self._set_preview(focused_item.get_id()) + self._update_options(self._options) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: selected_option = event.option @@ -296,6 +331,8 @@ class SelectListScreen(BaseScreen[ValueT]): BINDINGS: ClassVar = [ Binding('j', 'cursor_down', 'Down', show=False), Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), ] CSS = """ @@ -366,12 +403,17 @@ def __init__( allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, show_frame: bool = False, + enable_filter: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header self._preview_location = preview_location self._show_frame = show_frame + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options = self._get_selections() def action_cursor_down(self) -> None: select_list = self.query_one('#select_list_widget', OptionList) @@ -381,10 +423,24 @@ def action_cursor_up(self) -> None: select_list = self.query_one('#select_list_widget', OptionList) select_list.action_cursor_up() - def on_key(self, event: Key) -> None: - if event.key == 'enter': - items: list[MenuItem] = self.query_one(SelectionList).selected - _ = self.dismiss(Result(ResultType.Selection, _item=items)) + def action_search(self) -> None: + 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 @@ -394,7 +450,7 @@ def _get_selections(self) -> list[Selection[MenuItem]]: selections = [] for item in self._group.get_enabled_items(): - is_selected = item in self._group.selected_items + is_selected = item in self._selected_items selection = Selection(item.text, item, is_selected) selections.append(selection) @@ -404,13 +460,11 @@ def _get_selections(self) -> list[Selection[MenuItem]]: def compose(self) -> ComposeResult: yield from self._compose_header() - selections = self._get_selections() - with Vertical(classes='content-container'): if self._header: yield Static(self._header, classes='header', id='header') - selection_list = SelectionList[MenuItem](*selections, id='select_list_widget') + selection_list = SelectionList[MenuItem](id='select_list_widget') if not self._show_frame: selection_list.classes = 'no-border' @@ -428,21 +482,52 @@ def compose(self) -> ComposeResult: yield Rule(orientation=rule_orientation) yield Static('', id='preview_content') + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + yield Footer() - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_mount(self) -> None: + self._update_options(self._options) + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + selection_list = self.query_one(SelectionList) + selection_list.clear_options() + selection_list.add_options(options) + + selection_list.highlighted = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item) def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: if self._preview_location is None: return None - index = event.selection_index - selection: Selection[MenuItem] = self.query_one(SelectionList).get_option_at_index(index) - item: MenuItem = selection.value + item = event.selection.value + self._set_preview(item) + + def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[ValueT]) -> None: + item = 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', Static) @@ -465,34 +550,24 @@ class ConfirmationScreen(BaseScreen[ValueT]): CSS = """ ConfirmationScreen { - align: center middle; + align: center top; } - .dialog-wrapper { - align: center middle; - height: 100%; - width: 100%; + .header { + text-align: center; + padding-top: 2; + padding-bottom: 1; } - .dialog { + .content-container { width: 80; height: 10; border: none; background: transparent; } - .dialog-content { - padding: 1; - height: 100%; - } - - .message { - text-align: center; - margin-bottom: 1; - } - .buttons { - align: center middle; + align: center top; background: transparent; } @@ -530,13 +605,13 @@ async def run(self) -> Result[ValueT]: def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(classes='dialog-wrapper'): - with Vertical(classes='dialog'): - with Vertical(classes='dialog-content'): - yield Static(self._header, classes='message') - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) + yield Static(self._header, classes='header') + + with Center(): + with Vertical(classes='content-container'): + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) yield Footer() @@ -692,38 +767,65 @@ class TableSelectionScreen(BaseScreen[ValueT]): ] CSS = """ - TableSelectionScreen { - align: center middle; - background: transparent; - } + TableSelectionScreen { + align: center top; + background: transparent; + } - DataTable { - height: auto; - width: auto; - border: none; - background: transparent; - } + .header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } - DataTable .datatable--header { - background: transparent; - border: solid; - } + .content-container { + align: center top; + width: 1fr; + height: 1fr; + background: transparent; + } - .content-container { - width: auto; - background: transparent; - padding: 2 0; - } + .table-container { + align: center top; + height: auto; + background: transparent; + } - .header { - text-align: center; - margin-bottom: 1; - } + .preview-header { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } - LoadingIndicator { - height: auto; - background: transparent; - } + .preview-container { + align: center top; + height: auto; + background: transparent; + } + + DataTable { + width: auto; + height: auto; + border: none; + background: transparent; + } + + DataTable .datatable--header { + background: transparent; + border: solid; + } + + LoadingIndicator { + height: auto; + background: transparent; + } """ def __init__( @@ -735,6 +837,8 @@ def __init__( allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, + preview_header: str | None = None, + preview_callback: Callable[[ValueT], str | None] | None = None, ): super().__init__(allow_skip, allow_reset) self._header = header @@ -742,6 +846,8 @@ def __init__( self._data_callback = data_callback self._loading_header = loading_header self._multi = multi + self._preview_header = preview_header + self._preview_callback = preview_callback self._selected_keys: set[RowKey] = set() self._current_row_key: RowKey | None = None @@ -767,17 +873,25 @@ def action_cursor_up(self) -> None: def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(): - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') + if self._header: + yield Static(self._header, classes='header', id='header') + with Vertical(classes='content-container'): + with Vertical(classes='table-container'): if self._loading_header: yield Static(self._loading_header, classes='header', id='loading-header') yield LoadingIndicator(id='loader') yield DataTable(id='data_table') + yield Rule(orientation='horizontal') + + if self._preview_header: + yield Static(self._preview_header, classes='preview-header', id='preview-header') + + with Vertical(classes='preview-container'): + yield Static('', id='preview_content') + yield Footer() def on_mount(self) -> None: @@ -813,7 +927,7 @@ def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> No cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] if self._multi: - cols.insert(0, ' ') + cols.insert(0, ' ') table.add_columns(*cols) @@ -821,7 +935,7 @@ def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> No row_values = list(d.table_data().values()) # type: ignore[attr-defined] if self._multi: - row_values.insert(0, ' ') + row_values.insert(0, '[ ]') table.add_row(*row_values, key=d) # type: ignore[arg-type] @@ -845,14 +959,28 @@ def action_toggle_selection(self) -> None: 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, ' ') + 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') + table.update_cell(self._current_row_key, cell_key.column_key, '[X]') def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: self._current_row_key = event.row_key + if self._preview_callback is None: + return None + + preview_widget = self.query_one('#preview_content', Static) + data: ValueT = event.row_key.value + + if self._preview_callback is not None: + maybe_preview = self._preview_callback(data) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: if self._multi: if len(self._selected_keys) == 0: From 4c6609cb2ed48fbbe9e7db7da64f6a59fe2d2de8 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 10 Dec 2025 21:12:03 +1100 Subject: [PATCH 09/40] Add scrollbar --- archinstall/lib/disk/encryption_menu.py | 12 +- archinstall/lib/interactions/disk_conf.py | 25 +++- archinstall/lib/menu/helpers.py | 17 +-- archinstall/tui/menu_item.py | 2 +- archinstall/tui/ui/components.py | 168 ++++++++++++---------- 5 files changed, 130 insertions(+), 94 deletions(-) diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 5ae72ea2bb..7b463f39f9 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -284,9 +284,13 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: + items = [MenuItem(str(id(partition)), value=partition) for partition in partitions] + group = MenuItemGroup(items) + group.set_selected_by_value(preset) + result = Table[PartitionModification]( header=tr('Select disks for the installation'), - data=avail_partitions, + group=group, allow_skip=True, multi=True, ).show() @@ -310,9 +314,13 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: + items = [MenuItem(str(id(volume)), value=volume) for volume in volumes] + group = MenuItemGroup(items) + group.set_selected_by_value(preset) + result = Table[LvmVolume]( header=tr('Select disks for the installation'), - data=volumes, + group=group, allow_skip=True, multi=True, ).show() diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index a82dbd8611..8704462dc3 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -36,7 +36,8 @@ def select_devices(preset: list[BDevice] | None = []) -> list[BDevice]: - def _preview_device_selection(device: _DeviceInfo) -> str | None: + def _preview_device_selection(item: MenuItem) -> str | None: + device: _DeviceInfo = item.value dev = device_handler.get_device(device.path) if dev and dev.partition_infos: @@ -48,19 +49,31 @@ def _preview_device_selection(device: _DeviceInfo) -> str | None: devices = device_handler.devices - options = [d.device_info for d in devices] + items = [ + MenuItem( + str(d.device_info.path), + d.device_info, + preview_action=_preview_device_selection, + ) + for d in devices + ] + presets = [p.device_info for p in preset] + group = MenuItemGroup(items) + group.set_selected_by_value(presets) + result = Table[_DeviceInfo]( header=tr('Select disks for the installation'), - data=options, + group=group, presets=presets, allow_skip=True, multi=True, preview_header=tr('Partitions'), - preview_callback=_preview_device_selection, ).show() + debug(f'Result: {result}') + match result.type_: case ResultType.Reset: return [] @@ -74,6 +87,7 @@ def _preview_device_selection(device: _DeviceInfo) -> str | None: if device.device_info in selected_device_info: selected_devices.append(device) + debug(f'Selected devices: {selected_device_info}') return selected_devices @@ -165,6 +179,9 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay if not devices: return None + if devices == preset_devices: + return preset + if result.get_value() == default_layout: modifications = get_default_partition_layout(devices) if modifications: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 73248bcb5e..c2d91c8fdc 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -221,28 +221,26 @@ class Table[ValueT]: def __init__( self, header: str | None = None, - data: MenuItemGroup | None = None, - data_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, + group: MenuItemGroup | None = None, + group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, presets: list[ValueT] | None = None, allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, preview_header: str | None = None, - preview_callback: Callable[[ValueT], str | None] | None = None, ): self._header = header - self._data = data - self._data_callback = data_callback + self._group = group + self._data_callback = group_callback self._loading_header = loading_header self._allow_skip = allow_skip self._allow_reset = allow_reset self._multi = multi self._presets = presets self._preview_header = preview_header - self._preview_callback = preview_callback - if self._data is None and self._data_callback is None: + if self._group is None and self._data_callback is None: raise ValueError('Either data or data_callback must be provided') def show(self) -> Result[ValueT]: @@ -252,14 +250,13 @@ def show(self) -> Result[ValueT]: async def _run(self) -> None: result = await TableSelectionScreen[ValueT]( header=self._header, - data=self._data, - data_callback=self._data_callback, + group=self._group, + group_callback=self._data_callback, allow_skip=self._allow_skip, allow_reset=self._allow_reset, loading_header=self._loading_header, multi=self._multi, preview_header=self._preview_header, - preview_callback=self._preview_callback, ).run() if result.type_ == ResultType.Reset: diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index f7a862f7cc..81ce94d543 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -112,7 +112,7 @@ def __init__( self.focus_first() if self.focus_item not in self.items: - raise ValueError(f'Selected item not in menu: {focus_item}') + raise ValueError(f'Selected item not in menu: {self.focus_item}') def add_item(self, item: MenuItem) -> None: self._menu_items.append(item) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 3b22c0320b..69d3b62a38 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -6,11 +6,11 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Center, Horizontal, Vertical +from textual.containers import Center, Horizontal, HorizontalScroll, Vertical from textual.events import Key from textual.screen import Screen from textual.validation import Validator -from textual.widgets import Button, DataTable, Footer, Input, LoadingIndicator, OptionList, Rule, SelectionList, Static +from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList, Static from textual.widgets._data_table import RowKey from textual.widgets.option_list import Option from textual.widgets.selection_list import Selection @@ -174,7 +174,7 @@ class OptionListScreen(BaseScreen[ValueT]): OptionList { width: auto; height: auto; - min-width: 20%; + min-width: 15%; max-height: 1fr; padding-top: 0; @@ -208,16 +208,19 @@ def __init__( self._options = self._get_options() def action_cursor_down(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_down() + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() def action_cursor_up(self) -> None: - option_list = self.query_one('#option_list_widget', OptionList) - option_list.action_cursor_up() + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() def action_search(self) -> None: - if self._filter: - self._handle_search_action() + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() @override def action_cancel_operation(self) -> None: @@ -271,7 +274,7 @@ def compose(self) -> ComposeResult: with Container(): yield option_list yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') + yield HorizontalScroll(Label('', id='preview_content')) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -280,6 +283,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self._update_options(self._options) + self.query_one(OptionList).focus() def on_input_changed(self, event: Input.Changed) -> None: search_term = event.value.lower() @@ -366,7 +370,8 @@ class SelectListScreen(BaseScreen[ValueT]): .list-container { width: auto; height: auto; - max-height: 100%; + min-width: 15%; + max-height: 1fr; margin-top: 2; margin-bottom: 2; @@ -416,16 +421,19 @@ def __init__( self._options = self._get_selections() def action_cursor_down(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_down() + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() def action_cursor_up(self) -> None: - select_list = self.query_one('#select_list_widget', OptionList) - select_list.action_cursor_up() + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() def action_search(self) -> None: - if self._filter: - self._handle_search_action() + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() @override def action_cancel_operation(self) -> None: @@ -480,7 +488,7 @@ def compose(self) -> ComposeResult: with Container(): yield selection_list yield Rule(orientation=rule_orientation) - yield Static('', id='preview_content') + yield HorizontalScroll(Label('', id='preview_content')) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -489,6 +497,7 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: self._update_options(self._options) + self.query_one(SelectionList).focus() def on_key(self, event: Key) -> None: if self.query_one(SelectionList).has_focus: @@ -783,19 +792,18 @@ class TableSelectionScreen(BaseScreen[ValueT]): } .content-container { - align: center top; width: 1fr; height: 1fr; - background: transparent; - } + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; - .table-container { - align: center top; - height: auto; background: transparent; } - .preview-header { + .preview { text-align: center; width: 100%; padding-bottom: 1; @@ -804,7 +812,7 @@ class TableSelectionScreen(BaseScreen[ValueT]): background: transparent; } - .preview-container { + HorizontalScroll { align: center top; height: auto; background: transparent; @@ -813,6 +821,9 @@ class TableSelectionScreen(BaseScreen[ValueT]): DataTable { width: auto; height: auto; + + padding-bottom: 2; + border: none; background: transparent; } @@ -831,28 +842,26 @@ class TableSelectionScreen(BaseScreen[ValueT]): def __init__( self, header: str | None = None, - data: list[ValueT] | None = None, - data_callback: Callable[[], Awaitable[list[ValueT]]] | None = None, + group: MenuItemGroup | None = None, + group_callback: Callable[[], Awaitable[MenuItemGroup]] | None = None, allow_reset: bool = False, allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, preview_header: str | None = None, - preview_callback: Callable[[ValueT], str | None] | None = None, ): super().__init__(allow_skip, allow_reset) self._header = header - self._data = data - self._data_callback = data_callback + self._group = group + self._group_callback = group_callback self._loading_header = loading_header self._multi = multi self._preview_header = preview_header - self._preview_callback = preview_callback self._selected_keys: set[RowKey] = set() self._current_row_key: RowKey | None = None - if self._data is None and self._data_callback is None: + if self._group is None and self._group_callback is None: raise ValueError('Either data or data_callback must be provided') async def run(self) -> Result[ValueT]: @@ -877,20 +886,22 @@ def compose(self) -> ComposeResult: yield Static(self._header, classes='header', id='header') with Vertical(classes='content-container'): - with Vertical(classes='table-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') - yield LoadingIndicator(id='loader') - yield DataTable(id='data_table') + yield LoadingIndicator(id='loader') - yield Rule(orientation='horizontal') - - if self._preview_header: - yield Static(self._preview_header, classes='preview-header', id='preview-header') + if self._preview_header is None: + with Center(): + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) - with Vertical(classes='preview-container'): - yield Static('', id='preview_content') + else: + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) + yield Rule(orientation='horizontal') + yield Static(self._preview_header, classes='preview', id='preview-header') + yield HorizontalScroll(Label('', id='preview_content')) yield Footer() @@ -899,16 +910,16 @@ def on_mount(self) -> None: data_table = self.query_one(DataTable) data_table.cell_padding = 2 - if self._data: - self._put_data_to_table(data_table, self._data) + if self._group: + self._put_data_to_table(data_table, self._group) else: self._load_data(data_table) @work async def _load_data(self, table: DataTable[ValueT]) -> None: - assert self._data_callback is not None - data = await self._data_callback() - self._put_data_to_table(table, data) + assert self._group_callback is not None + group = await self._group_callback() + self._put_data_to_table(table, group) def _display_header(self, is_loading: bool) -> None: try: @@ -919,25 +930,33 @@ def _display_header(self, is_loading: bool) -> None: except Exception: pass - def _put_data_to_table(self, table: DataTable[ValueT], data: list[ValueT]) -> None: - if not data: + def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: + items = group.items + selected = group.selected_items + + if not items: _ = self.dismiss(Result(ResultType.Selection)) return - cols = list(data[0].table_data().keys()) # type: ignore[attr-defined] + cols = list(items[0].value.table_data().keys()) # type: ignore[attr-defined] if self._multi: cols.insert(0, ' ') table.add_columns(*cols) - for d in data: - row_values = list(d.table_data().values()) # type: ignore[attr-defined] + for item in items: + row_values = list(item.value.table_data().values()) # type: ignore[attr-defined] if self._multi: - row_values.insert(0, '[ ]') + if item in selected: + row_values.insert(0, '[X]') + else: + row_values.insert(0, '[ ]') - table.add_row(*row_values, key=d) # type: ignore[arg-type] + 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 @@ -966,42 +985,37 @@ def action_toggle_selection(self) -> None: def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: self._current_row_key = event.row_key + item: MenuItem = event.row_key.value - if self._preview_callback is None: - return None + if not item.preview_action: + return preview_widget = self.query_one('#preview_content', Static) - data: ValueT = event.row_key.value - if self._preview_callback is not None: - maybe_preview = self._preview_callback(data) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return preview_widget.update('') def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: if self._multi: + debug(f'Selected keys: {self._selected_keys}') + if len(self._selected_keys) == 0: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _data=[event.row_key.value], # type: ignore[list-item] - ) - ) + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) else: - _ = self.dismiss( - Result( - ResultType.Selection, - _data=[row_key.value for row_key in self._selected_keys], # type: ignore[misc] - ) - ) + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[misc] else: _ = self.dismiss( Result[ValueT]( ResultType.Selection, - _data=event.row_key.value, # type: ignore[arg-type] + _item=event.row_key.value, # type: ignore[arg-type] ) ) From bd5c4dc8e50fa08a86b5d6ef2004b42485b3c004 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 10 Dec 2025 21:18:06 +1100 Subject: [PATCH 10/40] Update --- archinstall/tui/ui/components.py | 2084 +++++++++++++++--------------- archinstall/tui/ui/menu_item.py | 518 ++++++++ 2 files changed, 1560 insertions(+), 1042 deletions(-) create mode 100644 archinstall/tui/ui/menu_item.py diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 69d3b62a38..9986442e36 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -25,1105 +25,1105 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - self._filter = enable_filter - - self._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - 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 HorizontalScroll(Label('', id='preview_content')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + self._filter = enable_filter + + self._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + 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 HorizontalScroll(Label('', id='preview_content')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - 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 HorizontalScroll(Label('', id='preview_content')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + 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 HorizontalScroll(Label('', id='preview_content')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + selection_list = self.query_one(SelectionList) + selection_list.clear_options() + selection_list.add_options(options) - selection_list.highlighted = self._group.get_focused_index() + selection_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: - if self._preview_location is None: - return None + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: + if self._preview_location is None: + return None - item = event.selection.value - self._set_preview(item) + item = event.selection.value + self._set_preview(item) - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[ValueT]) -> None: - item = event.selection.value - if item not in self._selected_items: - self._selected_items.append(item) - else: - self._selected_items.remove(item) + def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[ValueT]) -> None: + item = 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Static) - 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 + 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('') + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center top; - } - - .header { - text-align: center; - padding-top: 2; - padding-bottom: 1; - } - - .content-container { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .buttons { - align: center top; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(self._header, classes='header') - - with Center(): - with Vertical(classes='content-container'): - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - yield Footer() - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .header { + text-align: center; + padding-top: 2; + padding-bottom: 1; + } + + .content-container { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .buttons { + align: center top; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='header') + + with Center(): + with Vertical(classes='content-container'): + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Footer() + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + _ = self.dismiss(Result(ResultType.Selection, _item=item)) class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - align: center middle; - } - - .input-header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ - - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(self._header, classes='input-header') - - 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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + CSS = """ + InputScreen { + align: center middle; + } + + .input-header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='input-header') + + 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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center top; - background: transparent; - } - - .header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .preview { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - HorizontalScroll { - 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; - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header', id='header') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header is None: - with Center(): - with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) - - else: - with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) - yield Rule(orientation='horizontal') - yield Static(self._preview_header, classes='preview', id='preview-header') - yield HorizontalScroll(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: - items = group.items - selected = group.selected_items - - if not items: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(items[0].value.table_data().keys()) # type: ignore[attr-defined] - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for item in items: - row_values = list(item.value.table_data().values()) # type: ignore[attr-defined] - - 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) + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center top; + background: transparent; + } + + .header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .preview { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + HorizontalScroll { + 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; + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header', id='header') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header is None: + with Center(): + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) + + else: + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) + yield Rule(orientation='horizontal') + yield Static(self._preview_header, classes='preview', id='preview-header') + yield HorizontalScroll(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: + items = group.items + selected = group.selected_items + + if not items: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(items[0].value.table_data().keys()) # type: ignore[attr-defined] + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for item in items: + row_values = list(item.value.table_data().values()) # type: ignore[attr-defined] + + 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._current_row_key = event.row_key - item: MenuItem = event.row_key.value - - if not item.preview_action: - return - - preview_widget = self.query_one('#preview_content', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - debug(f'Selected keys: {self._selected_keys}') + 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._current_row_key = event.row_key + item: MenuItem = event.row_key.value + + if not item.preview_action: + return + + preview_widget = self.query_one('#preview_content', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + debug(f'Selected keys: {self._selected_keys}') - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[misc] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[misc] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - _ = self.screen.query('HelpPanel').remove() - else: - _ = self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] - - @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() + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] + + @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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() diff --git a/archinstall/tui/ui/menu_item.py b/archinstall/tui/ui/menu_item.py new file mode 100644 index 0000000000..81ce94d543 --- /dev/null +++ b/archinstall/tui/ui/menu_item.py @@ -0,0 +1,518 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import Enum +from functools import cached_property +from typing import Any, ClassVar, Self, override + +from archinstall.lib.translationhandler import tr + +from ..lib.utils.unicode import unicode_ljust + + +@dataclass +class MenuItem: + text: str + value: Any | None = None + action: Callable[[Any], Any] | None = None + enabled: bool = True + read_only: bool = False + mandatory: bool = False + dependencies: list[str | Callable[[], bool]] = field(default_factory=list) + dependencies_not: list[str] = field(default_factory=list) + display_action: Callable[[Any], str] | None = None + preview_action: Callable[[Self], str | None] | None = None + key: str | None = None + + _id: str = '' + + _yes: ClassVar[MenuItem | None] = None + _no: ClassVar[MenuItem | None] = None + + def __post_init__(self) -> None: + if self.key is not None: + self._id = self.key + else: + self._id = str(id(self)) + + @override + def __hash__(self) -> int: + return hash(self._id) + + def get_id(self) -> str: + return self._id + + def get_value(self) -> Any: + assert self.value is not None + return self.value + + @classmethod + def yes(cls, action: Callable[[Any], Any] | None = None) -> 'MenuItem': + if cls._yes is None: + cls._yes = cls(tr('Yes'), value=True, key='yes', action=action) + + return cls._yes + + @classmethod + def no(cls, action: Callable[[Any], Any] | None = None) -> 'MenuItem': + if cls._no is None: + cls._no = cls(tr('No'), value=False, key='no', action=action) + + return cls._no + + def is_empty(self) -> bool: + return self.text == '' or self.text is None + + def has_value(self) -> bool: + if self.value is None: + return False + elif isinstance(self.value, list) and len(self.value) == 0: + return False + elif isinstance(self.value, dict) and len(self.value) == 0: + return False + else: + return True + + def get_display_value(self) -> str | None: + if self.display_action is not None: + return self.display_action(self.value) + + return None + + +class MenuItemGroup: + def __init__( + self, + menu_items: list[MenuItem], + focus_item: MenuItem | None = None, + default_item: MenuItem | None = None, + sort_items: bool = False, + sort_case_sensitive: bool = True, + checkmarks: bool = False, + ) -> None: + if len(menu_items) < 1: + raise ValueError('Menu must have at least one item') + + if sort_items: + if sort_case_sensitive: + menu_items = sorted(menu_items, key=lambda x: x.text) + else: + menu_items = sorted(menu_items, key=lambda x: x.text.lower()) + + self._filter_pattern: str = '' + self._checkmarks: bool = checkmarks + + self._menu_items: list[MenuItem] = menu_items + self.focus_item: MenuItem | None = focus_item + self.selected_items: list[MenuItem] = [] + self.default_item: MenuItem | None = default_item + + if not focus_item: + self.focus_first() + + if self.focus_item not in self.items: + raise ValueError(f'Selected item not in menu: {self.focus_item}') + + def add_item(self, item: MenuItem) -> None: + self._menu_items.append(item) + delattr(self, 'items') # resetting the cache + + def find_by_id(self, item_id: str) -> MenuItem: + for item in self._menu_items: + if item.get_id() == item_id: + return item + + raise ValueError(f'No item found for id: {item_id}') + + def find_by_key(self, key: str) -> MenuItem: + for item in self._menu_items: + if item.key == key: + return item + + raise ValueError(f'No item found for key: {key}') + + def get_enabled_items(self) -> list[MenuItem]: + return [it for it in self.items if self.is_enabled(it)] + + @staticmethod + def yes_no() -> 'MenuItemGroup': + return MenuItemGroup( + [MenuItem.yes(), MenuItem.no()], + sort_items=True, + ) + + @staticmethod + def from_enum( + enum_cls: type[Enum], + sort_items: bool = False, + preset: Enum | None = None, + ) -> 'MenuItemGroup': + items = [MenuItem(elem.value, value=elem) for elem in enum_cls] + group = MenuItemGroup(items, sort_items=sort_items) + + if preset is not None: + group.set_selected_by_value(preset) + + return group + + def set_preview_for_all(self, action: Callable[[Any], str | None]) -> None: + for item in self.items: + item.preview_action = action + + def set_focus_by_value(self, value: Any) -> None: + for item in self._menu_items: + if item.value == value: + self.focus_item = item + break + + def set_default_by_value(self, value: Any) -> None: + for item in self._menu_items: + if item.value == value: + self.default_item = item + break + + def set_selected_by_value(self, values: Any | list[Any] | None) -> None: + if values is None: + return + + if not isinstance(values, list): + values = [values] + + for item in self._menu_items: + if item.value in values: + self.selected_items.append(item) + + if values: + self.set_focus_by_value(values[0]) + + def get_focused_index(self) -> int | None: + items = self.get_enabled_items() + + if self.focus_item and items: + try: + return items.index(self.focus_item) + except ValueError: + # on large menus (15k+) when filtering very quickly + # the index search is too slow while the items are reduced + # by the filter and it will blow up as it cannot find the + # focus item + pass + + return None + + def index_focus(self) -> int | None: + if self.focus_item and self.items: + try: + return self.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 + + @property + def size(self) -> int: + return len(self.items) + + def get_max_width(self) -> int: + # use the menu_items not the items here otherwise the preview + # will get resized all the time when a filter is applied + return max([len(self.get_item_text(item)) for item in self._menu_items]) + + @cached_property + def _max_items_text_width(self) -> int: + return max([len(item.text) for item in self._menu_items]) + + def get_item_text(self, item: MenuItem) -> str: + if item.is_empty(): + return '' + + max_width = self._max_items_text_width + display_text = item.get_display_value() + + default_text = self._default_suffix(item) + + text = unicode_ljust(str(item.text), max_width, ' ') + spacing = ' ' * 4 + + if display_text: + text = f'{text}{spacing}{display_text}' + elif self._checkmarks: + from .types import Chars + + if item.has_value(): + if item.get_value() is not False: + text = f'{text}{spacing}{Chars.Check}' + else: + text = item.text + + if default_text: + text = f'{text} {default_text}' + + return text.rstrip(' ') + + def _default_suffix(self, item: MenuItem) -> str: + if self.default_item == item: + return 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 + + @property + def filter_pattern(self) -> str: + return self._filter_pattern + + def has_filter(self) -> bool: + return self._filter_pattern != '' + + def set_filter_pattern(self, pattern: str) -> None: + self._filter_pattern = pattern + delattr(self, 'items') # resetting the cache + self.focus_first() + + def append_filter(self, pattern: str) -> None: + self._filter_pattern += pattern + delattr(self, 'items') # resetting the cache + self.focus_first() + + def reduce_filter(self) -> None: + self._filter_pattern = self._filter_pattern[:-1] + delattr(self, 'items') # resetting the cache + self.focus_first() + + def _reload_focus_item(self) -> None: + if len(self.items) > 0: + if self.focus_item not in self.items: + self.focus_first() + else: + self.focus_item = None + + def is_item_selected(self, item: MenuItem) -> bool: + return item in self.selected_items + + def select_current_item(self) -> None: + if self.focus_item: + if self.focus_item in self.selected_items: + self.selected_items.remove(self.focus_item) + else: + self.selected_items.append(self.focus_item) + + 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 is_mandatory_fulfilled(self) -> bool: + for item in self._menu_items: + if item.mandatory and not item.value: + return False + return True + + 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 + + +class MenuItemsState: + def __init__( + self, + item_group: MenuItemGroup, + total_cols: int, + total_rows: int, + with_frame: bool, + ) -> None: + self._item_group = item_group + self._total_cols = total_cols + self._total_rows = total_rows - 2 if with_frame else total_rows + + self._prev_row_idx: int = -1 + self._prev_visible_rows: list[int] = [] + self._view_items: list[list[MenuItem]] = [] + + def _determine_focus_row(self) -> int | None: + focus_index = self._item_group.index_focus() + + if focus_index is None: + return None + + row_index = focus_index // self._total_cols + return row_index + + def get_view_items(self) -> list[list[MenuItem]]: + enabled_items = self._item_group.get_enabled_items() + focus_row_idx = self._determine_focus_row() + + if focus_row_idx is None: + return [] + + start, end = 0, 0 + + if len(self._view_items) == 0 or self._prev_row_idx == -1 or focus_row_idx == 0: # initial setup + if focus_row_idx < self._total_rows: + start = 0 + end = self._total_rows + elif focus_row_idx > len(enabled_items) - self._total_rows: + start = len(enabled_items) - self._total_rows + end = len(enabled_items) + else: + start = focus_row_idx + end = focus_row_idx + self._total_rows + elif len(enabled_items) <= self._total_rows: # the view can handle oll items + start = 0 + end = self._total_rows + elif not self._item_group.has_filter() and focus_row_idx in self._prev_visible_rows: # focus is in the same view + self._prev_row_idx = focus_row_idx + return self._view_items + else: + if self._item_group.has_filter(): + start = focus_row_idx + end = focus_row_idx + self._total_rows + else: + delta = focus_row_idx - self._prev_row_idx + + if delta > 0: # cursor is on the bottom most row + start = focus_row_idx - self._total_rows + 1 + end = focus_row_idx + 1 + else: # focus is on the top most row + start = focus_row_idx + end = focus_row_idx + self._total_rows + + self._view_items = self._get_view_items(enabled_items, start, end) + self._prev_visible_rows = list(range(start, end)) + self._prev_row_idx = focus_row_idx + + return self._view_items + + def _get_view_items( + self, + items: list[MenuItem], + start_row: int, + total_rows: int, + ) -> list[list[MenuItem]]: + groups: list[list[MenuItem]] = [] + nr_items = self._total_cols * min(total_rows, len(items)) + + for x in range(start_row, nr_items, self._total_cols): + groups.append( + items[x : x + self._total_cols], + ) + + return groups + + def _max_visible_items(self) -> int: + return self._total_cols * self._total_rows + + def _remaining_next_spots(self) -> int: + return self._max_visible_items() - self._prev_row_idx + + def _remaining_prev_spots(self) -> int: + return self._max_visible_items() - self._remaining_next_spots() From 3866834c40d827140e126044935f475584983f01 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 10 Dec 2025 21:54:32 +1100 Subject: [PATCH 11/40] Update --- archinstall/default_profiles/desktop.py | 2 +- .../default_profiles/desktops/hyprland.py | 2 +- .../default_profiles/desktops/labwc.py | 2 +- archinstall/default_profiles/desktops/niri.py | 2 +- archinstall/default_profiles/desktops/sway.py | 2 +- archinstall/default_profiles/server.py | 2 +- .../lib/applications/application_menu.py | 10 +- .../lib/authentication/authentication_menu.py | 2 +- archinstall/lib/bootloader/bootloader_menu.py | 11 +- archinstall/lib/configuration.py | 13 +- archinstall/lib/disk/disk_menu.py | 2 +- archinstall/lib/disk/encryption_menu.py | 2 +- archinstall/lib/disk/partitioning_menu.py | 2 +- archinstall/lib/global_menu.py | 2 +- archinstall/lib/interactions/disk_conf.py | 4 +- archinstall/lib/interactions/general_conf.py | 16 +- .../lib/interactions/manage_users_conf.py | 2 +- archinstall/lib/interactions/network_menu.py | 2 +- archinstall/lib/interactions/system_conf.py | 2 +- archinstall/lib/locale/locale_menu.py | 2 +- archinstall/lib/menu/abstract_menu.py | 2 +- archinstall/lib/menu/helpers.py | 4 +- archinstall/lib/menu/list_manager.py | 2 +- archinstall/lib/menu/menu_helper.py | 2 +- archinstall/lib/mirrors.py | 2 +- archinstall/lib/network/wifi_handler.py | 11 +- archinstall/lib/profile/profile_menu.py | 4 +- archinstall/tui/ui/components.py | 2095 +++++++++-------- archinstall/tui/ui/menu_item.py | 188 -- archinstall/tui/ui/result.py | 2 +- 30 files changed, 1102 insertions(+), 1294 deletions(-) diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 11b8ed0f2d..a09cfbbe1b 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -4,7 +4,7 @@ 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.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType if TYPE_CHECKING: diff --git a/archinstall/default_profiles/desktops/hyprland.py b/archinstall/default_profiles/desktops/hyprland.py index fbaa87cfe7..c565e6d144 100644 --- a/archinstall/default_profiles/desktops/hyprland.py +++ b/archinstall/default_profiles/desktops/hyprland.py @@ -5,7 +5,7 @@ from archinstall.default_profiles.xorg import XorgProfile from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/default_profiles/desktops/labwc.py b/archinstall/default_profiles/desktops/labwc.py index f515c758b9..a53dff6c85 100644 --- a/archinstall/default_profiles/desktops/labwc.py +++ b/archinstall/default_profiles/desktops/labwc.py @@ -5,7 +5,7 @@ from archinstall.default_profiles.xorg import XorgProfile from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/default_profiles/desktops/niri.py b/archinstall/default_profiles/desktops/niri.py index f24e67b043..5258dc50e6 100644 --- a/archinstall/default_profiles/desktops/niri.py +++ b/archinstall/default_profiles/desktops/niri.py @@ -5,7 +5,7 @@ from archinstall.default_profiles.xorg import XorgProfile from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/default_profiles/desktops/sway.py b/archinstall/default_profiles/desktops/sway.py index f164b7f4fa..b7841f2357 100644 --- a/archinstall/default_profiles/desktops/sway.py +++ b/archinstall/default_profiles/desktops/sway.py @@ -5,7 +5,7 @@ from archinstall.default_profiles.xorg import XorgProfile from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index 3c3dd35c60..2d356dc49c 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -4,7 +4,7 @@ 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.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType if TYPE_CHECKING: diff --git a/archinstall/lib/applications/application_menu.py b/archinstall/lib/applications/application_menu.py index 9be995f88c..dd0d785648 100644 --- a/archinstall/lib/applications/application_menu.py +++ b/archinstall/lib/applications/application_menu.py @@ -4,7 +4,7 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models.application import ApplicationConfiguration, Audio, AudioConfiguration, BluetoothConfiguration from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -66,17 +66,13 @@ def _prev_audio(self, item: MenuItem) -> str | None: def select_bluetooth(preset: BluetoothConfiguration | None) -> BluetoothConfiguration | None: - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.no() - - if preset is not None: - group.set_selected_by_value(preset.enabled) - header = tr('Would you like to configure Bluetooth?') + '\n' + preset_val = preset.enabled if preset else False result = Confirmation( header=header, allow_skip=True, + preset=preset_val, ).show() match result.type_: diff --git a/archinstall/lib/authentication/authentication_menu.py b/archinstall/lib/authentication/authentication_menu.py index 3f7846371e..937d2002b9 100644 --- a/archinstall/lib/authentication/authentication_menu.py +++ b/archinstall/lib/authentication/authentication_menu.py @@ -9,7 +9,7 @@ from archinstall.lib.output import FormattedOutput from archinstall.lib.translationhandler import tr from archinstall.lib.utils.util import get_password -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 6f06c7a2a4..46dc0ef54c 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -3,7 +3,7 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..args import arch_config_handler @@ -154,20 +154,17 @@ def _select_removable(self, preset: bool) -> bool: + '\n' ) - group = MenuItemGroup.yes_no() - group.set_focus_by_value(preset) - - result = Selection[bool]( - group, + result = Confirmation( header=prompt, allow_skip=True, + preset=preset, ).show() match result.type_: case ResultType.Skip: return preset case ResultType.Selection: - return result.item() == MenuItem.yes() + return result.get_value() case ResultType.Reset: raise ValueError('Unhandled result type') diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index bd2b727ac1..4e95405ae3 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -5,7 +5,7 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from .args import ArchConfig @@ -55,18 +55,13 @@ def confirm_config(self) -> bool: header = f'{tr("The specified configuration will be applied")}. ' header += tr('Would you like to continue?') + '\n' - group = MenuItemGroup.yes_no() - group.focus_item = MenuItem.yes() - group.set_preview_for_all(lambda x: self.user_config_to_json()) - - result = Selection[bool]( - group, + result = Confirmation( header=header, allow_skip=False, - preview_location='bottom', + preset=True, ).show() - if result.item() != MenuItem.yes(): + if not result.get_value(): return False return True diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py index 7d103f67d7..4f982c224b 100644 --- a/archinstall/lib/disk/disk_menu.py +++ b/archinstall/lib/disk/disk_menu.py @@ -15,7 +15,7 @@ SnapshotType, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..interactions.disk_conf import select_disk_config, select_lvm_config diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 7b463f39f9..6e13a477a5 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -12,7 +12,7 @@ PartitionModification, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 24e1abcbeb..22ed339335 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -19,7 +19,7 @@ Unit, ) from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 5c348fbced..054307b907 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -7,7 +7,7 @@ from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.device import DiskLayoutConfiguration, DiskLayoutType, EncryptionType, FilesystemType, PartitionModification from archinstall.lib.packages import list_available_packages -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from .applications.application_menu import ApplicationMenu from .args import ArchConfig diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 8704462dc3..2d90437000 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -28,7 +28,7 @@ ) from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..output import FormattedOutput @@ -37,7 +37,7 @@ def select_devices(preset: list[BDevice] | None = []) -> list[BDevice]: def _preview_device_selection(item: MenuItem) -> str | None: - device: _DeviceInfo = item.value + device: _DeviceInfo = item.value # type: ignore[assignment] dev = device_handler.get_device(device.path) if dev and dev.partition_infos: diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index aa4c63cbee..96e24a2488 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -7,7 +7,7 @@ from archinstall.lib.models.packages import Repository from archinstall.lib.packages.packages import list_available_packages from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..locale.utils import list_timezones @@ -31,14 +31,10 @@ def ask_ntp(preset: bool = True) -> bool: + '\n' ) - preset_val = MenuItem.yes() if preset else MenuItem.no() - group = MenuItemGroup.yes_no() - group.focus_item = preset_val - - result = Selection[bool]( - group, + result = Confirmation( header=header, allow_skip=True, + preset=preset, ).show() match result.type_: @@ -197,7 +193,7 @@ def ask_additional_packages_to_install( menu_group = MenuItemGroup(items, sort_items=True) menu_group.set_selected_by_value(preset_packages) - result = Selection[AvailablePackage | PackageGroup]( + pck_result = Selection[AvailablePackage | PackageGroup]( menu_group, header=header, allow_reset=True, @@ -208,13 +204,13 @@ def ask_additional_packages_to_install( enable_filter=True, ).show() - match result.type_: + match pck_result.type_: case ResultType.Skip: return preset case ResultType.Reset: return [] case ResultType.Selection: - selected_pacakges = result.get_values() + selected_pacakges = pck_result.get_values() return [pkg.name for pkg in selected_pacakges] diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index 6471980b7b..fc85c41755 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -5,7 +5,7 @@ from archinstall.lib.menu.helpers import Confirmation, Input from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem +from archinstall.tui.ui.menu_item import MenuItem from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index d321b2af3e..512c2c2305 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -5,7 +5,7 @@ from archinstall.lib.menu.helpers import Input, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..menu.list_manager import ListManager diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 7da2994988..60fa396056 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -4,7 +4,7 @@ from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.models import Bootloader from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..hardware import GfxDriver, SysInfo diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index afb0964495..5e7a40bb24 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -2,7 +2,7 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..menu.abstract_menu import AbstractSubMenu diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index f7c60a0038..facfd1d15d 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -6,8 +6,8 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr from archinstall.tui.curses_menu import Tui -from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.types import Chars +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..output import error diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index c2d91c8fdc..93d827e632 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -4,7 +4,6 @@ from textual.validation import ValidationResult, Validator from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItemGroup from archinstall.tui.ui.components import ( ConfirmationScreen, InputScreen, @@ -15,6 +14,7 @@ TableSelectionScreen, tui, ) +from archinstall.tui.ui.menu_item import MenuItemGroup from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') @@ -89,7 +89,7 @@ def __init__( self._allow_reset = allow_reset self._preset = preset - self._group: MenuItemGroup = MenuItemGroup.yes_no() + self._group = MenuItemGroup.yes_no() self._group.set_focus_by_value(preset) def show(self) -> Result[bool]: diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 2cd276dad7..d622082d52 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -4,7 +4,7 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.menu.menu_helper import MenuHelper from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType diff --git a/archinstall/lib/menu/menu_helper.py b/archinstall/lib/menu/menu_helper.py index 6ca3b9531f..47e04fb88d 100644 --- a/archinstall/lib/menu/menu_helper.py +++ b/archinstall/lib/menu/menu_helper.py @@ -1,5 +1,5 @@ from archinstall.lib.output import FormattedOutput -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup class MenuHelper[ValueT]: diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 25b814d83b..813359c7a2 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -5,7 +5,7 @@ from archinstall.lib.menu.helpers import Input, Loading, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from .menu.abstract_menu import AbstractSubMenu diff --git a/archinstall/lib/network/wifi_handler.py b/archinstall/lib/network/wifi_handler.py index 6e5d09d5b0..ede61ef5c7 100644 --- a/archinstall/lib/network/wifi_handler.py +++ b/archinstall/lib/network/wifi_handler.py @@ -9,8 +9,8 @@ from archinstall.lib.network.wpa_supplicant import WpaSupplicantConfig from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItemGroup from archinstall.tui.ui.components import ConfirmationScreen, InputScreen, LoadingScreen, NotifyScreen, TableSelectionScreen, tui +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import Result, ResultType @@ -115,16 +115,19 @@ async def _setup_wifi(self, wifi_iface: str) -> bool: debug(f'Found wifi interface: {wifi_iface}') - async def get_wifi_networks() -> list[WifiNetwork]: + async def get_wifi_networks() -> MenuItemGroup: debug('Scanning Wifi networks') result = self._wpa_cli('scan', wifi_iface) if not result.success: debug(f'Failed to scan wifi networks: {result.error}') - return [] + return MenuItemGroup([]) await sleep(5) - return self._get_scan_results(wifi_iface) + wifi_networks = self._get_scan_results(wifi_iface) + + items = [MenuItem(network.ssid, value=network) for network in wifi_networks] + return MenuItemGroup(items) result = await TableSelectionScreen[WifiNetwork]( header=tr('Select wifi network to connect to'), diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index 369e0ad6af..a657584d8c 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -5,7 +5,7 @@ from archinstall.default_profiles.profile import GreeterType, Profile from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType from ..hardware import GfxDriver @@ -108,7 +108,7 @@ def _select_gfx_driver(self, preset: GfxDriver | None = None) -> GfxDriver | Non preset=False, ).show() - if result.item() == MenuItem.no(): + if result.get_value(): return preset return driver diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 9986442e36..d51d7a4870 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -18,1112 +18,1121 @@ from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr -from archinstall.tui.menu_item import MenuItem, MenuItemGroup +from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import Result, ResultType ValueT = TypeVar('ValueT') class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - self._filter = enable_filter - - self._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - 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 HorizontalScroll(Label('', id='preview_content')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + self._filter = enable_filter + + self._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + 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 HorizontalScroll(Label('', id='preview_content')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = show_frame - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header', id='header') - - 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 HorizontalScroll(Label('', id='preview_content')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = show_frame + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(self._header, classes='header', id='header') + + 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 HorizontalScroll(Label('', id='preview_content')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) - selection_list.highlighted = self._group.get_focused_index() + def _update_options(self, options: list[Selection[MenuItem]]) -> None: + selection_list = self.query_one(SelectionList) + selection_list.clear_options() + selection_list.add_options(options) - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + selection_list.highlighted = self._group.get_focused_index() - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[ValueT]) -> None: - if self._preview_location is None: - return None + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - item = event.selection.value - self._set_preview(item) + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[ValueT]) -> None: - item = event.selection.value - if item not in self._selected_items: - self._selected_items.append(item) - else: - self._selected_items.remove(item) + item: MenuItem = event.selection.value + self._set_preview(item) - def _set_preview(self, item: MenuItem) -> None: - if self._preview_location is None: - return + def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: + item: MenuItem = event.selection.value - preview_widget = self.query_one('#preview_content', Static) + if item not in self._selected_items: + self._selected_items.append(item) + else: + self._selected_items.remove(item) - 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget.update('') + preview_widget = self.query_one('#preview_content', Static) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center top; - } - - .header { - text-align: center; - padding-top: 2; - padding-bottom: 1; - } - - .content-container { - width: 80; - height: 10; - border: none; - background: transparent; - } - - .buttons { - align: center top; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(self._header, classes='header') - - with Center(): - with Vertical(classes='content-container'): - with Horizontal(classes='buttons'): - for item in self._group.items: - yield Button(item.text, id=item.key) - - yield Footer() - - def on_mount(self) -> None: - 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() - else: - button.remove_class('-active') - - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() - - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() - - def on_key(self, event: Key) -> None: - if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .header { + text-align: center; + padding-top: 2; + padding-bottom: 1; + } + + .content-container { + width: 80; + height: 10; + border: none; + background: transparent; + } + + .buttons { + align: center top; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='header') + + with Center(): + with Vertical(classes='content-container'): + with Horizontal(classes='buttons'): + for item in self._group.items: + yield Button(item.text, id=item.key) + + yield Footer() + + def on_mount(self) -> None: + 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() + else: + button.remove_class('-active') + + def action_focus_right(self) -> None: + self._group.focus_next() + self.update_selection() + + def action_focus_left(self) -> None: + self._group.focus_prev() + self.update_selection() + + def on_key(self, event: Key) -> None: + if event.key == 'enter': + item = self._group.focus_item + if not item: + return None + _ = self.dismiss(Result(ResultType.Selection, _item=item)) class NotifyScreen(ConfirmationScreen[ValueT]): - def __init__(self, header: str): - group = MenuItemGroup([MenuItem(tr('Ok'))]) - super().__init__(group, header) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ - InputScreen { - align: center middle; - } - - .input-header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ - - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(self._header, classes='input-header') - - 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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + CSS = """ + InputScreen { + align: center middle; + } + + .input-header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ + + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(self._header, classes='input-header') + + 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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center top; - background: transparent; - } - - .header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .preview { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - HorizontalScroll { - 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; - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header', id='header') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header is None: - with Center(): - with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) - - else: - with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) - yield Rule(orientation='horizontal') - yield Static(self._preview_header, classes='preview', id='preview-header') - yield HorizontalScroll(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: - items = group.items - selected = group.selected_items - - if not items: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(items[0].value.table_data().keys()) # type: ignore[attr-defined] - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for item in items: - row_values = list(item.value.table_data().values()) # type: ignore[attr-defined] - - 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) + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center top; + background: transparent; + } + + .header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .preview { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + HorizontalScroll { + 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; + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header', id='header') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header is None: + with Center(): + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) + + else: + with Vertical(): + yield HorizontalScroll(DataTable(id='data_table')) + yield Rule(orientation='horizontal') + yield Static(self._preview_header, classes='preview', id='preview-header') + yield HorizontalScroll(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + 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 + + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for 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 - 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._current_row_key = event.row_key - item: MenuItem = event.row_key.value - - if not item.preview_action: - return - - preview_widget = self.query_one('#preview_content', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - debug(f'Selected keys: {self._selected_keys}') + if not self._current_row_key: + return + + table = self.query_one(DataTable) + cell_key = table.coordinate_to_cell_key(table.cursor_coordinate) + + if self._current_row_key in self._selected_keys: + self._selected_keys.remove(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[ ]') + else: + self._selected_keys.add(self._current_row_key) + table.update_cell(self._current_row_key, cell_key.column_key, '[X]') + + def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None: + self._current_row_key = event.row_key + item: MenuItem = event.row_key.value # type: ignore[assignment] + + if not item.preview_action: + return + + preview_widget = self.query_one('#preview_content', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + debug(f'Selected keys: {self._selected_keys}') + + if len(self._selected_keys) == 0: + if not self._allow_skip: + return - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[misc] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - _ = self.screen.query('HelpPanel').remove() - else: - _ = self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] - - @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() + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] + + @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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() diff --git a/archinstall/tui/ui/menu_item.py b/archinstall/tui/ui/menu_item.py index 81ce94d543..6ff9e7e064 100644 --- a/archinstall/tui/ui/menu_item.py +++ b/archinstall/tui/ui/menu_item.py @@ -8,8 +8,6 @@ from archinstall.lib.translationhandler import tr -from ..lib.utils.unicode import unicode_ljust - @dataclass class MenuItem: @@ -201,60 +199,10 @@ def get_focused_index(self) -> int | None: return None - def index_focus(self) -> int | None: - if self.focus_item and self.items: - try: - return self.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 - - @property - def size(self) -> int: - return len(self.items) - - def get_max_width(self) -> int: - # use the menu_items not the items here otherwise the preview - # will get resized all the time when a filter is applied - return max([len(self.get_item_text(item)) for item in self._menu_items]) - @cached_property def _max_items_text_width(self) -> int: return max([len(item.text) for item in self._menu_items]) - def get_item_text(self, item: MenuItem) -> str: - if item.is_empty(): - return '' - - max_width = self._max_items_text_width - display_text = item.get_display_value() - - default_text = self._default_suffix(item) - - text = unicode_ljust(str(item.text), max_width, ' ') - spacing = ' ' * 4 - - if display_text: - text = f'{text}{spacing}{display_text}' - elif self._checkmarks: - from .types import Chars - - if item.has_value(): - if item.get_value() is not False: - text = f'{text}{spacing}{Chars.Check}' - else: - text = item.text - - if default_text: - text = f'{text} {default_text}' - - return text.rstrip(' ') - def _default_suffix(self, item: MenuItem) -> str: if self.default_item == item: return tr(' (default)') @@ -277,45 +225,11 @@ def _items_score(self, item: MenuItem) -> int: return 0 return 1 - @property - def filter_pattern(self) -> str: - return self._filter_pattern - - def has_filter(self) -> bool: - return self._filter_pattern != '' - def set_filter_pattern(self, pattern: str) -> None: self._filter_pattern = pattern delattr(self, 'items') # resetting the cache self.focus_first() - def append_filter(self, pattern: str) -> None: - self._filter_pattern += pattern - delattr(self, 'items') # resetting the cache - self.focus_first() - - def reduce_filter(self) -> None: - self._filter_pattern = self._filter_pattern[:-1] - delattr(self, 'items') # resetting the cache - self.focus_first() - - def _reload_focus_item(self) -> None: - if len(self.items) > 0: - if self.focus_item not in self.items: - self.focus_first() - else: - self.focus_item = None - - def is_item_selected(self, item: MenuItem) -> bool: - return item in self.selected_items - - def select_current_item(self) -> None: - if self.focus_item: - if self.focus_item in self.selected_items: - self.selected_items.remove(self.focus_item) - else: - self.selected_items.append(self.focus_item) - def focus_index(self, index: int) -> None: enabled = self.get_enabled_items() self.focus_item = enabled[index] @@ -382,12 +296,6 @@ def _find_next_selectable_item( return None - def is_mandatory_fulfilled(self) -> bool: - for item in self._menu_items: - if item.mandatory and not item.value: - return False - return True - def max_item_width(self) -> int: spaces = [len(str(it.text)) for it in self.items] if spaces: @@ -420,99 +328,3 @@ def is_enabled(self, item: MenuItem) -> bool: return False return True - - -class MenuItemsState: - def __init__( - self, - item_group: MenuItemGroup, - total_cols: int, - total_rows: int, - with_frame: bool, - ) -> None: - self._item_group = item_group - self._total_cols = total_cols - self._total_rows = total_rows - 2 if with_frame else total_rows - - self._prev_row_idx: int = -1 - self._prev_visible_rows: list[int] = [] - self._view_items: list[list[MenuItem]] = [] - - def _determine_focus_row(self) -> int | None: - focus_index = self._item_group.index_focus() - - if focus_index is None: - return None - - row_index = focus_index // self._total_cols - return row_index - - def get_view_items(self) -> list[list[MenuItem]]: - enabled_items = self._item_group.get_enabled_items() - focus_row_idx = self._determine_focus_row() - - if focus_row_idx is None: - return [] - - start, end = 0, 0 - - if len(self._view_items) == 0 or self._prev_row_idx == -1 or focus_row_idx == 0: # initial setup - if focus_row_idx < self._total_rows: - start = 0 - end = self._total_rows - elif focus_row_idx > len(enabled_items) - self._total_rows: - start = len(enabled_items) - self._total_rows - end = len(enabled_items) - else: - start = focus_row_idx - end = focus_row_idx + self._total_rows - elif len(enabled_items) <= self._total_rows: # the view can handle oll items - start = 0 - end = self._total_rows - elif not self._item_group.has_filter() and focus_row_idx in self._prev_visible_rows: # focus is in the same view - self._prev_row_idx = focus_row_idx - return self._view_items - else: - if self._item_group.has_filter(): - start = focus_row_idx - end = focus_row_idx + self._total_rows - else: - delta = focus_row_idx - self._prev_row_idx - - if delta > 0: # cursor is on the bottom most row - start = focus_row_idx - self._total_rows + 1 - end = focus_row_idx + 1 - else: # focus is on the top most row - start = focus_row_idx - end = focus_row_idx + self._total_rows - - self._view_items = self._get_view_items(enabled_items, start, end) - self._prev_visible_rows = list(range(start, end)) - self._prev_row_idx = focus_row_idx - - return self._view_items - - def _get_view_items( - self, - items: list[MenuItem], - start_row: int, - total_rows: int, - ) -> list[list[MenuItem]]: - groups: list[list[MenuItem]] = [] - nr_items = self._total_cols * min(total_rows, len(items)) - - for x in range(start_row, nr_items, self._total_cols): - groups.append( - items[x : x + self._total_cols], - ) - - return groups - - def _max_visible_items(self) -> int: - return self._total_cols * self._total_rows - - def _remaining_next_spots(self) -> int: - return self._max_visible_items() - self._prev_row_idx - - def _remaining_prev_spots(self) -> int: - return self._max_visible_items() - self._remaining_next_spots() diff --git a/archinstall/tui/ui/result.py b/archinstall/tui/ui/result.py index ad6e121470..0d89f23c64 100644 --- a/archinstall/tui/ui/result.py +++ b/archinstall/tui/ui/result.py @@ -2,7 +2,7 @@ from enum import Enum, auto from typing import Self, cast -from archinstall.tui import MenuItem +from archinstall.tui.ui.menu_item import MenuItem class ResultType(Enum): From bfe5423530323e0d682681c74bf0c4293f7f79c7 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 10 Dec 2025 22:12:49 +1100 Subject: [PATCH 12/40] Update --- archinstall/__init__.py | 11 +++-------- .../authentication/authentication_handler.py | 11 +++++------ archinstall/lib/disk/filesystem.py | 8 +++----- archinstall/lib/installer.py | 19 +++++++++---------- archinstall/lib/interactions/general_conf.py | 2 +- archinstall/lib/menu/abstract_menu.py | 3 +-- archinstall/lib/output.py | 4 +--- 7 files changed, 23 insertions(+), 35 deletions(-) diff --git a/archinstall/__init__.py b/archinstall/__init__.py index ebba9510f7..9a221653bd 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -11,14 +11,13 @@ from archinstall.lib.network.wifi_handler import wifi_handler from archinstall.lib.networking import ping from archinstall.lib.packages.packages import check_package_upgrade -from archinstall.tui.ui.components import tui as ttui +from archinstall.tui.ui.components import tui as tui from .lib.hardware import SysInfo from .lib.output import FormattedOutput, debug, error, info, log, warn from .lib.pacman import Pacman from .lib.plugins import load_plugin, plugins from .lib.translationhandler import Language, tr, translation_handler -from .tui.curses_menu import Tui # @archinstall.plugin decorator hook to programmatically add @@ -96,7 +95,7 @@ def main() -> int: _log_sys_info() - ttui.global_header = 'Archinstall' + tui.global_header = 'Archinstall' if not arch_config_handler.args.offline: _check_online() @@ -106,7 +105,7 @@ def main() -> int: new_version = check_version_upgrade() if new_version: - ttui.global_header = f'{ttui.global_header} {new_version}' + tui.global_header = f'{tui.global_header} {new_version}' info(new_version) time.sleep(3) @@ -128,9 +127,6 @@ def run_as_a_module() -> None: except Exception as e: exc = e finally: - # restore the terminal to the original state - Tui.shutdown() - if exc: err = ''.join(traceback.format_exception(exc)) error(err) @@ -152,7 +148,6 @@ def run_as_a_module() -> None: 'Language', 'Pacman', 'SysInfo', - 'Tui', 'arch_config_handler', 'debug', 'disk_layouts', diff --git a/archinstall/lib/authentication/authentication_handler.py b/archinstall/lib/authentication/authentication_handler.py index 918b968b78..1fc46b5554 100644 --- a/archinstall/lib/authentication/authentication_handler.py +++ b/archinstall/lib/authentication/authentication_handler.py @@ -5,9 +5,8 @@ from archinstall.lib.general import SysCommandWorker from archinstall.lib.models.authentication import AuthenticationConfiguration, U2FLoginConfiguration, U2FLoginMethod from archinstall.lib.models.users import User -from archinstall.lib.output import debug +from archinstall.lib.output import debug, info from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -82,7 +81,7 @@ def _configure_u2f_mapping( install_session.pacman.strap('pam-u2f') - Tui.print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')) + print(tr(f'Setting up U2F login: {u2f_config.u2f_login_method.value}')) # https://developers.yubico.com/pam-u2f/ u2f_auth_file = install_session.target / 'etc/u2f_mappings' @@ -92,9 +91,9 @@ def _configure_u2f_mapping( registered_keys: list[str] = [] for user in users: - Tui.print('') - Tui.print(tr('Setting up U2F device for user: {}').format(user.username)) - Tui.print(tr('You may need to enter the PIN and then touch your U2F device to register it')) + print('') + info(tr('Setting up U2F device for user: {}').format(user.username)) + info(tr('You may need to enter the PIN and then touch your U2F device to register it')) cmd = ' '.join( ['arch-chroot', '-S', str(install_session.target), 'pamu2fcfg', '-u', user.username, '-o', f'pam://{hostname}', '-i', f'pam://{hostname}'] diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index c397afa5d6..1d961c32a1 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -5,7 +5,6 @@ from pathlib import Path from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui from ..interactions.general_conf import ask_abort from ..luks import Luks2 @@ -339,15 +338,14 @@ def _final_warning(self) -> bool: # Issue a final warning before we continue with something un-revertable. # We mention the drive one last time, and count from 5 to 0. out = tr('Starting device modifications in ') - Tui.print(out, row=0, endl='', clear_screen=True) + print(out, end='', flush=True) try: countdown = '\n5...4...3...2...1\n' for c in countdown: - Tui.print(c, row=0, endl='') + print(c, end='', flush=True) time.sleep(0.25) except KeyboardInterrupt: - with Tui(): - ask_abort() + ask_abort() return True diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 621573255a..080dbd7dc6 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -32,7 +32,6 @@ from archinstall.lib.models.packages import Repository from archinstall.lib.packages import installed_package from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui from .args import arch_config_handler from .exceptions import DiskError, HardwareIncompatibilityError, RequirementError, ServiceException, SysCallError @@ -133,8 +132,8 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseExceptio # We avoid printing /mnt/ because that might confuse people if they note it down # and then reboot, and an identical log file will be found in the ISO medium anyway. - Tui.print(str(tr('[!] A log file has been created here: {}').format(logger.path))) - Tui.print(tr('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues')) + print(tr('[!] A log file has been created here: {}').format(logger.path)) + print(tr('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues')) # Return None to propagate the exception return None @@ -200,7 +199,7 @@ def _verify_service_stop(self) -> None: # info('Waiting for pacman-init.service to complete.') # while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'): - # time.sleep(1) + # time.sleep(1) if not arch_config_handler.args.skip_wkd: info(tr('Waiting for Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.')) @@ -1047,7 +1046,7 @@ def _get_kernel_params_partition( if root_partition in self._disk_encryption.partitions: # TODO: We need to detect if the encrypted device is a whole disk encryption, - # or simply a partition encryption. Right now we assume it's a partition (and we always have) + # or simply a partition encryption. Right now we assume it's a partition (and we always have) if self._disk_encryption.hsm_device: debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}') @@ -1163,9 +1162,9 @@ def _create_bls_entries( f"""\ # Created by: archinstall # Created on: {self.init_time} - title Arch Linux ({{kernel}}) - linux /vmlinuz-{{kernel}} - initrd /initramfs-{{kernel}}.img + title Arch Linux ({{kernel}}) + linux /vmlinuz-{{kernel}} + initrd /initramfs-{{kernel}}.img options {' '.join(self._get_kernel_params(root))} """, ) @@ -1493,7 +1492,7 @@ def _add_limine_bootloader( f'cmdline: {kernel_params}', ] config_contents += f'\n/Arch Linux ({kernel})\n' - config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' else: entry = [ 'protocol: linux', @@ -1502,7 +1501,7 @@ def _add_limine_bootloader( f'module_path: {path_root}:/initramfs-{kernel}.img', ] config_contents += f'\n/Arch Linux ({kernel})\n' - config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' + config_contents += '\n'.join([f' {it}' for it in entry]) + '\n' config_path.write_text(config_contents) diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 96e24a2488..c8dcd4dc3c 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -293,5 +293,5 @@ def ask_abort() -> None: preset=False, ).show() - if result.item() == MenuItem.yes(): + if result.get_value(): exit(0) diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index facfd1d15d..830d2085df 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -5,7 +5,6 @@ from archinstall.lib.menu.helpers import Selection from archinstall.lib.translationhandler import tr -from archinstall.tui.curses_menu import Tui from archinstall.tui.types import Chars from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -43,7 +42,7 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseExceptio # TODO: skip processing when it comes from a planified exit if exc_type is not None: error(str(exc_value)) - Tui.print('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues') + print('Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues') # Return None to propagate the exception return None diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 2cac13bc1c..52e718dbcf 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -333,6 +333,4 @@ def log( Journald.log(text, level=level) if level != logging.DEBUG: - from archinstall.tui.curses_menu import Tui - - Tui.print(text) + print(text) From 2dd56e277d6ebe2e32c903b32007bd21e207d54f Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 11 Dec 2025 20:15:11 +1100 Subject: [PATCH 13/40] Update --- archinstall/lib/configuration.py | 5 ++ archinstall/lib/menu/helpers.py | 13 +++- archinstall/tui/ui/components.py | 106 ++++++++++++++++++++++--------- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 4e95405ae3..1a9f8007c5 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -55,10 +55,15 @@ def confirm_config(self) -> bool: header = f'{tr("The specified configuration will be applied")}. ' header += tr('Would you like to continue?') + '\n' + group = MenuItemGroup.yes_no() + group.set_preview_for_all(lambda x: self.user_config_to_json()) + result = Confirmation( + group=group, header=header, allow_skip=False, preset=True, + preview_header=tr('Configuration preview'), ).show() if not result.get_value(): diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 93d827e632..6500e07dfb 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -80,17 +80,23 @@ class Confirmation: def __init__( self, header: str, + group: MenuItemGroup | None = None, allow_skip: bool = True, allow_reset: bool = False, preset: bool = False, + preview_header: str | None = None, ): self._header = header self._allow_skip = allow_skip self._allow_reset = allow_reset self._preset = preset + self._preview_header = preview_header - self._group = MenuItemGroup.yes_no() - self._group.set_focus_by_value(preset) + if not group: + self._group = MenuItemGroup.yes_no() + self._group.set_focus_by_value(preset) + else: + self._group = group def show(self) -> Result[bool]: result: Result[bool] = tui.run(self) @@ -98,10 +104,11 @@ def show(self) -> Result[bool]: async def _run(self) -> None: result = await ConfirmationScreen[bool]( - self._group, + group=self._group, header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, + preview_header=self._preview_header, ).run() if result.type_ == ResultType.Reset: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index d51d7a4870..870ea812e9 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -6,7 +6,7 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding -from textual.containers import Center, Horizontal, HorizontalScroll, Vertical +from textual.containers import Center, Horizontal, ScrollableContainer, Vertical from textual.events import Key from textual.screen import Screen from textual.validation import Validator @@ -274,7 +274,7 @@ def compose(self) -> ComposeResult: with Container(): yield option_list yield Rule(orientation=rule_orientation) - yield HorizontalScroll(Label('', id='preview_content')) + yield ScrollableContainer(Label('', id='preview_content')) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -488,7 +488,7 @@ def compose(self) -> ComposeResult: with Container(): yield selection_list yield Rule(orientation=rule_orientation) - yield HorizontalScroll(Label('', id='preview_content')) + yield ScrollableContainer(Label('', id='preview_content')) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -565,19 +565,35 @@ class ConfirmationScreen(BaseScreen[ValueT]): .header { text-align: center; - padding-top: 2; - padding-bottom: 1; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + + background: transparent; } .content-container { - width: 80; - height: 10; + width: 1fr; + height: 1fr; + max-height: 100%; + border: none; background: transparent; } - .buttons { + .preview-header { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + .buttons-container { align: center top; + height: 3; background: transparent; } @@ -602,10 +618,12 @@ def __init__( header: str, allow_skip: bool = False, allow_reset: bool = False, + preview_header: str | None = None, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header + self._preview_header = preview_header async def run(self) -> Result[ValueT]: assert TApp.app @@ -617,18 +635,37 @@ def compose(self) -> ComposeResult: yield Static(self._header, classes='header') - with Center(): + if self._preview_header is None: with Vertical(classes='content-container'): - with Horizontal(classes='buttons'): + 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') + yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content')) + yield Footer() def on_mount(self) -> None: - self.update_selection() + self._update_selection() - def update_selection(self) -> None: + 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) @@ -639,23 +676,34 @@ def update_selection(self) -> None: if button.id == focused.key: button.add_class('-active') button.focus() + + if self._preview_header is not None: + preview = self.query_one('#preview_content', Label) + + if focused.preview_action is None: + preview.update('') + else: + text = focused.preview_action(focused) + if text is not None: + preview.update(text) else: button.remove_class('-active') - def action_focus_right(self) -> None: - self._group.focus_next() - self.update_selection() + def _is_btn_focus(self) -> bool: + buttons = self.query(Button) + for button in buttons: + if button.has_focus: + return True - def action_focus_left(self) -> None: - self._group.focus_prev() - self.update_selection() + return False def on_key(self, event: Key) -> None: if event.key == 'enter': - item = self._group.focus_item - if not item: - return None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + if self._is_btn_focus(): + item = self._group.focus_item + if not item: + return None + _ = self.dismiss(Result(ResultType.Selection, _item=item)) class NotifyScreen(ConfirmationScreen[ValueT]): @@ -804,7 +852,7 @@ class TableSelectionScreen(BaseScreen[ValueT]): background: transparent; } - .preview { + .preview-header { text-align: center; width: 100%; padding-bottom: 1; @@ -813,7 +861,7 @@ class TableSelectionScreen(BaseScreen[ValueT]): background: transparent; } - HorizontalScroll { + ScrollableContainer { align: center top; height: auto; background: transparent; @@ -895,14 +943,14 @@ def compose(self) -> ComposeResult: if self._preview_header is None: with Center(): with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) + yield ScrollableContainer(DataTable(id='data_table')) else: with Vertical(): - yield HorizontalScroll(DataTable(id='data_table')) + yield ScrollableContainer(DataTable(id='data_table')) yield Rule(orientation='horizontal') - yield Static(self._preview_header, classes='preview', id='preview-header') - yield HorizontalScroll(Label('', id='preview_content')) + yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content')) yield Footer() @@ -1010,8 +1058,6 @@ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: if self._multi: - debug(f'Selected keys: {self._selected_keys}') - if len(self._selected_keys) == 0: if not self._allow_skip: return From 014e98f9bf9a6c5c28a499df3034a0c1ede2b7d7 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 11 Dec 2025 20:46:42 +1100 Subject: [PATCH 14/40] Update --- archinstall/tui/ui/components.py | 664 +++++++++++++++---------------- 1 file changed, 332 insertions(+), 332 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 870ea812e9..477788dcce 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -51,27 +51,27 @@ def _compose_header(self) -> ComposeResult: class LoadingScreen(BaseScreen[None]): CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ def __init__( self, @@ -128,65 +128,65 @@ class OptionListScreen(BaseScreen[ValueT]): ] CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ def __init__( self, @@ -202,7 +202,7 @@ def __init__( self._group = group self._header = header self._preview_location = preview_location - self._show_frame = show_frame + self._show_frame = False self._filter = enable_filter self._options = self._get_options() @@ -340,65 +340,65 @@ class SelectListScreen(BaseScreen[ValueT]): ] CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .no-border { + border: none; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-top: 0; + padding-bottom: 0; + padding-left: 1; + padding-right: 1; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ def __init__( self, @@ -414,7 +414,7 @@ def __init__( self._group = group self._header = header self._preview_location = preview_location - self._show_frame = show_frame + self._show_frame = False self._filter = enable_filter self._selected_items: list[MenuItem] = self._group.selected_items @@ -559,58 +559,58 @@ class ConfirmationScreen(BaseScreen[ValueT]): ] CSS = """ - ConfirmationScreen { - align: center top; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - border: none; - background: transparent; - } - - .preview-header { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .buttons-container { - align: center top; - height: 3; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ + ConfirmationScreen { + align: center top; + } + + .header { + text-align: center; + margin-top: 1; + margin-bottom: 0; + width: 100%; + height: auto; + + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + border: none; + background: transparent; + } + + .preview-header { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + .buttons-container { + align: center top; + height: 3; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ def __init__( self, @@ -714,51 +714,51 @@ def __init__(self, header: str): class InputScreen(BaseScreen[str]): CSS = """ - InputScreen { - align: center middle; - } - - .input-header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ + InputScreen { + align: center middle; + } + + .input-header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + margin: 0 0; + color: white; + text-style: bold; + background: transparent; + } + + .container-wrapper { + align: center top; + width: 100%; + height: 1fr; + } + + .input-content { + width: 60; + height: 10; + } + + .input-failure { + color: red; + text-align: center; + } + + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ def __init__( self, @@ -825,68 +825,68 @@ class TableSelectionScreen(BaseScreen[ValueT]): ] CSS = """ - TableSelectionScreen { - align: center top; - background: transparent; - } - - .header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .preview-header { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - 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; - background: transparent; - } - """ + TableSelectionScreen { + align: center top; + background: transparent; + } + + .header { + text-align: center; + width: 100%; + padding-top: 2; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + padding: 0; + + background: transparent; + } + + .preview-header { + text-align: center; + width: 100%; + padding-bottom: 1; + color: white; + text-style: bold; + background: transparent; + } + + 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; + background: transparent; + } + """ def __init__( self, @@ -1081,38 +1081,38 @@ class _AppInstance(App[ValueT]): ] CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + text-style: bold; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ def __init__(self, main: Any) -> None: super().__init__(ansi_color=True) From b83570fc16ddf32ed16ef71e0a8b1951cdc87448 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 11 Dec 2025 21:30:44 +1100 Subject: [PATCH 15/40] update --- archinstall/lib/interactions/system_conf.py | 2 +- archinstall/lib/profile/profile_menu.py | 2 +- archinstall/tui/ui/components.py | 611 +++++++++----------- 3 files changed, 275 insertions(+), 340 deletions(-) diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 60fa396056..fed77d2a7d 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -153,7 +153,7 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None def ask_for_swap(preset: bool = True) -> bool: - prompt = tr('Would you like to use swap on zram?') + '\n' + prompt = tr('Would you like to use swap on zram?') result = Confirmation( header=prompt, diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index a657584d8c..9db477bb9d 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -187,7 +187,7 @@ def select_profile( top_level_profiles = profile_handler.get_top_level_profiles() if header is None: - header = tr('This is a list of pre-programmed default_profiles') + '\n' + header = tr('Select a profile type') items = [MenuItem(p.name, value=p) for p in top_level_profiles] group = MenuItemGroup(items, sort_items=True) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 477788dcce..d8678a76f0 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -51,27 +51,27 @@ def _compose_header(self) -> ComposeResult: class LoadingScreen(BaseScreen[None]): CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - LoadingIndicator { - align: center middle; - } - """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + LoadingIndicator { + align: center middle; + } + """ def __init__( self, @@ -128,65 +128,42 @@ class OptionListScreen(BaseScreen[ValueT]): ] CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ + 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%; + + background: transparent; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ def __init__( self, @@ -256,7 +233,7 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): if self._header: - yield Static(self._header, classes='header', id='header') + yield Static(self._header, classes='header-text', id='header_text') option_list = OptionList(id='option_list_widget') @@ -340,65 +317,42 @@ class SelectListScreen(BaseScreen[ValueT]): ] CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .no-border { - border: none; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-top: 0; - padding-bottom: 0; - padding-left: 1; - padding-right: 1; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ + 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; + + background: transparent; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ def __init__( self, @@ -470,7 +424,7 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): if self._header: - yield Static(self._header, classes='header', id='header') + yield Static(self._header, classes='header-text', id='header_text') selection_list = SelectionList[MenuItem](id='select_list_widget') @@ -559,58 +513,39 @@ class ConfirmationScreen(BaseScreen[ValueT]): ] CSS = """ - ConfirmationScreen { - align: center top; - } - - .header { - text-align: center; - margin-top: 1; - margin-bottom: 0; - width: 100%; - height: auto; - - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - border: none; - background: transparent; - } - - .preview-header { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .buttons-container { - align: center top; - height: 3; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ + 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: #1793D1; + color: white; + border: none; + text-style: none; + } + """ def __init__( self, @@ -633,7 +568,7 @@ async def run(self) -> Result[ValueT]: def compose(self) -> ComposeResult: yield from self._compose_header() - yield Static(self._header, classes='header') + yield Static(self._header, classes='header-text', id='header_text') if self._preview_header is None: with Vertical(classes='content-container'): @@ -714,51 +649,40 @@ def __init__(self, header: str): class InputScreen(BaseScreen[str]): CSS = """ - InputScreen { - align: center middle; - } - - .input-header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - margin: 0 0; - color: white; - text-style: bold; - background: transparent; - } - - .container-wrapper { - align: center top; - width: 100%; - height: 1fr; - } - - .input-content { - width: 60; - height: 10; - } - - .input-failure { - color: red; - text-align: center; - } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - """ + 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 { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + """ def __init__( self, @@ -787,7 +711,7 @@ async def run(self) -> Result[str]: def compose(self) -> ComposeResult: yield from self._compose_header() - yield Static(self._header, classes='input-header') + yield Static(self._header, classes='header-text', id='header_text') with Center(classes='container-wrapper'): with Vertical(classes='input-content'): @@ -825,68 +749,48 @@ class TableSelectionScreen(BaseScreen[ValueT]): ] CSS = """ - TableSelectionScreen { - align: center top; - background: transparent; - } - - .header { - text-align: center; - width: 100%; - padding-top: 2; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-bottom: 2; - padding: 0; - - background: transparent; - } - - .preview-header { - text-align: center; - width: 100%; - padding-bottom: 1; - color: white; - text-style: bold; - background: transparent; - } - - 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; - background: transparent; - } - """ + TableSelectionScreen { + align: center top; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-bottom: 2; + + background: transparent; + } + + 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; + background: transparent; + } + """ def __init__( self, @@ -932,7 +836,7 @@ def compose(self) -> ComposeResult: yield from self._compose_header() if self._header: - yield Static(self._header, classes='header', id='header') + yield Static(self._header, classes='header-text', id='header_text') with Vertical(classes='content-container'): if self._loading_header: @@ -1011,7 +915,7 @@ def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> else: row_values.insert(0, '[ ]') - row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] + row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] if item in selected: self._selected_keys.add(row_key) @@ -1070,7 +974,7 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: _ = self.dismiss( Result[ValueT]( ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] + _item=event.row_key.value, # type: ignore[arg-type] ) ) @@ -1081,38 +985,69 @@ class _AppInstance(App[ValueT]): ] CSS = """ - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - text-style: bold; - } - - Footer { - dock: bottom; - background: #184956; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ + Screen { + color: white; + } + + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + 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; + } + + Footer { + dock: bottom; + background: #184956; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ def __init__(self, main: Any) -> None: super().__init__(ansi_color=True) @@ -1138,7 +1073,7 @@ async def _run_worker(self) -> None: except Exception as err: debug(f'Error while running main app: {err}') # this will terminate the textual app and return the exception - self.exit(err) # type: ignore[arg-type] + self.exit(err) # type: ignore[arg-type] @work async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: From 77fbcd0d7868c1c4ece16cdcc952a1d8c2f4b982 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 11 Dec 2025 21:31:44 +1100 Subject: [PATCH 16/40] update --- archinstall/tui/ui/components.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index d8678a76f0..3b7dcda674 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -989,6 +989,10 @@ class _AppInstance(App[ValueT]): color: white; } + Input { + color: white; + } + .app-header { dock: top; height: auto; From 4b2b1b396bc9bbda0f125331593c75bf54e77778 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 11 Dec 2025 21:34:58 +1100 Subject: [PATCH 17/40] update --- archinstall/tui/ui/components.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 3b7dcda674..aade9988c6 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -668,20 +668,6 @@ class InputScreen(BaseScreen[str]): color: red; text-align: center; } - - Input { - border: solid $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } """ def __init__( @@ -1025,11 +1011,24 @@ class _AppInstance(App[ValueT]): background: transparent; } - .no-border { border: none; } + Input { + border: solid $accent; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid $primary; + } + Footer { dock: bottom; background: #184956; From 3c877a9ebaea8acf87806c46e3a2e9707cab9fb2 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:08:50 +1100 Subject: [PATCH 18/40] Update --- archinstall/lib/bootloader/bootloader_menu.py | 4 ++-- archinstall/tui/ui/components.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/archinstall/lib/bootloader/bootloader_menu.py b/archinstall/lib/bootloader/bootloader_menu.py index 46dc0ef54c..d310f8a94c 100644 --- a/archinstall/lib/bootloader/bootloader_menu.py +++ b/archinstall/lib/bootloader/bootloader_menu.py @@ -173,7 +173,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options = [] hidden_options = [] default = None - header = None + header = tr('Select bootloader to install') if arch_config_handler.args.skip_boot: default = Bootloader.NO_BOOTLOADER @@ -184,7 +184,7 @@ def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: options += [Bootloader.Grub, Bootloader.Limine] if not default: default = Bootloader.Grub - header = tr('UEFI is not detected and some options are disabled') + header += '\n' + tr('UEFI is not detected and some options are disabled') else: options += [b for b in Bootloader if b not in hidden_options] if not default: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index aade9988c6..4c380d9118 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -751,9 +751,18 @@ class TableSelectionScreen(BaseScreen[ValueT]): background: transparent; } - ScrollableContainer { + .table-container { + align: center top; + width: 1fr; + height: 1fr; + + background: transparent; + } + + .table-container ScrollableContainer { align: center top; height: auto; + background: transparent; } @@ -832,11 +841,11 @@ def compose(self) -> ComposeResult: if self._preview_header is None: with Center(): - with Vertical(): + with Vertical(classes='table-container'): yield ScrollableContainer(DataTable(id='data_table')) else: - with Vertical(): + with Vertical(classes='table-container'): yield ScrollableContainer(DataTable(id='data_table')) yield Rule(orientation='horizontal') yield Static(self._preview_header, classes='preview-header', id='preview-header') @@ -1031,7 +1040,8 @@ class _AppInstance(App[ValueT]): Footer { dock: bottom; - background: #184956; + width: 100%; + background: transparent; color: white; height: 1; } From aafd4bae0499ea2830268c70d22e2430879a5090 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:17:09 +1100 Subject: [PATCH 19/40] Update --- archinstall/tui/ui/components.py | 2090 +++++++++++++++--------------- 1 file changed, 1045 insertions(+), 1045 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 4c380d9118..e4af11cf96 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -25,1108 +25,1108 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .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%; - - background: transparent; - } - - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - 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._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .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%; + + background: transparent; + } + + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + 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._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-left: 2; - - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - background: transparent; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - scrollbar-size-vertical: 1; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = False - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options: list[Selection[MenuItem]] = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + background: transparent; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + scrollbar-size-vertical: 1; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) - def _update_options(self, options: list[Selection[MenuItem]]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + 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() + selection_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: - if self._preview_location is None: - return None + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None - item: MenuItem = event.selection.value - self._set_preview(item) + item: MenuItem = event.selection.value + self._set_preview(item) - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: - item: MenuItem = event.selection.value + 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) + 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Static) - 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 + 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('') + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center top; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - border: none; - background: transparent; - } - - .buttons-container { - align: center top; - height: 3; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - 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 from self._compose_header() - - yield Static(self._header, classes='header-text', id='header_text') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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 None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + border: none; + background: transparent; + } + + .buttons-container { + align: center top; + height: 3; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + 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 from self._compose_header() + + yield Static(self._header, classes='header-text', id='header_text') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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 None + _ = 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) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) 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; - } - """ - - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + 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; + } + """ + + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center 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; - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header-text', id='header_text') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - 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 - - value = items[0].value - if not value: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(value.table_data().keys()) - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for 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._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', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center 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; + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + 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 + + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for 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._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', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - Screen { - color: white; - } - - Input { - color: white; - } - - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - 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 $accent; - background: transparent; - height: 3; - } - - Input .input--cursor { - color: white; - } - - Input:focus { - border: solid $primary; - } - - Footer { - dock: bottom; - width: 100%; - background: transparent; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - _ = self.screen.query('HelpPanel').remove() - else: - _ = self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] - - @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() + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + Screen { + color: white; + } + + Input { + color: white; + } + + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + 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: tall #000000; + background: transparent; + height: 3; + } + + Input .input--cursor { + color: white; + } + + Input:focus { + border: solid #1793D1; + } + + Footer { + dock: bottom; + width: 100%; + background: transparent; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] + + @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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() From bd4398ce7c0141908f2c6ef2520f780d2bc6e177 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:25:09 +1100 Subject: [PATCH 20/40] Update --- archinstall/tui/ui/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index e4af11cf96..2e1981acc8 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -1025,7 +1025,7 @@ class _AppInstance(App[ValueT]): } Input { - border: tall #000000; + border: solid gray 50%; background: transparent; height: 3; } From 95316d0fa23c743834888d623ee22f4fcc63294c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:37:29 +1100 Subject: [PATCH 21/40] Update --- archinstall/tui/ui/components.py | 1520 +++++++++++++++--------------- 1 file changed, 768 insertions(+), 752 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 2e1981acc8..f103531501 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -25,32 +25,32 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ + CSS = """ LoadingScreen { align: center middle; background: transparent; @@ -73,61 +73,61 @@ class LoadingScreen(BaseScreen[None]): } """ - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ + """ + List single selection menu + """ - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] - CSS = """ + CSS = """ OptionListScreen { align-horizontal: center; align-vertical: middle; @@ -150,6 +150,8 @@ class OptionListScreen(BaseScreen[ValueT]): height: auto; max-height: 100%; + padding-bottom: 3; + background: transparent; } @@ -160,163 +162,164 @@ class OptionListScreen(BaseScreen[ValueT]): max-height: 1fr; scrollbar-size-vertical: 1; + padding-bottom: 3; background: transparent; } """ - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - 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._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + 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._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ SelectListScreen { align-horizontal: center; align-vertical: middle; @@ -340,6 +343,8 @@ class SelectListScreen(BaseScreen[ValueT]): min-width: 15%; max-height: 1fr; + padding-bottom: 3; + background: transparent; } @@ -349,170 +354,171 @@ class SelectListScreen(BaseScreen[ValueT]): max-height: 1fr; scrollbar-size-vertical: 1; + padding-bottom: 3; background: transparent; } """ - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = False - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options: list[Selection[MenuItem]] = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) - def _update_options(self, options: list[Selection[MenuItem]]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + 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() + selection_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: - if self._preview_location is None: - return None + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None - item: MenuItem = event.selection.value - self._set_preview(item) + item: MenuItem = event.selection.value + self._set_preview(item) - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: - item: MenuItem = event.selection.value + 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) + 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Static) - 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 + 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('') + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ ConfirmationScreen { align: center top; } @@ -547,108 +553,108 @@ class ConfirmationScreen(BaseScreen[ValueT]): } """ - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - 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 from self._compose_header() - - yield Static(self._header, classes='header-text', id='header_text') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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 None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + 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 from self._compose_header() + + yield Static(self._header, classes='header-text', id='header_text') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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 None + _ = 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) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ + CSS = """ InputScreen { align: center middle; } @@ -670,71 +676,71 @@ class InputScreen(BaseScreen[str]): } """ - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] - CSS = """ + CSS = """ TableSelectionScreen { align: center top; background: transparent; @@ -787,205 +793,214 @@ class TableSelectionScreen(BaseScreen[ValueT]): } """ - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header-text', id='header_text') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - 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 - - value = items[0].value - if not value: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(value.table_data().keys()) - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for 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._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', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + 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 + + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for 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._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', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] - CSS = """ + CSS = """ Screen { color: white; } - Input { - color: white; + * { + /* Set a consistent, thin vertical scrollbar */ + scrollbar-size: 0 1; + + /* Standard colors */ + scrollbar-color: #6699FF; /* Light Blue Thumb */ + scrollbar-background: #111111; /* Very Dark Gray Track */ + + /* Hover colors */ + scrollbar-color-hover: #FFFFFF; /* White Thumb on hover */ + scrollbar-background-hover: #333333; } .app-header { @@ -1028,6 +1043,7 @@ class _AppInstance(App[ValueT]): border: solid gray 50%; background: transparent; height: 3; + color: white; } Input .input--cursor { @@ -1062,71 +1078,71 @@ class _AppInstance(App[ValueT]): } """ - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel + 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()) + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) - def on_mount(self) -> None: - self._run_worker() + def on_mount(self) -> None: + self._run_worker() - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] - @work - async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: - return await self.push_screen_wait(screen) + @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() + async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: + return await self._show_async(screen).wait() class TApp: - app: _AppInstance[Any] | None = None + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() From 41be8068bf3cc63d91e4e6db6add233acb2b8821 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:46:18 +1100 Subject: [PATCH 22/40] Update --- archinstall/tui/ui/components.py | 1506 +++++++++++++++--------------- 1 file changed, 752 insertions(+), 754 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index f103531501..949784f061 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -25,32 +25,32 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ + CSS = """ LoadingScreen { align: center middle; background: transparent; @@ -73,61 +73,61 @@ class LoadingScreen(BaseScreen[None]): } """ - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ + """ + List single selection menu + """ - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] - CSS = """ + CSS = """ OptionListScreen { align-horizontal: center; align-vertical: middle; @@ -168,158 +168,158 @@ class OptionListScreen(BaseScreen[ValueT]): } """ - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - 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._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + 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._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ SelectListScreen { align-horizontal: center; align-vertical: middle; @@ -360,165 +360,165 @@ class SelectListScreen(BaseScreen[ValueT]): } """ - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = False - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options: list[Selection[MenuItem]] = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) - def _update_options(self, options: list[Selection[MenuItem]]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + 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() + selection_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: - if self._preview_location is None: - return None + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None - item: MenuItem = event.selection.value - self._set_preview(item) + item: MenuItem = event.selection.value + self._set_preview(item) - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: - item: MenuItem = event.selection.value + 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) + 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Static) - 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 + 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('') + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ ConfirmationScreen { align: center top; } @@ -553,108 +553,108 @@ class ConfirmationScreen(BaseScreen[ValueT]): } """ - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - 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 from self._compose_header() - - yield Static(self._header, classes='header-text', id='header_text') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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 None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + 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 from self._compose_header() + + yield Static(self._header, classes='header-text', id='header_text') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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 None + _ = 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) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) class InputScreen(BaseScreen[str]): - CSS = """ + CSS = """ InputScreen { align: center middle; } @@ -676,71 +676,71 @@ class InputScreen(BaseScreen[str]): } """ - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] - CSS = """ + CSS = """ TableSelectionScreen { align: center top; background: transparent; @@ -793,212 +793,210 @@ class TableSelectionScreen(BaseScreen[ValueT]): } """ - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header-text', id='header_text') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - 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 - - value = items[0].value - if not value: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(value.table_data().keys()) - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for 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._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', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + 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 + + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for 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._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', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - ] + BINDINGS: ClassVar = [ + Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + ] - CSS = """ + CSS = """ Screen { color: white; } * { - /* Set a consistent, thin vertical scrollbar */ - scrollbar-size: 0 1; + scrollbar-size: 5 5; - /* Standard colors */ scrollbar-color: #6699FF; /* Light Blue Thumb */ scrollbar-background: #111111; /* Very Dark Gray Track */ + scrollbar-corner-color: transparent; - /* Hover colors */ scrollbar-color-hover: #FFFFFF; /* White Thumb on hover */ scrollbar-background-hover: #333333; } @@ -1078,71 +1076,71 @@ class _AppInstance(App[ValueT]): } """ - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel + 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()) + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) - def on_mount(self) -> None: - self._run_worker() + def on_mount(self) -> None: + self._run_worker() - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] - @work - async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: - return await self.push_screen_wait(screen) + @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() + async def show(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: + return await self._show_async(screen).wait() class TApp: - app: _AppInstance[Any] | None = None + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() From 648120eb97f8e3176dba85f92b783b6ef7685a9c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:48:49 +1100 Subject: [PATCH 23/40] Update --- archinstall/tui/ui/components.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 949784f061..9ab05a7886 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -161,7 +161,6 @@ class OptionListScreen(BaseScreen[ValueT]): min-width: 15%; max-height: 1fr; - scrollbar-size-vertical: 1; padding-bottom: 3; background: transparent; @@ -353,7 +352,6 @@ class SelectListScreen(BaseScreen[ValueT]): height: auto; max-height: 1fr; - scrollbar-size-vertical: 1; padding-bottom: 3; background: transparent; @@ -991,8 +989,6 @@ class _AppInstance(App[ValueT]): } * { - scrollbar-size: 5 5; - scrollbar-color: #6699FF; /* Light Blue Thumb */ scrollbar-background: #111111; /* Very Dark Gray Track */ scrollbar-corner-color: transparent; From 63d1de08f8e16d055e9bf54068031ce4634aba7c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:51:49 +1100 Subject: [PATCH 24/40] Update --- archinstall/tui/ui/components.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 9ab05a7886..1e68eac143 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -989,12 +989,11 @@ class _AppInstance(App[ValueT]): } * { - scrollbar-color: #6699FF; /* Light Blue Thumb */ - scrollbar-background: #111111; /* Very Dark Gray Track */ - scrollbar-corner-color: transparent; + scrollbar-size: 0 2; - scrollbar-color-hover: #FFFFFF; /* White Thumb on hover */ - scrollbar-background-hover: #333333; + /* Use high contrast colors */ + scrollbar-color: white; + scrollbar-background: black; } .app-header { From 3a54663552c46961817050dba102a6f32ca741e9 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:55:42 +1100 Subject: [PATCH 25/40] Update --- archinstall/tui/ui/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 1e68eac143..19a9b82874 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -981,6 +981,7 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: class _AppInstance(App[ValueT]): BINDINGS: ClassVar = [ Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), + Binding('alt+h', 'trigger_help', 'Show/Hide help', show=True), ] CSS = """ @@ -989,7 +990,7 @@ class _AppInstance(App[ValueT]): } * { - scrollbar-size: 0 2; + scrollbar-size: 0 1; /* Use high contrast colors */ scrollbar-color: white; From abad73bd8d4e06fb59953ceab397d5294a1fb646 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 21:57:43 +1100 Subject: [PATCH 26/40] Update --- archinstall/tui/ui/components.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 19a9b82874..e6a29600e6 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -982,6 +982,8 @@ class _AppInstance(App[ValueT]): BINDINGS: ClassVar = [ Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), Binding('alt+h', 'trigger_help', 'Show/Hide help', show=True), + Binding('shift+h', 'trigger_help', 'Show/Hide help', show=True), + Binding('ctrl+u', 'trigger_help', 'Show/Hide help', show=True), ] CSS = """ From fdb002fa31471d86e1d5e99bc6ec4c21ba1a3b15 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 22:09:21 +1100 Subject: [PATCH 27/40] Update --- archinstall/tui/ui/components.py | 2109 +++++++++++++++--------------- 1 file changed, 1053 insertions(+), 1056 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index e6a29600e6..a8bd28fb28 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -25,1120 +25,1117 @@ class BaseScreen(Screen[Result[ValueT]]): - BINDINGS: ClassVar = [ - Binding('escape', 'cancel_operation', 'Cancel', show=False), - Binding('ctrl+c', 'reset_operation', 'Reset', show=False), - ] + BINDINGS: ClassVar = [ + Binding('escape', 'cancel_operation', 'Cancel', show=False), + Binding('ctrl+c', 'reset_operation', 'Reset', show=False), + ] - def __init__(self, allow_skip: bool = False, allow_reset: bool = False): - super().__init__() - self._allow_skip = allow_skip - self._allow_reset = allow_reset + 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)) + 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)) + async def action_reset_operation(self) -> None: + if self._allow_reset: + _ = self.dismiss(Result(ResultType.Reset)) - def _compose_header(self) -> ComposeResult: - """Compose the app header if global header text is available""" - if tui.global_header: - yield Static(tui.global_header, classes='app-header') + def _compose_header(self) -> ComposeResult: + """Compose the app header if global header text is available""" + if tui.global_header: + yield Static(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): - CSS = """ - LoadingScreen { - align: center middle; - background: transparent; - } - - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } - - .header { - text-align: center; - margin-bottom: 1; - } - - 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[None]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Static(self._header, classes='header') - yield Center(LoadingIndicator()) - - yield Footer() - - def on_mount(self) -> None: - if self._data_callback: - self._exec_callback() - else: - self.set_timer(self._timer, self.action_pop_screen) - - @work(thread=True) - def _exec_callback(self) -> None: - assert self._data_callback - result = self._data_callback() - _ = self.dismiss(Result(ResultType.Selection, _data=result)) - - def action_pop_screen(self) -> None: - _ = self.dismiss() + CSS = """ + LoadingScreen { + align: center middle; + background: transparent; + } + + .dialog { + align: center middle; + width: 100%; + border: none; + background: transparent; + } + + .header { + text-align: center; + margin-bottom: 1; + } + + 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[None]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Center(): + with Vertical(classes='dialog'): + if self._header: + yield Static(self._header, classes='header') + yield Center(LoadingIndicator()) + + yield Footer() + + def on_mount(self) -> None: + if self._data_callback: + self._exec_callback() + else: + self.set_timer(self._timer, self.action_pop_screen) + + @work(thread=True) + def _exec_callback(self) -> None: + assert self._data_callback + result = self._data_callback() + _ = self.dismiss(Result(ResultType.Selection, _data=result)) + + def action_pop_screen(self) -> None: + _ = self.dismiss() class OptionListScreen(BaseScreen[ValueT]): - """ - List single selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - ] - - CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .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; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - 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._options = self._get_options() - - def action_cursor_down(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_down() - - def action_cursor_up(self) -> None: - option_list = self.query_one(OptionList) - if option_list.has_focus: - option_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_options(self) -> list[Option]: - options = [] - - for item in self._group.get_enabled_items(): - disabled = True if item.read_only else False - options.append(Option(item.text, id=item.get_id(), disabled=disabled)) - - return options - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(OptionList).focus() - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_options() - self._update_options(filtered_options) - - def _update_options(self, options: list[Option]) -> None: - option_list = self.query_one(OptionList) - option_list.clear_options() - option_list.add_options(options) - - option_list.highlighted = self._group.get_focused_index() + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .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; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + 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._options = self._get_options() + + def action_cursor_down(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_down() + + def action_cursor_up(self) -> None: + option_list = self.query_one(OptionList) + if option_list.has_focus: + option_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_options(self) -> list[Option]: + options = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + options.append(Option(item.text, id=item.get_id(), disabled=disabled)) + + return options + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(OptionList).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_options() + self._update_options(filtered_options) + + def _update_options(self, options: list[Option]) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + option_list.add_options(options) + + option_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item.get_id()) + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) - def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: - selected_option = event.option - if selected_option.id is not None: - item = self._group.find_by_id(selected_option.id) - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + selected_option = event.option + if selected_option.id is not None: + item = self._group.find_by_id(selected_option.id) + _ = self.dismiss(Result(ResultType.Selection, _item=item)) - def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: - if event.option.id: - self._set_preview(event.option.id) + def on_option_list_option_highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.id: + self._set_preview(event.option.id) - def _set_preview(self, item_id: str) -> None: - if self._preview_location is None: - return None + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None - preview_widget = self.query_one('#preview_content', Static) - item = self._group.find_by_id(item_id) + preview_widget = self.query_one('#preview_content', Static) + 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 + 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('') + preview_widget.update('') class SelectListScreen(BaseScreen[ValueT]): - """ - Multi selection menu - """ - - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('/', 'search', 'Search', show=False), - Binding('enter', '', 'Search', show=False), - ] - - CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - margin-top: 2; - margin-left: 2; - - background: transparent; - } - - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; - - padding-bottom: 3; - - background: transparent; - } - - SelectionList { - width: auto; - height: auto; - max-height: 1fr; - - padding-bottom: 3; - - background: transparent; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str | None = None, - allow_skip: bool = False, - allow_reset: bool = False, - preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, - enable_filter: bool = False, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - self._preview_location = preview_location - self._show_frame = False - self._filter = enable_filter - - self._selected_items: list[MenuItem] = self._group.selected_items - self._options: list[Selection[MenuItem]] = self._get_selections() - - def action_cursor_down(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_down() - - def action_cursor_up(self) -> None: - select_list = self.query_one(OptionList) - if select_list.has_focus: - select_list.action_cursor_up() - - def action_search(self) -> None: - if self.query_one(OptionList).has_focus: - if self._filter: - self._handle_search_action() - - @override - def action_cancel_operation(self) -> None: - if self._filter and self.query_one(Input).has_focus: - self._handle_search_action() - else: - super().action_cancel_operation() - - def _handle_search_action(self) -> None: - search_input = self.query_one(Input) - - if search_input.has_focus: - self.query_one(OptionList).focus() - else: - search_input.focus() - - async def run(self) -> Result[ValueT]: - assert TApp.app - return await TApp.app.show(self) - - def _get_selections(self) -> list[Selection[MenuItem]]: - selections = [] - - for item in self._group.get_enabled_items(): - is_selected = item in self._selected_items - selection = Selection(item.text, item, is_selected) - selections.append(selection) - - return selections - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - with Vertical(classes='content-container'): - if self._header: - yield Static(self._header, classes='header-text', id='header_text') + """ + Multi selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + Binding('enter', '', 'Search', show=False), + ] + + CSS = """ + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + margin-top: 2; + margin-left: 2; + + background: transparent; + } + + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + + SelectionList { + width: auto; + height: auto; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + show_frame: bool = False, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._show_frame = False + self._filter = enable_filter + + self._selected_items: list[MenuItem] = self._group.selected_items + self._options: list[Selection[MenuItem]] = self._get_selections() + + def action_cursor_down(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_down() + + def action_cursor_up(self) -> None: + select_list = self.query_one(OptionList) + if select_list.has_focus: + select_list.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(OptionList).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(OptionList).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_selections(self) -> list[Selection[MenuItem]]: + selections = [] + + for item in self._group.get_enabled_items(): + is_selected = item in self._selected_items + selection = Selection(item.text, item, is_selected) + selections.append(selection) + + return selections + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Static(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')) - - if self._filter: - yield Input(placeholder='/filter', id='filter-input') - - yield Footer() - - def on_mount(self) -> None: - self._update_options(self._options) - self.query_one(SelectionList).focus() - - def on_key(self, event: Key) -> None: - if self.query_one(SelectionList).has_focus: - if event.key == 'enter': - _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) - - def on_input_changed(self, event: Input.Changed) -> None: - search_term = event.value.lower() - self._group.set_filter_pattern(search_term) - filtered_options = self._get_selections() - self._update_options(filtered_options) + 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')) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_options(self._options) + self.query_one(SelectionList).focus() + + def on_key(self, event: Key) -> None: + if self.query_one(SelectionList).has_focus: + if event.key == 'enter': + _ = self.dismiss(Result(ResultType.Selection, _item=self._selected_items)) + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_options = self._get_selections() + self._update_options(filtered_options) - def _update_options(self, options: list[Selection[MenuItem]]) -> None: - selection_list = self.query_one(SelectionList) - selection_list.clear_options() - selection_list.add_options(options) + 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() + selection_list.highlighted = self._group.get_focused_index() - if focus_item := self._group.focus_item: - self._set_preview(focus_item) + if focus_item := self._group.focus_item: + self._set_preview(focus_item) - def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: - if self._preview_location is None: - return None + def on_selection_list_selection_highlighted(self, event: SelectionList.SelectionHighlighted[MenuItem]) -> None: + if self._preview_location is None: + return None - item: MenuItem = event.selection.value - self._set_preview(item) + item: MenuItem = event.selection.value + self._set_preview(item) - def on_selection_list_selection_toggled(self, event: SelectionList.SelectionToggled[MenuItem]) -> None: - item: MenuItem = event.selection.value + 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) + 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 + def _set_preview(self, item: MenuItem) -> None: + if self._preview_location is None: + return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Static) - 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 + 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('') + preview_widget.update('') class ConfirmationScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('l', 'focus_right', 'Focus right', show=False), - Binding('h', 'focus_left', 'Focus left', show=False), - Binding('right', 'focus_right', 'Focus right', show=False), - Binding('left', 'focus_left', 'Focus left', show=False), - ] - - CSS = """ - ConfirmationScreen { - align: center top; - } - - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; - - border: none; - background: transparent; - } - - .buttons-container { - align: center top; - height: 3; - background: transparent; - } - - Button { - width: 4; - height: 3; - background: transparent; - margin: 0 1; - } - - Button.-active { - background: #1793D1; - color: white; - border: none; - text-style: none; - } - """ - - def __init__( - self, - group: MenuItemGroup, - header: str, - allow_skip: bool = False, - allow_reset: bool = False, - preview_header: str | None = None, - ): - super().__init__(allow_skip, allow_reset) - self._group = group - self._header = header - 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 from self._compose_header() - - yield Static(self._header, classes='header-text', id='header_text') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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 None - _ = self.dismiss(Result(ResultType.Selection, _item=item)) + BINDINGS: ClassVar = [ + Binding('l', 'focus_right', 'Focus right', show=False), + Binding('h', 'focus_left', 'Focus left', show=False), + Binding('right', 'focus_right', 'Focus right', show=False), + Binding('left', 'focus_left', 'Focus left', show=False), + ] + + CSS = """ + ConfirmationScreen { + align: center top; + } + + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; + + border: none; + background: transparent; + } + + .buttons-container { + align: center top; + height: 3; + background: transparent; + } + + Button { + width: 4; + height: 3; + background: transparent; + margin: 0 1; + } + + Button.-active { + background: #1793D1; + color: white; + border: none; + text-style: none; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str, + allow_skip: bool = False, + allow_reset: bool = False, + preview_header: str | None = None, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + 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 from self._compose_header() + + yield Static(self._header, classes='header-text', id='header_text') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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 None + _ = 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) + def __init__(self, header: str): + group = MenuItemGroup([MenuItem(tr('Ok'))]) + super().__init__(group, header) 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; - } - """ - - 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, - ): - 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 - - async def run(self) -> Result[str]: - assert TApp.app - return await TApp.app.show(self) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - yield Static(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 Static('', classes='input-failure', id='input-failure') - - yield Footer() - - def on_mount(self) -> None: - input_field = self.query_one('#main_input', Input) - input_field.focus() - - def on_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', Static).update(failure_out) - else: - _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) + 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; + } + """ + + 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, + ): + 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 + + async def run(self) -> Result[str]: + assert TApp.app + return await TApp.app.show(self) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + yield Static(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 Static('', classes='input-failure', id='input-failure') + + yield Footer() + + def on_mount(self) -> None: + input_field = self.query_one('#main_input', Input) + input_field.focus() + + def on_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', Static).update(failure_out) + else: + _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) class TableSelectionScreen(BaseScreen[ValueT]): - BINDINGS: ClassVar = [ - Binding('j', 'cursor_down', 'Down', show=False), - Binding('k', 'cursor_up', 'Up', show=False), - Binding('space', 'toggle_selection', 'Toggle Selection', show=False), - ] - - CSS = """ - TableSelectionScreen { - align: center 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; - 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_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_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) - - def action_cursor_down(self) -> None: - table = self.query_one(DataTable) - next_row = min(table.cursor_row + 1, len(table.rows) - 1) - table.move_cursor(row=next_row, column=table.cursor_column or 0) - - def action_cursor_up(self) -> None: - table = self.query_one(DataTable) - prev_row = max(table.cursor_row - 1, 0) - table.move_cursor(row=prev_row, column=table.cursor_column or 0) - - @override - def compose(self) -> ComposeResult: - yield from self._compose_header() - - if self._header: - yield Static(self._header, classes='header-text', id='header_text') - - with Vertical(classes='content-container'): - if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') - - yield LoadingIndicator(id='loader') - - if self._preview_header 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') - yield Static(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content')) - - yield Footer() - - def on_mount(self) -> None: - 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: - try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass - - 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 - - value = items[0].value - if not value: - _ = self.dismiss(Result(ResultType.Selection)) - return - - cols = list(value.table_data().keys()) - - if self._multi: - cols.insert(0, ' ') - - table.add_columns(*cols) - - for 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._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', Static) - - maybe_preview = item.preview_action(item) - if maybe_preview is not None: - preview_widget.update(maybe_preview) - return - - preview_widget.update('') - - def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: - if self._multi: - if len(self._selected_keys) == 0: - if not self._allow_skip: - return - - _ = self.dismiss(Result[ValueT](ResultType.Skip)) - else: - items = [row_key.value for row_key in self._selected_keys] - _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] - else: - _ = self.dismiss( - Result[ValueT]( - ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] - ) - ) + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('space', 'toggle_selection', 'Toggle Selection', show=False), + ] + + CSS = """ + TableSelectionScreen { + align: center 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; + 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_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_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) + + def action_cursor_down(self) -> None: + table = self.query_one(DataTable) + next_row = min(table.cursor_row + 1, len(table.rows) - 1) + table.move_cursor(row=next_row, column=table.cursor_column or 0) + + def action_cursor_up(self) -> None: + table = self.query_one(DataTable) + prev_row = max(table.cursor_row - 1, 0) + table.move_cursor(row=prev_row, column=table.cursor_column or 0) + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + if self._header: + yield Static(self._header, classes='header-text', id='header_text') + + with Vertical(classes='content-container'): + if self._loading_header: + yield Static(self._loading_header, classes='header', id='loading-header') + + yield LoadingIndicator(id='loader') + + if self._preview_header 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') + yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield ScrollableContainer(Label('', id='preview_content')) + + yield Footer() + + def on_mount(self) -> None: + 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: + try: + loading_header = self.query_one('#loading-header', Static) + header = self.query_one('#header', Static) + loading_header.display = is_loading + header.display = not is_loading + except Exception: + pass + + 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 + + value = items[0].value + if not value: + _ = self.dismiss(Result(ResultType.Selection)) + return + + cols = list(value.table_data().keys()) + + if self._multi: + cols.insert(0, ' ') + + table.add_columns(*cols) + + for 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._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', Static) + + maybe_preview = item.preview_action(item) + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + if self._multi: + if len(self._selected_keys) == 0: + if not self._allow_skip: + return + + _ = self.dismiss(Result[ValueT](ResultType.Skip)) + else: + items = [row_key.value for row_key in self._selected_keys] + _ = self.dismiss(Result(ResultType.Selection, _item=items)) # type: ignore[arg-type] + else: + _ = self.dismiss( + Result[ValueT]( + ResultType.Selection, + _item=event.row_key.value, # type: ignore[arg-type] + ) + ) class _AppInstance(App[ValueT]): - BINDINGS: ClassVar = [ - Binding('ctrl+h', 'trigger_help', 'Show/Hide help', show=True), - Binding('alt+h', 'trigger_help', 'Show/Hide help', show=True), - Binding('shift+h', 'trigger_help', 'Show/Hide help', show=True), - Binding('ctrl+u', 'trigger_help', 'Show/Hide help', show=True), - ] - - CSS = """ - Screen { - color: white; - } - - * { - scrollbar-size: 0 1; - - /* Use high contrast colors */ - scrollbar-color: white; - scrollbar-background: black; - } - - .app-header { - dock: top; - height: auto; - width: 100%; - content-align: center middle; - background: #1793D1; - color: black; - 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 #1793D1; - } - - Footer { - dock: bottom; - width: 100%; - background: transparent; - color: white; - height: 1; - } - - .footer-key--key { - background: black; - color: white; - } - - .footer-key--description { - background: black; - color: white; - } - - FooterKey.-command-palette { - background: black; - border-left: vkey ansi_black; - } - """ - - def __init__(self, main: Any) -> None: - super().__init__(ansi_color=True) - self._main = main - - def action_trigger_help(self) -> None: - from textual.widgets import HelpPanel - - if self.screen.query('HelpPanel'): - _ = self.screen.query('HelpPanel').remove() - else: - _ = self.screen.mount(HelpPanel()) - - def on_mount(self) -> None: - self._run_worker() - - @work - async def _run_worker(self) -> None: - try: - await self._main._run() - 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(err) # type: ignore[arg-type] - - @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() + BINDINGS: ClassVar = [ + Binding('f1', 'trigger_help', 'Show/Hide help', show=True), + ] + + CSS = """ + Screen { + color: white; + } + + * { + scrollbar-size: 0 1; + + /* Use high contrast colors */ + scrollbar-color: white; + scrollbar-background: black; + } + + .app-header { + dock: top; + height: auto; + width: 100%; + content-align: center middle; + background: #1793D1; + color: black; + 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 #1793D1; + } + + Footer { + dock: bottom; + width: 100%; + background: transparent; + color: white; + height: 1; + } + + .footer-key--key { + background: black; + color: white; + } + + .footer-key--description { + background: black; + color: white; + } + + FooterKey.-command-palette { + background: black; + border-left: vkey ansi_black; + } + """ + + def __init__(self, main: Any) -> None: + super().__init__(ansi_color=True) + self._main = main + + def action_trigger_help(self) -> None: + from textual.widgets import HelpPanel + + if self.screen.query('HelpPanel'): + _ = self.screen.query('HelpPanel').remove() + else: + _ = self.screen.mount(HelpPanel()) + + def on_mount(self) -> None: + self._run_worker() + + @work + async def _run_worker(self) -> None: + try: + await self._main._run() + 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(err) # type: ignore[arg-type] + + @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 + app: _AppInstance[Any] | None = None - def __init__(self) -> None: - self._main = None - self._global_header: str | None = None + def __init__(self) -> None: + self._main = None + self._global_header: str | None = None - @property - def global_header(self) -> str | None: - return self._global_header + @property + def global_header(self) -> str | None: + return self._global_header - @global_header.setter - def global_header(self, value: str | None) -> None: - self._global_header = value + @global_header.setter + def global_header(self, value: str | None) -> None: + self._global_header = value - def run(self, main: Any) -> Result[ValueT]: - TApp.app = _AppInstance(main) - result: Result[ValueT] | Exception | None = TApp.app.run() + def run(self, main: Any) -> Result[ValueT]: + TApp.app = _AppInstance(main) + result: Result[ValueT] | Exception | None = TApp.app.run() - if isinstance(result, Exception): - raise result + if isinstance(result, Exception): + raise result - if result is None: - raise ValueError('No result returned') + if result is None: + raise ValueError('No result returned') - return result + return result - def exit(self, result: Result[ValueT]) -> None: - assert TApp.app - TApp.app.exit(result) - return + def exit(self, result: Result[ValueT]) -> None: + assert TApp.app + TApp.app.exit(result) + return tui = TApp() From c0fcbbbec7d66de0e451ee45cc6d89dea1525bac Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Dec 2025 22:23:20 +1100 Subject: [PATCH 28/40] Updaet --- archinstall/lib/interactions/system_conf.py | 44 --------------------- archinstall/tui/__init__.py | 6 --- 2 files changed, 50 deletions(-) diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index fed77d2a7d..2a5ffa9c6a 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -1,8 +1,6 @@ from __future__ import annotations -from archinstall.lib.args import arch_config_handler from archinstall.lib.menu.helpers import Confirmation, Selection -from archinstall.lib.models import Bootloader from archinstall.lib.translationhandler import tr from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.result import ResultType @@ -45,48 +43,6 @@ def select_kernel(preset: list[str] = []) -> list[str]: return result.get_values() -def ask_for_bootloader(preset: Bootloader | None) -> Bootloader | None: - # Systemd is UEFI only - options = [] - hidden_options = [] - default = None - header = tr('Select which bootloader to install') - - if arch_config_handler.args.skip_boot: - default = Bootloader.NO_BOOTLOADER - else: - hidden_options += [Bootloader.NO_BOOTLOADER] - - if not SysInfo.has_uefi(): - options += [Bootloader.Grub, Bootloader.Limine] - if not default: - default = Bootloader.Grub - header += '\n' + tr('UEFI is not detected and some options are disabled') - else: - options += [b for b in Bootloader if b not in hidden_options] - if not default: - default = Bootloader.Systemd - - items = [MenuItem(o.value, value=o) for o in options] - group = MenuItemGroup(items) - group.set_default_by_value(default) - group.set_focus_by_value(preset) - - result = Selection[Bootloader]( - group, - header=header, - allow_skip=True, - ).show() - - match result.type_: - case ResultType.Skip: - return preset - case ResultType.Selection: - return result.get_value() - case ResultType.Reset: - raise ValueError('Unhandled result type') - - def ask_for_uki(preset: bool = True) -> bool: prompt = tr('Would you like to use unified kernel images?') + '\n' diff --git a/archinstall/tui/__init__.py b/archinstall/tui/__init__.py index 092d93b078..e69de29bb2 100644 --- a/archinstall/tui/__init__.py +++ b/archinstall/tui/__init__.py @@ -1,6 +0,0 @@ -from .menu_item import MenuItem, MenuItemGroup - -__all__ = [ - 'MenuItem', - 'MenuItemGroup', -] From fefe2926a0a6633487885f7942d0cc1be247b3a0 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 20:10:51 +1100 Subject: [PATCH 29/40] Update linting --- archinstall/lib/disk/subvolume_menu.py | 8 ++++---- archinstall/lib/models/device.py | 4 ---- archinstall/scripts/minimal.py | 13 +++++-------- archinstall/scripts/only_hd.py | 23 ++++++++++------------- archinstall/tui/curses_menu.py | 3 ++- examples/mac_address_installation.py | 20 -------------------- 6 files changed, 21 insertions(+), 50 deletions(-) delete mode 100644 examples/mac_address_installation.py diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index 31c5a25289..9be6b91f67 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -78,7 +78,7 @@ def handle_action( entry: SubvolumeModification | None, data: list[SubvolumeModification], ) -> list[SubvolumeModification]: - if action == self._actions[0]: # add + if action == self._actions[0]: new_subvolume = self._add_subvolume() if new_subvolume is not None: @@ -86,15 +86,15 @@ def handle_action( # was created we'll replace the existing one data = [d for d in data if d.name != new_subvolume.name] data += [new_subvolume] - elif entry is not None: # edit - if action == self._actions[1]: # edit subvolume + elif entry is not None: + if action == self._actions[1]: new_subvolume = self._add_subvolume(entry) if new_subvolume is not None: # we'll remove the original subvolume and add the modified version data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name] data += [new_subvolume] - elif action == self._actions[2]: # delete + elif action == self._actions[2]: data = [d for d in data if d != entry] return data diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index a094c65fe0..d1856ffd08 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -1065,10 +1065,6 @@ def display_msg(self) -> str: match self: case LvmLayoutType.Default: return tr('Default layout') - # case LvmLayoutType.Manual: - # return str(_('Manual configuration')) - - raise ValueError(f'Unknown type: {self}') class _LvmVolumeGroupSerialization(TypedDict): diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 2c0c26c2f0..e70417c12f 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -11,7 +11,6 @@ from archinstall.lib.models.users import Password, User from archinstall.lib.output import debug, error, info from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.tui import Tui def perform_installation(mountpoint: Path) -> None: @@ -60,9 +59,8 @@ def perform_installation(mountpoint: Path) -> None: def _minimal() -> None: - with Tui(): - disk_config = DiskLayoutConfigurationMenu(disk_layout_config=None).run() - arch_config_handler.config.disk_config = disk_config + disk_config = DiskLayoutConfigurationMenu(disk_layout_config=None).run() + arch_config_handler.config.disk_config = disk_config config = ConfigurationOutput(arch_config_handler.config) config.write_debug() @@ -73,10 +71,9 @@ def _minimal() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return _minimal() diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index b63ee94072..036bfe344f 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -7,20 +7,18 @@ from archinstall.lib.disk.utils import disk_layouts from archinstall.lib.global_menu import GlobalMenu from archinstall.lib.installer import Installer -from archinstall.tui import Tui def ask_user_questions() -> None: - with Tui(): - global_menu = GlobalMenu(arch_config_handler.config) - global_menu.disable_all() + global_menu = GlobalMenu(arch_config_handler.config) + global_menu.disable_all() - global_menu.set_enabled('archinstall_language', True) - global_menu.set_enabled('disk_config', True) - global_menu.set_enabled('swap', True) - global_menu.set_enabled('__config__', True) + global_menu.set_enabled('archinstall_language', True) + global_menu.set_enabled('disk_config', True) + global_menu.set_enabled('swap', True) + global_menu.set_enabled('__config__', True) - global_menu.run() + global_menu.run() def perform_installation(mountpoint: Path) -> None: @@ -69,10 +67,9 @@ def _only_hd() -> None: if not arch_config_handler.args.silent: aborted = False - with Tui(): - if not config.confirm_config(): - debug('Installation aborted') - aborted = True + if not config.confirm_config(): + debug('Installation aborted') + aborted = True if aborted: return _only_hd() diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index 7c4a677b54..c45b594815 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -227,7 +227,8 @@ def _get_frame_dim( # 2 for frames, 1 for extra space start away from frame # must align with def _adjust_entries - frame_end += 3 # 2 for frame + # 2 for frame + frame_end += 3 frame_height = len(rows) + 1 if frame_height > max_height: diff --git a/examples/mac_address_installation.py b/examples/mac_address_installation.py deleted file mode 100644 index 1c2a3ecb54..0000000000 --- a/examples/mac_address_installation.py +++ /dev/null @@ -1,20 +0,0 @@ -import time - -from archinstall.lib.output import info -from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall.lib.storage import storage -from archinstall.tui import Tui - -for _profile in profile_handler.get_mac_addr_profiles(): - # Tailored means it's a match for this machine - # based on its MAC address (or some other criteria - # that fits the requirements for this machine specifically). - info(f'Found a tailored profile for this machine called: "{_profile.name}"') - - print('Starting install in:') - for i in range(10, 0, -1): - Tui.print(f'{i}...') - time.sleep(1) - - install_session = storage['installation_session'] - _profile.install(install_session) From ec2d342a33be0fa4c3e6ca74bd3adbb571aa8f8b Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 20:31:49 +1100 Subject: [PATCH 30/40] Update --- archinstall/lib/disk/encryption_menu.py | 13 +++++++++++-- archinstall/lib/disk/partitioning_menu.py | 1 - archinstall/lib/interactions/disk_conf.py | 2 -- archinstall/lib/interactions/general_conf.py | 2 -- archinstall/lib/interactions/network_menu.py | 2 -- archinstall/lib/interactions/system_conf.py | 1 - archinstall/lib/locale/locale_menu.py | 3 +-- archinstall/lib/menu/abstract_menu.py | 1 - archinstall/lib/menu/helpers.py | 4 ---- archinstall/lib/menu/list_manager.py | 2 -- archinstall/lib/mirrors.py | 1 - archinstall/tui/ui/components.py | 11 +++++++---- 12 files changed, 19 insertions(+), 24 deletions(-) diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 6e13a477a5..d80f826752 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -225,7 +225,12 @@ def select_encryption_type( group = MenuItemGroup(items) group.set_focus_by_value(preset_value) - result = Selection[EncryptionType](group, header=tr('Select encryption type'), allow_skip=True, allow_reset=True, show_frame=False).show() + result = Selection[EncryptionType]( + group, + header=tr('Select encryption type'), + allow_skip=True, + allow_reset=True, + ).show() match result.type_: case ResultType.Reset: @@ -257,7 +262,11 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: if fido_devices: group = MenuHelper(data=fido_devices).create_menu_group() - result = Selection[Fido2Device](group, header=header, allow_skip=True, show_frame=False).show() + result = Selection[Fido2Device]( + group, + header=header, + allow_skip=True, + ).show() match result.type_: case ResultType.Reset: diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 22ed339335..be4980e3f6 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -420,7 +420,6 @@ def _prompt_partition_fs_type(self, prompt: str | None = None) -> FilesystemType group, header=prompt, allow_skip=False, - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 2d90437000..35d1f035c9 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -144,7 +144,6 @@ def select_disk_config(preset: DiskLayoutConfiguration | None = None) -> DiskLay header=tr('Select a disk configuration'), allow_skip=True, allow_reset=True, - show_frame=False, ).show() match result.type_: @@ -266,7 +265,6 @@ def select_main_filesystem_format() -> FilesystemType: group, header=tr('Select main filesystem'), allow_skip=False, - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index c8dcd4dc3c..20e2363a54 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -79,7 +79,6 @@ def ask_for_a_timezone(preset: str | None = None) -> str | None: header=tr('Select timezone'), allow_reset=True, allow_skip=True, - show_frame=True, ).show() match result.type_: @@ -200,7 +199,6 @@ def ask_additional_packages_to_install( allow_skip=True, multi=True, preview_location='right', - show_frame=False, enable_filter=True, ).show() diff --git a/archinstall/lib/interactions/network_menu.py b/archinstall/lib/interactions/network_menu.py index 512c2c2305..e4c0508bd1 100644 --- a/archinstall/lib/interactions/network_menu.py +++ b/archinstall/lib/interactions/network_menu.py @@ -128,7 +128,6 @@ def _edit_iface(self, edit_nic: Nic) -> Nic: group, header=header, allow_skip=False, - show_frame=False, ).show() match result.type_: @@ -182,7 +181,6 @@ def ask_to_configure_network(preset: NetworkConfiguration | None) -> NetworkConf header=tr('Choose network configuration'), allow_reset=True, allow_skip=True, - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index 2a5ffa9c6a..a2cf318b3f 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -31,7 +31,6 @@ def select_kernel(preset: list[str] = []) -> list[str]: allow_skip=True, allow_reset=True, multi=True, - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py index 5e7a40bb24..ee99c55516 100644 --- a/archinstall/lib/locale/locale_menu.py +++ b/archinstall/lib/locale/locale_menu.py @@ -77,7 +77,6 @@ def select_locale_lang(preset: str | None = None) -> str | None: result = Selection[str]( header=tr('Locale language'), group=group, - show_frame=True, enable_filter=True, ).show() @@ -101,6 +100,7 @@ def select_locale_enc(preset: str | None = None) -> str | None: result = Selection[str]( header=tr('Locale encoding'), group=group, + enable_filter=True, ).show() match result.type_: @@ -131,7 +131,6 @@ def select_kb_layout(preset: str | None = None) -> str | None: result = Selection[str]( header=tr('Keyboard layout'), group=group, - show_frame=True, enable_filter=True, ).show() diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 830d2085df..e79ede8b3d 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -104,7 +104,6 @@ def run(self, additional_title: str | None = None) -> ValueT | None: allow_skip=False, allow_reset=self._allow_reset, preview_location='right', - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 6500e07dfb..d69b33784a 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -30,7 +30,6 @@ def __init__( preview_location: Literal['right', 'bottom'] | None = None, multi: bool = False, enable_filter: bool = False, - show_frame: bool = False, ): self._header = header self._group: MenuItemGroup = group @@ -39,7 +38,6 @@ def __init__( self._preview_location = preview_location self._multi = multi self._enable_filter = enable_filter - self._show_frame = show_frame def show(self) -> Result[ValueT]: result: Result[ValueT] = tui.run(self) @@ -53,7 +51,6 @@ async def _run(self) -> None: allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_location, - show_frame=self._show_frame, enable_filter=self._enable_filter, ).run() else: @@ -63,7 +60,6 @@ async def _run(self) -> None: allow_skip=self._allow_skip, allow_reset=self._allow_reset, preview_location=self._preview_location, - show_frame=self._show_frame, enable_filter=self._enable_filter, ).run() diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index d622082d52..b495d78843 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -72,7 +72,6 @@ def run(self) -> list[ValueT]: header=prompt, enable_filter=False, allow_skip=False, - show_frame=False, ).show() match result.type_: @@ -112,7 +111,6 @@ def _run_actions_on_entry(self, entry: ValueT) -> None: header=header, enable_filter=False, allow_skip=False, - show_frame=False, ).show() match result.type_: diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 813359c7a2..fd5870d319 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -318,7 +318,6 @@ def select_mirror_regions(preset: list[MirrorRegion]) -> list[MirrorRegion]: allow_reset=True, allow_skip=True, multi=True, - show_frame=True, enable_filter=True, ).show() diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index a8bd28fb28..2da3478cad 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys from collections.abc import Awaitable, Callable from typing import Any, ClassVar, Literal, TypeVar, override @@ -174,15 +175,14 @@ def __init__( allow_skip: bool = False, allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, 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._show_frame = False self._options = self._get_options() @@ -365,7 +365,6 @@ def __init__( allow_skip: bool = False, allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, - show_frame: bool = False, enable_filter: bool = False, ): super().__init__(allow_skip, allow_reset) @@ -979,8 +978,11 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: 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 = """ @@ -1128,7 +1130,8 @@ def run(self, main: Any) -> Result[ValueT]: raise result if result is None: - raise ValueError('No result returned') + debug('App returned no result, assuming exit') + sys.exit(0) return result From 210d7fab725a6716f19e771e99738cf54374cc0b Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 20:53:45 +1100 Subject: [PATCH 31/40] Update --- archinstall/lib/interactions/general_conf.py | 1 + archinstall/tui/ui/components.py | 36 ++++++++++---------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 20e2363a54..54cf7520b0 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -79,6 +79,7 @@ def ask_for_a_timezone(preset: str | None = None) -> str | None: header=tr('Select timezone'), allow_reset=True, allow_skip=True, + enable_filter=True, ).show() match result.type_: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 2da3478cad..a92760dfb1 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -11,7 +11,7 @@ from textual.events import Key from textual.screen import Screen from textual.validation import Validator -from textual.widgets import Button, DataTable, Footer, Input, Label, LoadingIndicator, OptionList, Rule, SelectionList, Static +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 @@ -47,7 +47,7 @@ async def action_reset_operation(self) -> None: def _compose_header(self) -> ComposeResult: """Compose the app header if global header text is available""" if tui.global_header: - yield Static(tui.global_header, classes='app-header') + yield Label(tui.global_header, classes='app-header') class LoadingScreen(BaseScreen[None]): @@ -96,7 +96,7 @@ def compose(self) -> ComposeResult: with Center(): with Vertical(classes='dialog'): if self._header: - yield Static(self._header, classes='header') + yield Label(self._header, classes='header') yield Center(LoadingIndicator()) yield Footer() @@ -235,7 +235,7 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): if self._header: - yield Static(self._header, classes='header-text', id='header_text') + yield Label(self._header, classes='header-text', id='header_text') option_list = OptionList(id='option_list_widget') @@ -294,7 +294,7 @@ def _set_preview(self, item_id: str) -> None: if self._preview_location is None: return None - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Label) item = self._group.find_by_id(item_id) if item.preview_action is not None: @@ -427,7 +427,7 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): if self._header: - yield Static(self._header, classes='header-text', id='header_text') + yield Label(self._header, classes='header-text', id='header_text') selection_list = SelectionList[MenuItem](id='select_list_widget') @@ -496,7 +496,7 @@ def _set_preview(self, item: MenuItem) -> None: if self._preview_location is None: return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Label) if item.preview_action is not None: maybe_preview = item.preview_action(item) @@ -571,7 +571,7 @@ async def run(self) -> Result[ValueT]: def compose(self) -> ComposeResult: yield from self._compose_header() - yield Static(self._header, classes='header-text', id='header_text') + yield Label(self._header, classes='header-text', id='header_text') if self._preview_header is None: with Vertical(classes='content-container'): @@ -585,7 +585,7 @@ def compose(self) -> ComposeResult: yield Button(item.text, id=item.key) yield Rule(orientation='horizontal') - yield Static(self._preview_header, classes='preview-header', id='preview_header') + yield Label(self._preview_header, classes='preview-header', id='preview_header') yield ScrollableContainer(Label('', id='preview_content')) yield Footer() @@ -700,7 +700,7 @@ async def run(self) -> Result[str]: def compose(self) -> ComposeResult: yield from self._compose_header() - yield Static(self._header, classes='header-text', id='header_text') + yield Label(self._header, classes='header-text', id='header_text') with Center(classes='container-wrapper'): with Vertical(classes='input-content'): @@ -712,7 +712,7 @@ def compose(self) -> ComposeResult: validators=self._validator, validate_on=['submitted'], ) - yield Static('', classes='input-failure', id='input-failure') + yield Label('', classes='input-failure', id='input-failure') yield Footer() @@ -725,7 +725,7 @@ def on_input_submitted(self, event: Input.Submitted) -> None: failures = [failure.description for failure in event.validation_result.failures if failure.description] failure_out = ', '.join(failures) - self.query_one('#input-failure', Static).update(failure_out) + self.query_one('#input-failure', Label).update(failure_out) else: _ = self.dismiss(Result(ResultType.Selection, _data=event.value)) @@ -834,11 +834,11 @@ def compose(self) -> ComposeResult: yield from self._compose_header() if self._header: - yield Static(self._header, classes='header-text', id='header_text') + yield Label(self._header, classes='header-text', id='header_text') with Vertical(classes='content-container'): if self._loading_header: - yield Static(self._loading_header, classes='header', id='loading-header') + yield Label(self._loading_header, classes='header', id='loading-header') yield LoadingIndicator(id='loader') @@ -851,7 +851,7 @@ def compose(self) -> ComposeResult: with Vertical(classes='table-container'): yield ScrollableContainer(DataTable(id='data_table')) yield Rule(orientation='horizontal') - yield Static(self._preview_header, classes='preview-header', id='preview-header') + yield Label(self._preview_header, classes='preview-header', id='preview-header') yield ScrollableContainer(Label('', id='preview_content')) yield Footer() @@ -874,8 +874,8 @@ async def _load_data(self, table: DataTable[ValueT]) -> None: def _display_header(self, is_loading: bool) -> None: try: - loading_header = self.query_one('#loading-header', Static) - header = self.query_one('#header', Static) + loading_header = self.query_one('#loading-header', Label) + header = self.query_one('#header', Label) loading_header.display = is_loading header.display = not is_loading except Exception: @@ -949,7 +949,7 @@ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None if not item.preview_action: return - preview_widget = self.query_one('#preview_content', Static) + preview_widget = self.query_one('#preview_content', Label) maybe_preview = item.preview_action(item) if maybe_preview is not None: From 4a00bf1f083a2a44f38a73695ad17cf34d9900b7 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 20:56:46 +1100 Subject: [PATCH 32/40] Update github action --- .github/workflows/python-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index 55cbabcfe6..08ff387c5f 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -18,7 +18,7 @@ jobs: pacman --noconfirm -Sy archlinux-keyring pacman --noconfirm -Syyu pacman --noconfirm -Sy python-uv python-setuptools python-pip - pacman --noconfirm -Sy python-pyparted python-pydantic + pacman --noconfirm -Sy python-pyparted python-pydantic python-textual - name: Remove existing archinstall (if any) run: uv pip uninstall archinstall --break-system-packages --system From ee3251fbc16cf624cbcab17d7b48b1e6208eb129 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 21:47:18 +1100 Subject: [PATCH 33/40] Update --- archinstall/lib/configuration.py | 1 + archinstall/lib/interactions/disk_conf.py | 1 + archinstall/lib/menu/helpers.py | 6 ++++++ archinstall/tui/ui/components.py | 25 +++++++++++++++-------- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 1a9f8007c5..1b5f93b0a7 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -63,6 +63,7 @@ def confirm_config(self) -> bool: header=header, allow_skip=False, preset=True, + preview_location='bottom', preview_header=tr('Configuration preview'), ).show() diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 35d1f035c9..8019412f0f 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -69,6 +69,7 @@ def _preview_device_selection(item: MenuItem) -> str | None: presets=presets, allow_skip=True, multi=True, + preview_location='bottom', preview_header=tr('Partitions'), ).show() diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index d69b33784a..58bc6ff80c 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -80,12 +80,14 @@ def __init__( allow_skip: bool = True, allow_reset: bool = False, preset: bool = False, + preview_location: Literal['bottom'] | None = None, preview_header: str | None = None, ): self._header = header self._allow_skip = allow_skip self._allow_reset = allow_reset self._preset = preset + self._preview_location = preview_location self._preview_header = preview_header if not group: @@ -104,6 +106,7 @@ async def _run(self) -> None: header=self._header, allow_skip=self._allow_skip, allow_reset=self._allow_reset, + preview_location=self._preview_location, preview_header=self._preview_header, ).run() @@ -231,6 +234,7 @@ def __init__( allow_skip: bool = False, loading_header: str | None = None, multi: bool = False, + preview_location: Literal['bottom'] | None = None, preview_header: str | None = None, ): self._header = header @@ -241,6 +245,7 @@ def __init__( self._allow_reset = allow_reset self._multi = multi self._presets = presets + self._preview_location = preview_location self._preview_header = preview_header if self._group is None and self._data_callback is None: @@ -259,6 +264,7 @@ async def _run(self) -> None: allow_reset=self._allow_reset, loading_header=self._loading_header, multi=self._multi, + preview_location=self._preview_location, preview_header=self._preview_header, ).run() diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index a92760dfb1..ed8a03c912 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -253,7 +253,7 @@ def compose(self) -> ComposeResult: with Container(): yield option_list yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content')) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -299,6 +299,7 @@ def _set_preview(self, item_id: str) -> None: 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 @@ -445,7 +446,7 @@ def compose(self) -> ComposeResult: with Container(): yield selection_list yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content')) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -556,11 +557,13 @@ def __init__( header: str, allow_skip: bool = False, allow_reset: bool = False, + preview_location: Literal['bottom'] | None = None, preview_header: str | None = None, ): super().__init__(allow_skip, allow_reset) self._group = group self._header = header + self._preview_location = preview_location self._preview_header = preview_header async def run(self) -> Result[ValueT]: @@ -573,7 +576,7 @@ def compose(self) -> ComposeResult: yield Label(self._header, classes='header-text', id='header_text') - if self._preview_header is None: + if self._preview_location is None: with Vertical(classes='content-container'): with Horizontal(classes='buttons-container'): for item in self._group.items: @@ -585,8 +588,9 @@ def compose(self) -> ComposeResult: yield Button(item.text, id=item.key) yield Rule(orientation='horizontal') - yield Label(self._preview_header, classes='preview-header', id='preview_header') - yield ScrollableContainer(Label('', id='preview_content')) + 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() @@ -799,6 +803,7 @@ def __init__( 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) @@ -807,6 +812,7 @@ def __init__( 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() @@ -842,7 +848,7 @@ def compose(self) -> ComposeResult: yield LoadingIndicator(id='loader') - if self._preview_header is None: + if self._preview_location is None: with Center(): with Vertical(classes='table-container'): yield ScrollableContainer(DataTable(id='data_table')) @@ -851,8 +857,9 @@ def compose(self) -> ComposeResult: with Vertical(classes='table-container'): yield ScrollableContainer(DataTable(id='data_table')) yield Rule(orientation='horizontal') - yield Label(self._preview_header, classes='preview-header', id='preview-header') - yield ScrollableContainer(Label('', id='preview_content')) + 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() @@ -991,7 +998,7 @@ class _AppInstance(App[ValueT]): } * { - scrollbar-size: 0 1; + scrollbar-size: 1 1; /* Use high contrast colors */ scrollbar-color: white; From 8a7a4640f4af72f349f58ed1fa98a51f91cf8c56 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 21:57:10 +1100 Subject: [PATCH 34/40] Update wifi --- archinstall/__init__.py | 1 + archinstall/tui/ui/components.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 7325653130..d2a95a3098 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -40,6 +40,7 @@ def _log_sys_info() -> None: def _check_online() -> None: + success = not wifi_handler.setup() try: ping('1.1.1.1') except OSError as ex: diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index ed8a03c912..946548cf6b 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -790,6 +790,8 @@ class TableSelectionScreen(BaseScreen[ValueT]): LoadingIndicator { height: auto; + padding-top: 2; + background: transparent; } """ @@ -844,7 +846,8 @@ def compose(self) -> ComposeResult: with Vertical(classes='content-container'): if self._loading_header: - yield Label(self._loading_header, classes='header', id='loading-header') + with Center(): + yield Label(self._loading_header, classes='header', id='loading_header') yield LoadingIndicator(id='loader') @@ -880,13 +883,11 @@ async def _load_data(self, table: DataTable[ValueT]) -> None: self._put_data_to_table(table, group) def _display_header(self, is_loading: bool) -> None: - try: - loading_header = self.query_one('#loading-header', Label) - header = self.query_one('#header', Label) - loading_header.display = is_loading - header.display = not is_loading - except Exception: - pass + loading_header = self.query_one('#loading_header', Label) + header = self.query_one('#header_text', Label) + + loading_header.display = is_loading + header.display = not is_loading def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: items = group.items From c4cc00b7c13f364a12048076877decffc1bae4ba Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 16 Dec 2025 22:06:34 +1100 Subject: [PATCH 35/40] Update wifi --- archinstall/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/archinstall/__init__.py b/archinstall/__init__.py index d2a95a3098..7325653130 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -40,7 +40,6 @@ def _log_sys_info() -> None: def _check_online() -> None: - success = not wifi_handler.setup() try: ping('1.1.1.1') except OSError as ex: From 68b3cd8692fb15ec473d1ff38181ae97df53b5b7 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 17 Dec 2025 16:59:29 +1100 Subject: [PATCH 36/40] Update --- archinstall/lib/interactions/manage_users_conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index fc85c41755..458c0e5532 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -94,7 +94,7 @@ def _add_user(self) -> User | None: prompt = f'{header}\n' + tr('Should "{}" be a superuser (sudo)?\n').format(username) result = Confirmation( - header=header, + header=prompt, allow_skip=False, preset=True, ).show() From c8e414160a83c8f8bb3bfacb3f1cd0aa60c8e680 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 17 Dec 2025 17:09:50 +1100 Subject: [PATCH 37/40] Update --- archinstall/lib/disk/encryption_menu.py | 6 ++---- archinstall/tui/ui/components.py | 10 ++++++---- archinstall/tui/ui/menu_item.py | 5 +++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index d80f826752..6d1958e725 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -293,8 +293,7 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: - items = [MenuItem(str(id(partition)), value=partition) for partition in partitions] - group = MenuItemGroup(items) + group = MenuItemGroup.from_objects(partitions) group.set_selected_by_value(preset) result = Table[PartitionModification]( @@ -323,8 +322,7 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: - items = [MenuItem(str(id(volume)), value=volume) for volume in volumes] - group = MenuItemGroup(items) + group = MenuItemGroup.from_objects(volumes) group.set_selected_by_value(preset) result = Table[LvmVolume]( diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 946548cf6b..a4e5a1679e 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -883,11 +883,13 @@ async def _load_data(self, table: DataTable[ValueT]) -> None: self._put_data_to_table(table, group) def _display_header(self, is_loading: bool) -> None: - loading_header = self.query_one('#loading_header', Label) - header = self.query_one('#header_text', Label) + if self._loading_header: + loading_header = self.query_one('#loading_header', Label) + loading_header.display = is_loading - loading_header.display = is_loading - header.display = not is_loading + if self._header: + header = self.query_one('#header_text', Label) + header.display = not is_loading def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> None: items = group.items diff --git a/archinstall/tui/ui/menu_item.py b/archinstall/tui/ui/menu_item.py index 6ff9e7e064..f630ea0ef7 100644 --- a/archinstall/tui/ui/menu_item.py +++ b/archinstall/tui/ui/menu_item.py @@ -112,6 +112,11 @@ def __init__( if self.focus_item not in self.items: raise ValueError(f'Selected item not in menu: {self.focus_item}') + @classmethod + def from_objects(cls, items: list[Any]) -> Self: + items = [MenuItem(str(id(item)), value=item) for item in items] + return cls(items) + def add_item(self, item: MenuItem) -> None: self._menu_items.append(item) delattr(self, 'items') # resetting the cache From 30598e4daaa2fc60eb30680662e09679fd0ede93 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 28 Dec 2025 10:08:55 +1100 Subject: [PATCH 38/40] Update color scheme --- archinstall/tui/ui/components.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index a4e5a1679e..ac4243769c 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -544,7 +544,7 @@ class ConfirmationScreen(BaseScreen[ValueT]): } Button.-active { - background: #1793D1; + background: blue; color: white; border: none; text-style: none; @@ -1013,8 +1013,8 @@ class _AppInstance(App[ValueT]): height: auto; width: 100%; content-align: center middle; - background: #1793D1; - color: black; + background: blue; + color: white; text-style: bold; } @@ -1056,7 +1056,7 @@ class _AppInstance(App[ValueT]): } Input:focus { - border: solid #1793D1; + border: solid blue; } Footer { From 35b9d602ec675d94c79288acb211506fd5f97ed3 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Sun, 28 Dec 2025 10:50:23 +1100 Subject: [PATCH 39/40] Update color scheme --- archinstall/tui/ui/components.py | 152 +++++++++++++++++-------------- 1 file changed, 82 insertions(+), 70 deletions(-) diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index ac4243769c..67e057f7fe 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -57,16 +57,15 @@ class LoadingScreen(BaseScreen[None]): background: transparent; } - .dialog { - align: center middle; - width: 100%; - border: none; - background: transparent; - } + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; - .header { - text-align: center; - margin-bottom: 1; + margin-top: 2; + margin-bottom: 2; + + background: transparent; } LoadingIndicator { @@ -93,11 +92,12 @@ async def run(self) -> Result[None]: def compose(self) -> ComposeResult: yield from self._compose_header() - with Center(): - with Vertical(classes='dialog'): - if self._header: - yield Label(self._header, classes='header') - yield Center(LoadingIndicator()) + with Vertical(classes='content-container'): + if self._header: + with Center(): + yield Label(self._header, classes='header', id='loading_header') + + yield Center(LoadingIndicator()) yield Footer() @@ -129,43 +129,49 @@ class OptionListScreen(BaseScreen[ValueT]): ] CSS = """ - OptionListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } + OptionListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; - margin-top: 2; - margin-left: 2; + margin-top: 2; + margin-left: 2; - background: transparent; - } + background: transparent; + } - .list-container { - width: auto; - height: auto; - max-height: 100%; + .list-container { + width: auto; + height: auto; + max-height: 100%; - padding-bottom: 3; + padding-bottom: 3; - background: transparent; - } + background: transparent; + } - OptionList { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; + OptionList { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; - padding-bottom: 3; + padding-bottom: 3; + + background: transparent; + } - background: transparent; - } + OptionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } """ def __init__( @@ -320,43 +326,49 @@ class SelectListScreen(BaseScreen[ValueT]): ] CSS = """ - SelectListScreen { - align-horizontal: center; - align-vertical: middle; - background: transparent; - } + SelectListScreen { + align-horizontal: center; + align-vertical: middle; + background: transparent; + } - .content-container { - width: 1fr; - height: 1fr; - max-height: 100%; + .content-container { + width: 1fr; + height: 1fr; + max-height: 100%; - margin-top: 2; - margin-left: 2; + margin-top: 2; + margin-left: 2; - background: transparent; - } + background: transparent; + } - .list-container { - width: auto; - height: auto; - min-width: 15%; - max-height: 1fr; + .list-container { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; - padding-bottom: 3; + padding-bottom: 3; - background: transparent; - } + background: transparent; + } - SelectionList { - width: auto; - height: auto; - max-height: 1fr; + SelectionList { + width: auto; + height: auto; + max-height: 1fr; - padding-bottom: 3; + padding-bottom: 3; - background: transparent; - } + background: transparent; + } + + SelectionList > .option-list--option-highlighted { + background: blue; + color: white; + text-style: bold; + } """ def __init__( From b01ee57225f22cca3b0891cd864135aa8600e9d3 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 30 Dec 2025 16:36:23 +1100 Subject: [PATCH 40/40] test espeakup --- archinstall/lib/interactions/system_conf.py | 13 +- archinstall/lib/menu/helpers.py | 29 ++- archinstall/tui/ui/components.py | 217 +++++++++++++++++++- 3 files changed, 245 insertions(+), 14 deletions(-) diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index a2cf318b3f..8b7329e5a0 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -110,12 +110,21 @@ def select_driver(options: list[GfxDriver] = [], preset: GfxDriver | None = None def ask_for_swap(preset: bool = True) -> bool: prompt = tr('Would you like to use swap on zram?') - result = Confirmation( + result = Selection[GfxDriver]( + MenuItemGroup.yes_no(), header=prompt, allow_skip=True, - preset=preset, + allow_reset=True, + preview_location='right', + test=True ).show() + # result = Confirmation( + # header=prompt, + # allow_skip=True, + # preset=preset, + # ).show() + match result.type_: case ResultType.Skip: return preset diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index 58bc6ff80c..b1d7828545 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -7,6 +7,7 @@ from archinstall.tui.ui.components import ( ConfirmationScreen, InputScreen, + ListViewScreen, LoadingScreen, NotifyScreen, OptionListScreen, @@ -30,6 +31,7 @@ def __init__( preview_location: Literal['right', 'bottom'] | None = None, multi: bool = False, enable_filter: bool = False, + test=False, ): self._header = header self._group: MenuItemGroup = group @@ -38,6 +40,7 @@ def __init__( self._preview_location = preview_location self._multi = multi self._enable_filter = enable_filter + self._test = test def show(self) -> Result[ValueT]: result: Result[ValueT] = tui.run(self) @@ -54,14 +57,24 @@ async def _run(self) -> None: enable_filter=self._enable_filter, ).run() else: - result = await OptionListScreen[ValueT]( - self._group, - header=self._header, - allow_skip=self._allow_skip, - allow_reset=self._allow_reset, - preview_location=self._preview_location, - enable_filter=self._enable_filter, - ).run() + if self._test: + result = await ListViewScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + enable_filter=self._enable_filter, + ).run() + else: + result = await OptionListScreen[ValueT]( + self._group, + header=self._header, + allow_skip=self._allow_skip, + allow_reset=self._allow_reset, + preview_location=self._preview_location, + enable_filter=self._enable_filter, + ).run() if result.type_ == ResultType.Reset: confirmed = await _confirm_reset() diff --git a/archinstall/tui/ui/components.py b/archinstall/tui/ui/components.py index 67e057f7fe..95df4f1e5c 100644 --- a/archinstall/tui/ui/components.py +++ b/archinstall/tui/ui/components.py @@ -11,7 +11,7 @@ from textual.events import Key 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 import Button, DataTable, Footer, Input, Label, ListItem, ListView, LoadingIndicator, OptionList, Rule, SelectionList from textual.widgets._data_table import RowKey from textual.widgets.option_list import Option from textual.widgets.selection_list import Selection @@ -117,6 +117,215 @@ def action_pop_screen(self) -> None: _ = self.dismiss() + +class ListViewScreen(BaseScreen[ValueT]): + """ + List single selection menu + """ + + BINDINGS: ClassVar = [ + Binding('j', 'cursor_down', 'Down', show=False), + Binding('k', 'cursor_up', 'Up', show=False), + Binding('/', 'search', 'Search', show=False), + ] + + CSS = """ + ListViewScreen { + 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; + } + + ListView { + width: auto; + height: auto; + min-width: 15%; + max-height: 1fr; + + padding-bottom: 3; + + background: transparent; + } + """ + + def __init__( + self, + group: MenuItemGroup, + header: str | None = None, + allow_skip: bool = False, + allow_reset: bool = False, + preview_location: Literal['right', 'bottom'] | None = None, + enable_filter: bool = False, + ): + super().__init__(allow_skip, allow_reset) + self._group = group + self._header = header + self._preview_location = preview_location + self._filter = enable_filter + self._show_frame = False + + self._items = self._get_items() + + def action_cursor_down(self) -> None: + list_view = self.query_one(ListView) + if list_view.has_focus: + list_view.action_cursor_down() + + def action_cursor_up(self) -> None: + list_view = self.query_one(ListView) + if list_view.has_focus: + list_view.action_cursor_up() + + def action_search(self) -> None: + if self.query_one(ListView).has_focus: + if self._filter: + self._handle_search_action() + + @override + def action_cancel_operation(self) -> None: + if self._filter and self.query_one(Input).has_focus: + self._handle_search_action() + else: + super().action_cancel_operation() + + def _handle_search_action(self) -> None: + search_input = self.query_one(Input) + + if search_input.has_focus: + self.query_one(ListView).focus() + else: + search_input.focus() + + async def run(self) -> Result[ValueT]: + assert TApp.app + return await TApp.app.show(self) + + def _get_items(self) -> list[ListItem]: + items = [] + + for item in self._group.get_enabled_items(): + disabled = True if item.read_only else False + items.append( + ListItem( + name=item.text, + id=item.get_id(), + disabled=disabled, + ) + ) + + return items + + @override + def compose(self) -> ComposeResult: + yield from self._compose_header() + + with Vertical(classes='content-container'): + if self._header: + yield Label(self._header, classes='header-text', id='header_text') + + list_view = ListView(id='list_widget') + + list_view = ListView( + ListItem(Label("One")), + ListItem(Label("Two")), + ListItem(Label("Three")), + ) + + if not self._show_frame: + list_view.classes = 'no-border' + + if self._preview_location is None: + with Center(): + with Vertical(classes='list-container'): + yield list_view + else: + Container = Horizontal if self._preview_location == 'right' else Vertical + rule_orientation: Literal['horizontal', 'vertical'] = 'vertical' if self._preview_location == 'right' else 'horizontal' + + with Container(): + yield list_view + yield Rule(orientation=rule_orientation) + yield ScrollableContainer(Label('', id='preview_content', markup=False)) + + if self._filter: + yield Input(placeholder='/filter', id='filter-input') + + yield Footer() + + def on_mount(self) -> None: + self._update_items(self._items) + self.query_one(ListView).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + search_term = event.value.lower() + self._group.set_filter_pattern(search_term) + filtered_items = self._get_items() + self._update_items(filtered_items) + + def _update_items(self, items: list[ListItem]) -> None: + list_view = self.query_one(ListView) + # list_view.clear() + # list_view.insert(0, items) + + # list_view.highlighted_child = self._group.get_focused_index() + + if focus_item := self._group.focus_item: + self._set_preview(focus_item.get_id()) + + # def on_option_list_option_selected(self, event: ListView.Highlighted) -> None: + # item = event.item + # if not item: + # return + # + # if item.id is not None: + # item = self._group.find_by_id(item.id) + # _ = self.dismiss(Result(ResultType.Selection, _item=item)) + # + # def on_option_list_option_highlighted(self, event: ListView.Highlighted) -> None: + # item = event.item + # if not item: + # return + # + # self._set_preview(item.id) + + def _set_preview(self, item_id: str) -> None: + if self._preview_location is None: + return None + + preview_widget = self.query_one('#preview_content', Label) + item = self._group.find_by_id(item_id) + + if item.preview_action is not None: + maybe_preview = item.preview_action(item) + + if maybe_preview is not None: + preview_widget.update(maybe_preview) + return + + preview_widget.update('') + + class OptionListScreen(BaseScreen[ValueT]): """ List single selection menu @@ -935,7 +1144,7 @@ def _put_data_to_table(self, table: DataTable[ValueT], group: MenuItemGroup) -> else: row_values.insert(0, '[ ]') - row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] + row_key = table.add_row(*row_values, key=item) # type: ignore[arg-type] if item in selected: self._selected_keys.add(row_key) @@ -994,7 +1203,7 @@ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: _ = self.dismiss( Result[ValueT]( ResultType.Selection, - _item=event.row_key.value, # type: ignore[arg-type] + _item=event.row_key.value, # type: ignore[arg-type] ) ) @@ -1119,7 +1328,7 @@ async def _run_worker(self) -> None: except Exception as err: debug(f'Error while running main app: {err}') # this will terminate the textual app and return the exception - self.exit(err) # type: ignore[arg-type] + self.exit(err) # type: ignore[arg-type] @work async def _show_async(self, screen: Screen[Result[ValueT]]) -> Result[ValueT]: