From b9ab1e2b1670e1f21c9493e1ae768aa5260cc9f0 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Fri, 12 Jul 2024 03:54:24 +1000 Subject: [PATCH] Curses menu - Continued (#2569) * Edit text menu * Fix alignment * Scroll functionality * Fix flake8 --- archinstall/scripts/guided.py | 3 +- archinstall/tui/curses_menu.py | 1609 +++++++++++++++++--------------- archinstall/tui/help.py | 95 ++ archinstall/tui/menu_item.py | 247 +++++ archinstall/tui/types.py | 147 +++ 5 files changed, 1321 insertions(+), 780 deletions(-) create mode 100644 archinstall/tui/help.py create mode 100644 archinstall/tui/menu_item.py create mode 100644 archinstall/tui/types.py diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 385ff500..b6d8c538 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -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 diff --git a/archinstall/tui/curses_menu.py b/archinstall/tui/curses_menu.py index 8bb75f0c..8e847273 100644 --- a/archinstall/tui/curses_menu.py +++ b/archinstall/tui/curses_menu.py @@ -1,661 +1,720 @@ import curses +import curses.panel import os import signal from abc import ABCMeta, abstractmethod -from dataclasses import dataclass, field -from enum import Enum, auto -from typing import Any, Self, Optional, Tuple, Dict, List, TYPE_CHECKING, TypeVar, Generic, Literal +from curses.textpad import Textbox +from dataclasses import dataclass +from pprint import pformat +from types import NoneType +from typing import Any, Optional, Tuple, Dict, List, TYPE_CHECKING, Literal from typing import Callable -from ..lib.output import unicode_ljust, debug + +from .help import Help +from .menu_item import MenuItem, MenuItemGroup +from .types import ( + Result, ResultType, ViewportEntry, + STYLE, FrameProperties, FrameStyle, Alignment, + Chars, MenuKeys, MenuOrientation, PreviewStyle, + MenuCell, _FrameDim, SCROLL_INTERVAL +) +from ..lib.output import debug if TYPE_CHECKING: _: Any -class STYLE(Enum): - NORMAL = 1 - CURSOR_STYLE = 2 - MENU_STYLE = 3 - HELP = 4 +class AbstractCurses(metaclass=ABCMeta): + def __init__(self): + self._help_window: Optional[Viewport] = None + self._set_help_viewport() + @abstractmethod + def resize_win(self): + pass -@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 + def clear_help_win(self): + if self._help_window: + self._help_window.erase() - @classmethod - def default_yes(cls) -> Self: - return cls(str(_('Yes'))) + @abstractmethod + def kickoff(self, win: 'curses._CursesWindow') -> Result: + pass - @classmethod - def default_no(cls) -> Self: - return cls(str(_('No'))) + def _set_help_viewport(self): + max_height, max_width = tui.max_yx + width = max_width - 10 + height = max_height - 10 - 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}' - - -@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 + self._help_window = Viewport( + width, + height, + int((max_width / 2) - width / 2), + int((max_height / 2) - height / 2), + frame=FrameProperties(str(_('Archinstall help')), FrameStyle.MAX) ) - def index_of(self, item) -> int: - return self.items.index(item) + def _confirm_interrupt(self, screen: Any, warning: str) -> bool: + # when a interrupt signal happens then getchr + # doesn't seem to work anymore so we need to + # call it twice to get it to block and wait for input + screen.getch() - def index_focus(self) -> int: - return self.index_of(self.focus_item) + while True: + choice = SelectMenu(MenuItemGroup.default_confirm(), header=warning).single() - def index_last(self) -> int: - return self.index_of(self.items[-1]) + match choice.type_: + case ResultType.Selection: + if choice.value == MenuItem.default_yes(): + return True - 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 + def help_entry(self) -> ViewportEntry: + return ViewportEntry(str(_('Press Ctrl+h for help')), 0, 0, STYLE.NORMAL) - for dep in item.dependencies_not: - if dep.value is not None: - return False + def _show_help(self): + if not self._help_window: + return - return True + help_text = Help.get_help_text() + lines = help_text.split('\n') - return False + entries = [ViewportEntry(e, idx, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] + self._help_window.update(entries, 0) - -class MenuKeys(Enum): - # alphabet keys - STD_KEYS = set(range(32, 127)) - # numbers - NUM_KEYS = set(range(49, 58)) - # up k - MENU_UP = {259, 107} - # down j, down arrow - MENU_DOWN = {258, 106} - # left h, left arrow - MENU_LEFT = {260, 104} - # right l, right arrow - MENU_RIGHT = {261, 108} - # home ctrl-a, Home - MENU_START = {262, 1} - # end ctrl-e, End - MENU_END = {360, 5} - # enter - ACCEPT = {10} - # space tab - MULTI_SELECT = {32, 9} - # / - ENABLE_SEARCH = {47} - # esc - ESC = {27} - # backspace - BACKSPACE = {127, 263} - # help - HELP = {72} - - @classmethod - def from_ord(cls, key: int) -> List['MenuKeys']: - matches = [] - for group in MenuKeys: - if key in group.value: - matches.append(group) - - return matches - - -class ResultType(Enum): - Selection = auto() - Skip = auto() - Reset = auto() - - -V = TypeVar('V', MenuItem, List[MenuItem]) - - -@dataclass -class Result(Generic[V]): - type_: ResultType - value: V - - -@dataclass -class ViewportEntry: - text: str - row: int - col: int - style: STYLE - - -class AbstractCurses(metaclass=ABCMeta): - @abstractmethod - def draw(self): - pass - - @abstractmethod - def process_input_key(self, key: int) -> Optional[Result]: - pass - - @abstractmethod - def handle_interrupt(self) -> bool: - pass - - -class PreviewStyle(Enum): - NONE = auto() - BOTTOM = auto() - RIGHT = auto() - TOP = auto() - - -class FrameChars: - Horizontal = "─" - Vertical = "│" - Upper_left = "┌" - Upper_right = "┐" - Lower_left = "└" - Lower_right = "┘" - - -@dataclass -class Viewport: - width: int - height: int - x_start: int - y_start: int - - _screen: Any = None - - def __post_init__(self): - self._screen = curses.newwin(self.height, self.width, self.y_start, self.x_start) - self._screen.nodelay(False) - - def getch(self): - return self._screen.getch() - - def erase(self): - self._screen.erase() - self._screen.refresh() - - def update( + def get_header_entries( self, - entries: List[ViewportEntry], - cursor_idx: int = 0, - header: List[ViewportEntry] = [], - footer: List[ViewportEntry] = [], - frame: bool = False, - frame_header: Optional[str] = None, - ): - visible_rows = self._find_visible_rows( - entries, - cursor_idx, - frame, - header, - footer, - ) - - if frame: - visible_rows = self._add_frame(visible_rows, frame_header) - - visible_entries = header + visible_rows + footer - self._screen.erase() - - for entry in visible_entries: - # try: - self._add_str( - entry.row, - entry.col, - entry.text, - entry.style - ) - # except Exception: - # pass - - # the parameters of display will determine which section of the pad is shown - # p_1, p_2 : coordinate of upper-left corner of pad area to display. - # p_3, p_4 : coordinate of upper-left corner of window area to be filled with pad content. - # p_5, p_6 : coordinate of lower-right corner of window area to be filled with pad content. - self._screen.refresh() - - def _available_visible_rows( - self, - header: List[ViewportEntry] = [], - footer: List[ViewportEntry] = [], - frame: bool = True - ) -> int: - y_offset = len(header) + len(footer) - y_offset += 2 if frame else 0 - return self.height - y_offset - - def _find_visible_rows( - self, - entries: List[ViewportEntry], - cursor_pos: int, - frame: bool, - header: List[ViewportEntry] = [], - footer: List[ViewportEntry] = [], + header: Optional[str], + alignment: Alignment = Alignment.LEFT ) -> List[ViewportEntry]: - available_rows = self._available_visible_rows(header, footer, frame) + cur_row = 0 + full_header = [] + if header: + for header in header.split('\n'): + full_header += [ViewportEntry(header, cur_row, 0, STYLE.NORMAL)] + cur_row += 1 + + if full_header: + ViewportEntry('', cur_row, 0, STYLE.NORMAL) + cur_row += 1 + + aligned_headers = self._align_headers(alignment, full_header) + return aligned_headers + + def _align_headers( + self, + alignment: Alignment, + headers: List[ViewportEntry] + ) -> List[ViewportEntry]: + if alignment == Alignment.CENTER and headers: + longest_header = max([len(h.text) for h in headers]) + x_offset = int((tui.max_yx[1] / 2) - (longest_header / 2)) + headers = [ViewportEntry(h.text, h.row, x_offset, h.style) for h in headers] + + return headers + + +@dataclass +class AbstractViewport: + def __init__(self): + pass + + def add_str(self, screen: Any, row: int, col: int, text: str, color: STYLE): + try: + screen.addstr(row, col, text, tui.get_color(color)) + except curses.error: + debug('Curses error while adding string to viewport') + + def add_frame( + self, + entries: List[ViewportEntry], + max_width: int, + max_height: int, + frame: FrameProperties, + scroll_pct: Optional[int] = None + ) -> List[ViewportEntry]: if not entries: return [] - if not next(filter(lambda x: x.row == cursor_pos, entries), None): - raise ValueError('cursor position not in entry list') + dim = self._get_frame_dim(entries, max_width, max_height, frame) - if len(entries) <= available_rows: - start = 0 - end = len(entries) - elif cursor_pos < available_rows: - start = 0 - end = available_rows + h_bar = Chars.Horizontal * (dim.x_delta() - 2) + top_ve = self._get_top(dim, h_bar, frame, scroll_pct) + bottom_ve = self._get_bottom(dim, h_bar, scroll_pct) + + frame_border = [] + + for i in range(1, dim.height): + frame_border += [ViewportEntry(Chars.Vertical, i, dim.x_start, STYLE.NORMAL)] + + frame_border += self._get_right_frame(dim, scroll_pct) + + # adjust the original rows and cols of the entries as they need to be + # shrunk by 1 to make space for the frame + entries = self._adjust_entries(entries) + + framed_entries = [ + top_ve, + bottom_ve, + *frame_border, + *entries + ] + + debug(pformat(framed_entries)) + + return framed_entries + + def _get_right_frame( + self, + dim: _FrameDim, + scroll_percentage: Optional[int] = None + ) -> List[ViewportEntry]: + right_frame = [] + scroll_height = int(dim.height * scroll_percentage // 100) if scroll_percentage else 0 + + if scroll_height <= 0: + scroll_height = 1 + elif scroll_height >= dim.height: + scroll_height = dim.height - 1 + + if scroll_percentage is not None: + right_frame = [ + ViewportEntry(Chars.Triangle_up, 0, dim.x_end - 1, STYLE.NORMAL), + ViewportEntry(Chars.Block, scroll_height, dim.x_end - 1, STYLE.NORMAL), + ViewportEntry(Chars.Triangle_down, dim.height, dim.x_end - 1, STYLE.NORMAL) + ] else: - start = cursor_pos - available_rows + 1 - end = cursor_pos + 1 + for i in range(1, dim.height): + right_frame += [ + ViewportEntry(Chars.Vertical, i, dim.x_end - 1, STYLE.NORMAL) + ] - rows = [entry for entry in entries if start <= entry.row < end] - smallest = min([e.row for e in rows]) + return right_frame - for entry in rows: - entry.row = entry.row - smallest + len(header) + def _get_top( + self, + dim: _FrameDim, + h_bar: str, + frame: FrameProperties, + scroll_percentage: Optional[int] = None + ) -> ViewportEntry: + top = self._replace_str(h_bar, 1, f' {frame.header} ') if frame.header else h_bar - return rows + if scroll_percentage is None: + top = Chars.Upper_left + top + Chars.Upper_right + else: + top = Chars.Upper_left + top[:-1] + + return ViewportEntry(top, 0, dim.x_start, STYLE.NORMAL) + + def _get_bottom( + self, + dim: _FrameDim, + h_bar: str, + scroll_pct: Optional[int] = None + ): + if scroll_pct is None: + bottom = Chars.Lower_left + h_bar + Chars.Lower_right + else: + bottom = Chars.Lower_left + h_bar[:-1] + + return ViewportEntry(bottom, dim.height, dim.x_start, STYLE.NORMAL) + + def _get_frame_dim( + self, + entries: List[ViewportEntry], + max_width: int, + max_height: int, + frame: FrameProperties + ) -> _FrameDim: + rows = self._assemble_entries(entries).split('\n') + header_len = len(frame.header) if frame.header else 0 + header_len += 3 # for header padding + + if frame.w_frame_style == FrameStyle.MIN: + frame_start = min([e.col for e in entries]) + frame_end = max([len(r) for r in rows] + [header_len + frame_start]) + frame_end += 3 # 2 for frame, 1 for padding + else: + frame_start = 0 + frame_end = max_width + + if frame.h_frame_style == FrameStyle.MIN: + frame_height = len(rows) + 1 + if frame_height > max_height: + frame_height = max_height + else: + frame_height = max_height - 1 + + return _FrameDim(frame_start, frame_end, frame_height) + + def _adjust_entries(self, entries: List[ViewportEntry]) -> List[ViewportEntry]: + for entry in entries: + entry.row += 1 + entry.col += 1 + + return entries + + def _unique_rows(self, entries: List[ViewportEntry]) -> int: + return len(set([e.row for e in entries])) + + def _max_col(self, entries: List[ViewportEntry]) -> int: + return max([len(e.text) + e.col for e in entries]) + 1 def _replace_str(self, text: str, index: int = 0, replacement: str = '') -> str: len_replace = len(replacement) return f'{text[:index]}{replacement}{text[index + len_replace:]}' - def _add_frame( - self, - entries: List[ViewportEntry], - frame_header: Optional[str] = None, - ) -> List[ViewportEntry]: - rows = self._assemble_str(entries).split('\n') - top = (self.width - 2) * FrameChars.Horizontal + def _assemble_entries(self, entries: List[ViewportEntry]) -> str: + if not entries: + return '' - if frame_header: - top = self._replace_str(top, 3, f' {frame_header} ') - - frame_width = len(FrameChars.Vertical) + 1 - - filler = ' ' * (self.width - frame_width) - filler_nr = self.height - self._unique_rows(entries) - 2 # header and bottom of frame - filler_rows = [filler] * filler_nr - - empty_rows = '\n'.join([f'{FrameChars.Vertical}{r}{FrameChars.Vertical}' for r in filler_rows]) - empty_rows += '\n' if empty_rows else '' - - content_rows = '' - for row in rows: - row = row.expandtabs() - row = row[:self.width] - row = row.ljust(self.width - frame_width) - content_rows += f'{FrameChars.Vertical}{row[:-frame_width]}{FrameChars.Vertical}\n' - - framed = ( - FrameChars.Upper_left + top + FrameChars.Upper_right + '\n' + - content_rows + - empty_rows + - FrameChars.Lower_left + (self.width - 2) * FrameChars.Horizontal + FrameChars.Lower_right - ) - - preview = framed.split('\n') - return [ViewportEntry(e, idx, 0, STYLE.NORMAL) for idx, e in enumerate(preview)] - - def _unique_rows(self, entries: List[ViewportEntry]) -> int: - return len(set([e.row for e in entries])) - - def _assemble_str(self, entries: List[ViewportEntry]) -> str: - view = [self.width * ' '] * self._unique_rows(entries) + max_col = self._max_col(entries) + view = [max_col * ' '] * self._unique_rows(entries) for e in entries: view[e.row] = self._replace_str(view[e.row], e.col, e.text) + view = [v.rstrip() for v in view] return '\n'.join(view) - def _add_str(self, row: int, col: int, text: str, color: STYLE): - if row >= self.height: - raise ValueError(f'Cannot insert row outside available window height: {row} > {self.height - 1}') - if col >= self.width: - raise ValueError(f'Cannot insert col outside available window width: {col} > {self.width - 1}') - self._screen.insstr(row, col, text, tui.get_color(color)) +class EditViewport(AbstractViewport): + def __init__( + self, + width: int, + height: int, + x_start: int, + y_start: int, + process_key: Callable[[int], int], + frame: FrameProperties + ): + super().__init__() + self._max_height, self._max_width = tui.max_yx -class HelpTextGroupId(Enum): - GENERAL = 'General' - NAVIGATION = 'Navigation' - SELECTION = 'Selection' - SEARCH = 'Search' + self.width = width + self.height = height + self.x_start = x_start + self.y_start = y_start + self.process_key = process_key + self._frame = frame + + self._main_win: Optional['curses._CursesWindow'] = None + self._edit_win: Optional['curses._CursesWindow'] = None + self._textbox: Optional[Textbox] = None + + self._init_wins() + + def _init_wins(self): + self._main_win = curses.newwin(self.height, self.width, self.y_start, self.x_start) + self._main_win.nodelay(False) + + self._edit_win = self._main_win.subwin( + 1, + self.width - 2, + self.y_start + 1, + self.x_start + 1 + ) + + def update(self): + if not self._main_win: + return + + self._main_win.erase() + + framed = self.add_frame( + [ViewportEntry('', 0, 0, STYLE.NORMAL)], + self.width, + 3, + frame=self._frame + ) + + for row in framed: + self.add_str(self._main_win, row.row, row.col, row.text, row.style) + + self._main_win.refresh() + + def erase(self): + if self._main_win: + self._main_win.erase() + self._main_win.refresh() + + def edit(self): + if not self._edit_win or not self._main_win: + return + + self._edit_win.erase() + + # if this gets initialized multiple times it will be an overlay + # and ENTER has to be pressed multiple times to accept + if not self._textbox: + self._textbox = curses.textpad.Textbox(self._edit_win) + self._main_win.refresh() + + self._textbox.edit(self.process_key) + + def gather(self) -> Optional[str]: + if not self._textbox: + return None + + return self._textbox.gather().strip() @dataclass -class HelpText: - description: str - keys: List[str] = field(default_factory=list) +class Viewport(AbstractViewport): + def __init__( + self, + width: int, + height: int, + x_start: int, + y_start: int, + enable_scroll: bool = False, + frame: Optional[FrameProperties] = None + ): + super().__init__() + + self.width = width + self.height = height + self.x_start = x_start + self.y_start = y_start + self._enable_scroll = enable_scroll + self._frame = frame + + self._main_win = curses.newwin(self.height, self.width, self.y_start, self.x_start) + self._main_win.nodelay(False) + + def getch(self): + return self._main_win.getch() + + def erase(self): + self._main_win.erase() + self._main_win.refresh() + + def update( + self, + entries: List[ViewportEntry], + cursor_pos: int = 0, + scroll_pos: Optional[int] = 0 + ): + visible_rows, percentage = self._find_visible_rows(entries, cursor_pos, scroll_pos) + + if self._frame: + visible_rows = self.add_frame( + visible_rows, + self.width, + self.height, + frame=self._frame, + scroll_pct=percentage + ) + + self._main_win.erase() + + for entry in visible_rows: + self.add_str( + self._main_win, + entry.row, + entry.col, + entry.text, + entry.style + ) + + self._main_win.refresh() + + def _get_nr_available_rows(self) -> int: + y_offset = 3 if self._frame else 0 + return self.height - y_offset + + def _calc_scroll_percent( + self, total: int, + available_rows: int, + scroll_pos: int + ) -> Optional[int]: + if total <= available_rows: + return None + + percentage = int(scroll_pos / total * 100) + + if percentage + SCROLL_INTERVAL > 100: + percentage = 100 + + return percentage + + def _find_visible_rows( + self, + entries: List[ViewportEntry], + cursor_pos: int, + scroll_pos: Optional[int] = 0 + ) -> Tuple[List[ViewportEntry], Optional[int]]: + if not entries: + return [], 0 + + total_rows = max([e.row for e in entries]) + 1 # rows start with 0 and we need the count + available_rows = self._get_nr_available_rows() + + if scroll_pos is not None: + if total_rows <= available_rows: + start = 0 + end = total_rows + else: + start = scroll_pos + end = scroll_pos + available_rows + else: + if total_rows <= available_rows: + start = 0 + end = total_rows + elif cursor_pos < available_rows: + start = 0 + end = available_rows + else: + start = cursor_pos - available_rows + 1 + end = cursor_pos + 1 + + rows = [entry for entry in entries if start <= entry.row < end] + smallest = min([e.row for e in rows]) + + for entry in rows: + entry.row = entry.row - smallest + + if scroll_pos is not None: + percentage = self._calc_scroll_percent(total_rows, available_rows, scroll_pos) + else: + percentage = None + + return rows, percentage + + def _replace_str(self, text: str, index: int = 0, replacement: str = '') -> str: + len_replace = len(replacement) + return f'{text[:index]}{replacement}{text[index + len_replace:]}' + + def _unique_rows(self, entries: List[ViewportEntry]) -> int: + return len(set([e.row for e in entries])) -@dataclass -class HelpGroup: - group_id: HelpTextGroupId - group_entries: List[HelpText] +class EditMenu(AbstractCurses): + def __init__( + self, + title: str, + header: Optional[str] = None, + validator: Optional[Callable[[str], Optional[str]]] = None, + allow_skip: bool = False, + allow_reset: bool = False, + reset_warning_msg: Optional[str] = None, + alignment: Alignment = Alignment.LEFT + ): + super().__init__() - def get_desc_width(self) -> int: - return max([len(e.description) for e in self.group_entries]) + self._max_height, self._max_width = tui.max_yx - def get_key_width(self) -> int: - return max([len(', '.join(e.keys)) for e in self.group_entries]) + self._header = header + self._validator = validator + self._allow_skip = allow_skip + self._allow_reset = allow_reset + self._interrupt_warning = reset_warning_msg + self._headers = self.get_header_entries(header, alignment=alignment) + self._alignment = alignment + + title = f'* {title}' if not self._allow_skip else title + self._frame = FrameProperties(title, FrameStyle.MAX) + + self._help_vp: Optional[Viewport] = None + self._header_vp: Optional[Viewport] = None + self._input_vp: Optional[EditViewport] = None + self._error_vp: Optional[Viewport] = None + + self._init_viewports() + + self._last_state: Optional[Result] = None + self._help_active = False + + def _init_viewports(self): + x_offset = 0 + y_offset = 0 + edit_width = 50 + + if self._alignment == Alignment.CENTER: + x_offset = int((self._max_width / 2) - edit_width / 2) + + self._help_vp = Viewport(self._max_width, 2, 0, y_offset) + y_offset += 2 + + if self._headers: + header_height = len(self._headers) + 1 + self._header_vp = Viewport(self._max_width, header_height, 0, y_offset) + y_offset += header_height + + self._input_vp = EditViewport( + edit_width, + 3, + x_offset, + y_offset, + self._process_edit_key, + frame=self._frame + ) + y_offset += 3 + + self._error_vp = Viewport(self._max_width, 1, x_offset, y_offset) + + def input(self, ) -> Result[str]: + result = tui.run(self) + + assert isinstance(result.value, (str, NoneType)) + + self._clear_all() + return result + + def resize_win(self): + self._draw() + + def _clear_all(self): + if self._help_vp: + self._help_vp.erase() + if self._header_vp: + self._header_vp.erase() + if self._input_vp: + self._input_vp.erase() + if self._error_vp: + self._error_vp.erase() + + def _get_input_text(self) -> Optional[str]: + assert self._input_vp + assert self._error_vp + + text = self._input_vp.gather() + + if text and self._validator: + if (err := self._validator(text)) is not None: + entry = ViewportEntry(err, 0, 0, STYLE.ERROR) + self._error_vp.update([entry], 0) + return None + + return text + + def _draw(self): + if self._help_vp: + self._help_vp.update([self.help_entry()], 0) + + if self._headers and self._header_vp: + self._header_vp.update(self._headers, 0) + + if self._input_vp: + self._input_vp.update() + self._input_vp.edit() + + def kickoff(self, win: 'curses._CursesWindow') -> Result: + try: + self._draw() + except KeyboardInterrupt: + if not self._handle_interrupt(): + return self.kickoff(win) + else: + self._last_state = Result(ResultType.Reset, None) + + if self._last_state is None: + return self.kickoff(win) + + if self._last_state.type_ == ResultType.Selection: + text = self._get_input_text() + + if text is None: + return self.kickoff(win) + else: + if not text and not self._allow_skip: + return self.kickoff(win) + + return Result(ResultType.Selection, text) + + return self._last_state + + def _process_edit_key(self, key: int): + key_handles = MenuKeys.from_ord(key) + + if self._help_active: + if MenuKeys.ESC in key_handles: + self._help_active = False + self.clear_help_win() + return 7 + return None + + # remove standard keys from the list of key handles + key_handles = [key for key in key_handles if key != MenuKeys.STD_KEYS] + + # regular key stroke should be passed to the editor + if not key_handles: + return key + + special_key = key_handles[0] + + match special_key: + case MenuKeys.HELP: + self._clear_all() + self._help_active = True + self._show_help() + return None + case MenuKeys.ESC: + if self._help_active: + self._help_active = False + self._draw() + if self._allow_skip: + self._last_state = Result(ResultType.Skip, None) + key = 7 + case MenuKeys.ACCEPT: + self._last_state = Result(ResultType.Selection, None) + key = 7 + + return key + + def _handle_interrupt(self) -> bool: + if self._allow_reset: + if self._interrupt_warning: + return self._confirm_interrupt(self._input_vp, self._interrupt_warning) + else: + return False + + return True -class Help: - general = HelpGroup( - group_id=HelpTextGroupId.GENERAL, - group_entries=[ - HelpText('Show help', ['H']), - HelpText('Exit help', ['Esc']), - ] - ) - - navigation = HelpGroup( - group_id=HelpTextGroupId.NAVIGATION, - group_entries=[ - 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 - - -class MenuOrientation(Enum): - VERTICAL = auto() - HORIZONTAL = auto() - - -class MenuAlignment(Enum): - LEFT = auto() - CENTER = auto() - RIGHT = auto() - - -@dataclass -class MenuCell: - item: MenuItem - text: str - - -class NewMenu(AbstractCurses): +class SelectMenu(AbstractCurses): def __init__( self, group: MenuItemGroup, orientation: MenuOrientation = MenuOrientation.VERTICAL, + alignment: Alignment = Alignment.LEFT, columns: int = 1, column_spacing: int = 10, header: Optional[str] = None, + frame: Optional[FrameProperties] = None, cursor_char: str = '>', search_enabled: bool = True, - allow_skip: bool = True, + allow_skip: bool = False, allow_reset: bool = False, reset_warning_msg: Optional[str] = None, preview_style: PreviewStyle = PreviewStyle.NONE, preview_size: float | Literal['auto'] = 0.2, - preview_frame: bool = True, - preview_header: Optional[str] = None + preview_frame: Optional[FrameProperties] = None, ): - self._header = header - self._cursor_char = cursor_char + super().__init__() + + self._cursor_char = f'{cursor_char} ' self._search_enabled = search_enabled self._multi = False self._interrupt_warning = reset_warning_msg self._allow_skip = allow_skip self._allow_reset = allow_reset self._active_search = False + self._help_active = False self._skip_empty_entries = True self._item_group = group self._preview_style = preview_style self._preview_frame = preview_frame - self._preview_header = preview_header self._orientation = orientation self._column_spacing = column_spacing + self._alignment = alignment + self._headers = self.get_header_entries(header, alignment) + self._footers = self._footer_entries() + self._frame = frame if self._orientation == MenuOrientation.HORIZONTAL: self._horizontal_cols = columns @@ -663,129 +722,67 @@ class NewMenu(AbstractCurses): self._horizontal_cols = 1 self._row_entries: List[List[MenuCell]] = [] + self._prev_scroll_pos = 0 self._visible_entries: List[ViewportEntry] = [] self._max_height, self._max_width = tui.max_yx - self._header_viewport: Optional[Viewport] = None - self._footer_viewport: Optional[Viewport] = None - self._menu_viewport: Optional[Viewport] = None - self._preview_viewport: Optional[Viewport] = None - self._help_viewport: Optional[Viewport] = None + self._help_vp: Optional[Viewport] = None + self._header_vp: Optional[Viewport] = None + self._footer_vp: Optional[Viewport] = None + self._menu_vp: Optional[Viewport] = None + self._preview_vp: Optional[Viewport] = None - self._set_viewports(preview_size) - self._set_help_viewport() - - def _clear_all(self): - if self._header_viewport: - self._header_viewport.erase() - if self._menu_viewport: - self._menu_viewport.erase() - if self._preview_viewport: - self._preview_viewport.erase() - if self._footer_viewport: - self._footer_viewport.erase() - if self._help_viewport: - self._help_viewport.erase() - - def _set_help_viewport(self): - width = self._max_width - 10 - height = self._max_height - 10 - - self._help_viewport = Viewport( - width, - height, - int((self._max_width / 2) - width / 2), - int((self._max_height / 2) - height / 2) - ) - - def _set_viewports(self, preview_size): - header_height = 0 - footer_height = 1 # possible filter at the bottom - - if self._header: - header_height = self._header.count('\n') + 2 - self._header_viewport = Viewport(self._max_width, header_height, 0, 0) - - preview_offset = header_height + footer_height - preview_size = self._determine_prev_size(preview_size, offset=preview_offset) - - match self._preview_style: - case PreviewStyle.BOTTOM: - y_split = int(self._max_height * (1 - preview_size)) - height = self._max_height - y_split - footer_height - - self._menu_viewport = Viewport(self._max_width, y_split, 0, header_height) - self._preview_viewport = Viewport(self._max_width, height, 0, y_split) - case PreviewStyle.RIGHT: - x_split = int(self._max_width * (1 - preview_size)) - height = self._max_height - header_height - footer_height - - self._menu_viewport = Viewport(x_split, height, 0, header_height) - self._preview_viewport = Viewport(self._max_width - x_split, height, x_split, header_height) - case PreviewStyle.TOP: - y_split = int(self._max_height * (1 - preview_size)) - height = self._max_height - y_split - footer_height - - self._menu_viewport = Viewport(self._max_width, y_split, 0, height) - self._preview_viewport = Viewport(self._max_width, height - header_height, 0, header_height) - case PreviewStyle.NONE: - height = self._max_height - header_height - footer_height - self._menu_viewport = Viewport(self._max_width, height, 0, header_height) - - self._footer_viewport = Viewport(self._max_width, 1, 0, self._max_height - 1) - - def _determine_prev_size( - self, - preview_size: float | Literal['auto'], - offset: int = 0 - ) -> float: - if not isinstance(preview_size, float) and preview_size != 'auto': - raise ValueError('preview size must be a float or "auto"') - - size: float = 0 - - if preview_size != 'auto': - size = preview_size - else: - match self._preview_style: - case PreviewStyle.RIGHT: - menu_width = self._item_group.max_width + 5 - size = 1 - (menu_width / self._max_width) - case PreviewStyle.BOTTOM: - offset += len(self._item_group.items) - available_height = self._max_height - offset - size = available_height / self._max_height - case PreviewStyle.TOP: - offset += len(self._item_group.items) - available_height = self._max_height - offset - size = available_height / self._max_height - - if size > 0.9: - size = 0.9 - - return size + self._init_viewports(preview_size) def single(self) -> Result[MenuItem]: self._multi = False result = tui.run(self) - assert isinstance(result.value, MenuItem) + assert isinstance(result.value, (MenuItem, NoneType)) + + self._clear_all() return result def multi(self) -> Result[List[MenuItem]]: self._multi = True result = tui.run(self) - assert isinstance(result.value, list) + assert isinstance(result.value, (list, NoneType)) + + self._clear_all() return result - def _header_entries(self) -> List[ViewportEntry]: - if self._header: - header = self._header.split('\n') - return [ViewportEntry(h, idx, 0, STYLE.NORMAL) for idx, h in enumerate(header)] + def kickoff(self, win: 'curses._CursesWindow') -> Result: + self._draw() - return [] + while True: + try: + key = win.getch() + ret = self._process_input_key(key) + + if ret is not None: + return ret + except KeyboardInterrupt: + if self._handle_interrupt(): + return Result(ResultType.Reset, None) + + def resize_win(self): + self._draw() + + def _clear_all(self): + self.clear_help_win() + + if self._header_vp: + self._header_vp.erase() + if self._menu_vp: + self._menu_vp.erase() + if self._preview_vp: + self._preview_vp.erase() + if self._footer_vp: + self._footer_vp.erase() + if self._help_vp: + self._help_vp.erase() def _footer_entries(self) -> List[ViewportEntry]: if self._active_search: @@ -794,39 +791,112 @@ class NewMenu(AbstractCurses): return [] - def draw(self): - header_entries = self._header_entries() + def _init_viewports(self, arg_prev_size: float | Literal['auto']): + footer_height = 2 # possible filter at the bottom + y_offset = 0 + + self._help_vp = Viewport(self._max_width, 2, 0, y_offset) + y_offset += 2 + + if self._headers: + header_height = len(self._headers) + 1 + self._header_vp = Viewport(self._max_width, header_height, 0, y_offset) + y_offset += header_height + + prev_offset = y_offset + footer_height + prev_size = self._determine_prev_size(arg_prev_size, offset=prev_offset) + available_height = self._max_height - y_offset - footer_height + + match self._preview_style: + case PreviewStyle.BOTTOM: + menu_height = available_height - prev_size + + self._menu_vp = Viewport(self._max_width, menu_height, 0, y_offset, frame=self._frame) + self._preview_vp = Viewport(self._max_width, prev_size, 0, menu_height + y_offset, + frame=self._preview_frame) + case PreviewStyle.RIGHT: + menu_width = self._max_width - prev_size + + self._menu_vp = Viewport(menu_width, available_height, 0, y_offset, frame=self._frame) + self._preview_vp = Viewport(prev_size, available_height, menu_width, y_offset, + frame=self._preview_frame) + case PreviewStyle.TOP: + menu_height = available_height - prev_size + + self._menu_vp = Viewport(self._max_width, menu_height, 0, prev_size + y_offset, frame=self._frame) + self._preview_vp = Viewport(self._max_width, prev_size, 0, y_offset, frame=self._preview_frame) + case PreviewStyle.NONE: + self._menu_vp = Viewport(self._max_width, available_height, 0, y_offset, frame=self._frame) + + self._footer_vp = Viewport(self._max_width, footer_height, 0, self._max_height - footer_height) + + def _determine_prev_size( + self, + preview_size: float | Literal['auto'], + offset: int = 0 + ) -> int: + if not isinstance(preview_size, float) and preview_size != 'auto': + raise ValueError('preview size must be a float or "auto"') + + prev_size: int = 0 + + if preview_size == 'auto': + match self._preview_style: + case PreviewStyle.RIGHT: + menu_width = self._item_group.max_width + 5 + prev_size = self._max_width - menu_width + case PreviewStyle.BOTTOM: + menu_height = len(self._item_group.items) + 1 # leave empty line between menu and preview + prev_size = self._max_height - offset - menu_height + case PreviewStyle.TOP: + menu_height = len(self._item_group.items) + prev_size = self._max_height - offset - menu_height + else: + match self._preview_style: + case PreviewStyle.RIGHT: + prev_size = int(self._max_width * preview_size) + case PreviewStyle.BOTTOM: + prev_size = int((self._max_height - offset) * preview_size) + case PreviewStyle.TOP: + prev_size = int((self._max_height - offset) * preview_size) + + return prev_size + + def _draw(self): footer_entries = self._footer_entries() vp_entries = self._get_row_entries() - cursor_idx = self._cursor_index() + cursor_pos = self._get_cursor_pos() - if self._header_viewport: - self._update_viewport(self._header_viewport, header_entries) + if self._help_vp: + self._update_viewport(self._help_vp, [self.help_entry()]) - if self._menu_viewport: - self._update_viewport(self._menu_viewport, vp_entries, cursor_idx) + if self._header_vp: + self._update_viewport(self._header_vp, self._headers) + + if self._menu_vp: + self._update_viewport(self._menu_vp, vp_entries, cursor_pos=cursor_pos) if vp_entries: self._update_preview() - elif self._preview_viewport: - self._update_viewport(self._preview_viewport, []) + elif self._preview_vp: + self._update_viewport(self._preview_vp, []) - if self._footer_viewport: - self._update_viewport(self._footer_viewport, footer_entries, 0) + if self._footer_vp: + self._update_viewport(self._footer_vp, footer_entries, 0) def _update_viewport( self, viewport: Viewport, entries: List[ViewportEntry], - cursor_idx: int = 0 + cursor_pos: int = 0 ): if entries: - viewport.update(entries, cursor_idx) + viewport.update(entries, cursor_pos=cursor_pos) else: viewport.update([]) - def _cursor_index(self) -> int: + def _get_cursor_pos(self) -> int: for idx, cell in enumerate(self._row_entries): if self._item_group.focus_item in cell: return idx @@ -835,36 +905,50 @@ class NewMenu(AbstractCurses): def _get_visible_items(self) -> List[MenuItem]: return [it for it in self._item_group.items if self._item_group.verify_item_enabled(it)] - def _to_cols(self, items: List[MenuItem], cols: int) -> List[List[MenuItem]]: + def _list_to_cols(self, items: List[MenuItem], cols: int) -> List[List[MenuItem]]: return [items[i:i + cols] for i in range(0, len(items), cols)] - def _get_row_entries(self) -> List[ViewportEntry]: - cells = self._determine_menu_cells() - cursor = f'{self._cursor_char} ' - entries = [] - cols = self._horizontal_cols + def _get_col_widths(self) -> List[int]: + cols_widths = self._calc_col_widths(self._row_entries, self._horizontal_cols) + return [col_width + len(self._cursor_char) + self._item_distance() for col_width in cols_widths] - if cols == 1: - item_distance = 0 + def _item_distance(self) -> int: + if self._horizontal_cols == 1: + return 0 else: - item_distance = self._column_spacing + return self._column_spacing - self._row_entries = [cells[x:x + cols] for x in range(0, len(cells), cols)] - cols_widths = self._calc_col_widths(self._row_entries, cols) - cols_widths = [col_width + len(cursor) + item_distance for col_width in cols_widths] + def _cols_x_align_offset(self) -> int: + assert self._menu_vp + + x_offset = 0 + if self._alignment == Alignment.CENTER: + cols_widths = self._get_col_widths() + total_col_width = sum(cols_widths) + x_offset = int((self._menu_vp.width / 2) - (total_col_width / 2)) + + return x_offset + + def _get_row_entries(self) -> List[ViewportEntry]: + cells = self._assemble_menu_cells() + entries = [] + + self._row_entries = [cells[x:x + self._horizontal_cols] for x in range(0, len(cells), self._horizontal_cols)] + cols_widths = self._get_col_widths() + x_offset = self._cols_x_align_offset() for row_idx, row in enumerate(self._row_entries): - cur_pos = len(cursor) + cur_pos = len(self._cursor_char) + x_offset for col_idx, cell in enumerate(row): cur_text = '' style = STYLE.NORMAL if cell.item == self._item_group.focus_item: - cur_text = cursor + cur_text = self._cursor_char style = STYLE.MENU_STYLE - entries += [ViewportEntry(cur_text, row_idx, cur_pos - len(cursor), STYLE.CURSOR_STYLE)] + entries += [ViewportEntry(cur_text, row_idx, cur_pos - len(self._cursor_char), STYLE.CURSOR_STYLE)] entries += [ViewportEntry(cell.text, row_idx, cur_pos, style)] cur_pos += len(cell.text) @@ -893,7 +977,7 @@ class NewMenu(AbstractCurses): return col_widths - def _determine_menu_cells(self) -> List[MenuCell]: + def _assemble_menu_cells(self) -> List[MenuCell]: items = self._get_visible_items() entries = [] @@ -911,63 +995,35 @@ class NewMenu(AbstractCurses): return entries def _update_preview(self): - if not self._preview_viewport: + if not self._preview_vp: return focus_item = self._item_group.focus_item if not focus_item or focus_item.preview_action is None: - self._preview_viewport.update([]) + self._preview_vp.update([]) return action_text = focus_item.preview_action(focus_item) if not action_text: - self._preview_viewport.update([]) + self._preview_vp.update([]) return preview_text = action_text.split('\n') entries = [ViewportEntry(e, idx, 0, STYLE.NORMAL) for idx, e in enumerate(preview_text)] - self._preview_viewport.update( - entries, - frame=self._preview_frame, - frame_header=self._preview_header, - ) + self._calc_prev_scroll_pos(entries) - def _show_help(self): - assert self._help_viewport + self._preview_vp.update(entries, scroll_pos=self._prev_scroll_pos) - help_text = Help.get_help_text() - lines = help_text.split('\n') + def _calc_prev_scroll_pos(self, entries: List[ViewportEntry]): + total_rows = max([e.row for e in entries]) + 1 # rows start with 0 and we need the count - entries = [ViewportEntry(e, idx, 0, STYLE.NORMAL) for idx, e in enumerate(lines)] - self._clear_all() - - self._help_viewport.update(entries, 0, frame=True, frame_header=str(_('Archinstall help'))) - - def _confirm_interrupt(self) -> bool: - # when a interrupt signal happens then getchr - # doesn't seem to work anymore so we need to - # call it twice to get it to block and wait for input - assert self._menu_viewport is not None - self._menu_viewport.getch() - - while True: - warning_text = f'{self._interrupt_warning}' - - choice = NewMenu( - MenuItemGroup.default_confirm(), - header=warning_text, - cursor_char=self._cursor_char - ).single() - - match choice.type_: - case ResultType.Selection: - if choice.value == MenuItem.default_yes(): - return True - - return False + if self._prev_scroll_pos >= total_rows: + self._prev_scroll_pos = total_rows - 2 + elif self._prev_scroll_pos < 0: + self._prev_scroll_pos = 0 def _default_suffix(self, item: MenuItem) -> str: suffix = '' @@ -983,41 +1039,43 @@ class NewMenu(AbstractCurses): else: return '[ ] ' - def handle_interrupt(self) -> bool: - debug('Signal interrupt') - + def _handle_interrupt(self) -> bool: if self._allow_reset: if self._interrupt_warning: - return self._confirm_interrupt() + return self._confirm_interrupt(self._menu_vp, self._interrupt_warning) else: return False return True - def process_input_key(self, key: int) -> Optional[Result]: + def _process_input_key(self, key: int) -> Optional[Result]: key_handles = MenuKeys.from_ord(key) - debug(f'key: {key}, key_handles: {key_handles}') + if self._help_active: + if MenuKeys.ESC in key_handles: + self._help_active = False + self.clear_help_win() + self._draw() + return None # special case when search is currently active if self._active_search: if MenuKeys.STD_KEYS in key_handles: self._item_group.append_filter(chr(key)) - self.draw() + self._draw() return None elif MenuKeys.BACKSPACE in key_handles: self._item_group.reduce_filter() - self.draw() + self._draw() return None # remove standard keys from the list of key handles key_handles = [key for key in key_handles if key != MenuKeys.STD_KEYS] if len(key_handles) > 1: - byte_str = curses.keyname(key) - dec_str = byte_str.decode('utf-8') + decoded = MenuKeys.decode(key) handles = ', '.join([k.name for k in key_handles]) - raise ValueError(f'Multiple key matches for key {dec_str}: {handles}') + raise ValueError(f'Multiple key matches for key {decoded}: {handles}') elif len(key_handles) == 0: return None @@ -1025,6 +1083,8 @@ class NewMenu(AbstractCurses): match handle: case MenuKeys.HELP: + self._help_active = True + self._clear_all() self._show_help() return None case MenuKeys.ACCEPT: @@ -1064,10 +1124,14 @@ class NewMenu(AbstractCurses): return Result(ResultType.Skip, None) case MenuKeys.NUM_KEYS: self._item_group.set_focus_item_index(key - 49) + case MenuKeys.SCROLL_DOWN: + self._prev_scroll_pos += SCROLL_INTERVAL + case MenuKeys.SCROLL_UP: + self._prev_scroll_pos -= SCROLL_INTERVAL case _: pass - self.draw() + self._draw() return None def _focus_item(self, key: MenuKeys): @@ -1115,6 +1179,13 @@ class NewMenu(AbstractCurses): class Tui: def __init__(self): + self._screen: Any = None + self._colors: Dict[str, int] = {} + self._component: Optional[AbstractCurses] = None + + signal.signal(signal.SIGWINCH, self._sig_win_resize) + + def init(self): self._screen = curses.initscr() curses.noecho() @@ -1128,13 +1199,8 @@ class Tui: curses.start_color() self._set_up_colors() - self._colors: Dict[str, int] = {} self._soft_clear_terminal() - self._component: Optional[AbstractCurses] = None - - signal.signal(signal.SIGWINCH, self._sig_win_resize) - @property def screen(self) -> Any: return self._screen @@ -1144,29 +1210,15 @@ class Tui: return self._screen.getmaxyx() def run(self, component: AbstractCurses) -> Result: - ret = self._main_loop(component) - return ret + return self._main_loop(component) def _sig_win_resize(self, signum: int, frame): if self._component: - self._component.draw() + self._component.resize_win() def _main_loop(self, component: AbstractCurses) -> Result: self._screen.refresh() - component.draw() - - while True: - try: - key = self._screen.getch() - ret = component.process_input_key(key) - - if ret is not None: - return ret - except KeyboardInterrupt: - if component.handle_interrupt(): - return Result(ResultType.Reset, None) - else: - component.draw() + return component.kickoff(self._screen) def _reset_terminal(self): os.system("reset") @@ -1181,6 +1233,7 @@ class Tui: curses.init_pair(STYLE.MENU_STYLE.value, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(STYLE.MENU_STYLE.value, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(STYLE.HELP.value, curses.COLOR_GREEN, curses.COLOR_BLACK) + curses.init_pair(STYLE.ERROR.value, curses.COLOR_RED, curses.COLOR_BLACK) def get_color(self, color: STYLE) -> int: return curses.color_pair(color.value) diff --git a/archinstall/tui/help.py b/archinstall/tui/help.py new file mode 100644 index 00000000..2b833336 --- /dev/null +++ b/archinstall/tui/help.py @@ -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 diff --git a/archinstall/tui/menu_item.py b/archinstall/tui/menu_item.py new file mode 100644 index 00000000..d2e66b51 --- /dev/null +++ b/archinstall/tui/menu_item.py @@ -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 diff --git a/archinstall/tui/types.py b/archinstall/tui/types.py new file mode 100644 index 00000000..da69e806 --- /dev/null +++ b/archinstall/tui/types.py @@ -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