From ceacab789b8105e46c8e3a855986a4155b9dbd9a Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 15 Feb 2026 01:38:02 +0000 Subject: [PATCH] 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 --- console/core/keybindings.py | 87 +++++++++++++++ console/core/navigation.py | 60 ++++++++++ console/core/screens.py | 46 ++++++++ console/tests/test_core.py | 214 ++++++++++++++++++++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 console/core/keybindings.py create mode 100644 console/core/navigation.py create mode 100644 console/core/screens.py create mode 100644 console/tests/test_core.py diff --git a/console/core/keybindings.py b/console/core/keybindings.py new file mode 100644 index 0000000..3896698 --- /dev/null +++ b/console/core/keybindings.py @@ -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) diff --git a/console/core/navigation.py b/console/core/navigation.py new file mode 100644 index 0000000..bf0b1cb --- /dev/null +++ b/console/core/navigation.py @@ -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) diff --git a/console/core/screens.py b/console/core/screens.py new file mode 100644 index 0000000..d7d3b45 --- /dev/null +++ b/console/core/screens.py @@ -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. + """ diff --git a/console/tests/test_core.py b/console/tests/test_core.py new file mode 100644 index 0000000..45ff9f9 --- /dev/null +++ b/console/tests/test_core.py @@ -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)