Skip to content

Profiler Integration for Adapter Authors

This document is specifically for developers writing a custom UI adapter. If you're using the built-in Bubble Tea adapter, all of this is handled automatically — you can skip this.

If you're writing your own adapter for tview, tcell, termbox, raw escape codes, or anything else, this is the contract you need to fulfill to get meaningful data in the inspector.

What the Profiler Needs From You

The profiler collects three classes of data. Only one of them requires your cooperation:

ClassSourceRequires your adapter?
Memory & goroutinesruntime.ReadMemStats()No — always available
Boot timesRecorded during Application.Boot()No — automatic
Event logBus wildcard "*" subscriptionNo — automatic
Hook timingsHook Registry MetricsCollectorNo — automatic
FPS / Update / View timingRecordUpdate(), RecordView()Yes — you must call these

The bottom row is the only thing you need to worry about. The rest works regardless.

The Two Methods

go
// Both available via BaseAdapter, or call profiler directly if you don't embed BaseAdapter

func (b *BaseAdapter) RecordUpdate(dur time.Duration)
func (b *BaseAdapter) RecordView(dur time.Duration)

Both are no-ops if the profiler isn't running (i.e., in production builds or when TAKO_PROFILER_ENABLED is false). Safe to call unconditionally.

RecordUpdate(dur)

Call this after processing a cycle of input or state changes. The "update cycle" concept maps to:

UI LibraryWhen to call
Bubble TeaIn Update() method, defer from start to end
tviewIn SetInputCapture handler, wrap the key processing
tcellAfter processing a PollEvent() event
CustomAfter any input/event processing block
go
// Bubble Tea — automatic in the built-in adapter
func (m *takoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    start := time.Now()
    defer func() { m.RecordUpdate(time.Since(start)) }()
    // ...
}

// tview — in your input capture
prevCapture := a.app.GetInputCapture()
a.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
    start := time.Now()
    defer func() { a.RecordUpdate(time.Since(start)) }()
    a.HandleKey(tcellEventToTakoKey(event))
    if prevCapture != nil {
        return prevCapture(event)
    }
    return event
})

// tcell — in your poll loop
for {
    ev := screen.PollEvent()
    start := time.Now()
    // ... process ev ...
    a.RecordUpdate(time.Since(start))
}

RecordView(dur)

Call this after rendering/drawing to the screen. This also increments the FPS counter — so it should be called once per frame, not once per widget render.

UI LibraryWhen to call
Bubble TeaIn View() method, defer from start to end
tviewSetAfterDrawFunc callback if available; otherwise approximate
tcellAfter screen.Show()
CustomAfter the terminal is updated
go
// Bubble Tea — automatic in the built-in adapter
func (m *takoModel) View() tea.View {
    start := time.Now()
    defer func() { m.RecordView(time.Since(start)) }()
    // ...
}

// tcell — after screen.Show()
func (a *MyAdapter) renderLoop() {
    for {
        start := time.Now()
        a.drawEverything(screen)
        screen.Show()
        a.RecordView(time.Since(start))

        time.Sleep(16 * time.Millisecond) // ~60fps cap
    }
}

// tview — tview doesn't expose a post-draw hook cleanly
// Option 1: wrap in SetBeforeDrawFunc (measures draw start, not end — approximate)
// Option 2: skip RecordView and accept that FPS shows 0 in inspector
// Option 3: use a custom draw wrapper

What if Your Library Doesn't Have Draw Hooks?

Some libraries (like certain tview versions) don't provide clean post-draw callbacks. Your options:

  1. Skip it — The inspector will show FPS: 0, Avg View: 0ms. All other metrics still work. This is perfectly acceptable.

  2. Use SetBeforeDrawFunc — Records the start of draw but not the end. The timing will be slightly off (measures draw scheduling, not draw completion), but FPS will still increment correctly.

  3. Measure a wider cycle — Time from "received event" to "screen updated" and call RecordView at the end. Less precise but gives a real-world number.

  4. Use StartEventLoop callback — Call RecordView inside the event loop callback. This means "one frame per event poll tick" rather than "one frame per actual render", but gives a rough FPS.

None of these are wrong. Choose the option that matches how your UI library actually works. The profiler is a development aid, not a production requirement.

InitTelemetry — Timing Matters

The profiler is registered in the container after adapters are created (during Application.Boot()). This means you can't resolve it in the constructor.

Always call InitTelemetry() at the start of Render(), not in NewMyAdapter():

go
// ✅ correct
func (a *MyAdapter) Render() error {
    a.InitTelemetry()  // ← resolves *profiler.Profiler from container
    // ...
}

// ❌ wrong — profiler not registered yet when constructor runs
func NewMyAdapter(ctx *tako.Context, ...) *MyAdapter {
    a := &MyAdapter{...}
    a.InitTelemetry()  // profiler is nil here, this is a no-op
    return a
}

InitTelemetry() is idempotent — calling it multiple times is safe. If the profiler isn't registered (production mode), it leaves b.Profiler as nil, and all RecordUpdate/RecordView calls become no-ops.

Full Instrumentation Example

Here's a complete, properly instrumented custom adapter using tcell:

go
package tcadapter

import (
    "context"
    "time"

    "github.com/gdamore/tcell/v2"
    "github.com/takoterm/tako/contracts"
    "github.com/takoterm/tako/internal/router"
    "github.com/takoterm/tako/internal/tako"
    "github.com/takoterm/tako/pkg/adapter"
)

type TcellAdapter struct {
    *adapter.BaseAdapter
    screen tcell.Screen
    draw   func(screen tcell.Screen)
    quit   chan struct{}
}

func NewTcellAdapter(
    ctx *tako.Context,
    bus contracts.EventBus,
    r *router.Router,
    draw func(screen tcell.Screen),
) (*TcellAdapter, error) {
    screen, err := tcell.NewScreen()
    if err != nil {
        return nil, err
    }
    if err := screen.Init(); err != nil {
        return nil, err
    }
    return &TcellAdapter{
        BaseAdapter: adapter.NewBaseAdapter(ctx, bus, r),
        screen:      screen,
        draw:        draw,
        quit:        make(chan struct{}),
    }, nil
}

func (a *TcellAdapter) Render() error {
    // Must be called here, not in constructor
    a.InitTelemetry()

    // Start event bus poller — triggers redraws when events arrive
    a.StartEventLoop(a.Ctx.Context, 16*time.Millisecond, func(events []contracts.Event) {
        a.redraw()
    })

    // Initial draw
    a.redraw()

    // Main event loop
    for {
        ev := a.screen.PollEvent()

        updateStart := time.Now()
        switch ev := ev.(type) {
        case *tcell.EventKey:
            key := tcellKeyToTakoKey(ev)
            if key != "" {
                a.HandleKey(key)
            }
        case *tcell.EventResize:
            a.screen.Sync()
        case nil:
            return nil
        }
        a.RecordUpdate(time.Since(updateStart))  // ← instrument update

        a.redraw()
    }
}

func (a *TcellAdapter) redraw() {
    start := time.Now()
    a.screen.Clear()
    a.draw(a.screen)
    a.screen.Show()
    a.RecordView(time.Since(start))  // ← instrument view, increments FPS
}

func (a *TcellAdapter) Stop() error {
    a.screen.Fini()
    return nil
}

With this instrumentation, the inspector will show accurate FPS, update timing, and view timing alongside the automatic hook/memory/event data.


Up next: 05 — Plugins In Depth — the plugin system: manifest, types, dependency declarations, and the DAG sort.