diff --git a/agentstack/tui.py b/agentstack/tui.py index 5fd65419..797d67bd 100644 --- a/agentstack/tui.py +++ b/agentstack/tui.py @@ -30,7 +30,7 @@ class RenderException(Exception): POS_ABSOLUTE = "absolute" -class Node: # TODO this needs a better name +class Node: """ A simple data node that can be updated and have callbacks. This is used to populate and retrieve data from an input field inside the user interface. @@ -59,6 +59,17 @@ def remove_callback(self, callback): class Key: + """ + Conversions and convenience methods for key codes. + + Provides booleans about the key pressed: + + `key.BACKSPACE` + `key.is_numeric` + `key.is_alpha` + ... + """ + const = { 'UP': 259, 'DOWN': 258, @@ -75,7 +86,7 @@ class Key: def __init__(self, ch: int): self.ch = ch - def __getattr__(self, name): + def __getattr__(self, name) -> bool: try: return self.ch == self.const[name] except KeyError: @@ -86,11 +97,11 @@ def chr(self): return chr(self.ch) @property - def is_numeric(self): + def is_numeric(self) -> bool: return self.ch >= 48 and self.ch <= 57 @property - def is_alpha(self): + def is_alpha(self) -> bool: return self.ch >= 65 and self.ch <= 122 @@ -248,6 +259,12 @@ def initialize(cls) -> None: class ColorAnimation(Color): + """ + Animate between two colors over a duration. + + Compatible interface with `Color` to add animation to element's color. + """ + start: Color end: Color reversed: bool = False @@ -273,13 +290,13 @@ def to_curses(self) -> int: self.end.reversed = True elif self.start.reversed: self.reversed = True - + if self.bold: self.start.bold = True self.end.bold = True elif self.start.bold: self.bold = True - + elapsed = time.time() - self._start_time if elapsed > self.duration: if self.loop: @@ -308,6 +325,12 @@ def to_curses(self) -> int: class Renderable: + """ + A base class for all renderable modules. + + Handles sizing, positioning, and inserting the module into the grid. + """ + _grid: Optional[curses.window] = None y: int x: int @@ -331,7 +354,6 @@ def __repr__(self): @property def grid(self): - # TODO cleanup # TODO validate that coords and size are within the parent window and give # an explanatory error message. if not self._grid: @@ -352,6 +374,7 @@ def grid(self): return self._grid def move(self, y: int, x: int): + """Move the module's grid to a new position.""" self.y, self.x = y, x if self._grid: if self.positioning == POS_RELATIVE: @@ -376,6 +399,7 @@ def abs_y(self): return self.y def render(self): + """Render the module to the screen.""" pass def hit(self, y, x): @@ -420,6 +444,12 @@ def __repr__(self): return f"{type(self)} at ({self.y}, {self.x}) with value '{self.value[:20]}'" def _get_lines(self, value: str) -> list[str]: + """ + Get the lines to render. + + Called by `render()` using the value of the element. This allows us to have + word wrapping and alignment in all module types. + """ if self.word_wrap: splits = [''] * self.height words = value.split() @@ -464,6 +494,10 @@ def render(self): class NodeElement(Element): + """ + A module that is bound to a node and updates when the node changes. + """ + format: Optional[Callable] = None def __init__( @@ -490,6 +524,12 @@ def destroy(self): class Editable(NodeElement): + """ + A module that can be edited by the user. + + Handles mouse clicks, key input, and managing global editing state. + """ + active: bool _original_value: Any @@ -497,7 +537,7 @@ def __init__( self, coords, dims, - node: Node, + node: Node, color=None, ): super().__init__(coords, dims, node=node, color=color) @@ -596,7 +636,7 @@ def render(self) -> None: color = self.color.to_curses() | curses.A_ITALIC else: color = self.color.to_curses() - + for i, line in enumerate(self._get_lines(str(self.value))): self.grid.addstr(i, 0, line, color) @@ -609,14 +649,20 @@ def render(self) -> None: class Text(Element): + """Basic text module""" + pass class WrappedText(Text): + """Text module with word wrapping""" + word_wrap: bool = True class ASCIIText(Text): + """Text module that renders as ASCII art""" + default_font: str = "pepper" formatter: Figlet _ascii_render: Optional[str] = None # rendered content @@ -643,6 +689,8 @@ def _get_lines(self, value: str) -> list[str]: class BoldText(Text): + """Text module with bold text""" + def __init__( self, coords: tuple[int, int], @@ -655,11 +703,15 @@ def __init__( class Title(BoldText): + """A title module; shortcut for bold, centered text""" + h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE class Button(Element): + """A clickable button module""" + h_align: str = ALIGN_CENTER v_align: str = ALIGN_MIDDLE active: bool = False @@ -736,6 +788,8 @@ class CheckButton(RadioButton): class Contains(Renderable): + """A container for other modules""" + _grid: Optional[curses.window] = None y: int x: int @@ -1115,6 +1169,8 @@ def layout(self) -> list[Renderable]: class App: + """The main application class.""" + stdscr: curses.window height: int width: int @@ -1128,7 +1184,7 @@ class App: def __init__(self, stdscr: curses.window) -> None: self.stdscr = stdscr - self.height, self.width = self.stdscr.getmaxyx() # TODO dynamic resizing + self.height, self.width = self.stdscr.getmaxyx() if not self.width >= self.min_width or not self.height >= self.min_height: raise RenderException( @@ -1210,7 +1266,7 @@ def render(self): if cls == self.view.__class__: break self.load(name) - + # render loop try: self.view.render() @@ -1223,7 +1279,7 @@ def render(self): raise RenderException("Grid not large enough to render all modules.") if "curses function returned NULL" in str(e): pass - #raise RenderException("Window not large enough to render.") + # raise RenderException("Window not large enough to render.") raise e def click(self, y, x):