Skip to content

App Structure

Use lazy-cuh as a set of small layers. Keep app state in your app, keep shell state in pure models, and let Textual widgets adapt those models to the terminal.

  1. Define item models and option sets.
  2. Define panel and tab specs.
  3. Create a ShellController.
  4. Create ActionMap bindings and a BindingRegistry.
  5. Create ShellRuntime.from_widgets(...) inside your Textual app.
  6. Route matched actions through ActionDispatcher.
  7. Handle widget messages by updating app state and refreshing models.

This order keeps the dependency direction clear: app state feeds models, models feed widgets, widgets emit messages, and action handlers update app state again.

Define panel specs before mounting widgets. Specs are plain data and validate layout mistakes early.

from lazy_cuh import Direction, FocusGraph, PanelSpec, ShellController, TabSpec
left = PanelSpec(id="files", index=1, tabs=(TabSpec("files", "Files"),))
right = PanelSpec(id="work", index=2, tabs=(TabSpec("list", "List"),))
controller = ShellController.from_panels(
(left, right),
focus_graph=FocusGraph(edges={("files", Direction.RIGHT): "work"}),
)

At the Textual boundary, connect those specs to mounted widget IDs:

from lazy_cuh import BindingRegistry
from lazy_cuh.widgets import ContentWidgetSpec, PanelWidgetSpec, ShellRuntime
runtime = ShellRuntime.from_widgets(
app,
controller=controller,
panels=(
PanelWidgetSpec(left, "files-panel"),
PanelWidgetSpec(right, "work-panel"),
),
content=(
ContentWidgetSpec("files", "files", "files-tree"),
ContentWidgetSpec("work", "list", "work-list"),
),
registry=BindingRegistry(),
)

Use ShellViewAdapter directly only when you want manual control without the runtime input and keybar helpers.

Use ActionMap as metadata and ActionDispatcher as execution. Bindings stay renderable and testable; handlers stay app-owned.

from enum import Enum, auto
from lazy_cuh import ActionBinding, ActionDispatcher, ActionMap, ShellCommand
from lazy_cuh import shell_command_handlers
class AppAction(Enum):
FOCUS_FILES = auto()
FOCUS_WORK = auto()
actions = ActionMap(
(
ActionBinding.from_key("1", AppAction.FOCUS_FILES, label="Files"),
ActionBinding.from_key("2", AppAction.FOCUS_WORK, label="Work"),
)
)
dispatcher = ActionDispatcher(
shell_command_handlers(
{
AppAction.FOCUS_FILES: ShellCommand.FOCUS_1,
AppAction.FOCUS_WORK: ShellCommand.FOCUS_2,
},
controller=lambda: runtime.controller,
set_controller=runtime.set_controller,
)
)

For tab changes, use shell_tab_handlers(...).

Widgets should emit messages upward; they should not mutate app domain state.

Typical handlers:

  • ItemHighlightedMessage: update a details text model.
  • ItemSelectedMessage: open a modal, expand a tree node, or dispatch an app action.
  • ModalResultMessage: apply the modal result to app state, then refresh affected models.

For editable options, OptionSet owns the modal routing:

modal = options.input_modal_for_item(selected_item)
if modal is not None:
modal_host.open(modal)

When options change, preserve list cursor position with ListViewModel.with_items(...).

  • Do not store domain state in Textual widgets.
  • Do not make widgets parse app-specific result IDs.
  • Do not duplicate list/tree rendering in app code.
  • Do not create a new abstraction just to hide five clear lines of setup.

If a pattern repeats across examples and keeps the same ownership boundaries, extract it into lazy-cuh. If it needs app-specific decisions, keep it in the app.