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

214
console/tests/test_core.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Tests for the core framework: keybindings, navigation, and screen base class.
"""
import pytest
from console.core.keybindings import Key, KeyBindings
from console.core.navigation import Navigation
from console.core.screens import Screen
# =========================================================================
# Key constants
# =========================================================================
class TestKeyConstants:
def test_escape_is_27(self):
assert Key.ESCAPE == 27
def test_enter_is_10(self):
assert Key.ENTER == 10
def test_tab_is_9(self):
assert Key.TAB == 9
def test_backspace_is_127(self):
assert Key.BACKSPACE == 127
def test_arrow_keys_are_not_none(self):
assert Key.UP is not None
assert Key.DOWN is not None
assert Key.LEFT is not None
assert Key.RIGHT is not None
def test_page_keys_are_not_none(self):
assert Key.PGUP is not None
assert Key.PGDN is not None
def test_home_end_are_not_none(self):
assert Key.HOME is not None
assert Key.END is not None
def test_f1_is_not_none(self):
assert Key.F1 is not None
def test_f10_is_not_none(self):
assert Key.F10 is not None
def test_f_keys_are_sequential(self):
"""F1 through F10 should be sequential curses key codes."""
for i in range(1, 10):
f_current = getattr(Key, f"F{i}")
f_next = getattr(Key, f"F{i + 1}")
assert f_next == f_current + 1
# =========================================================================
# KeyBindings
# =========================================================================
class TestKeyBindings:
def test_bind_and_handle_calls_callback(self):
kb = KeyBindings()
called = []
kb.bind(Key.ENTER, lambda: called.append(True))
result = kb.handle(Key.ENTER)
assert result is True
assert len(called) == 1
def test_handle_returns_false_for_unbound_key(self):
kb = KeyBindings()
result = kb.handle(Key.ESCAPE)
assert result is False
def test_bind_overwrites_previous(self):
kb = KeyBindings()
called_a = []
called_b = []
kb.bind(Key.ENTER, lambda: called_a.append(True))
kb.bind(Key.ENTER, lambda: called_b.append(True))
kb.handle(Key.ENTER)
assert len(called_a) == 0
assert len(called_b) == 1
def test_multiple_bindings(self):
kb = KeyBindings()
results = {}
kb.bind(Key.ENTER, lambda: results.update(enter=True))
kb.bind(Key.ESCAPE, lambda: results.update(escape=True))
kb.handle(Key.ENTER)
kb.handle(Key.ESCAPE)
assert results == {"enter": True, "escape": True}
def test_set_footer_and_get_footer_labels(self):
kb = KeyBindings()
labels = [("F1", "Help"), ("F10", "Quit")]
kb.set_footer(labels)
assert kb.get_footer_labels() == labels
def test_get_footer_labels_empty_by_default(self):
kb = KeyBindings()
assert kb.get_footer_labels() == []
# =========================================================================
# Navigation
# =========================================================================
class TestNavigation:
def test_initial_state_is_empty(self):
nav = Navigation()
assert nav.current() is None
assert nav.depth() == 0
def test_push_and_current(self):
nav = Navigation()
nav.push("brands", context={"page": 1}, label="Brands")
result = nav.current()
assert result is not None
screen_name, context = result
assert screen_name == "brands"
assert context == {"page": 1}
def test_push_increases_depth(self):
nav = Navigation()
nav.push("brands", label="Brands")
assert nav.depth() == 1
nav.push("models", label="Models")
assert nav.depth() == 2
def test_pop_returns_previous_screen(self):
nav = Navigation()
nav.push("brands", context={"page": 1}, label="Brands")
nav.push("models", context={"brand": "TOYOTA"}, label="Models")
popped = nav.pop()
assert popped is not None
screen_name, context = popped
assert screen_name == "models"
assert context == {"brand": "TOYOTA"}
# Current should now be brands
current = nav.current()
assert current[0] == "brands"
def test_pop_on_empty_returns_none(self):
nav = Navigation()
result = nav.pop()
assert result is None
def test_pop_on_single_item_returns_it_and_empties(self):
nav = Navigation()
nav.push("home", label="Home")
popped = nav.pop()
assert popped is not None
assert popped[0] == "home"
assert nav.current() is None
assert nav.depth() == 0
def test_breadcrumb_returns_label_list(self):
nav = Navigation()
nav.push("brands", label="Brands")
nav.push("models", label="Toyota Models")
nav.push("years", label="2020")
assert nav.breadcrumb() == ["Brands", "Toyota Models", "2020"]
def test_breadcrumb_empty_when_no_items(self):
nav = Navigation()
assert nav.breadcrumb() == []
def test_breadcrumb_uses_screen_name_as_fallback(self):
nav = Navigation()
nav.push("brands")
assert nav.breadcrumb() == ["brands"]
def test_clear_empties_stack(self):
nav = Navigation()
nav.push("brands", label="Brands")
nav.push("models", label="Models")
nav.clear()
assert nav.depth() == 0
assert nav.current() is None
assert nav.breadcrumb() == []
def test_context_defaults_to_none(self):
nav = Navigation()
nav.push("home", label="Home")
screen_name, context = nav.current()
assert context is None
# =========================================================================
# Screen base class
# =========================================================================
class TestScreen:
def test_has_name_and_title(self):
screen = Screen("brands", "Select Brand")
assert screen.name == "brands"
assert screen.title == "Select Brand"
def test_on_enter_is_callable(self):
screen = Screen("test", "Test")
# Should not raise
screen.on_enter(context=None, db=None, renderer=None)
def test_on_key_is_callable(self):
screen = Screen("test", "Test")
# Should not raise, returns None by default
result = screen.on_key(key=10, context=None, db=None, renderer=None, nav=None)
assert result is None
def test_render_is_callable(self):
screen = Screen("test", "Test")
# Should not raise
screen.render(context=None, db=None, renderer=None)