diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index ec14d242..9ca5ec88 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -262,7 +262,7 @@ def select_encrypted_password() -> Password | None: def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: - header = str(_('Select a FIDO2 device to use for HSM')) + header = str(_('Select a FIDO2 device to use for HSM')) + '\n' try: fido_devices = Fido2.get_fido2_devices() @@ -270,13 +270,13 @@ def select_hsm(preset: Fido2Device | None = None) -> Fido2Device | None: return None if fido_devices: - group, table_header = MenuHelper.create_table(data=fido_devices) - header = f'{header}\n\n{table_header}' + group = MenuHelper(data=fido_devices).create_menu_group() result = SelectMenu[Fido2Device]( group, header=header, alignment=Alignment.CENTER, + allow_skip=True ).run() match result.type_: @@ -307,13 +307,14 @@ def select_partitions_to_encrypt( avail_partitions = [p for p in partitions if not p.exists()] if avail_partitions: - group, header = MenuHelper.create_table(data=avail_partitions) + group = MenuHelper(data=avail_partitions).create_menu_group() + group.set_selected_by_value(preset) result = SelectMenu[PartitionModification]( group, - header=header, alignment=Alignment.CENTER, - multi=True + multi=True, + allow_skip=True ).run() match result.type_: @@ -335,11 +336,10 @@ def select_lvm_vols_to_encrypt( volumes: list[LvmVolume] = lvm_config.get_all_volumes() if volumes: - group, header = MenuHelper.create_table(data=volumes) + group = MenuHelper(data=volumes).create_menu_group() result = SelectMenu[LvmVolume]( group, - header=header, alignment=Alignment.CENTER, multi=True ).run() diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 02f66684..dcf67054 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -60,13 +60,12 @@ def select_devices(preset: list[BDevice] | None = []) -> list[BDevice]: options = [d.device_info for d in devices] presets = [p.device_info for p in preset] - group, header = MenuHelper.create_table(data=options) + group = MenuHelper(options).create_menu_group() group.set_selected_by_value(presets) group.set_preview_for_all(_preview_device_selection) result = SelectMenu[_DeviceInfo]( group, - header=header, alignment=Alignment.CENTER, search_enabled=False, multi=True, diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 5433a603..2560ef58 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -53,14 +53,14 @@ class AbstractMenu[ValueT]: self.sync_all_to_config() def _sync_from_config(self) -> None: - for item in self._menu_item_group.menu_items: + for item in self._menu_item_group._menu_items: if item.key is not None and not item.key.startswith(CONFIG_KEY): config_value = getattr(self._config, item.key) if config_value is not None: item.value = config_value def sync_all_to_config(self) -> None: - for item in self._menu_item_group.menu_items: + for item in self._menu_item_group._menu_items: if item.key: setattr(self._config, item.key, item.value) @@ -135,7 +135,7 @@ class AbstractSubMenu[ValueT](AbstractMenu[ValueT]): allow_reset: bool = False ): back_text = f'{Chars.Right_arrow} ' + str(_('Back')) - item_group.menu_items.append(MenuItem(text=back_text)) + item_group.add_item(MenuItem(text=back_text)) super().__init__( item_group, diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 96bd4a21..2ee6677a 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -1,13 +1,12 @@ import copy -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, cast +from archinstall.lib.menu.menu_helper import MenuHelper 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 ..output import FormattedOutput - if TYPE_CHECKING: from collections.abc import Callable @@ -63,23 +62,23 @@ class ListManager[ValueT]: return False def run(self) -> list[ValueT]: + additional_options = self._base_actions + self._terminate_actions + while True: - # this will return a dictionary with the key as the menu entry to be displayed - # and the value is the original value from the self._data container - data_formatted = self.reformat(self._data) - options = self._prepare_selection(data_formatted) - - header = self._get_header(data_formatted) + group = MenuHelper( + data=self._data, + additional_options=additional_options + ).create_menu_group() + prompt = None if self._prompt is not None: - header = f'{self._prompt}\n\n{header}' + prompt = f'{self._prompt}\n\n' - items = [MenuItem(o[0], value=o[1]) for o in options] - group = MenuItemGroup(items, sort_items=False) + prompt = None result = SelectMenu[ValueT | str]( group, - header=header, + header=prompt, search_enabled=False, allow_skip=False, alignment=Alignment.CENTER @@ -109,25 +108,6 @@ class ListManager[ValueT]: else: return self._data - def _get_header(self, data_formatted: dict[str, Any]) -> str: - table_header = [key for key, val in data_formatted.items() if val is None] - header = '\n'.join(table_header) - return header - - def _prepare_selection(self, data_formatted: dict[str, Any]) -> list[tuple[str, str | ValueT]]: - # header rows are mapped to None so make sure - # to exclude those from the selectable data - options = [(key, val) for key, val in data_formatted.items() if val is not None] - - if len(options) > 0: - options.append((self._separator, None)) - - additional_options = self._base_actions + self._terminate_actions - for o in additional_options: - options.append((o, o)) - - return options - def _run_actions_on_entry(self, entry: ValueT) -> None: options = self.filter_options(entry, self._sub_menu_actions) + [self._cancel_action] @@ -153,27 +133,6 @@ class ListManager[ValueT]: if value != self._cancel_action: self._data = self.handle_action(value, entry, self._data) - def reformat(self, data: list[Any]) -> dict[str, Any | None]: - """ - Default implementation of the table to be displayed. - Override if any custom formatting is needed - """ - display_data: dict[str, Any | None] = {} - - if data: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data = {f'{rows[0]}': None, f'{rows[1]}': None} - - for row, entry in zip(rows[2:], data): - display_data[row] = entry - - return display_data - def selected_action_display(self, selection: ValueT) -> str: """ this will return the value to be displayed in the diff --git a/archinstall/lib/menu/menu_helper.py b/archinstall/lib/menu/menu_helper.py index 50d85d64..79ffb5ea 100644 --- a/archinstall/lib/menu/menu_helper.py +++ b/archinstall/lib/menu/menu_helper.py @@ -5,57 +5,52 @@ from archinstall.tui.menu_item import MenuItem, MenuItemGroup class MenuHelper: - @staticmethod - def create_table( - data: list[Any] | None = None, - table_data: tuple[list[Any], str] | None = None, - ) -> tuple[MenuItemGroup, str]: - if data is not None: - table_text = FormattedOutput.as_table(data) - rows = table_text.split('\n') - table = MenuHelper._create_table(data, rows) - elif table_data is not None: - # we assume the table to be - # h1 | h2 - # ----------- - # r1 | r2 - data = table_data[0] - rows = table_data[1].split('\n') - table = MenuHelper._create_table(data, rows) - else: - raise ValueError('Either "data" or "table_data" must be provided') + def __init__( + self, + data: list[Any], + additional_options: list[str] = [] + ) -> None: + self._separator = '' + self._data = data + self._additional_options = additional_options - table, header = MenuHelper._prepare_selection(table) + def create_menu_group(self) -> MenuItemGroup: + table_data_mapping = self._table_to_data_mapping(self._data) + + items = [] + for key, value in table_data_mapping.items(): + item = MenuItem(key, value=value) + + if value is None: + item.read_only = True + + items.append(item) - items = [ - MenuItem(text, value=entry) - for text, entry in table.items() - ] group = MenuItemGroup(items, sort_items=False) - return group, header + return group - @staticmethod - def _create_table(data: list[Any], rows: list[str], header_padding: int = 2) -> dict[str, Any]: - # these are the header rows of the table and do not map to any data obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - padding = ' ' * header_padding - display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None} + def _get_table_header(self, data_formatted: dict[str, Any]) -> list[str]: + table_header = [key for key, val in data_formatted.items() if val is None] + return table_header - for row, entry in zip(rows[2:], data): - display_data[row] = entry + def _table_to_data_mapping(self, data: list[Any]) -> dict[str, Any | None]: + display_data: dict[str, Any | None] = {} + + if data: + table = FormattedOutput.as_table(data) + rows = table.split('\n') + + # these are the header rows of the table + display_data = {f'{rows[0]}': None, f'{rows[1]}': None} + + for row, entry in zip(rows[2:], data): + display_data[row] = entry + + if self._additional_options: + display_data[self._separator] = None + + for option in self._additional_options: + display_data[option] = option return display_data - - @staticmethod - def _prepare_selection(table: dict[str, Any]) -> tuple[dict[str, Any], str]: - # header rows are mapped to None so make sure to exclude those from the selectable data - options = {key: val for key, val in table.items() if val is not None} - header = '' - - if len(options) > 0: - table_header = [key for key, val in table.items() if val is None] - header = '\n'.join(table_header) - - return options, header diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index e8c255b3..d6c6be53 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -101,18 +101,12 @@ class AbstractCurses[ValueT](metaclass=ABCMeta): 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 | None, - offset: int = 0 - ) -> list[ViewportEntry]: - cur_row = 0 + def get_header_entries(self, header: str) -> list[ViewportEntry]: full_header = [] + rows = header.split('\n') - if header: - for line in header.split('\n'): - full_header += [ViewportEntry(line, cur_row, offset, STYLE.NORMAL)] - cur_row += 1 + for cur_row, line in enumerate(rows): + full_header += [ViewportEntry(line, cur_row, 0, STYLE.NORMAL)] return full_header @@ -480,11 +474,15 @@ class EditMenu[ValueT](AbstractCurses[ValueT]): self._max_height, self._max_width = Tui.t().max_yx self._header = header + + self._header_entries = [] + if header: + self._header_entries = self.get_header_entries(header) + self._validator = validator self._allow_skip = allow_skip self._allow_reset = allow_reset self._interrupt_warning = reset_warning_msg - self._headers = self.get_header_entries(header, offset=0) self._alignment = alignment self._edit_width = edit_width self._default_text = default_text @@ -516,8 +514,8 @@ class EditMenu[ValueT](AbstractCurses[ValueT]): self._help_vp = Viewport(self._max_width, 2, 0, y_offset) y_offset += 2 - if self._headers: - header_height = len(self._headers) + if self._header_entries: + header_height = len(self._header_entries) self._header_vp = Viewport(self._max_width, header_height, 0, y_offset, alignment=self._alignment) y_offset += header_height @@ -581,8 +579,8 @@ class EditMenu[ValueT](AbstractCurses[ValueT]): if self._help_vp: self._help_vp.update([self.help_entry()], 0) - if self._headers and self._header_vp: - self._header_vp.update(self._headers, 0) + if self._header_entries and self._header_vp: + self._header_vp.update(self._header_entries, 0) if self._input_vp: self._input_vp.update() @@ -720,9 +718,9 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]): self._interrupt_warning = reset_warning_msg self._header = header - header_offset = self._get_header_offset(header) - - self._headers = self.get_header_entries(header, offset=header_offset) + self._header_entries = [] + if header: + self._header_entries = self.get_header_entries(header) if self._interrupt_warning is None: self._interrupt_warning = str(_('Are you sure you want to reset this setting?')) + '\n' @@ -753,22 +751,6 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]): with_frame=self._frame is not None ) - def _get_header_offset(self, header: str | None) -> int: - # WARNING: any changes here will impact the list manager table view - if self._orientation == Orientation.HORIZONTAL: - return 0 - - lines = header.split('\n') if header else [] - table_header = [line for line in lines if '-' in line] - - longest_header = len(table_header[0]) if table_header else 0 - longest_entry = self._item_group.get_max_width() - - delta = longest_header - longest_entry - offset = delta + 2 # 2 because it seems to align it... - - return offset - def run(self) -> Result[ValueT]: result = Tui.run(self) self._clear_all() @@ -827,8 +809,8 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]): self._help_vp = Viewport(self._max_width, 2, 0, y_offset) y_offset += 2 - if self._headers: - header_height = len(self._headers) + if self._header_entries: + header_height = len(self._header_entries) self._header_vp = Viewport( self._max_width, header_height, @@ -960,7 +942,7 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]): self._update_viewport(self._help_vp, [self.help_entry()]) if self._header_vp: - self._update_viewport(self._header_vp, self._headers) + self._update_viewport(self._header_vp, self._header_entries) if self._menu_vp: self._update_viewport(self._menu_vp, vp_entries) @@ -1126,7 +1108,9 @@ class SelectMenu[ValueT](AbstractCurses[ValueT]): self._prev_scroll_pos = 0 def _multi_prefix(self, item: MenuItem) -> str: - if self._item_group.is_item_selected(item): + if item.read_only: + return ' ' + elif self._item_group.is_item_selected(item): return '[x] ' else: return '[ ] ' diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py index c651c845..46cf0570 100644 --- a/archinstall/tui/menu_item.py +++ b/archinstall/tui/menu_item.py @@ -19,6 +19,7 @@ class MenuItem: 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) @@ -86,23 +87,26 @@ class MenuItemGroup: else: menu_items = sorted(menu_items, key=lambda x: x.text.lower()) - if not focus_item: - focus_item = menu_items[0] + self._filter_pattern: str = '' + self._checkmarks: bool = checkmarks - if focus_item not in menu_items: - raise ValueError('Selected item not in menu') - - self.menu_items: list[MenuItem] = menu_items + 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 - self._checkmarks: bool = checkmarks + if not focus_item: + self.focus_first() - self._filter_pattern: str = '' + if self.focus_item not in self.items: + raise ValueError(f'Selected item not in menu: {focus_item}') + + def add_item(self, item: MenuItem) -> None: + self._menu_items.append(item) + delattr(self, 'items') # resetting the cache def find_by_key(self, key: str) -> MenuItem: - for item in self.menu_items: + for item in self._menu_items: if item.key == key: return item @@ -123,13 +127,13 @@ class MenuItemGroup: item.preview_action = action def set_focus_by_value(self, value: Any) -> None: - for item in self.menu_items: + 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: + for item in self._menu_items: if item.value == value: self.default_item = item break @@ -141,7 +145,7 @@ class MenuItemGroup: if not isinstance(values, list): values = [values] - for item in self.menu_items: + for item in self._menu_items: if item.value in values: self.selected_items.append(item) @@ -168,11 +172,11 @@ class MenuItemGroup: 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]) + 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]) + return max([len(item.text) for item in self._menu_items]) def get_item_text(self, item: MenuItem) -> str: if item.is_empty(): @@ -209,8 +213,9 @@ class MenuItemGroup: @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) - return list(items) + items = filter(lambda item: item.is_empty() or pattern in item.text.lower(), self._menu_items) + l_items = list(items) + return l_items @property def filter_pattern(self) -> str: @@ -299,29 +304,20 @@ class MenuItemGroup: start_item: MenuItem, direction: int ) -> MenuItem | None: - index = self.items.index(start_item) + start_index = self.items.index(start_item) + n = len(items) - start = index + direction - end = 0 + current_index = start_index + for _ in range(n): + current_index = (current_index + direction) % n - if direction == 1: - end = len(items) + index - elif direction == -1: - if index == 0: - end = len(items) * direction - else: - end = index * direction - - for idx in range(start, end, direction): - idx = idx % len(items) - - if self._is_selectable(items[idx]): - return items[idx] + 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: + for item in self._menu_items: if item.mandatory and not item.value: return False return True @@ -335,6 +331,8 @@ class MenuItemGroup: def _is_selectable(self, item: MenuItem) -> bool: if item.is_empty(): return False + elif item.read_only: + return False return self.is_enabled(item)