Fix 3376 - header alignment (#3414)
This commit is contained in:
parent
c161d6fbd7
commit
4c20331633
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 '[ ] '
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue