Remove the old curses-based TUI code (#4497)

The new Textual-based TUI shipped in the April .iso.
This commit is contained in:
correctmost 2026-04-29 03:08:24 -04:00 committed by GitHub
parent 468e33d54f
commit 3223ae4212
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1 additions and 2176 deletions

View File

@ -5,7 +5,6 @@ from typing import Any, Self, override
from archinstall.lib.menu.helpers import Selection from archinstall.lib.menu.helpers import Selection
from archinstall.lib.output import error from archinstall.lib.output import error
from archinstall.lib.translationhandler import tr from archinstall.lib.translationhandler import tr
from archinstall.tui.types import Chars
from archinstall.tui.ui.components import InstanceRunnable from archinstall.tui.ui.components import InstanceRunnable
from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.ui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.ui.result import ResultType from archinstall.tui.ui.result import ResultType
@ -154,7 +153,7 @@ class AbstractSubMenu[ValueT](AbstractMenu[ValueT]):
auto_cursor: bool = True, auto_cursor: bool = True,
allow_reset: bool = False, allow_reset: bool = False,
): ):
back_text = f'{Chars.Right_arrow} ' + tr('Back') back_text = ' ' + tr('Back')
item_group.add_item(MenuItem(text=back_text)) item_group.add_item(MenuItem(text=back_text))
super().__init__( super().__init__(

File diff suppressed because it is too large Load Diff

View File

@ -1,109 +0,0 @@
from dataclasses import dataclass, field
from enum import Enum
from archinstall.lib.translationhandler import tr
class HelpTextGroupId(Enum):
GENERAL = 'General'
NAVIGATION = 'Navigation'
SELECTION = 'Selection'
SEARCH = 'Search'
@dataclass
class HelpText:
description: str
keys: list[str] = field(default_factory=list)
@dataclass
class HelpGroup:
group_id: HelpTextGroupId
group_entries: list[HelpText]
def get_desc_width(self) -> int:
return max([len(e.description) for e in self.group_entries])
def get_key_width(self) -> int:
return max([len(', '.join(e.keys)) for e in self.group_entries])
class Help:
# the groups needs to be classmethods not static methods
# because they rely on the DeferredTranslation setup first;
# if they are static methods, they will be called before the
# translation setup is done
@staticmethod
def general() -> HelpGroup:
return HelpGroup(
group_id=HelpTextGroupId.GENERAL,
group_entries=[
HelpText(tr('Show help'), ['Ctrl+h']),
HelpText(tr('Exit help'), ['Esc']),
],
)
@staticmethod
def navigation() -> HelpGroup:
return HelpGroup(
group_id=HelpTextGroupId.NAVIGATION,
group_entries=[
HelpText(tr('Preview scroll up'), ['PgUp']),
HelpText(tr('Preview scroll down'), ['PgDown']),
HelpText(tr('Move up'), ['k', '']),
HelpText(tr('Move down'), ['j', '']),
HelpText(tr('Move right'), ['l', '']),
HelpText(tr('Move left'), ['h', '']),
HelpText(tr('Jump to entry'), ['1..9']),
],
)
@staticmethod
def selection() -> HelpGroup:
return HelpGroup(
group_id=HelpTextGroupId.SELECTION,
group_entries=[
HelpText(tr('Skip selection (if available)'), ['Esc']),
HelpText(tr('Reset selection (if available)'), ['Ctrl+c']),
HelpText(tr('Select on single select'), ['Enter']),
HelpText(tr('Select on multi select'), ['Space', 'Tab']),
HelpText(tr('Reset'), ['Ctrl-C']),
HelpText(tr('Skip selection menu'), ['Esc']),
],
)
@staticmethod
def search() -> HelpGroup:
return HelpGroup(
group_id=HelpTextGroupId.SEARCH,
group_entries=[
HelpText(tr('Start search mode'), ['/']),
HelpText(tr('Exit search mode'), ['Esc']),
],
)
@staticmethod
def get_help_text() -> str:
help_output = ''
help_texts = [
Help.general(),
Help.navigation(),
Help.selection(),
Help.search(),
]
max_desc_width = max([help.get_desc_width() for help in help_texts]) + 2
max_key_width = max([help.get_key_width() for help in help_texts])
for help_group in help_texts:
help_output += f'{help_group.group_id.value}\n'
divider_len = max_desc_width + max_key_width
help_output += '-' * divider_len + '\n'
for entry in help_group.group_entries:
help_output += entry.description.ljust(max_desc_width, ' ') + ', '.join(entry.keys) + '\n'
help_output += '\n'
return help_output

View File

@ -1,515 +0,0 @@
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()

View File

@ -1,37 +0,0 @@
from dataclasses import dataclass
from enum import Enum, auto
from archinstall.tui.menu_item import MenuItem
class ResultType(Enum):
Selection = auto()
Skip = auto()
Reset = auto()
@dataclass
class Result[ValueT]:
type_: ResultType
_item: MenuItem | list[MenuItem] | str | None
def has_item(self) -> bool:
return self._item is not None
def get_value(self) -> ValueT:
return self.item().get_value() # type: ignore[no-any-return]
def get_values(self) -> list[ValueT]:
return [i.get_value() for i in self.items()]
def item(self) -> MenuItem:
assert self._item is not None and isinstance(self._item, MenuItem)
return self._item
def items(self) -> list[MenuItem]:
assert self._item is not None and isinstance(self._item, list)
return self._item
def text(self) -> str:
assert self._item is not None and isinstance(self._item, str)
return self._item

View File

@ -1,144 +0,0 @@
import curses
from dataclasses import dataclass
from enum import Enum, auto
from typing import Self
SCROLL_INTERVAL = 10
class STYLE(Enum):
NORMAL = 1
CURSOR_STYLE = 2
MENU_STYLE = 3
HELP = 4
ERROR = 5
class MenuKeys(Enum):
# latin keys
STD_KEYS = frozenset(range(32, 127))
# numbers
NUM_KEYS = frozenset(range(49, 58))
# Menu up: up, k
MENU_UP = frozenset({259, 107})
# Menu down: down, j
MENU_DOWN = frozenset({258, 106})
# Menu left: left, h
MENU_LEFT = frozenset({260, 104})
# Menu right: right, l
MENU_RIGHT = frozenset({261, 108})
# Menu start: home CTRL-a
MENU_START = frozenset({262, 1})
# Menu end: end CTRL-e
MENU_END = frozenset({360, 5})
# Enter
ACCEPT = frozenset({10})
# Selection: space, tab
MULTI_SELECT = frozenset({32, 9})
# Search: /
ENABLE_SEARCH = frozenset({47})
# ESC
ESC = frozenset({27})
# BACKSPACE (search)
BACKSPACE = frozenset({127, 263})
# Help view: ctrl+h
HELP = frozenset({8})
# Scroll up: PGUP
SCROLL_UP = frozenset({339})
# Scroll down: PGDOWN
SCROLL_DOWN = frozenset({338})
@classmethod
def from_ord(cls, key: int) -> list[Self]:
matches: list[Self] = []
for group in cls:
if key in group.value:
matches.append(group)
return matches
@classmethod
def decode(cls, key: int) -> str:
byte_str = curses.keyname(key)
return byte_str.decode('utf-8')
class FrameStyle(Enum):
MAX = auto()
MIN = auto()
@dataclass
class FrameProperties:
header: str
w_frame_style: FrameStyle = FrameStyle.MAX
h_frame_style: FrameStyle = FrameStyle.MAX
@classmethod
def max(cls, header: str) -> Self:
return cls(
header,
FrameStyle.MAX,
FrameStyle.MAX,
)
@classmethod
def min(cls, header: str) -> Self:
return cls(
header,
FrameStyle.MIN,
FrameStyle.MIN,
)
class Orientation(Enum):
VERTICAL = auto()
HORIZONTAL = auto()
class PreviewStyle(Enum):
NONE = auto()
BOTTOM = auto()
RIGHT = auto()
TOP = auto()
# https://www.compart.com/en/unicode/search?q=box+drawings#characters
# https://en.wikipedia.org/wiki/Box-drawing_characters
class Chars:
Horizontal = ''
Vertical = ''
Upper_left = ''
Upper_right = ''
Lower_left = ''
Lower_right = ''
Block = ''
Triangle_up = ''
Triangle_down = ''
Check = '+'
Cross = 'x'
Right_arrow = ''
@dataclass
class ViewportEntry:
text: str
row: int
col: int
style: STYLE
class Alignment(Enum):
LEFT = auto()
CENTER = auto()
@dataclass
class FrameDim:
x_start: int
x_end: int
height: int
def x_delta(self) -> int:
return self.x_end - self.x_start