Curses menu - Continued (#2569)

* Edit text menu

* Fix alignment

* Scroll functionality

* Fix flake8
This commit is contained in:
Daniel Girtler 2024-07-12 03:54:24 +10:00 committed by GitHub
parent 0f1c8ab4be
commit b9ab1e2b16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1321 additions and 780 deletions

View File

@ -10,8 +10,7 @@ from archinstall.lib.global_menu import GlobalMenu
from archinstall.lib.configuration import ConfigurationOutput
from archinstall.lib.installer import Installer
from archinstall.lib.menu import Menu
from archinstall.lib.models import AudioConfiguration
from archinstall.lib.models.bootloader import Bootloader
from archinstall.lib.models import AudioConfiguration, Bootloader
from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.profile.profiles_handler import profile_handler

File diff suppressed because it is too large Load Diff

95
archinstall/tui/help.py Normal file
View File

@ -0,0 +1,95 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import List
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:
general = HelpGroup(
group_id=HelpTextGroupId.GENERAL,
group_entries=[
HelpText('Show help', ['Ctrl+h']),
HelpText('Exit help', ['Esc']),
]
)
navigation = HelpGroup(
group_id=HelpTextGroupId.NAVIGATION,
group_entries=[
HelpText('Scroll up', ['Ctrl+↑']),
HelpText('Scroll down', ['Ctrl+↓']),
HelpText('Move up', ['k', '']),
HelpText('Move down', ['j', '']),
HelpText('Move right', ['l', '']),
HelpText('Move left', ['h', '']),
HelpText('Jump to entry', ['1..9'])
]
)
selection = HelpGroup(
group_id=HelpTextGroupId.SELECTION,
group_entries=[
HelpText('Select on single select', ['Enter']),
HelpText('Select on select', ['Space', 'Tab']),
HelpText('Reset', ['Ctrl-C']),
HelpText('Skip selection menu', ['Esc']),
]
)
search = HelpGroup(
group_id=HelpTextGroupId.SEARCH,
group_entries=[
HelpText('Start search mode', ['/']),
HelpText('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])
max_key_width = max([help.get_key_width() for help in help_texts])
margin = ' ' * 3
for help in help_texts:
help_output += f'{margin}{help.group_id.value}\n'
divider_len = max_desc_width + max_key_width + len(margin * 2)
help_output += margin + '-' * divider_len + '\n'
for entry in help.group_entries:
help_output += (
margin +
entry.description.ljust(max_desc_width, ' ') +
margin +
', '.join(entry.keys) + '\n'
)
help_output += '\n'
return help_output

View File

@ -0,0 +1,247 @@
from dataclasses import dataclass, field
from typing import Any, Self, Optional, List, TYPE_CHECKING
from typing import Callable
from ..lib.output import unicode_ljust
if TYPE_CHECKING:
_: Any
@dataclass
class MenuItem:
text: str
value: Optional[Any] = None
action: Optional[Callable[[Any], Any]] = None
enabled: bool = True
mandatory: bool = False
dependencies: List[Self] = field(default_factory=list)
dependencies_not: List[Self] = field(default_factory=list)
display_action: Optional[Callable[[Any], str]] = None
preview_action: Optional[Callable[[Any], Optional[str]]] = None
key: Optional[Any] = None
@classmethod
def default_yes(cls) -> Self:
return cls(str(_('Yes')))
@classmethod
def default_no(cls) -> Self:
return cls(str(_('No')))
def is_empty(self) -> bool:
return self.text == '' or self.text is None
def get_text(self, spacing: int = 0, suffix: str = '') -> str:
if self.is_empty():
return ''
value_text = ''
if self.display_action:
value_text = self.display_action(self.value)
else:
if self.value is not None:
value_text = str(self.value)
if value_text:
spacing += 2
text = unicode_ljust(str(self.text), spacing, ' ')
else:
text = self.text
return f'{text} {value_text}{suffix}'.rstrip(' ')
@dataclass
class MenuItemGroup:
menu_items: List[MenuItem]
focus_item: Optional[MenuItem] = None
default_item: Optional[MenuItem] = None
selected_items: List[MenuItem] = field(default_factory=list)
sort_items: bool = True
_filter_pattern: str = ''
def __post_init__(self):
if len(self.menu_items) < 1:
raise ValueError('Menu must have at least one item')
if self.sort_items:
self.menu_items = sorted(self.menu_items, key=lambda x: x.text)
if not self.focus_item:
if self.selected_items:
self.focus_item = self.selected_items[0]
else:
self.focus_item = self.menu_items[0]
if self.focus_item not in self.menu_items:
raise ValueError('Selected item not in menu')
@staticmethod
def default_confirm():
return MenuItemGroup(
[MenuItem.default_yes(), MenuItem.default_no()],
sort_items=False
)
def index_of(self, item) -> int:
return self.items.index(item)
def index_focus(self) -> int:
return self.index_of(self.focus_item)
def index_last(self) -> int:
return self.index_of(self.items[-1])
def index_first(self) -> int:
return self.index_of(self.items[0])
@property
def size(self) -> int:
return len(self.items)
@property
def 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(item.text) for item in self.menu_items])
@property
def items(self) -> List[MenuItem]:
f = self._filter_pattern.lower()
items = filter(lambda item: item.is_empty() or f in item.text.lower(), self.menu_items)
return list(items)
@property
def filter_pattern(self):
return self._filter_pattern
def set_filter_pattern(self, pattern: str):
self._filter_pattern = pattern
self.reload_focus_itme()
def append_filter(self, pattern: str):
self._filter_pattern += pattern
self.reload_focus_itme()
def reduce_filter(self):
self._filter_pattern = self._filter_pattern[:-1]
self.reload_focus_itme()
def set_focus_item_index(self, index: int):
items = self.items
non_empty_items = [item for item in items if not item.is_empty()]
if index < 0 or index >= len(non_empty_items):
return
for item in non_empty_items[index:]:
if not item.is_empty():
self.focus_item = item
return
def reload_focus_itme(self):
if self.focus_item not in self.items:
self.focus_first()
def is_item_selected(self, item: MenuItem) -> bool:
return item in self.selected_items
def select_current_item(self):
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 is_focused(self, item: MenuItem) -> bool:
if isinstance(self.focus_item, list):
return item in self.focus_item
else:
return item == self.focus_item
def _first(self, items: List[MenuItem], ignore_empty: bool) -> Optional[MenuItem]:
for item in items:
if not ignore_empty:
return item
if not item.is_empty():
return item
return None
def get_first_item(self, ignore_empty: bool = True) -> Optional[MenuItem]:
return self._first(self.items, ignore_empty)
def get_last_item(self, ignore_empty: bool = True) -> Optional[MenuItem]:
items = self.items
rev_items = list(reversed(items))
return self._first(rev_items, ignore_empty)
def focus_first(self):
first_item = self.get_first_item()
if first_item:
self.focus_item = first_item
def focus_last(self):
last_item = self.get_last_item()
if last_item:
self.focus_item = last_item
def focus_prev(self, skip_empty: bool = True):
items = self.items
if self.focus_item not in items:
return
if self.focus_item == items[0]:
self.focus_item = items[-1]
else:
self.focus_item = items[items.index(self.focus_item) - 1]
if self.focus_item.is_empty() and skip_empty:
self.focus_prev(skip_empty)
def focus_next(self, skip_empty: bool = True):
items = self.items
if self.focus_item not in items:
return
if self.focus_item == items[-1]:
self.focus_item = items[0]
else:
self.focus_item = items[items.index(self.focus_item) + 1]
if self.focus_item.is_empty() and skip_empty:
self.focus_next(skip_empty)
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 verify_item_enabled(self, item: MenuItem) -> bool:
if not item.enabled:
return False
if item in self.menu_items:
for dep in item.dependencies:
if not self.verify_item_enabled(dep):
return False
for dep in item.dependencies_not:
if dep.value is not None:
return False
return True
return False

147
archinstall/tui/types.py Normal file
View File

@ -0,0 +1,147 @@
import curses
from dataclasses import dataclass
from enum import Enum, auto
from typing import Optional, List, TypeVar, Generic
from .menu_item import MenuItem
ItemType = TypeVar('ItemType', MenuItem, List[MenuItem], str)
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 = set(range(32, 127))
# numbers
NUM_KEYS = set(range(49, 58))
# Menu up: up, k
MENU_UP = {259, 107}
# Menu down: down, j
MENU_DOWN = {258, 106}
# Menu left: left, h
MENU_LEFT = {260, 104}
# Menu right: right, l
MENU_RIGHT = {261, 108}
# Menu start: home CTRL-a
MENU_START = {262, 1}
# Menu end: end CTRL-e
MENU_END = {360, 5}
# Enter
ACCEPT = {10}
# Selection: space, tab
MULTI_SELECT = {32, 9}
# Search: /
ENABLE_SEARCH = {47}
# ESC
ESC = {27}
# BACKSPACE (search)
BACKSPACE = {127, 263}
# Help view: CTRL+h
HELP = {8}
# Scroll up: CTRL+up, CTRL+k
SCROLL_UP = {581}
# Scroll down: CTRL+down, CTRL+j
SCROLL_DOWN = {540}
@classmethod
def from_ord(cls, key: int) -> List['MenuKeys']:
matches = []
for group in MenuKeys:
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
class ResultType(Enum):
Selection = auto()
Skip = auto()
Reset = auto()
class MenuOrientation(Enum):
VERTICAL = auto()
HORIZONTAL = auto()
@dataclass
class MenuCell:
item: MenuItem
text: str
class PreviewStyle(Enum):
NONE = auto()
BOTTOM = auto()
RIGHT = auto()
TOP = auto()
# https://www.compart.com/en/unicode/search?q=box+drawings#characters
class Chars:
Horizontal = ""
Vertical = ""
Upper_left = ""
Upper_right = ""
Lower_left = ""
Lower_right = ""
Block = ""
Triangle_up = ""
Triangle_down = ""
@dataclass
class Result(Generic[ItemType]):
type_: ResultType
value: Optional[ItemType]
@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