Source code for Karana.KUtils.MultibodyTUI.dialog

# Copyright (c) 2024-2026 Karana Dynamics Pty Ltd. All rights reserved.
#
# NOTICE TO USER:
#
# This source code and/or documentation (the "Licensed Materials") is
# the confidential and proprietary information of Karana Dynamics Inc.
# Use of these Licensed Materials is governed by the terms and conditions
# of a separate software license agreement between Karana Dynamics and the
# Licensee ("License Agreement"). Unless expressly permitted under that
# agreement, any reproduction, modification, distribution, or disclosure
# of the Licensed Materials, in whole or in part, to any third party
# without the prior written consent of Karana Dynamics is strictly prohibited.
#
# THE LICENSED MATERIALS ARE PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND.
# KARANA DYNAMICS DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
# BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, NON-INFRINGEMENT, AND
# FITNESS FOR A PARTICULAR PURPOSE.
#
# IN NO EVENT SHALL KARANA DYNAMICS BE LIABLE FOR ANY DAMAGES WHATSOEVER,
# INCLUDING BUT NOT LIMITED TO LOSS OF PROFITS, DATA, OR USE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGES, WHETHER IN CONTRACT, TORT,
# OR OTHERWISE ARISING OUT OF OR IN CONNECTION WITH THE LICENSED MATERIALS.
#
# U.S. Government End Users: The Licensed Materials are a "commercial item"
# as defined at 48 C.F.R. 2.101, and are provided to the U.S. Government
# only as a commercial end item under the terms of this license.
#
# Any use of the Licensed Materials in individual or commercial software must
# include, in the user documentation and internal source code comments,
# this Notice, Disclaimer, and U.S. Government Use Provision.

"""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