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:
87
console/core/keybindings.py
Normal file
87
console/core/keybindings.py
Normal 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)
|
||||||
60
console/core/navigation.py
Normal file
60
console/core/navigation.py
Normal 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
46
console/core/screens.py
Normal 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
214
console/tests/test_core.py
Normal 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)
|
||||||
Reference in New Issue
Block a user