Skip to content

Component Interface

A Component bundles a UI view and its keybindings into a single, self-describing struct. Instead of scattering hook registration and key registration across your plugin's OnInit, a Component declares everything about itself in one place, and you show it via ctx.Overlay().ShowComponent(c).

The Problem It Solves

go
// ❌ Without Component — wiring is spread across OnInit
func (p *Plugin) OnInit(ctx *tako.Context) error {
    // hook (render)
    ctx.Hooks().Set("tako.overlay.search", func() any {
        return p.renderSearch()
    })
    // key bindings at level 1, zone "search"
    ctx.Keys().Zone("search", 1).Bind("esc", func() {
        ctx.Overlay().Close()
    })
    ctx.Keys().Zone("search", 1).Bind("enter", func() {
        p.confirmSelection()
    })
    // push the overlay
    ctx.Overlay().Show("search", p.renderSearch)
    return nil
}

With ten overlays this becomes a maintenance nightmare — especially when you want to reuse a component in multiple contexts.

Defining a Component

Implement the contracts.Component interface:

go
type SearchBox struct {
    query string
}

// ID is used as both the layer ID and the default zone name.
func (s *SearchBox) ID() string { return "search" }

// Render returns the view output — any type your UI adapter understands.
// The framework never inspects the value; it's passed straight to your layout's hook.
func (s *SearchBox) Render() any {
    return "Search: [" + s.query + "]"
}

// RegisterKeys declares the component's keybindings.
// `keys` is scoped to this component's stack level and zone by default.
func (s *SearchBox) RegisterKeys(keys contracts.KeyManager) {
    // Zone-scoped binding (zone == s.ID(), level == stack level at time of Show)
    keys.Zone(s.ID()).Bind("esc", func() {
        // The OverlayManager will close this; emit an event if needed.
    })
    keys.Zone(s.ID()).Bind("enter", func() {
        // handle confirm
    })

    // Global binding (fires regardless of focused zone)
    keys.Bind("ctrl+r", func() {
        s.query = ""  // reset
    })
}

Showing a Component

go
// In a plugin's OnInit:
func (p *Plugin) OnInit(ctx *tako.Context) error {
    ctx.Keys().Bind("ctrl+f", func() {
        ctx.Overlay().ShowComponent(&SearchBox{})
    })
    return nil
}

Overlay().ShowComponent() does all three steps automatically:

  1. Registers the render hook under "tako.overlay.search"
  2. Calls RegisterKeys() at the correct stack level
  3. Calls Overlay().Show() to push the layer and shift focus

Register vs ShowComponent

MethodWhat it does
Overlay().Register(c)Wires hook + keys without pushing to the stack. Use when the layer is already active or when pre-registering.
Overlay().ShowComponent(c)Register + Overlay().Show(). The standard way to display a component.
go
// Pre-register before the layer is pushed (rare)
ctx.Overlay().Register(&SearchBox{})

// Show immediately (common)
ctx.Overlay().ShowComponent(&SearchBox{})

The KeyManager Interface

RegisterKeys receives a contracts.KeyManager, not the full internal router. This keeps Component clean and testable:

go
type KeyManager interface {
    Bind(key any, handler func())          // global binding
    Zone(zone string, level ...int) ZoneKeyManager
}

type ZoneKeyManager interface {
    Bind(key any, handler func())          // zone-scoped binding
}

This is the same fluent API you're used to from ctx.Keys() and app.Keys().

Stack Level Inference

When Overlay().ShowComponent(c) is called, the component's keybindings are registered at stackLevel = currentStackSize, which is the level the component will occupy after the push. This is automatic — you don't pass the level manually.

If you call Overlay().Register(c) before the push, be aware that the level is inferred at registration time, not push time. In most cases, ShowComponent() is what you want.

Reusable Components

Because a Component is just a struct, you can instantiate it multiple times with different state:

go
type ConfirmModal struct {
    Message  string
    OnAccept func()
}

func (c *ConfirmModal) ID() string { return "confirm" }

func (c *ConfirmModal) Render() any { return c.Message }

func (c *ConfirmModal) RegisterKeys(keys contracts.KeyManager) {
    keys.Zone(c.ID()).Bind([]string{"y", "enter"}, func() {
        if c.OnAccept != nil { c.OnAccept() }
    })
    keys.Zone(c.ID()).Bind([]string{"n", "esc"}, func() {
        // cancelled — close handled by caller or esc global
    })
}

// Usage:
ctx.Keys().Bind("ctrl+f", func() {
    ctx.Overlay().ShowComponent(&ConfirmModal{
        Message:  "Delete this file?",
        OnAccept: func() { ctx.Emit("file:delete", filename) },
    })
})

Note: For simple confirm/cancel patterns, consider ctx.Overlay().Dialog().Confirm(msg, cb) instead. Component is for richer, stateful UI units.

Hook Consumption in Layouts

Your layout reads component output the same way it reads any other overlay hook:

go
// In Layout.View():
top := r.Stack().Top()
content := ctx.Hooks().Get("tako.overlay." + top)
if content != nil {
    // render overlay on top of base view
}

Since UI().Show() uses the same "tako.overlay.<id>" namespace as OverlayManager.Show(), layouts work identically for both.

Testing a Component in Isolation

Because Component has no dependency on the framework internals, you can test it with a mock KeyManager:

go
type mockKeyManager struct {
    bindings map[string]func()
}

func (m *mockKeyManager) Bind(key any, fn func()) {
    m.bindings[fmt.Sprint(key)] = fn
}
func (m *mockKeyManager) Zone(zone string, _ ...int) contracts.ZoneKeyManager {
    return m
}

func TestSearchBox_RegisterKeys(t *testing.T) {
    s := &SearchBox{}
    km := &mockKeyManager{bindings: map[string]func(){}}
    s.RegisterKeys(km)
    assert.Contains(t, km.bindings, "esc")
}

Up next: 04.04 — DialogService — built-in event-driven confirm/cancel dialogs without any rendering logic in the framework.