Source code for Karana.KUtils.MultibodyTUI.dialog

"""Classes for controlling dialog in TUIs."""

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 = ""): """Create an EntryDialog instance. 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.tuiMode(on_resume=self.redraw): try: while True: self.redraw() while not (key := terminal.pollKey()): pass if key in ("\r", "\n"): if self.isValid(): 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 isValid(self) -> bool: """Check if the user entered content is valid. Returns ------- bool Always returns True. """ return True
[docs] def footer(self) -> list[str]: """Return the footer message. Returns ------- list[str] The footer message. """ return []
[docs] def redraw(self): """Redraw the TUI.""" terminal.clearScreen() terminal.printLines(self.prompt) entry_str = terminal.invert(self.entry) lines = [f" > {entry_str} ", ""] lines.extend(self.footer()) terminal.printLines(lines)
[docs] class SearchDialog(EntryDialog): """Dialog for searching.""" def __init__(self, universe: list[str], prompt=list[str] | None): """Create an instance of SearchDialog. Parameters ---------- prompt : list[str] | None The search prompt. universe : list[str] The list of possible things to search for. """ self.universe = universe super().__init__(prompt=prompt)
[docs] def matches(self) -> list[str]: """Return a list of matches. Returns ------- list[str] A list of matches from the users search. """ if not self.entry: return [] return [el for el in self.universe if self.entry in el]
[docs] def isValid(self) -> bool: """Check if matches were found. Returns ------- bool True if matches were found, False otherwise. """ return bool(self.matches())
[docs] @staticmethod def boldMatch(string: str, substr: str) -> str: """Make part of a string bold. Parameters ---------- string : str The full string. substr : str The substring to make bold. Returns ------- str A string with the substr bolded. """ 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] def footer(self) -> list[str]: """Return the footer message. Returns ------- list[str] The footer message. """ if not self.entry: return ["Please type a search term (Ctrl-C to abort)"] matches = self.matches() if not matches: return ["No matches found (Ctrl-C to abort)"] max_display = 8 if len(matches) == 1: lines = ["1 match:"] else: lines = [f"{len(matches)} matches:"] displayed_matches = matches[:max_display] for match in displayed_matches: formatted = self.boldMatch(match, self.entry) lines.append(f" {formatted}") remaining_matches = len(matches) - max_display if remaining_matches > 0: lines.append(f" ...{remaining_matches} more") lines.extend(["", "Press Enter to confirm"]) return lines
[docs] @dataclass class FlagData: """Simple class to track the value of a flag.""" brief: str enabled: bool
[docs] def toggle(self): """Toggle the flag.""" self.enabled = not self.enabled
[docs] def __bool__(self): """Return true if the flag is true. Returns ------- bool: True if the flag is True, False otherwise. """ return self.enabled
[docs] class FlagsTUI(TUIBase): """TUI for controlling a set of flags.""" def __init__(self, header: list[str], flags: list[FlagData]): """Create an instance of FlagsTUI. Parameters ---------- header : list[str] The headers to put before the flags. flags : list[FlagData] The the flags to control. """ 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 true if confirmed. Returns ------- bool True if confirmed, False otherwise. """ return self._confirmed
[docs] def redraw(self): """Redraw the TUI.""" 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.clearScreen() terminal.printLines(lines)
def _prev(self): self._index = (self._index - 1) % len(self._flags) def _next(self): self._index = (self._index + 1) % len(self._flags) def _postUpdate(self, flag: FlagData): pass def _toggleCurrent(self): flag = self._flags[self._index] flag.toggle() self._postUpdate(flag) def _toggleShortcut(self, shortcut: str): try: index = self._shortcuts.index(shortcut) flag = self._flags[index] flag.toggle() self._postUpdate(flag) except IndexError: pass
[docs] def handleKey(self, key): """Handle user key presses. Parameters ---------- key : The key pressed. Returns ------- bool True to continue with this TUI, False to exit. """ 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._toggleCurrent() elif key in self._shortcuts: self._toggleShortcut(key) return True
[docs] @dataclass class ChoiceData: """Data associated with a ChoiceTUI.""" brief: str value: Any description: str = ""
[docs] class ChoiceTUI(TUIBase): """A TUI for controlling choices.""" _cancel_choice = ChoiceData(brief="Cancel", value=None) def __init__(self, header: list[str], choices: list[ChoiceData], index: int = 0): """Create an instance of ChoiceTUI. Parameters ---------- header : list[str] The strings to list before the coices. choices : list[ChoiceData] The choices. index : int The currently selected choice. """ 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: """The currently selected choice. Returns ------- ChoiceData | None The currently selected choice. """ if not self._confirmed: return None return self._choices[self._index]
[docs] def redraw(self): """Redraw the TUI.""" 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.clearScreen() terminal.printLines(lines)
def _prev(self): self._index = (self._index - 1) % len(self._choices) self._postUpdate() def _next(self): self._index = (self._index + 1) % len(self._choices) self._postUpdate() def _selectCurrent(self): choice = self._choices[self._index] if choice != self._cancel_choice: self._confirmed = True def _postUpdate(self): pass def _jumpShortcut(self, shortcut: str): try: index = self._shortcuts.index(shortcut) # Trigger an IndexError if not in bounds self._choices[index] self._index = index self._postUpdate() except IndexError: pass
[docs] def handleKey(self, key): """Handle user key presses. Parameters ---------- key : The key pressed. Returns ------- bool True to continue with this TUI, False to exit. """ if key == "q": return False elif key in ["\r", "\n"]: self._selectCurrent() 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._jumpShortcut(key) return True