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. Declare the shell with panels, tabs, navigation, and action metadata.
  3. Compose the shell inside your Textual app.
  4. Route matched actions through app-owned handlers.
  5. 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.

For normal app shells, start with lc.composition.shell.declare(...). The shell declaration keeps panel structure, content ids, focus navigation, action behavior, keybar decisions, and Textual binding projection together.

import lazy_cuh as lc
panels = (
lc.composition.shell.panel("files", index=1, content_id="files-tree"),
lc.composition.shell.panel("work", index=2, content_id="work-list"),
)
shell = lc.composition.shell.declare(
panels=panels,
actions=(
*lc.composition.shell.panel_focus_bindings(
panels=panels,
actions={
"files": AppAction.FOCUS_FILES,
"work": AppAction.FOCUS_WORK,
},
),
),
navigation=lc.composition.shell.navigation(
*lc.composition.shell.connect("files", lc.view.Direction.RIGHT, "work"),
),
)
bindings = shell.textual_bindings()

Inside the Textual app, compose the shell against mounted widgets and app-owned handlers:

import lazy_cuh as lc
composition = shell.with_handlers(
{
AppAction.FOCUS_FILES: focus_files,
AppAction.FOCUS_WORK: focus_work,
}
).compose(app)
composition.runtime.refresh(focus=True)

Use lower-level ShellController, ShellViewAdapter, or ShellRuntime.from_widgets(...) directly only when you want manual control without the composition helpers.

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

from enum import Enum, auto
import lazy_cuh as lc
class AppAction(Enum):
FOCUS_FILES = auto()
FOCUS_WORK = auto()
actions = lc.builders.actions.action_map(
lc.builders.actions.bind("1", AppAction.FOCUS_FILES, label="Files"),
lc.builders.actions.bind("2", AppAction.FOCUS_WORK, label="Work"),
)
dispatcher = lc.inputs.ActionDispatcher(
lc.builders.actions.shell(
{
AppAction.FOCUS_FILES: lc.inputs.ShellCommand.FOCUS_1,
AppAction.FOCUS_WORK: lc.inputs.ShellCommand.FOCUS_2,
},
controller=lambda: runtime.controller,
set_controller=runtime.set_controller,
)
)

For tab changes, use lc.builders.actions.tabs(...).

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, use the option builder helpers to keep modal routing and result application consistent:

modal = lc.builders.options.edit_modal_for_item(options, selected_item)
if modal is not None:
modal_host.open(modal)

When options change, refresh the list model with lc.builders.options.refresh_view_model(...) to preserve cursor position and view settings.

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