Layouts & Zones
A layout is just a View method that returns a tea.View. But to make it work well with the key router and the focus stack, you need to think about how you structure regions ("zones") and how layers ("overlays") appear on top of them.
Zones
A zone is a named, focusable region of your UI. The key router uses zones to scope keybindings — a key that means "scroll down" in your file list shouldn't fire when a text input is focused.
Zones have two identifiers:
- A stack level (which layer is active)
- A zone ID (which region within that layer is focused)
// In main.go:
app.Router().Focus(0, "file-list") // level 0 (base), zone "file-list"
// When the user tabs to the preview pane:
app.Router().Focus(0, "preview")// Keybindings scoped to zone — via app (host app) or ctx (plugin):
app.Keys().Zone("file-list").Bind("j", func() { list.MoveDown() })
app.Keys().Zone("file-list").Bind("k", func() { list.MoveUp() })
app.Keys().Zone("preview").Bind("j", func() { preview.ScrollDown() })
app.Keys().Zone("preview").Bind("k", func() { preview.ScrollUp() })Now j and k do the right thing in each zone, with no conditional logic in the key handler.
Layers
A layer is a stack level above the base. Overlays, modals, panels — any time the user "enters" a sub-context, you push to the stack. When they leave, you pop.
A Complete Layout Structure
Here's a layout that handles base state and two overlays:
type AppLayout struct{}
func (l *AppLayout) View(ctx *tako.Context, r *router.Router) tea.View {
stack := r.Stack()
top := stack.Top()
switch top {
case "base":
return l.renderBase(ctx, r)
case "search":
// Render base dimmed, then overlay on top
base := l.renderBase(ctx, r)
overlay := l.renderSearchOverlay(ctx)
return l.compose(base, overlay)
default:
// OverlayManager provides hooks under "tako.overlay.<id>"
hookName := "tako.overlay." + top
if content := ctx.Hooks().Get(hookName); content != nil {
if s, ok := content.(string); ok {
base := l.renderBase(ctx, r)
return l.compose(base, s)
}
}
// Fallback for legacy manually-managed overlays
legacyHook := "app.overlay." + top
if content := ctx.Hooks().Get(legacyHook); content != nil {
if s, ok := content.(string); ok {
base := l.renderBase(ctx, r)
return l.compose(base, s)
}
}
return l.renderBase(ctx, r)
}
}
func (l *AppLayout) renderBase(ctx *tako.Context, r *router.Router) tea.View {
// Collect sidebar widgets from all plugins
var sidebars []string
for _, w := range ctx.Hooks().All("app.sidebar") {
if s, ok := w.(string); ok {
sidebars = append(sidebars, s)
}
}
// Collect footer widgets
var footers []string
for _, w := range ctx.Hooks().All("app.footer") {
if s, ok := w.(string); ok {
footers = append(footers, s)
}
}
sidebar := lipgloss.JoinVertical(lipgloss.Left, sidebars...)
footer := lipgloss.JoinHorizontal(lipgloss.Bottom, footers...)
main := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Render("main content")
body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main)
view := tea.NewView(lipgloss.JoinVertical(lipgloss.Left, body, footer))
view.AltScreen = true
return view
}Plugin-Contributed Overlays
Plugins can display their own overlays using the OverlayManager. The host layout doesn't need to know about them upfront — it just reads from the "tako.overlay." + top hook.
// In a plugin's OnInit — clean and concise
ctx.Keys().Bind("?", func() {
if ctx.Overlay().Top() == "help" {
ctx.Overlay().Close()
} else {
ctx.Overlay().Show("help", func() any {
return lipgloss.NewStyle().
Border(lipgloss.DoubleBorder()).
Width(60).
Render(p.helpContent)
})
ctx.Emit("help:opened", nil)
}
})Under the Hood: Calling
Showautomatically registers the render hook and pushes the stack. Before the High-Level API was introduced, plugins had to do this manually: register the hook first, then callctx.Stack().Push("help"), and the layout would read from a custom"app.overlay.help"hook.
Tab/Focus Management
When the user tabs between zones, update the focused zone and publish an event:
app.Keys().Bind("tab", func() {
zones := []string{"file-list", "preview", "metadata"}
current := app.Router().FocusedZone(0) // hypothetical getter
// Switch the zone and notify plugins
app.Router().Focus(0, next)
app.Emit("focus:changed", next)
})Plugins that render zone-specific visual focus indicators subscribe to "focus:changed" and update their border/highlight accordingly.
Up next: 05.03 — Profiler Integration — if you wrote a custom adapter, this covers the exact contract for instrumenting FPS and render timing. If you're using Bubble Tea, you can jump straight to 06 — Plugins In Depth.
