archinstall/archinstall/tui/curses_menu.py

1369 lines
34 KiB
Python

from __future__ import annotations
import curses
import os
import signal
import sys
from abc import ABCMeta, abstractmethod
from collections.abc import Callable
from curses.ascii import isprint
from curses.textpad import Textbox
from types import FrameType, TracebackType
from typing import Literal, override
from archinstall.lib.translationhandler import tr
from .help import Help
from .menu_item import MenuItem, MenuItemGroup, MenuItemsState
from .result import Result, ResultType
from .types import (
SCROLL_INTERVAL,
STYLE,
Alignment,
Chars,
FrameDim,
FrameProperties,
FrameStyle,
MenuKeys,
Orientation,
PreviewStyle,
ViewportEntry,
)
class AbstractCurses[ValueT](metaclass=ABCMeta):
def __init__(self) -> None:
self._help_window = self._set_help_viewport()
@abstractmethod
def resize_win(self) -> None:
pass
@abstractmethod
def kickoff(self, win: curses.window) -> Result[ValueT]:
pass
def clear_all(self) -> None:
Tui.t().screen.clear()
Tui.t().screen.refresh()
def clear_help_win(self) -> None:
self._help_window.erase()
def _set_help_viewport(self) -> 'Viewport':
max_height, max_width = Tui.t().max_yx
height = max_height - 10
max_help_width = max([len(line) for line in Help.get_help_text().split('\n')])
x_start = int((max_width / 2) - (max_help_width / 2))
return Viewport(
max_help_width + 10,
height,
x_start,
int((max_height / 2) - height / 2),
frame=FrameProperties.min(tr('Archinstall help')),
)
def _confirm_interrupt(self, warning: str) -> bool:
while True:
result = SelectMenu[bool](
MenuItemGroup.yes_no(),
header=warning,
alignment=Alignment.CENTER,
columns=2,
orientation=Orientation.HORIZONTAL,
).run()
match result.type_:
case ResultType.Selection:
if result.item() == MenuItem.yes():
return True
return False
def help_text(self) -> str:
return tr('Press Ctrl+h for help')
def _show_help(self) -> None:
help_text = Help.get_help_text()
lines = help_text.split('\n')
entries = [ViewportEntry('', 0, 0, STYLE.NORMAL)]
entries += [ViewportEntry(f' {e} ', idx + 1, 0, STYLE.NORMAL) for idx, e in enumerate(lines)]
self._help_window.update(entries, 0)
def get_header_entries(self, header: str) -> list[ViewportEntry]:
full_header = []
rows = header.split('\n')
for cur_row, line in enumerate(rows):
full_header += [ViewportEntry(line, cur_row, 0, STYLE.NORMAL)]
return full_header
class AbstractViewport:
def __init__(self) -> None:
pass
def add_str(self, screen: curses.window, row: int, col: int, text: str, color: STYLE) -> None:
try:
screen.addstr(row, col, text, Tui.t().get_color(color))
except curses.error:
# debug(f'Curses error while adding string to viewport: {text}')
pass
def add_frame(
self,
entries: list[ViewportEntry],
max_width: int,
max_height: int,
frame: FrameProperties,
scroll_pct: int | None = None,
) -> list[ViewportEntry]:
if not entries:
return []
dim = self._get_frame_dim(entries, max_width, max_height, frame)
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,
]
return framed_entries
def align_center(self, lines: list[ViewportEntry], width: int) -> int:
max_col = self._max_col(lines)
x_offset = int((width / 2) - (max_col / 2))
return x_offset
def _get_right_frame(
self,
dim: FrameDim,
scroll_percentage: int | None = 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
for i in range(1, dim.height):
right_frame[i] = ViewportEntry(Chars.Vertical, i, dim.x_end - 1, STYLE.NORMAL)
if scroll_percentage is not None:
right_frame[0] = ViewportEntry(Chars.Triangle_up, 0, dim.x_end - 1, STYLE.NORMAL)
right_frame[scroll_height] = ViewportEntry(Chars.Block, scroll_height, dim.x_end - 1, STYLE.NORMAL)
right_frame[dim.height] = ViewportEntry(Chars.Triangle_down, dim.height, dim.x_end - 1, STYLE.NORMAL)
return list(right_frame.values())
def _get_top(
self,
dim: FrameDim,
h_bar: str,
frame: FrameProperties,
scroll_percentage: int | None = None,
) -> ViewportEntry:
top = self._replace_str(h_bar, 1, f' {frame.header} ') if frame.header else h_bar
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: int | None = None,
) -> ViewportEntry:
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])
max_row_cols = [(e.col + len(e.text) + 1) for e in entries]
max_row_cols.append(header_len)
frame_end = max(max_row_cols)
# 2 for frames, 1 for extra space start away from frame
# must align with def _adjust_entries
frame_end += 3 # 2 for frame
frame_height = len(rows) + 1
if frame_height > max_height:
frame_height = max_height
else:
frame_start = 0
frame_end = max_width
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:
# top row frame offset
entry.row += 1
# left side frame offset + extra space from frame to start from
entry.col += 2
return entries
def _num_unique_rows(self, entries: list[ViewportEntry]) -> int:
return len(set([e.row for e in entries]))
def _max_col(self, entries: list[ViewportEntry]) -> int:
values = [len(e.text) + e.col for e in entries]
if not values:
return 0
return max(values)
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 _assemble_entries(self, entries: list[ViewportEntry]) -> str:
if not entries:
return ''
max_col = self._max_col(entries)
view = [max_col * ' '] * self._num_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)
class EditViewport(AbstractViewport):
def __init__(
self,
width: int,
edit_width: int,
edit_height: int,
x_start: int,
y_start: int,
process_key: Callable[[int], int],
frame: FrameProperties,
alignment: Alignment = Alignment.CENTER,
hide_input: bool = False,
) -> None:
super().__init__()
self._max_height, self._max_width = Tui.t().max_yx
self._width = width
self._edit_width = edit_width
self._edit_height = edit_height
self.x_start = x_start
self.y_start = y_start
self._process_key_cb = process_key
self._frame = frame
self._alignment = alignment
self._hide_input = hide_input
self._main_win: curses.window | None = None
self._edit_win: curses.window | None = None
self._textbox: Textbox | None = None
self._init_wins()
def _init_wins(self) -> None:
self._main_win = curses.newwin(self._edit_height, self._width, self.y_start, 0)
self._main_win.nodelay(False)
x_offset = 0
if self._alignment == Alignment.CENTER:
x_offset = int((self._width / 2) - (self._edit_width / 2))
self._edit_win = self._main_win.subwin(
1,
self._edit_width - 2,
self.y_start + 1,
self.x_start + x_offset + 1,
)
def update(self) -> None:
if not self._main_win:
return
self._main_win.erase()
framed = self.add_frame(
[ViewportEntry('', 0, 0, STYLE.NORMAL)],
self._edit_width,
3,
frame=self._frame,
)
x_offset = 0
if self._alignment == Alignment.CENTER:
x_offset = self.align_center(framed, self._width)
for row in framed:
self.add_str(
self._main_win,
row.row,
row.col + x_offset,
row.text,
row.style,
)
self._main_win.refresh()
def textbox_value(self) -> str:
if not self._textbox:
return ''
return self._textbox.gather().strip()
def erase(self) -> None:
if self._main_win:
self._main_win.erase()
self._main_win.refresh()
def edit(self, default_text: str | None = None) -> None:
assert self._edit_win and self._main_win
self._edit_win.erase()
if default_text is not None and len(default_text) > 0:
self._edit_win.addstr(0, 0, default_text)
# 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 = Textbox(self._edit_win)
self._main_win.refresh()
self._textbox.edit(self._process_key_cb)
class Viewport(AbstractViewport):
def __init__(
self,
width: int,
height: int,
x_start: int,
y_start: int,
frame: FrameProperties | None = None,
alignment: Alignment = Alignment.LEFT,
):
super().__init__()
self.width = width
self.height = height
self.x_start = x_start
self.y_start = y_start
self._frame = frame
self._alignment = alignment
self._main_win = curses.newwin(self.height, self.width, self.y_start, self.x_start)
self._main_win.nodelay(False)
self._main_win.standout()
def getch(self) -> int:
return self._main_win.getch()
def erase(self) -> None:
self._main_win.erase()
self._main_win.refresh()
def update(
self,
entries: list[ViewportEntry],
cur_pos: int = 0,
scroll_pos: int | None = None,
) -> None:
# self._state = self._get_viewport_state(lines, cur_pos, scroll_pos)
# visible_entries = self._adjust_entries_row(self._state.displayed_entries)
visible_entries = entries
if self._frame:
visible_entries = self.add_frame(
visible_entries,
self.width,
self.height,
frame=self._frame,
scroll_pct=scroll_pos,
)
x_offset = 0
if self._alignment == Alignment.CENTER:
x_offset = self.align_center(visible_entries, self.width)
self._main_win.erase()
for entry in visible_entries:
self.add_str(
self._main_win,
entry.row,
entry.col + x_offset,
entry.text,
entry.style,
)
self._main_win.refresh()
class EditMenu(AbstractCurses[str]):
def __init__(
self,
title: str,
edit_width: int = 50,
header: str | None = None,
validator: Callable[[str | None], str | None] | None = None,
allow_skip: bool = False,
allow_reset: bool = False,
reset_warning_msg: str | None = None,
alignment: Alignment = Alignment.CENTER,
default_text: str | None = None,
hide_input: bool = False,
):
super().__init__()
self._max_height, self._max_width = Tui.t().max_yx
self._header = header
self._header_entries = []
if header:
self._header_entries = self.get_header_entries(header)
self._validator = validator
self._allow_skip = allow_skip
self._allow_reset = allow_reset
self._interrupt_warning = reset_warning_msg
self._alignment = alignment
self._edit_width = edit_width
self._default_text = default_text
self._hide_input = hide_input
if self._interrupt_warning is None:
self._interrupt_warning = tr('Are you sure you want to reset this setting?') + '\n'
title = f'* {title}' if not self._allow_skip else title
self._frame = FrameProperties(title, FrameStyle.MAX)
self._title_vp: Viewport | None = None
self._header_vp: Viewport | None = None
self._input_vp: EditViewport | None = None
self._info_vp: Viewport | None = None
self._set_default_info = True
self._only_ascii_text = ViewportEntry(tr('Only ASCII characters are supported'), 0, 0, STYLE.NORMAL)
self._init_viewports()
self._last_state: Result[str] | None = None
self._help_active = False
self._current_text = default_text or ''
self._real_input = default_text or ''
def _init_viewports(self) -> None:
y_offset = 0
self._title_vp = Viewport(self._max_width, 2, 0, y_offset)
y_offset += 2
if self._header_entries:
header_height = len(self._header_entries)
self._header_vp = Viewport(self._max_width, header_height, 0, y_offset, alignment=self._alignment)
y_offset += header_height
self._input_vp = EditViewport(
self._max_width,
self._edit_width,
3,
0,
y_offset,
self._process_edit_key,
frame=self._frame,
alignment=self._alignment,
hide_input=self._hide_input,
)
y_offset += 3
self._info_vp = Viewport(self._max_width, 1, 0, y_offset, alignment=self._alignment)
def input(self) -> Result[str]:
result = Tui.run(self)
assert not result.has_item() or isinstance(result.text(), str)
self._clear_all()
return result
@override
def resize_win(self) -> None:
self._draw()
def _clear_all(self) -> None:
if self._title_vp:
self._title_vp.erase()
if self._header_vp:
self._header_vp.erase()
if self._input_vp:
self._input_vp.erase()
if self._info_vp:
self._info_vp.erase()
def _get_input_text(self) -> str | None:
assert self._input_vp
assert self._info_vp
text = self._real_input
self.clear_all()
if self._validator:
if (err := self._validator(text)) is not None:
self.clear_all()
entry = ViewportEntry(err, 0, 0, STYLE.ERROR)
self._info_vp.update([entry], 0)
self._set_default_info = False
self._real_input = ''
return None
return text
def _draw(self) -> None:
if self._title_vp:
help_text = self.help_text()
help_entry = ViewportEntry(help_text, 0, 0, STYLE.NORMAL)
self._title_vp.update([help_entry], 0)
if self._header_entries and self._header_vp:
self._header_vp.update(self._header_entries, 0)
if self._input_vp:
self._input_vp.update()
if self._set_default_info and self._info_vp:
self._info_vp.update([self._only_ascii_text], 0)
self._input_vp.edit(default_text=self._current_text)
@override
def kickoff(self, win: curses.window) -> Result[str]:
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) -> 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 0
# 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 key_handles:
special_key = key_handles[0]
match special_key:
case MenuKeys.HELP:
assert self._input_vp
self._current_text = self._input_vp.textbox_value()
self._clear_all()
self._help_active = True
self._show_help()
return 0
case MenuKeys.ESC:
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
case MenuKeys.BACKSPACE:
if len(self._real_input) > 0:
self._real_input = self._real_input[:-1]
case _:
if isprint(key):
self._real_input += chr(key)
if self._hide_input:
key = 42
else:
try:
if isprint(key):
self._real_input += chr(key)
if self._hide_input:
key = 42
except Exception:
pass
return key
def _handle_interrupt(self) -> bool:
if self._allow_reset:
if self._interrupt_warning:
return self._confirm_interrupt(self._interrupt_warning)
else:
return False
return True
class SelectMenu[ValueT](AbstractCurses[ValueT]):
def __init__(
self,
group: MenuItemGroup,
multi: bool = False,
orientation: Orientation = Orientation.VERTICAL,
alignment: Alignment = Alignment.LEFT,
columns: int = 1,
column_spacing: int = 10,
header: str | None = None,
frame: FrameProperties | None = None,
cursor_char: str = '>',
search_enabled: bool = True,
allow_skip: bool = False,
allow_reset: bool = False,
reset_warning_msg: str | None = None,
preview_style: PreviewStyle = PreviewStyle.NONE,
preview_size: float | Literal['auto'] = 0.2,
preview_frame: FrameProperties | None = None,
additional_title: str | None = None,
):
super().__init__()
self._multi = multi
self._cursor_char = f'{cursor_char} '
self._search_enabled = search_enabled
self._allow_skip = allow_skip
self._allow_reset = allow_reset
self._active_search = False
self._help_active = False
self._item_group = group
self._preview_style = preview_style
self._preview_frame = preview_frame
self._orientation = orientation
self._column_spacing = column_spacing
self._alignment = alignment
self._footers = self._footer_entries()
self._frame = frame
self._interrupt_warning = reset_warning_msg
self._header = header
self._additional_title = additional_title
self._header_entries = []
if header:
self._header_entries = self.get_header_entries(header)
if self._interrupt_warning is None:
self._interrupt_warning = tr('Are you sure you want to reset this setting?') + '\n'
if self._orientation == Orientation.HORIZONTAL:
self._horizontal_cols = columns
else:
self._horizontal_cols = 1
self._prev_scroll_pos: int = 0
self._visible_entries: list[ViewportEntry] = []
self._max_height, self._max_width = Tui.t().max_yx
self._title_vp: Viewport | None = None
self._header_vp: Viewport | None = None
self._footer_vp: Viewport | None = None
self._menu_vp: Viewport | None = None
self._preview_vp: Viewport | None = None
self._init_viewports(preview_size)
assert self._menu_vp is not None
self._items_state: MenuItemsState = MenuItemsState( # type: ignore[unreachable]
self._item_group,
total_cols=self._horizontal_cols,
total_rows=self._menu_vp.height,
with_frame=self._frame is not None,
)
def run(self) -> Result[ValueT]:
result = Tui.run(self)
self._clear_all()
return result
@override
def kickoff(self, win: curses.window) -> Result[ValueT]:
self._draw()
while True:
try:
if not self._help_active:
self._draw()
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)
else:
return self.kickoff(win)
@override
def resize_win(self) -> None:
self._draw()
def _clear_all(self) -> None:
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._title_vp:
self._title_vp.erase()
def _footer_entries(self) -> list[ViewportEntry]:
if self._active_search:
filter_pattern = self._item_group.filter_pattern
return [ViewportEntry(f'/{filter_pattern}', 0, 0, STYLE.NORMAL)]
return []
def _init_viewports(self, arg_prev_size: float | Literal['auto']) -> None:
footer_height = 2 # possible filter at the bottom
y_offset = 0
self._title_vp = Viewport(self._max_width, 2, 0, y_offset)
y_offset += 2
if self._header_entries:
header_height = len(self._header_entries)
self._header_vp = Viewport(
self._max_width,
header_height,
0,
y_offset,
alignment=self._alignment,
)
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,
alignment=self._alignment,
)
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,
alignment=self._alignment,
)
self._preview_vp = Viewport(
prev_size,
available_height,
menu_width,
y_offset,
frame=self._preview_frame,
alignment=self._alignment,
)
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,
alignment=self._alignment,
)
self._preview_vp = Viewport(
self._max_width,
prev_size,
0,
y_offset,
frame=self._preview_frame,
alignment=self._alignment,
)
case PreviewStyle.NONE:
self._menu_vp = Viewport(
self._max_width,
available_height,
0,
y_offset,
frame=self._frame,
alignment=self._alignment,
)
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.get_max_width() + 5
if self._multi:
menu_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) -> None:
footer_entries = self._footer_entries()
items = self._items_state.get_view_items()
vp_entries = self._item_to_vp_entry(items)
if self._title_vp:
title_text = self.help_text()
if self._additional_title is not None:
title_text += f' {self._additional_title}'
title_vp_entry = ViewportEntry(title_text, 0, 0, STYLE.NORMAL)
self._update_viewport(self._title_vp, [title_vp_entry])
if self._header_vp:
self._update_viewport(self._header_vp, self._header_entries)
if self._menu_vp:
self._update_viewport(self._menu_vp, vp_entries)
if vp_entries:
self._update_preview()
elif self._preview_vp:
self._update_viewport(self._preview_vp, [])
if self._footer_vp:
self._update_viewport(self._footer_vp, footer_entries, 0)
def _update_viewport(
self,
viewport: Viewport,
entries: list[ViewportEntry],
cur_pos: int = 0,
) -> None:
if entries:
viewport.update(entries, cur_pos=cur_pos)
else:
viewport.update([])
def _get_col_widths(self, items: list[list[MenuItem]]) -> list[int]:
cols_widths = self._calc_col_widths(items, self._horizontal_cols)
return [col_width + len(self._cursor_char) + self._item_distance() for col_width in cols_widths]
def _item_distance(self) -> int:
if self._horizontal_cols == 1:
return 0
else:
return self._column_spacing
def _item_to_vp_entry(self, items: list[list[MenuItem]]) -> list[ViewportEntry]:
entries = []
cols_widths = self._get_col_widths(items)
for row_idx, row in enumerate(items):
cur_pos = len(self._cursor_char)
for col_idx, cell in enumerate(row):
cur_text = ''
style = STYLE.NORMAL
if cell == self._item_group.focus_item:
cur_text = self._cursor_char
style = STYLE.MENU_STYLE
entries += [ViewportEntry(cur_text, row_idx, cur_pos - len(self._cursor_char), STYLE.CURSOR_STYLE)]
menu_item_text = self._menu_item_text(cell)
entries += [ViewportEntry(menu_item_text, row_idx, cur_pos, style)]
cur_pos += len(menu_item_text)
if col_idx < len(row) - 1:
spacer_len = cols_widths[col_idx] - len(menu_item_text)
entries += [ViewportEntry(' ' * spacer_len, row_idx, cur_pos, STYLE.NORMAL)]
cur_pos += spacer_len
return entries
def _calc_col_widths(self, rows: list[list[MenuItem]], columns: int) -> list[int]:
col_widths = []
for row in rows:
col_entries = []
for column in range(columns):
if column < len(row):
col_entries += [len(row[column].text)]
if col_entries:
col_widths += [max(col_entries)]
return col_widths
def _menu_item_text(self, item: MenuItem) -> str:
item_text = ''
if self._multi and not item.is_empty():
item_text += self._multi_prefix(item)
item_text += self._item_group.get_item_text(item)
return item_text
def _update_preview(self) -> None:
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_vp.update([])
return
action_text = focus_item.preview_action(focus_item)
if not action_text:
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)]
total_prev_rows = max([e.row for e in entries]) + 1 # rows start with 0 and we need the count
available_rows = self._preview_vp.height - 2 # for the preview frame
self._calc_prev_scroll_pos(entries, total_prev_rows)
prev_entries = self._get_scroll_win_prev_entries(entries, total_prev_rows, available_rows)
scroll_pct = self._get_scroll_pct(total_prev_rows, available_rows)
self._preview_vp.update(prev_entries, scroll_pos=scroll_pct)
def _get_scroll_pct(
self,
total_prev_rows: int,
available_rows: int,
) -> int | None:
assert self._preview_vp is not None
if total_prev_rows <= available_rows:
return None
pct = int(self._prev_scroll_pos / total_prev_rows * 100)
if pct + SCROLL_INTERVAL > 100:
pct = 100
if pct < 0:
pct = 0
return pct
def _get_scroll_win_prev_entries(
self,
entries: list[ViewportEntry],
total_prev_rows: int,
available_rows: int,
) -> list[ViewportEntry]:
assert self._preview_vp is not None
if total_prev_rows <= available_rows:
start_row = 0
else:
start_row = self._prev_scroll_pos
end_row = start_row + available_rows
if end_row > total_prev_rows:
end_row = total_prev_rows
prev_entries = [e for e in entries if start_row <= e.row < end_row]
# normalize the rows
for e in prev_entries:
e.row -= start_row
return prev_entries
def _calc_prev_scroll_pos(
self,
entries: list[ViewportEntry],
total_prev_rows: int,
) -> None:
if self._prev_scroll_pos >= total_prev_rows:
self._prev_scroll_pos = total_prev_rows - 2
elif self._prev_scroll_pos < 0:
self._prev_scroll_pos = 0
def _multi_prefix(self, item: MenuItem) -> str:
if item.read_only:
return ' '
elif self._item_group.is_item_selected(item):
return '[x] '
else:
return '[ ] '
def _handle_interrupt(self) -> bool:
if self._allow_reset and self._interrupt_warning:
return self._confirm_interrupt(self._interrupt_warning)
else:
return False
def _process_input_key(self, key: int) -> Result[ValueT] | None:
key_handles = MenuKeys.from_ord(key)
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()
return None
elif MenuKeys.BACKSPACE in key_handles:
self._item_group.reduce_filter()
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:
decoded = MenuKeys.decode(key)
handles = ', '.join([k.name for k in key_handles])
raise ValueError(f'Multiple key matches for key {decoded}: {handles}')
elif len(key_handles) == 0:
return None
handle = key_handles[0]
match handle:
case MenuKeys.HELP:
self._help_active = True
self._clear_all()
self._show_help()
return None
case MenuKeys.ACCEPT:
if self._multi:
if self._item_group.is_mandatory_fulfilled():
if self._item_group.focus_item is not None:
if self._item_group.focus_item not in self._item_group.selected_items:
self._item_group.selected_items.append(self._item_group.focus_item)
return Result(ResultType.Selection, self._item_group.selected_items)
else:
item = self._item_group.focus_item
if item:
if item.action:
item.value = item.action(item.value)
if self._item_group.is_mandatory_fulfilled():
return Result(ResultType.Selection, self._item_group.focus_item)
return None
case MenuKeys.MENU_DOWN | MenuKeys.MENU_RIGHT:
self._focus_item('next')
case MenuKeys.MENU_UP | MenuKeys.MENU_LEFT:
self._focus_item('prev')
case MenuKeys.MENU_START:
self._focus_item('first')
case MenuKeys.MENU_END:
self._focus_item('last')
case MenuKeys.MULTI_SELECT:
if self._multi:
self._item_group.select_current_item()
case MenuKeys.ENABLE_SEARCH:
if self._search_enabled and not self._active_search:
self._active_search = True
self._item_group.set_filter_pattern('')
case MenuKeys.ESC:
if self._active_search:
self._active_search = False
self._item_group.set_filter_pattern('')
else:
if self._allow_skip:
return Result(ResultType.Skip, None)
case MenuKeys.NUM_KEYS:
self._item_group.focus_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
return None
def _focus_item(self, direction: Literal['next', 'prev', 'first', 'last']) -> None:
# reset the preview scroll as the newly focused item
# may have a different preview row count and it'll blow up
self._prev_scroll_pos = 0
match direction:
case 'next':
self._item_group.focus_next()
case 'prev':
self._item_group.focus_prev()
case 'first':
self._item_group.focus_first()
case 'last':
self._item_group.focus_last()
class Tui:
_t: Tui | None = None
def __enter__(self) -> None:
if Tui._t is None:
tui = self.init()
Tui._t = tui
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
self.stop()
@property
def screen(self) -> curses.window:
return self._screen
@staticmethod
def t() -> 'Tui':
assert Tui._t is not None
return Tui._t
@staticmethod
def shutdown() -> None:
if Tui._t is None:
return
Tui.t().stop()
def init(self) -> 'Tui':
self._screen = curses.initscr()
curses.noecho()
curses.cbreak()
curses.curs_set(0)
curses.set_escdelay(25)
self._screen.keypad(True)
self._screen.scrollok(True)
if curses.has_colors():
curses.start_color()
self._set_up_colors()
signal.signal(signal.SIGWINCH, self._sig_win_resize)
self._screen.refresh()
return self
def stop(self) -> None:
try:
curses.nocbreak()
try:
self.screen.keypad(False)
except Exception:
pass
curses.echo()
curses.curs_set(True)
curses.endwin()
except Exception:
# this may happen when curses has not been initialized
pass
Tui._t = None
@staticmethod
def print(
text: str,
row: int = 0,
col: int = 0,
endl: str = '\n',
clear_screen: bool = False,
) -> None:
if clear_screen:
os.system('clear')
if Tui._t is None:
print(text, end=endl)
sys.stdout.flush()
return
# will append the row at the very bottom of the screen
# and also scroll the existing text up by 1 line
if row == -1:
last_row = Tui.t().max_yx[0] - 1
Tui.t().screen.scroll(1)
Tui.t().screen.addstr(last_row, col, text)
else:
Tui.t().screen.addstr(row, col, text)
Tui.t().screen.refresh()
@property
def max_yx(self) -> tuple[int, int]:
return self._screen.getmaxyx()
@staticmethod
def run[ValueT](component: AbstractCurses[ValueT]) -> Result[ValueT]:
if Tui._t is None:
tui = Tui().init()
tui.screen.clear()
results = tui._main_loop(component)
Tui().stop()
return results
else:
tui = Tui._t
tui.screen.clear()
return Tui.t()._main_loop(component)
def _sig_win_resize(self, signum: int, frame: FrameType | None) -> None:
if hasattr(self, '_component') and self._component is not None: # pylint: disable=no-member
self._component.resize_win() # pylint: disable=no-member
def _main_loop[ValueT](self, component: AbstractCurses[ValueT]) -> Result[ValueT]:
self._screen.refresh()
return component.kickoff(self._screen)
def _reset_terminal(self) -> None:
os.system('reset')
def _set_up_colors(self) -> None:
curses.init_pair(STYLE.NORMAL.value, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(STYLE.CURSOR_STYLE.value, curses.COLOR_CYAN, curses.COLOR_BLACK)
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)