Skip to content

App Shell

Use lc.composition.app.shell(...) when an app has multiple panels or tabs. The app-shell declaration keeps the common screen shape in one place:

  • panel and tab specs
  • content slots and widget factories
  • focused-panel navigation
  • shell actions such as focus and tab cycling
  • contextual keybars and keybinding help
  • runtime wiring for Textual widgets

Lower-level shell state, shell declarations, and widget adapters are still available, but app guides should start from the app declaration layer.

Panels define stable ids, visible labels, indexes, tabs, and optional content kinds.

import lazy_cuh as lc
panels = (
lc.composition.shell.panel(
"files",
index=1,
key="1",
content_kind=lc.widgets.ContentKind.TREE,
),
lc.composition.shell.panel(
"work",
index=2,
key="2",
tabs=(
lc.composition.shell.tab(
"list",
"List",
content_id="work-list",
content_kind=lc.widgets.ContentKind.LIST,
key="l",
),
lc.composition.shell.tab(
"details",
"Details",
content_kind=lc.widgets.ContentKind.TEXT,
key="d",
),
),
),
)

A panel with one tab still uses tab metadata internally. That keeps single-tab and multi-tab panels on the same composition model.

index is structural metadata used for ordering and title numbering. key is the optional shortcut metadata. Panel keys always focus from the shell. Tab keys default to shell-level jump bindings:

lc.composition.shell.panel("work", index=2, key="w")
lc.composition.shell.tab("details", "Details", key="d")

With that declaration, w focuses the Work panel from anywhere, while d focuses the Work panel and selects the Details tab from anywhere. Use key_scope=lc.composition.shell.KeyScope.PANEL when a tab key should only work while its panel is the active input context. Omit either key when an app wants to provide bindings through presets or manual action declarations instead.

Shell presets provide common panel focus, directional focus, help, and quit bindings. App-specific actions, such as opening a selected item or saving data, stay explicit.

from enum import Enum, auto
class AppAction(Enum):
OPEN = auto()
FOCUS_FILES = auto()
FOCUS_WORK = auto()
FOCUS_LEFT = auto()
FOCUS_RIGHT = auto()
HELP = auto()
QUIT = auto()
actions = (
lc.composition.shell.binding(
"enter",
AppAction.OPEN,
label="Open",
visibility=lc.inputs.HintVisibility.COMPACT,
group="Primary",
order=10,
),
*lc.presets.shell.lazygit(
panels=panels,
focus_actions={
"files": AppAction.FOCUS_FILES,
"work": AppAction.FOCUS_WORK,
},
direction_actions={
lc.view.Direction.LEFT: AppAction.FOCUS_LEFT,
lc.view.Direction.RIGHT: AppAction.FOCUS_RIGHT,
},
help=AppAction.HELP,
quit=AppAction.QUIT,
),
)

Use lc.presets.shell.lazygit(...) when panel numbers should be bare keys such as 1 and 2. Use lc.presets.shell.lazyvim(...) when panel numbers should stay behind the configured leader so bare digits remain available for count prefixes.

Use lc.presets.shell.bindings(...) when you want to provide key templates directly.

The app-shell declaration validates setup errors early: panel ids, panel indexes, panel keys, tab ids, tab keys, content slots, shell actions, and focus graph edges must all reference known objects or be unique in their owning scope. Binding validation also includes content-owned navigation keys derived from each tab’s ContentKind, so shell-level keys cannot silently shadow widget navigation keys.

aliases = lc.presets.keys.leader("z")
app_spec = lc.composition.app.shell(
panels=panels,
content=(
lc.builders.content.tree("files", model_factory=build_file_tree),
lc.builders.content.list(
"work",
"list",
"work-list",
model_factory=build_work_items,
),
lc.builders.content.text("work", "details", model_factory=build_details),
),
actions=lambda panels: lc.presets.shell.lazygit(
panels=panels,
focus_actions={
"files": AppAction.FOCUS_FILES,
"work": AppAction.FOCUS_WORK,
},
direction_actions={
lc.view.Direction.LEFT: AppAction.FOCUS_LEFT,
lc.view.Direction.RIGHT: AppAction.FOCUS_RIGHT,
},
help=AppAction.HELP,
quit=AppAction.QUIT,
),
navigation=lc.composition.shell.navigation(
*lc.composition.shell.connect(
"files",
lc.view.Direction.RIGHT,
"work",
),
),
aliases=aliases,
key_display=lc.presets.keys.display(aliases),
keybar_widget_id="keybar",
view=lc.builders.view.relative_lines(),
action_help_view=lc.inputs.HintViewSpec(
visibility=(
lc.inputs.HintVisibility.COMPACT,
lc.inputs.HintVisibility.HELP,
),
groups=("Primary", "Pane", "Tabs", "System"),
),
)

lc.composition.app.shell(...) owns the shell/content composition. The lc.presets.shell.lazygit(...) call only contributes keybindings. Use lc.presets.shell.lazyvim(...) when panel numbers should stay behind the leader so bare digits remain available for count prefixes.

The view argument sets shell-level presentation defaults for navigable content. panel(..., view=...) can override those defaults for a panel, tab(..., view=...) can override them for one tab, and content declarations may pass their own view= to override the final slot.

action_help_view tells the shell how to project its own action map into expanded help for the focused panel. Content help for list, tree, and options views is derived separately from ContentKind.

If a shell uses custom item navigation for list, tree, or option content, pass the same presets through content_navigation. Shell validation reserves those content-owned navigation keys so a shell-scoped panel or tab shortcut cannot silently steal keys from focused content.

navigation = lc.presets.navigation.lazygit(include_select=True)
app_spec = lc.composition.app.shell(
panels=panels,
content=content,
actions=lambda panels: lc.presets.shell.lazygit(
panels=panels,
focus_actions=focus_actions,
),
content_navigation={
lc.widgets.ContentKind.LIST: navigation,
lc.widgets.ContentKind.OPTIONS: navigation,
lc.widgets.ContentKind.TREE: navigation,
},
)

When a slot is backed by one of lazy-cuh’s view models, use lc.builders.content to create the content slot and widget factory together:

content = (
lc.builders.content.tree("files", model_factory=build_file_tree),
lc.builders.content.list(
"work",
"list",
"work-list",
model_factory=build_work_items,
),
lc.builders.content.text("work", "details", model_factory=build_details),
)

The builders return ordinary ContentSlot values. They keep the pure view model explicit while hiding the repetitive Textual widget factory.

Use lc.composition.app directly when an app needs a custom widget factory:

app_spec = lc.composition.app.shell(
panels=panels,
content=(
lc.composition.app.tree("files"),
lc.composition.app.list("work", "list", "work-list"),
lc.composition.app.text("work", "details"),
),
actions=actions,
)

The app declaration validates that each content slot references a known panel/tab pair. It can then feed the explicit content specs into the existing shell runtime:

composition = app_spec.compose(app)

This layer is intentionally above lc.composition.shell. Shell declarations still own panel state, tab state, actions, keybars, and focus graph behavior; app declarations add content-slot ownership and preset wiring without moving widget factories into ShellSpec.

Attach app-owned handlers where side effects live, then compose the runtime inside the Textual app.

app_runtime = app_spec.with_handlers(
{
AppAction.HELP: open_help_modal,
AppAction.QUIT: app.exit,
},
set_controller=apply_controller,
).runtime(app)
runtime = app_runtime.composition.runtime
dispatcher = app_runtime.composition.dispatcher

The runtime owns Textual-boundary concerns:

  • applying panel titles and tab visibility
  • focusing the active content widget
  • keeping input context aligned with the focused panel
  • refreshing the keybar and expanded keybinding help

The app still owns domain state and command handlers.

Use the shell declaration to project Textual bindings for single-key handled actions, such as ?, q, or tab keys.

class MyApp(App):
BINDINGS = app_spec.shell.textual_bindings()

For ordinary key presses inside on_key, route through shell dispatch:

def on_key(self, event) -> None:
if app_runtime.dispatch_key(event.key):
event.prevent_default()
event.stop()

Most state changes should produce a new ShellController and pass it back to the runtime:

runtime.set_controller(next_controller, focus=True)

If an app changes its active input profile at runtime, rebuild the shell declaration and update the runtime input/keybar projection:

runtime.set_input_bindings(
registry=next_shell.registry(),
keybars=next_shell.keybars(),
)

The demo uses this pattern to switch between lazygit and lazyvim shell modes.

Use lower-level APIs when the declaration layer is too high-level:

  • ShellState and ShellController for pure shell state transitions
  • ShellViewAdapter for manual Textual widget coordination
  • ShellRuntime.from_widgets(...) for custom runtime composition
  • focus_binding(...), move_binding(...), and cycle_tab_binding(...) for hand-written shell bindings

These APIs are useful for framework work and unusual apps, but lc.composition.shell.declare(...) should be the first path for normal panel and tab screens.