Skip to content

API Layers

lazy-cuh is a layered framework for building lazygit-style TUIs. It needs the low-level primitives for correctness and advanced control, but app authors should not have to wire every primitive by hand for common TUI shapes.

The goal is a layered API:

  1. Primitive layer
  2. Builder and preset layer
  3. App composition layer

Each layer should build on the previous one without hiding escape hatches.

The package should optimize for an SDK-like developer experience without using SDK terminology as the product model. For lazy-cuh, that means:

  • idiomatic Python declarations
  • minimal runnable examples
  • strong defaults that still expose configuration
  • typed fields and commands where possible
  • validation close to the declaration that owns the value
  • clear errors that tell app authors what to change
  • focused imports for larger apps and examples

The public API is the contract. The framework layers are how that contract is organized so common tasks stay small while advanced apps can still reach lower levels. The package layout for these layers is documented in Package Layers.

When a domain grows, keep a facade module as the public entry point and split implementation files behind it. For example, lc.builders.options.* is the option-builder API even though field constructors and OptionsSpec live in focused implementation modules internally.

The primitive layer is the current foundation. It is explicit, testable, and highly configurable.

Examples:

  • PanelSpec, TabSpec, ShellController, FocusGraph
  • ActionBinding, ActionMap, BindingRegistry, ActionDispatcher
  • NavigableItem, TreeItem, ListViewModel, TreeViewModel
  • EditableOption, OptionSet
  • InputModalSpec, ConfirmModalSpec, ModalState
  • ShellViewAdapter, ShellRuntime, ListViewWidget, TreeViewWidget

This layer is allowed to be verbose because it is the escape hatch. It should stay mostly plain Python models and pure state transitions, with Textual only at the widget adapter boundary.

Docs and tests should continue to cover this layer because higher-level APIs depend on it.

The builder layer should capture repeated TUI patterns without removing configuration.

This is the missing layer exposed by the large demo app. The demo manually wires common patterns that should become reusable presets:

  • navigable item movement defaults shared by keymaps, keybars, and docs
  • editable options rendered as navigable rows
  • modal editing for option fields
  • enum, bool, int, string, and choice parsing
  • validation and error reporting
  • keybinding metadata, keybar hints, help text, and runtime resolution
  • shell focus commands and tab commands
  • details panels driven by highlighted or selected items
  • shell panels, tabs, and Textual adapter specs derived from one declaration

For options, the current declaration API is field-based:

options = lc.builders.options.declare(
lc.builders.options.choice("line_numbers", "relative", choices=("off", "absolute", "relative")),
lc.builders.options.integer("scrolloff", 2, min_value=0),
lc.builders.options.boolean("redact", True),
)

The framework can derive:

  • rendered option rows
  • parser and formatter defaults
  • input modal specs
  • validation messages
  • immutable update handling

This mirrors good Python framework patterns: one declaration feeds parsing, validation, rendering, help text, and editing behavior.

Professional patterns worth learning from:

  • Typer and Click use one command declaration for parsing, validation, help, and execution.
  • Pydantic and dataclasses use field declarations for type, default, metadata, and validation.
  • Django forms and admin derive editable UI behavior from field metadata while still allowing customization.
  • FastAPI keeps simple route declarations concise while preserving lower-level hooks.

Lazy-cuh should follow the same shape: common cases should be short, but users can drop down to primitives when the preset is not enough.

The same developer-experience rules apply to examples. If an example has to manually synchronize framework primitives for a normal use case, the framework is missing a builder or preset. See Code Quality for the contributor checklist.

The app composition layer should define complete lazygit-style screen structure with less manual wiring.

Without the composition layer, apps have to separately define:

  • panel specs
  • content widget specs
  • focus graph
  • runtime
  • keybar specs
  • action map
  • binding registry
  • Textual bindings
  • dispatcher handlers

That is too much for a normal app. The composition API should let app authors define the common shape directly:

shell = lc.composition.shell.declare(
panels=(
lc.composition.shell.panel("files", index=1),
lc.composition.shell.panel(
"work",
index=2,
tabs=(
lc.composition.shell.tab("list", content_id="work-list"),
lc.composition.shell.tab("details"),
),
),
),
actions=actions,
)

One app-level declaration should produce the boring shell/runtime/widget wiring for common layouts. The concrete app-shell direction is tracked in App Shell Declarations.

This layer should still keep ownership boundaries:

  • app domain state remains app-owned
  • lazy-cuh owns reusable TUI state, rendering, navigation, key handling, and Textual adapters
  • Textual remains the runtime adapter, not the primary app architecture

The root package exposes both common primitives and the focused subpackages. Full examples should prefer the namespace style so domains stay visible:

import lazy_cuh as lc
item = lc.core.NavigableItem(id="alpha", label="Alpha")
panel = lc.view.PanelSpec(id="main", index=1, tabs=(lc.view.TabSpec("main", "Main"),))

Direct root imports are still acceptable for tiny snippets:

from lazy_cuh.core import NavigableItem
from lazy_cuh.view import ListViewModel

Larger examples and apps should prefer focused subpackages so the architecture stays visible:

from lazy_cuh.core import NavigableItem, OptionSet
from lazy_cuh.inputs import ActionBinding, ActionMap
from lazy_cuh.view import PanelSpec, ShellController
from lazy_cuh.widgets import ShellRuntime

For higher-level layers, prefer importing the layer package and keeping the domain visible at the call site:

import lazy_cuh as lc
registry = lc.builders.actions.registry(actions)

The main demo should be cleaned up to follow this rule. If the structured imports still look noisy, that is evidence that a builder or composition layer is missing.

Before adding a new abstraction, check it against these questions:

  • Does it remove repeated app wiring while preserving escape hatches?
  • Does one declaration drive multiple outputs such as rendering, validation, key hints, modal specs, or runtime wiring?
  • Can the primitive layer still be used directly?
  • Does the abstraction avoid app-specific domain concepts?
  • Can a small example become shorter without becoming magical?

If the answer is yes, the abstraction belongs in lazy-cuh. If not, keep it in the example app until the pattern repeats.