Fix 3376 - header alignment (#3414)

This commit is contained in:
Daniel Girtler 2025-04-28 22:04:15 +10:00 committed by GitHub
parent c161d6fbd7
commit 4c20331633
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 118 additions and 183 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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