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.configuration import ConfigurationOutput
from archinstall.lib.installer import Installer from archinstall.lib.installer import Installer
from archinstall.lib.menu import Menu from archinstall.lib.menu import Menu
from archinstall.lib.models import AudioConfiguration from archinstall.lib.models import AudioConfiguration, Bootloader
from archinstall.lib.models.bootloader import Bootloader
from archinstall.lib.models.network_configuration import NetworkConfiguration from archinstall.lib.models.network_configuration import NetworkConfiguration
from archinstall.lib.profile.profiles_handler import profile_handler 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