archinstall/archinstall/tui/menu_item.py

516 lines
13 KiB
Python

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 archinstall.lib.utils.encoding 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[Self | None] = None
_no: ClassVar[Self | None] = None
def __post_init__(self) -> None:
if self.key is not None:
self._id = self.key
else:
self._id = str(id(self))
@override
def __hash__(self) -> int:
return hash(self._id)
def get_id(self) -> str:
return self._id
def get_value(self) -> Any:
assert self.value is not None
return self.value
@classmethod
def yes(cls, action: Callable[[Any], Any] | None = None) -> Self:
if cls._yes is None:
cls._yes = cls(tr('Yes'), value=True, key='yes', action=action)
return cls._yes
@classmethod
def no(cls, action: Callable[[Any], Any] | None = None) -> Self:
if cls._no is None:
cls._no = cls(tr('No'), value=False, key='no', action=action)
return cls._no
def is_empty(self) -> bool:
return self.text == '' or self.text is None
def has_value(self) -> bool:
if self.value is None:
return False
elif isinstance(self.value, list) and len(self.value) == 0:
return False
elif isinstance(self.value, dict) and len(self.value) == 0:
return False
else:
return True
def get_display_value(self) -> str | None:
if self.display_action is not None:
return self.display_action(self.value)
return None
class MenuItemGroup:
def __init__(
self,
menu_items: list[MenuItem],
focus_item: MenuItem | None = None,
default_item: MenuItem | None = None,
sort_items: bool = False,
sort_case_sensitive: bool = True,
checkmarks: bool = False,
) -> None:
if len(menu_items) < 1:
raise ValueError('Menu must have at least one item')
if sort_items:
if sort_case_sensitive:
menu_items = sorted(menu_items, key=lambda x: x.text)
else:
menu_items = sorted(menu_items, key=lambda x: x.text.lower())
self._filter_pattern: str = ''
self._checkmarks: bool = checkmarks
self._menu_items: list[MenuItem] = menu_items
self.focus_item: MenuItem | None = focus_item
self.selected_items: list[MenuItem] = []
self.default_item: MenuItem | None = default_item
if not focus_item:
self.focus_first()
if self.focus_item not in self.items:
raise ValueError(f'Selected item not in menu: {self.focus_item}')
def add_item(self, item: MenuItem) -> None:
self._menu_items.append(item)
del self.items # resetting the cache
def find_by_id(self, item_id: str) -> MenuItem:
for item in self._menu_items:
if item.get_id() == item_id:
return item
raise ValueError(f'No item found for id: {item_id}')
def find_by_key(self, key: str) -> MenuItem:
for item in self._menu_items:
if item.key == key:
return item
raise ValueError(f'No item found for key: {key}')
def get_enabled_items(self) -> list[MenuItem]:
return [it for it in self.items if self.is_enabled(it)]
@classmethod
def yes_no(cls) -> Self:
return cls(
[MenuItem.yes(), MenuItem.no()],
sort_items=True,
)
@classmethod
def from_enum(
cls,
enum_cls: type[Enum],
sort_items: bool = False,
preset: Enum | None = None,
) -> Self:
items = [MenuItem(elem.value, value=elem) for elem in enum_cls]
group = cls(items, sort_items=sort_items)
if preset is not None:
group.set_selected_by_value(preset)
return group
def set_preview_for_all(self, action: Callable[[Any], str | None]) -> None:
for item in self.items:
item.preview_action = action
def set_focus_by_value(self, value: Any) -> None:
for item in self._menu_items:
if item.value == value:
self.focus_item = item
break
def set_default_by_value(self, value: Any) -> None:
for item in self._menu_items:
if item.value == value:
self.default_item = item
break
def set_selected_by_value(self, values: Any | list[Any] | None) -> None:
if values is None:
return
if not isinstance(values, list):
values = [values]
for item in self._menu_items:
if item.value in values:
self.selected_items.append(item)
if values:
self.set_focus_by_value(values[0])
def get_focused_index(self) -> int | None:
items = self.get_enabled_items()
if self.focus_item and items:
try:
return items.index(self.focus_item)
except ValueError:
# on large menus (15k+) when filtering very quickly
# the index search is too slow while the items are reduced
# by the filter and it will blow up as it cannot find the
# focus item
pass
return None
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 archinstall.tui.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
del self.items # resetting the cache
self.focus_first()
def append_filter(self, pattern: str) -> None:
self._filter_pattern += pattern
del self.items # resetting the cache
self.focus_first()
def reduce_filter(self) -> None:
self._filter_pattern = self._filter_pattern[:-1]
del 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 all 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()