Curses menu - Continued (#2569)
* Edit text menu * Fix alignment * Scroll functionality * Fix flake8
This commit is contained in:
parent
0f1c8ab4be
commit
b9ab1e2b16
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue