feat(console): add core framework - keybindings, navigation, screen base

Add the three core modules that all screens depend on:
- keybindings.py: Key constants (curses codes) and KeyBindings registry
- navigation.py: Stack-based screen navigation with breadcrumbs
- screens.py: Screen base class with on_enter/on_key/render lifecycle

Includes 31 tests covering all public APIs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 01:38:02 +00:00
parent 3b884e24d3
commit ceacab789b
4 changed files with 407 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
"""
Key constants and key-binding registry for the console UI.
Key provides named constants matching curses key codes so that screens
and renderers never need to import curses directly.
KeyBindings maps key codes to callable actions and tracks the footer
labels displayed at the bottom of the screen.
"""
import curses
class Key:
"""Key constants matching curses key codes."""
ESCAPE = 27
ENTER = 10
TAB = 9
BACKSPACE = 127
UP = curses.KEY_UP
DOWN = curses.KEY_DOWN
LEFT = curses.KEY_LEFT
RIGHT = curses.KEY_RIGHT
PGUP = curses.KEY_PPAGE
PGDN = curses.KEY_NPAGE
HOME = curses.KEY_HOME
END = curses.KEY_END
F1 = curses.KEY_F1
F2 = curses.KEY_F2
F3 = curses.KEY_F3
F4 = curses.KEY_F4
F5 = curses.KEY_F5
F6 = curses.KEY_F6
F7 = curses.KEY_F7
F8 = curses.KEY_F8
F9 = curses.KEY_F9
F10 = curses.KEY_F10
class KeyBindings:
"""Registry that maps key codes to callable actions.
Usage::
kb = KeyBindings()
kb.bind(Key.ENTER, lambda: do_something())
handled = kb.handle(Key.ENTER) # True, callback was invoked
"""
def __init__(self):
self._bindings: dict[int, callable] = {}
self._footer_labels: list[tuple[str, str]] = []
def bind(self, key: int, action: callable) -> None:
"""Register *action* as the callback for *key*.
If *key* already has a binding it is replaced.
"""
self._bindings[key] = action
def handle(self, key: int) -> bool:
"""Look up *key* and invoke its callback if one exists.
Returns ``True`` if a callback was found and executed,
``False`` otherwise.
"""
action = self._bindings.get(key)
if action is not None:
action()
return True
return False
def set_footer(self, labels: list[tuple[str, str]]) -> None:
"""Set the footer bar labels.
*labels* is a list of ``(key_label, description)`` tuples, e.g.
``[("F1", "Help"), ("F10", "Quit")]``.
"""
self._footer_labels = list(labels)
def get_footer_labels(self) -> list[tuple[str, str]]:
"""Return the current footer labels list."""
return list(self._footer_labels)

View File

@@ -0,0 +1,60 @@
"""
Screen-stack navigation for the console UI.
Navigation maintains a stack of ``(screen_name, context, label)`` entries.
Screens push onto the stack when the user drills into a sub-view and pop
when they press Escape / Backspace to go back.
"""
class Navigation:
"""A simple stack-based navigator.
Each entry is a tuple ``(screen_name, context, label)`` where
*screen_name* identifies which screen to display, *context* carries
any data the screen needs, and *label* is the human-readable text
shown in the breadcrumb trail.
"""
def __init__(self):
self._stack: list[tuple[str, object, str]] = []
def push(self, screen_name: str, context=None, label: str | None = None) -> None:
"""Push a new screen onto the stack.
If *label* is ``None`` the *screen_name* is used as fallback in
the breadcrumb.
"""
self._stack.append((screen_name, context, label if label is not None else screen_name))
def pop(self) -> tuple[str, object] | None:
"""Remove and return the top entry as ``(screen_name, context)``.
Returns ``None`` if the stack is empty.
"""
if not self._stack:
return None
screen_name, context, _label = self._stack.pop()
return (screen_name, context)
def current(self) -> tuple[str, object] | None:
"""Return the top entry as ``(screen_name, context)`` without removing it.
Returns ``None`` if the stack is empty.
"""
if not self._stack:
return None
screen_name, context, _label = self._stack[-1]
return (screen_name, context)
def breadcrumb(self) -> list[str]:
"""Return the list of labels from bottom to top of the stack."""
return [label for _name, _ctx, label in self._stack]
def clear(self) -> None:
"""Remove all entries from the stack."""
self._stack.clear()
def depth(self) -> int:
"""Return the number of entries on the stack."""
return len(self._stack)

46
console/core/screens.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Base screen class for the console UI.
Every screen in the application inherits from :class:`Screen` and overrides
:meth:`on_enter`, :meth:`on_key`, and :meth:`render`.
"""
class Screen:
"""Abstract base for all console screens.
Subclasses must override the three lifecycle methods to provide real
behaviour. The base implementations are intentional no-ops so that
simple screens (e.g. a static splash page) need not implement every
method.
Attributes:
name: Machine-readable identifier used by :class:`Navigation`.
title: Human-readable heading displayed at the top of the screen.
"""
def __init__(self, name: str, title: str):
self.name = name
self.title = title
def on_enter(self, context, db, renderer) -> None:
"""Called once when this screen becomes the active screen.
Use this hook to load data, reset scroll positions, or set up
key bindings specific to the screen.
"""
def on_key(self, key: int, context, db, renderer, nav):
"""Handle a single keypress.
Returns a navigation instruction (e.g. a dict or tuple) when the
screen wants to push/pop, or ``None`` to stay on the current
screen.
"""
return None
def render(self, context, db, renderer) -> None:
"""Draw the screen contents using *renderer*.
Called after every keypress and on initial display.
"""