from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Generic, TypeVar, Any
from collections.abc import Callable
from . import terminal
from .base import TUIBase
from . import constants as c
[docs]
class EntryDialog:
"""Base class for a user text input TUI"""
def __init__(self, prompt: list[str] | None = None, entry: str = ""):
"""EntryDialog constructor
Parameters
----------
prompt: list[str]
Static prompt text displayed above the entry line
entry: str = ""
Initial value of the entry text
"""
if prompt is None:
prompt = []
self.prompt = prompt
self.entry = entry
[docs]
def run(self) -> str | None:
"""Run the TUI
This blocks until the user entry is confirmed or canceled
"""
with terminal.tui_mode(on_resume=self.redraw):
try:
while True:
self.redraw()
while not (key := terminal.poll_key()):
pass
if key in ("\r", "\n"):
if self.is_valid():
return self.entry
elif key in ("\x7f", "\x08"):
self.entry = self.entry[:-1]
elif key.isprintable():
self.entry += key
except KeyboardInterrupt:
return None
[docs]
def is_valid(self) -> bool:
return True
[docs]
def redraw(self):
terminal.clear_screen()
terminal.print_lines(self.prompt)
entry_str = terminal.invert(self.entry)
lines = [f" > {entry_str} ", ""]
lines.extend(self.footer())
terminal.print_lines(lines)
[docs]
class SearchDialog(EntryDialog):
def __init__(self, universe: list[str], prompt=list[str] | None):
self.universe = universe
super().__init__(prompt=prompt)
[docs]
def matches(self) -> list[str]:
if not self.entry:
return []
return [el for el in self.universe if self.entry in el]
[docs]
def is_valid(self) -> bool:
return bool(self.matches())
[docs]
@staticmethod
def bold_match(string: str, substr: str) -> str:
if not substr:
return string
start = string.find(substr)
if start == -1:
return string
end = start + len(substr)
return string[:start] + terminal.bold(string[start:end]) + string[end:]
[docs]
@dataclass
class FlagData:
brief: str
enabled: bool
[docs]
def toggle(self):
self.enabled = not self.enabled
[docs]
def __bool__(self):
return self.enabled
[docs]
class FlagsTUI(TUIBase):
def __init__(self, header: list[str], flags: list[FlagData]):
assert flags
self._header = header
self._flags = flags
self._index = 0
self._shortcuts = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]
self._confirmed = False
super().__init__()
@property
def confirmed(self):
return self._confirmed
[docs]
def redraw(self):
lines = self._header[:]
lines.append("")
for i, flag in enumerate(self._flags):
enabled_str = "*" if flag.enabled else " "
shortcut_str = " "
if i < len(self._shortcuts):
shortcut_str = f"({self._shortcuts[i]})"
line = f"{shortcut_str} [{enabled_str}] {flag.brief}"
if i == self._index:
line = terminal.invert(line)
lines.append(line)
terminal.clear_screen()
terminal.print_lines(lines)
def _prev(self):
self._index = (self._index - 1) % len(self._flags)
def _next(self):
self._index = (self._index + 1) % len(self._flags)
def _post_update(self, flag: FlagData):
pass
def _toggle_current(self):
flag = self._flags[self._index]
flag.toggle()
self._post_update(flag)
def _toggle_shortcut(self, shortcut: str):
try:
index = self._shortcuts.index(shortcut)
flag = self._flags[index]
flag.toggle()
self._post_update(flag)
except IndexError:
pass
[docs]
def handle_key(self, key):
if key == "q":
return False
elif key in ["\r", "\n"]:
self._confirmed = True
return False
elif key in ["j", c.KEY_DOWN]:
self._next()
elif key in ["k", c.KEY_UP]:
self._prev()
elif key == " ":
self._toggle_current()
elif key in self._shortcuts:
self._toggle_shortcut(key)
return True
[docs]
@dataclass
class ChoiceData:
brief: str
value: Any
description: str = ""
[docs]
class ChoiceTUI(TUIBase):
_cancel_choice = ChoiceData(brief="Cancel", value=None)
def __init__(self, header: list[str], choices: list[ChoiceData], index: int = 0):
assert choices
choices = choices + [self._cancel_choice]
self._header = header
self._choices = choices
self._index = index
self._shortcuts = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"]
self._confirmed = False
super().__init__()
@property
def choice(self) -> ChoiceData | None:
if not self._confirmed:
return None
return self._choices[self._index]
[docs]
def redraw(self):
lines = self._header[:]
lines.append("")
for i, choice in enumerate(self._choices):
shortcut_str = " "
if i < len(self._shortcuts):
shortcut_str = f"({self._shortcuts[i]})"
line = f"{shortcut_str} {choice.brief}"
if i == self._index:
line = terminal.invert(line)
lines.append(line)
lines.append("")
choice = self._choices[self._index]
lines.append(choice.description)
terminal.clear_screen()
terminal.print_lines(lines)
def _prev(self):
self._index = (self._index - 1) % len(self._choices)
self._post_update()
def _next(self):
self._index = (self._index + 1) % len(self._choices)
self._post_update()
def _select_current(self):
choice = self._choices[self._index]
if choice != self._cancel_choice:
self._confirmed = True
def _post_update(self):
pass
def _jump_shortcut(self, shortcut: str):
try:
index = self._shortcuts.index(shortcut)
# Trigger an IndexError if not in bounds
self._choices[index]
self._index = index
self._post_update()
except IndexError:
pass
[docs]
def handle_key(self, key):
if key == "q":
return False
elif key in ["\r", "\n"]:
self._select_current()
return False
elif key in ["j", c.KEY_DOWN]:
self._next()
elif key in ["k", c.KEY_UP]:
self._prev()
elif key in self._shortcuts:
self._jump_shortcut(key)
return True