gitstack

grindlemire/go-tui code browser

20.0 KB markdown 736 lines 2026-03-23 · a47141f raw

Components

Overview

go-tui has two kinds of components: pure templ functions and struct components.

Pure templ functions are stateless. They take parameters, return elements, and that's it. Use them for reusable visual pieces like cards, badges, and headers.

Struct components carry state. They own State[T] fields, handle keyboard input, run timers, and manage their own lifecycle. Use them for anything interactive or anything that changes over time.

Both kinds compose freely. A struct component's render method can call pure components, and pure components can nest other pure components inside themselves. You build complex UIs by stacking simple pieces.

Pure Components

A pure component is a templ function. It takes parameters and returns a chunk of UI:

package main

import tui "github.com/grindlemire/go-tui"

templ Badge(label string) {
    <span class="text-cyan font-bold px-1">{label}</span>
}

templ Header(title string) {
    <div class="border-rounded p-1 flex justify-center">
        <span class="text-gradient-cyan-magenta font-bold">{title}</span>
    </div>
}

templ StatusLine(label string, value string) {
    <div class="flex gap-1">
        <span class="font-dim">{label}</span>
        <span class="text-cyan font-bold">{value}</span>
    </div>
}

Call them from other templates just like HTML elements, using @:

templ (a *myApp) Render() {
    <div class="flex-col gap-1 p-1">
        @Header("Dashboard")
        @Badge("v1.0")
        @StatusLine("Status:", "Online")
    </div>
}

Parameters can be any Go type: strings, ints, booleans, slices, custom structs. The only rule is that pure components don't hold state between renders. Every call constructs a fresh view from the parameters you give it.

Children Slot

Pure components can accept nested content through the {children...} slot. This lets you build wrapper components that add layout, borders, or styling around arbitrary content:

templ Card(title string) {
    <div class="border-rounded p-1 flex-col gap-1">
        <span class="text-gradient-cyan-magenta font-bold">{title}</span>
        <hr class="border-single" />
        {children...}
    </div>
}

Pass children by nesting elements inside the component call:

templ (a *myApp) Render() {
    <div class="flex gap-2">
        @Card("System Info") {
            @StatusLine("Version:", "1.2.0")
            @StatusLine("Uptime:", "3d 14h")
        }
        @Card("Config") {
            @StatusLine("Theme:", "Dark")
            @StatusLine("Notify:", "On")
        }
    </div>
}

Each Card renders its title and divider, then places whatever children you passed where {children...} appears. The children can be elements, other pure components, or any mix.

tui generate compiles the children into a []*tui.Element slice and passes it as a parameter to the generated function. The slot expands to a loop that adds each child element to the parent container.

Struct components also support {children...}. See Children Slot in the struct components section below.

When to Use Pure Components

Use pure components for anything that doesn't need its own state or event handlers:

  • Visual elements: cards, badges, headers, dividers
  • Layout wrappers: bordered sections, padded containers, centered panels
  • Data display: status lines, key-value pairs, formatted labels
  • Repeated patterns: list items, table rows, form fields

If you find yourself wanting to add a *tui.State field, you need a struct component instead.

Struct Components

Struct components are Go structs with a templ render method. They hold state, handle input, and manage their own lifecycle:

package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

type counter struct {
    count *tui.State[int]
}

func Counter() *counter {
    return &counter{
        count: tui.NewState(0),
    }
}

func (c *counter) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
            c.count.Update(func(v int) int { return v + 1 })
        }),
        tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
            c.count.Update(func(v int) int { return v - 1 })
        }),
    }
}

templ (c *counter) Render() {
    <div class="flex-col p-1 border-rounded border-cyan items-center gap-1">
        <span class="font-bold">Counter</span>
        <span class="text-cyan font-bold">{fmt.Sprintf("%d", c.count.Get())}</span>
        <span class="font-dim">+/- to change, esc to quit</span>
    </div>
}

There are three parts: the struct with state fields, a constructor function that initializes it, and the render method using templ (receiver) Render() syntax.

The render method works exactly like a pure templ function, but it has access to the struct's fields through the receiver. The generated code turns it into a Render(app *App) *Element method, which satisfies the Component interface.

Children Slot

Struct components can also accept children using {children...}. Add a children []*tui.Element field to the struct and accept it in the constructor:

type panel struct {
    title    string
    children []*tui.Element
}

func NewPanel(title string, children []*tui.Element) *panel {
    return &panel{title: title, children: children}
}

templ (p *panel) Render() {
    <div class="border-rounded p-1 flex-col gap-1">
        <span class="font-bold text-gradient-cyan-magenta">{p.title}</span>
        {children...}
    </div>
}

Callers use the same block syntax as pure components:

templ (a *myApp) Render() {
    @NewPanel("Items") {
        <span>First item</span>
        <span>Second item</span>
    }
}

The generated code builds a []*tui.Element slice from the children block and passes it as the last constructor argument. On re-renders, UpdateProps copies the fresh children to the cached instance automatically, so the panel always reflects the latest content.

This is useful when you need a wrapper component that carries its own state (timers, scroll position, internal selections) while still accepting arbitrary content from the parent.

The Component Interface

Every struct component implements this interface:

type Component interface {
    Render(app *App) *Element
}

You don't write Render(app *App) *Element by hand. The templ keyword handles the signature. You write templ (c *counter) Render() and tui generate produces the correct Go method.

Component Interfaces

Struct components can implement additional interfaces for input handling, background tasks, and lifecycle hooks. All are optional. Implement only what you need.

KeyListener

Handle keyboard input by implementing KeyMap() tui.KeyMap:

type KeyListener interface {
    KeyMap() KeyMap
}

KeyMap() returns a slice of key bindings. The framework calls it on every render pass, so you can return different bindings based on current state:

func (s *search) KeyMap() tui.KeyMap {
    if s.active.Get() {
        return tui.KeyMap{
            tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { s.active.Set(false) }),
            tui.OnStop(tui.AnyRune,func(ke tui.KeyEvent) {
                s.query.Update(func(q string) string { return q + string(ke.Rune) })
            }),
        }
    }
    return tui.KeyMap{
        tui.On(tui.Rune('/'), func(ke tui.KeyEvent) { s.active.Set(true) }),
    }
}

MouseListener

Handle mouse input by implementing HandleMouse(MouseEvent) bool:

type MouseListener interface {
    HandleMouse(MouseEvent) bool
}

Return true if you handled the event, false to let it propagate. The typical pattern uses refs and HandleClicks:

func (c *counter) HandleMouse(me tui.MouseEvent) bool {
    return tui.HandleClicks(me,
        tui.Click(c.incBtn, func() {
            c.count.Update(func(v int) int { return v + 1 })
        }),
        tui.Click(c.decBtn, func() {
            c.count.Update(func(v int) int { return v - 1 })
        }),
    )
}

Mouse events require tui.WithMouse() when creating the app. See the Event Handling guide for details.

WatcherProvider

Run background timers or receive from Go channels by implementing Watchers() []Watcher:

type WatcherProvider interface {
    Watchers() []Watcher
}

Watchers start when the component mounts and stop when it unmounts. Callbacks run on the UI thread, so you can safely update state:

func (t *timer) Watchers() []tui.Watcher {
    return []tui.Watcher{
        tui.OnTimer(time.Second, func() {
            t.elapsed.Update(func(v int) int { return v + 1 })
        }),
    }
}

See Timers, Watchers, and Channels for the full API.

Initializer

Run setup code when a component first mounts by implementing Init() func():

type Initializer interface {
    Init() func()
}

Init() is called once when the component enters the tree. The returned function, if non-nil, runs when the component leaves the tree. This pairs setup and cleanup at the same call site:

func (c *conn) Init() func() {
    c.ws = connectWebSocket(c.url)
    go c.readLoop()
    return func() {
        c.ws.Close()
    }
}

AppBinder

Wire up State and Events fields to the app by implementing BindApp(app *App):

type AppBinder interface {
    BindApp(app *App)
}

You rarely write this by hand. The code generator produces BindApp methods automatically for any struct component that has *tui.State or *tui.Events fields. The mount system calls it when the component is first created and again after props updates.

PropsUpdater

Receive updated props when a cached component is re-rendered:

type PropsUpdater interface {
    UpdateProps(fresh Component)
}

You never write this by hand. The code generator produces UpdateProps methods automatically for all components. For struct components, it copies non-state fields (skipping *tui.State, *tui.Ref, channels, and functions) from a fresh instance to the cached one. The mount system calls it on subsequent renders so the cached instance picks up any changed props without losing its internal state.

Component Composition

Components compose by nesting. Call a pure component with @, and it inlines its elements into the parent. Call a struct component the same way, and the framework handles mounting and caching behind the scenes.

Pure Components Inside Struct Renders

The most common pattern. A struct component's render method calls pure components for layout and display:

package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

templ Badge(label string, style string) {
    <span class={style + " font-bold px-1"}>{label}</span>
}

templ InfoRow(label string, value string) {
    <div class="flex gap-1">
        <span class="font-dim">{label}</span>
        <span class="text-cyan font-bold">{value}</span>
    </div>
}

templ Card(title string) {
    <div class="border-rounded p-1 flex-col gap-1">
        <span class="text-gradient-cyan-magenta font-bold">{title}</span>
        <hr class="border-single" />
        {children...}
    </div>
}

type userProfile struct {
    name  string
    role  string
    email string
}

func UserProfile(name, role, email string) *userProfile {
    return &userProfile{name: name, role: role, email: email}
}

func (u *userProfile) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
    }
}

templ (u *userProfile) Render() {
    <div class="flex-col gap-1 p-1">
        @Card("User Profile") {
            <div class="flex-col gap-1">
                @InfoRow("Name:", u.name)
                @InfoRow("Role:", u.role)
                @InfoRow("Email:", u.email)
                <div class="flex gap-1">
                    <span class="font-dim">Status:</span>
                    @Badge("Active", "text-green")
                </div>
            </div>
        }
    </div>
}

Struct Components Inside Struct Renders

When one struct component renders another struct component, the framework uses app.Mount to cache the child instance. This means the child keeps its state across parent re-renders:

package main

import tui "github.com/grindlemire/go-tui"

type sidebar struct {
    category *tui.State[string]
    selected *tui.State[int]
}

func Sidebar(category *tui.State[string]) *sidebar {
    return &sidebar{
        category: category,
        selected: tui.NewState(0),
    }
}

func (s *sidebar) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.Rune('j'), func(ke tui.KeyEvent) {
            s.selected.Update(func(v int) int { return v + 1 })
        }),
        tui.On(tui.Rune('k'), func(ke tui.KeyEvent) {
            s.selected.Update(func(v int) int { return v - 1 })
        }),
    }
}

templ (s *sidebar) Render() {
    <div class="flex-col border-single p-1" width={20}>
        <span class="font-bold text-cyan">Sidebar</span>
        // ... sidebar content ...
    </div>
}

type myApp struct {
    category *tui.State[string]
}

func MyApp() *myApp {
    return &myApp{
        category: tui.NewState("Documents"),
    }
}

func (a *myApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
    }
}

templ (a *myApp) Render() {
    <div class="flex h-full">
        @Sidebar(a.category)
        <div class="flex-col flex-1 p-1">
            <span>Main content</span>
        </div>
    </div>
}

When tui generate processes this, it turns @Sidebar(a.category) inside a struct render method into an app.Mount call:

app.Mount(a, 0, func() tui.Component {
    return Sidebar(a.category)
})

The mount system caches the sidebar instance by its parent and position index. On the first render, it creates the sidebar, binds its state to the app, and calls Init() if implemented. On subsequent renders, it reuses the cached instance and calls UpdateProps if the child implements it.

The sidebar's selected state persists even when the parent re-renders, because the framework reuses the cached instance instead of creating a new one.

Shared State

Parent and child components can share state by passing *tui.State values through constructors. Changes from either side trigger a re-render:

type myApp struct {
    category *tui.State[string]
}

func MyApp() *myApp {
    return &myApp{
        category: tui.NewState("Documents"),
    }
}

templ (a *myApp) Render() {
    <div class="flex h-full">
        @Sidebar(a.category)
        @Content(a.category)
    </div>
}

Both Sidebar and Content receive the same category state. When the sidebar changes it, the content panel sees the new value on the next render.

Composition Patterns

Layout Wrapper

A pure component that wraps content in a consistent layout:

templ PageLayout(title string) {
    <div class="flex-col h-full">
        <div class="flex justify-center p-1 border-single">
            <span class="text-gradient-cyan-magenta font-bold">{title}</span>
        </div>
        <div class="flex-col flex-1 p-1">
            {children...}
        </div>
        <div class="flex justify-center p-1 border-single">
            <span class="font-dim">Press q to quit</span>
        </div>
    </div>
}

Data Display

Pure components for showing key-value data:

templ KeyValue(key string, value string) {
    <div class="flex gap-1">
        <span class="font-dim">{key + ":"}</span>
        <span class="text-cyan">{value}</span>
    </div>
}

templ Section(title string) {
    <div class="flex-col border-rounded p-1 gap-1">
        <span class="font-bold">{title}</span>
        <hr class="border-single" />
        {children...}
    </div>
}

Container Component

A reusable scrollable panel:

templ ScrollPanel(title string, height int) {
    <div class="flex-col border-rounded" height={height}>
        <div class="flex p-1">
            <span class="font-bold text-cyan">{title}</span>
        </div>
        <div class="flex-col overflow-y-scroll flex-1 p-1">
            {children...}
        </div>
    </div>
}

Complete Example

A full app with pure components, a struct component, and composition:

package main

import (
    "fmt"

    tui "github.com/grindlemire/go-tui"
)

// Pure components

templ Badge(label string, color string) {
    <span class={color + " font-bold px-1"}>{label}</span>
}

templ StatusLine(label string, value string) {
    <div class="flex gap-1">
        <span class="font-dim">{label}</span>
        <span class="text-cyan font-bold">{value}</span>
    </div>
}

templ Card(title string) {
    <div class="border-rounded p-1 flex-col gap-1 w-full" flexGrow={1.0}>
        <span class="text-gradient-cyan-magenta font-bold">{title}</span>
        <hr class="border-single" />
        {children...}
    </div>
}

// Tab content components

templ OverviewTab() {
    <div class="flex gap-1">
        @Card("System") {
            @StatusLine("CPU:", "42%")
            @StatusLine("Memory:", "1.2 GB")
            @StatusLine("Disk:", "68%")
        }
        @Card("Network") {
            @StatusLine("In:", "12 MB/s")
            @StatusLine("Out:", "3 MB/s")
            @StatusLine("Latency:", "24ms")
        }
    </div>
}

templ MetricsTab() {
    <div class="flex gap-1">
        @Card("Performance") {
            @StatusLine("Requests:", "1.2k/s")
            @StatusLine("P99:", "145ms")
            @StatusLine("Errors:", "0.02%")
        }
        @Card("Storage") {
            @StatusLine("Used:", "42 GB")
            @StatusLine("Free:", "118 GB")
            @StatusLine("IOPS:", "3.4k")
        }
    </div>
}

templ LogsTab() {
    <div class="flex gap-1">
        @Card("Application") {
            @StatusLine("Level:", "INFO")
            @StatusLine("Rate:", "84/min")
            @StatusLine("Errors:", "2")
        }
        @Card("Security") {
            @StatusLine("Auth:", "OK")
            @StatusLine("Blocked:", "17")
            @StatusLine("Alerts:", "0")
        }
    </div>
}

// Struct component

type dashboard struct {
    selected *tui.State[int]
    tabs     []string
}

func Dashboard() *dashboard {
    return &dashboard{
        selected: tui.NewState(0),
        tabs:     []string{"Overview", "Metrics", "Logs"},
    }
}

func (d *dashboard) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.KeyTab, func(ke tui.KeyEvent) {
            d.selected.Update(func(v int) int {
                return (v + 1) % len(d.tabs)
            })
        }),
    }
}

templ (d *dashboard) Render() {
    <div class="flex-col p-1 gap-1 border-rounded border-cyan">
        <div class="flex gap-2">
            for i, tab := range d.tabs {
                if i == d.selected.Get() {
                    @Badge(tab, "text-cyan")
                } else {
                    <span class="font-dim">{tab}</span>
                }
            }
        </div>

        if d.selected.Get() == 0 {
            @OverviewTab()
        } else if d.selected.Get() == 1 {
            @MetricsTab()
        } else {
            @LogsTab()
        }

        <div class="flex justify-center">
            <span class="font-dim">{fmt.Sprintf("Tab: switch tabs | esc: quit | Viewing: %s", d.tabs[d.selected.Get()])}</span>
        </div>
    </div>
}

With main.go:

package main

import (
    "fmt"
    "os"

    tui "github.com/grindlemire/go-tui"
)

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(Dashboard()),
    )
    if err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
    defer app.Close()

    if err := app.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

Generate and run:

tui generate ./...
go run .

When you run it, you'll get a tabbed dashboard:

Components screenshot

Next Steps