gitstack

grindlemire/go-tui code browser

27.3 KB markdown 885 lines 2026-06-27 · 4be96e4 raw

go-tui — Complete Reference for LLMs

Declarative terminal UI framework for Go with templ-like syntax and flexbox layout.
Pure Go, minimal dependencies, generates type-safe code from .gsx templates.

Installation

go get github.com/grindlemire/go-tui
go install github.com/grindlemire/go-tui/cmd/tui@latest

CLI Commands

tui generate [path...]       # Generate Go code from .gsx files
tui check [path...]          # Validate .gsx files without writing output
tui fmt [path...]            # Format .gsx files
tui fmt --check [path...]    # Check formatting without modifying
tui lsp                      # Start language server (stdio)

Architecture

.gsx files → tui generate → _gsx.go files → go build → binary

At runtime: Event Loop → Layout Engine (flexbox) → Double-buffered Render → ANSI Terminal Output

GSX Syntax

.gsx files are Go files with a templ keyword for declaring UI components. They compile to standard Go code.

Pure Components (stateless)

package main

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

templ Greeting(name string) {
    <span class="text-cyan font-bold">{"Hello, " + name}</span>
}

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

// Usage
templ App() {
    @Card("Info") {
        @Greeting("Alice")
    }
}

Struct Components (stateful)

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 })
        }),
    }
}

templ (c *counter) Render() {
    <div class="flex-col items-center justify-center h-full">
        <span class="text-cyan font-bold">{fmt.Sprintf("Count: %d", c.count.Get())}</span>
    </div>
}

Struct Components with Children

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">
        <span class="font-bold">{p.title}</span>
        {children...}
    </div>
}

Control Flow

// Conditionals
if condition {
    <span>True</span>
} else if otherCondition {
    <span>Other</span>
} else {
    <span>Default</span>
}

// Loops
for i, item := range items {
    <span>{fmt.Sprintf("%d: %s", i, item)}</span>
}

// Local element binding
badge := <span class="font-bold">{label}</span>
<div>{badge}</div>

Go Expressions

<span>{fmt.Sprintf("Count: %d", c.count.Get())}</span>
<span class={computedClass(isActive)}>Dynamic class</span>
<div width={42} height={10} flexGrow={1.5}>content</div>

main.go Pattern

package main

import (
    "fmt"
    "os"
    tui "github.com/grindlemire/go-tui"
)

func main() {
    app, err := tui.NewApp(
        tui.WithRootComponent(MyComponent()),
        // Optional:
        // tui.WithMouse(),
        // tui.WithFrameRate(60),
        // tui.WithInlineHeight(10),
        // tui.WithGlobalKeyHandler(func(ke tui.KeyEvent) bool { return false }),
    )
    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)
    }
}

Single-Frame Printing

For CLI tools that render styled output once and exit, without starting an interactive app:

// Print to stdout (auto-detects terminal width, falls back to 80)
tui.Print(MyComponent("hello"))

// Return as ANSI string (no trailing newline)
s := tui.Sprint(view, tui.WithPrintWidth(80))

// Write to any io.Writer (appends trailing newline)
tui.Fprint(w, view, tui.WithPrintWidth(120))

All three accept a Viewable (generated .gsx views and raw *Element values). Same components work with both Print and App.Run().

// PrintOption
tui.WithPrintWidth(w int)  // Explicit width; default: auto-detect, fallback 80

Built-in Elements

Container Elements

Element Description Default Direction
<div> Block flex container (primary layout element) Row
<span> Inline text container
<p> Paragraph with automatic text wrapping
<ul> Unordered list container Column
<li> List item (auto bullet prefix)
<button> Clickable element
<modal> Full-screen overlay dialog with backdrop
<table> Table container Column

Self-Closing (Void) Elements

Element Description
<input /> Single-line text input. Attrs: value (*State[string]), placeholder, width, border, focusColor, borderGradient, focusGradient, onSubmit, onChange
<textarea /> Multi-line text input. Attrs: value (*State[string]), placeholder, width, maxHeight, border, focusColor, borderGradient, focusGradient, submitKey, onSubmit
<progress /> Progress bar. Attrs: value, max
<hr /> Horizontal rule
<br /> Line break

Attributes

Layout

Attribute Type Description
width int Fixed width in characters
widthPercent int Width as percentage
height int Fixed height in rows
heightPercent int Height as percentage
minWidth / maxWidth int Min/max width constraints
minHeight / maxHeight int Min/max height constraints
direction tui.Direction tui.Row or tui.Column
justify tui.Justify Main axis alignment
align tui.Align Cross axis alignment
alignSelf tui.Align Override parent's align
gap int Gap between children
flexGrow float64 Flex grow factor
flexShrink float64 Flex shrink factor
padding int Padding on all sides
margin int Margin on all sides

Visual

Attribute Type Description
border tui.BorderStyle Border style
background tui.Color Background color
text string Text content
textStyle tui.Style Text styling
textAlign string "left", "center", "right"
borderStyle tui.Style Border line styling
borderTitle string Title text centered in the top border

Identity & Behavior

Attribute Type Description
id string Unique identifier
class string Tailwind-style classes
disabled bool Disable interaction
ref *tui.Ref Bind to a reference
deps expression Explicit state dependencies
focusable bool Enable focus
onFocus / onBlur func(*tui.Element) Focus callbacks
onActivate func() Enter activation callback

Scroll

Attribute Type Description
scrollable tui.ScrollMode ScrollVertical, ScrollHorizontal, ScrollBoth
scrollOffset int, int x, y scroll position
scrollbarStyle tui.Style Track style
scrollbarThumbStyle tui.Style Thumb style
hideScrollbar bool Hide scrollbar and reclaim gutter width

Modal

Attribute Type Description
open *State[bool] Controls modal visibility (required)
backdrop string "dim" (default), "blank", or "none"
closeOnEscape bool Escape closes modal (default true)
closeOnBackdropClick bool Backdrop click closes modal (default true)
trapFocus bool Restrict Tab to modal children and block unhandled keys from parents (default true)
keyMap expression Custom KeyMap bindings for the modal

Activation

Attribute Type Description
onActivate func() Called when Enter is pressed while focused

Tailwind Classes (Complete Reference)

Layout Direction

flex flex-row flex-col

Flex Sizing

grow grow-0 shrink shrink-0 flex-1 flex-auto flex-initial flex-none flex-grow-N flex-shrink-N

Width & Height

w-N h-N w-full h-full w-auto h-auto w-1/2 w-1/3 w-2/3 h-1/2 h-1/3 h-2/3 min-w-N max-w-N min-h-N max-h-N

Justify Content

justify-start justify-center justify-end justify-between justify-around justify-evenly

Align Items / Self

items-start items-center items-end items-stretch self-start self-center self-end self-stretch

Flex Wrap

flex-wrap flex-wrap-reverse flex-nowrap

Align Content (for wrapped containers)

content-start content-end content-center content-stretch content-between content-around

Spacing

gap-N p-N px-N py-N pt-N pr-N pb-N pl-N m-N mx-N my-N mt-N mr-N mb-N ml-N

Borders

border border-single border-double border-rounded border-thick border-COLOR border-[#hex] border-gradient-C1-C2[-dir]

Text Styling

font-bold font-dim text-dim italic underline strikethrough blink reverse text-left text-center text-right truncate wrap nowrap

Colors

Named: text-COLOR text-bright-COLOR bg-COLOR bg-bright-COLOR
Hex: text-[#hex] bg-[#hex]
Colors: red green blue cyan magenta yellow white black (plus bright- variants)

Gradients

text-gradient-C1-C2[-dir] bg-gradient-C1-C2[-dir] border-gradient-C1-C2[-dir]
Directions: -h (horizontal, default), -v (vertical), -dd (diagonal down), -du (diagonal up)

Scroll & Overflow

overflow-scroll overflow-y-scroll overflow-x-scroll overflow-hidden scrollbar-hidden scrollbar-COLOR scrollbar-thumb-COLOR scrollbar-[#hex] scrollbar-thumb-[#hex]

Other

focusable hidden

Key Types

// Dimensions
tui.Fixed(10)        // 10 characters
tui.Percent(50)      // 50% of parent
tui.Auto()           // Size to content

// Borders
tui.BorderNone / tui.BorderSingle / tui.BorderDouble / tui.BorderRounded / tui.BorderThick

// Direction
tui.Row / tui.Column

// Justify
tui.JustifyStart / tui.JustifyCenter / tui.JustifyEnd
tui.JustifySpaceBetween / tui.JustifySpaceAround / tui.JustifySpaceEvenly

// Align
tui.AlignStart / tui.AlignCenter / tui.AlignEnd / tui.AlignStretch

// TextAlign
tui.TextAlignLeft / tui.TextAlignCenter / tui.TextAlignRight

// ScrollMode
tui.ScrollNone / tui.ScrollVertical / tui.ScrollHorizontal / tui.ScrollBoth

// OverflowMode
tui.OverflowVisible / tui.OverflowHidden

// Colors
tui.Black / tui.Red / tui.Green / tui.Yellow / tui.Blue / tui.Magenta / tui.Cyan / tui.White
tui.BrightBlack / tui.BrightRed / tui.BrightGreen / ... / tui.BrightWhite
tui.ANSIColor(index)        // 256 palette
tui.RGBColor(r, g, b)       // 24-bit true color
tui.HexColor("#ff6600")     // Hex string
tui.DefaultColor()           // Terminal default

// Gradients
tui.NewGradient(tui.Red, tui.Blue)                                    // Horizontal
tui.NewGradient(tui.Red, tui.Blue).WithDirection(tui.GradientVertical) // Vertical
// Directions: GradientHorizontal, GradientVertical, GradientDiagonalDown, GradientDiagonalUp

// Style (chainable)
tui.NewStyle().Bold().Dim().Italic().Underline().Strikethrough().Reverse().Blink()
    .Foreground(tui.Cyan).Background(tui.Black)

// State (generic reactive container)
count := tui.NewState(0)
count.Get()                           // Read (safe from any goroutine)
count.Set(5)                          // Write (main loop only)
count.Update(func(v int) int { ... }) // Read-modify-write (main loop only)
unbind := count.Bind(func(v int) { }) // Called on change

// Refs (element references for hit testing)
ref := tui.NewRef()           // Single element
list := tui.NewRefList()      // Loop elements: list.At(i) to bind, list.El(i) to read
m := tui.NewRefMap[string]()  // Keyed elements: m.At(key) to bind, m.El(key) to read

// Events (cross-component pub/sub)
bus := tui.NewEvents[MyEvent]("topic")
bus.Emit(MyEvent{...})
unsub := bus.Subscribe(func(e MyEvent) { ... })

Component Interfaces

All optional except Component.Render:

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

// Keyboard input
type KeyListener interface {
    KeyMap() KeyMap
}

// Mouse input
type MouseListener interface {
    HandleMouse(MouseEvent) bool
}

// Background timers/channels
type WatcherProvider interface {
    Watchers() []Watcher
}

// Setup/cleanup on mount/unmount
type Initializer interface {
    Init() func()  // returned func is cleanup
}

// Receive updated props on re-render
type PropsUpdater interface {
    UpdateProps(fresh Component)
}

// Auto-generated: binds State/Events fields to app
type AppBinder interface {
    BindApp(app *App)
}

Key Bindings

func (c *myComp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, handler),           // Special key, propagates
        tui.OnStop(tui.KeyEnter, handler),        // Special key, stops propagation
        tui.On(tui.Rune('q'), handler),           // Specific character
        tui.OnStop(tui.Rune('/'), handler),        // Specific char, stops propagation
        tui.On(tui.AnyRune, handler),              // Any printable character
        tui.OnStop(tui.AnyRune, handler),          // Any printable, stops propagation

        // Preemptive binding (fires before normal handlers; used by modal)
        tui.OnPreemptStop(tui.AnyKey, func(ke tui.KeyEvent) {}),  // catch-all blocker
    }
}

KeyEvent fields: ke.Key, ke.Rune, ke.Mod (ModCtrl, ModAlt, ModShift), ke.App()

Special keys: KeyUp KeyDown KeyLeft KeyRight KeyEnter KeyTab KeyEscape KeyBackspace KeyDelete KeyHome KeyEnd KeyPageUp KeyPageDown KeyInsert KeyCtrlAKeyCtrlZ KeyF1KeyF12

Mouse Handling

Requires tui.WithMouse() in app options.

func (c *myComp) HandleMouse(me tui.MouseEvent) bool {
    return tui.HandleClicks(me,
        tui.Click(c.saveBtn, c.onSave),       // Single ref
        tui.Click(c.itemRefs, c.onItemClick),  // RefList
    )
}

MouseEvent fields: me.Button (MouseLeft, MouseRight, MouseWheelUp, MouseWheelDown), me.Action (MousePress, MouseRelease, MouseDrag), me.X, me.Y, me.App()

Watchers

func (c *myComp) Watchers() []tui.Watcher {
    return []tui.Watcher{
        tui.OnTimer(time.Second, c.tick),           // Timer
        tui.Watch(c.dataCh, c.onData),              // Channel watcher
    }
}

Callbacks run on the main event loop, so you can mutate state directly.

Scroll API

el.ScrollTo(x, y)          // Absolute position (clamped)
el.ScrollBy(dx, dy)        // Relative adjustment (clamped)
el.ScrollToTop()
el.ScrollToBottom()         // With deferred re-scroll after layout
el.ScrollIntoView(child)   // Minimal scroll to make child visible
el.ScrollOffset() (x, y)   // Current position
el.MaxScroll() (maxX, maxY)
el.ContentSize() (w, h)
el.ViewportSize() (w, h)
el.IsAtBottom() bool

Focus Management

// Element-level
element.Focus() / element.Blur() / element.IsFocused()

// App-level cycling
app.FocusNext() / app.FocusPrev() / app.Focused()

// FocusGroup for section-level switching
sidebar := tui.NewState(true)
content := tui.NewState(false)
fg := tui.MustNewFocusGroup(sidebar, content)
fg.Next() / fg.Prev() / fg.Current()
// fg.KeyMap() returns Tab/Shift+Tab bindings

// Modal focus trapping (automatic when trapFocus is enabled)
// Tab/Shift+Tab cycle within modal children only
// Enter triggers focused element's onActivate callback
// All parent key handlers are blocked via preemptive dispatch

State Batching

ke.App().Batch(func() {
    state1.Set(val1)
    state2.Set(val2)
    // Single re-render after batch completes
})

Thread Safety

Operation Main Loop Any Goroutine
state.Get() Yes Yes
state.Set() / state.Update() Yes Use app.QueueUpdate()
events.Emit() Yes Use app.QueueUpdate()
app.QueueUpdate(func() { ... }) Yes Yes

App Options

tui.WithRootComponent(comp)              // Required: root component
tui.WithMouse()                          // Enable mouse support
tui.WithFrameRate(fps int)               // Default: 60
tui.WithInlineHeight(rows int)           // Inline mode (not fullscreen)
tui.WithGlobalKeyHandler(func(KeyEvent) bool)  // Global key intercept
tui.WithInputLatency(d time.Duration)    // Coalesce rapid input
tui.WithEventQueueSize(n int)            // Event queue buffer size
tui.WithPreRenderHook(func())            // Runs at the start of each render cycle
tui.WithPostRenderHook(func())           // Runs after each render cycle
tui.WithManualCursor()                   // Disable framework-driven cursor placement

App Methods

// Standard lifecycle
app.Run() error              // Start event loop (blocks), calls Open() + Close() internally
app.Stop()                   // Stop the event loop
app.Close() error            // Restore terminal (idempotent)
app.StopCh() <-chan struct{} // Closed when app stops

// Manual event loop
app.Open() error             // Initialize without blocking (signals, input reader, initial render)
app.Events() <-chan Event    // Read-only channel for select multiplexing (input prioritized)
app.Dispatch(Event) bool     // Route a single event through key/mouse/resize dispatch
app.DispatchEvents() bool    // Drain all pending events (non-blocking), false if stopped
app.Step() bool              // DispatchEvents + Render in one call, false if stopped
app.Render()                 // Re-render if dirty (no-op when clean)
app.RenderFull()             // Force full redraw

// State and updates
app.Batch(func())            // Batch state updates
app.QueueUpdate(func())      // Schedule work on main loop (drops if full)
app.MarkDirty()              // Mark app as needing render

// Focus
app.FocusNext() / app.FocusPrev() / app.Focused()

// Inline mode
app.PrintAbove(string)       // Print above inline widget
app.StreamAbove() *StreamWriter  // Stream text char-by-char above inline widget

Manual Event Loop Patterns

Three ways to drive the event loop:

// 1. Run() - standard, go-tui owns the loop
app.Run()

// 2. Step() - you own frame timing
app.Open()
defer app.Close()
for {
    <-ticker.C
    drainMyChannels()    // mutate state directly, no QueueUpdate needed
    if !app.Step() { return }
}

// 3. Events() + select - multiplex with external channels
app.Open()
defer app.Close()
for {
    select {
    case ev := <-app.Events():
        app.Dispatch(ev)
    case msg := <-myChannel:
        handleMsg(msg)   // mutate state directly
    case <-app.StopCh():
        return
    }
    app.Render()
}

Inline Mode

For CLI tools that render a widget inline (not fullscreen):

app, _ := tui.NewApp(
    tui.WithRootComponent(myComp),
    tui.WithInlineHeight(5),  // Reserve 5 terminal rows
)

Inline mode limitations: modals (<modal>) are not supported because the buffer covers only a partial terminal region. Use app.EnterAlternateScreen() to switch to full-screen before opening a modal, then app.ExitAlternateScreen() when it closes.

StreamAbove

app.StreamAbove() returns a *StreamWriter for character-by-character streaming above the inline widget. The writer implements io.WriteCloser and adds WriteStyled and WriteGradient methods. Goroutine-safe. Close it when done to finalize the partial line.

go func() {
    w := app.StreamAbove()
    // Plain write (backward compatible)
    fmt.Fprint(w, "hello ")
    // Styled write
    w.WriteStyled("bold text", tui.NewStyle().Bold().Foreground(tui.Red))
    // Gradient write (per-character color interpolation)
    grad := tui.NewGradient(tui.Cyan, tui.Magenta)
    w.WriteGradient("gradient text", grad)
    // Gradient with base style (bold + gradient foreground)
    w.WriteGradient("bold gradient", grad, tui.NewStyle().Bold())
    w.Close()
}()

WriteStyled(text, style) wraps text in ANSI style prefix/reset. WriteGradient(text, gradient, base...) writes each character with an interpolated gradient foreground; optional base style adds attributes (bold, italic, etc.) and background.

Returns a no-op writer when not in inline mode. Only one stream writer is active at a time; calling StreamAbove() again finalizes the previous one. PrintAbove/PrintAboveln also finalize any active stream before printing.

PrintAboveElement

app.PrintAboveElement(el) renders an element tree at the terminal width and inserts the resulting rows into the inline scrollback as static ANSI text. Works with templ function output. No-op outside inline mode. Must be called from the main event loop. QueuePrintAboveElement(el) is the goroutine-safe variant.

StreamWriter.WriteElement(el) inserts a rendered element mid-stream. Finalizes the current partial line first.

// Standalone
app.PrintAboveElement(DataTable(rows))

// Mid-stream
w := app.StreamAbove()
w.Write([]byte("Here's the data:\n"))
w.WriteElement(DataTable(rows))
w.Write([]byte("Done.\n"))
w.Close()

Common Layout Patterns

// Full-screen with header/content/footer
<div class="flex-col h-full">
    <div class="border-single p-1"><span>Header</span></div>
    <div class="flex-col grow p-1"><span>Content</span></div>
    <div class="border-single p-1"><span>Footer</span></div>
</div>

// Sidebar + main
<div class="flex h-full">
    <div class="w-20 border-single flex-col p-1"><span>Sidebar</span></div>
    <div class="grow flex-col p-1"><span>Main</span></div>
</div>

// Centered card
<div class="flex items-center justify-center h-full">
    <div class="border-rounded p-2 w-40"><span>Centered</span></div>
</div>

// Dashboard grid
<div class="flex-col h-full gap-1">
    <div class="flex gap-1 grow">
        <div class="grow border-rounded p-1"><span>Panel 1</span></div>
        <div class="grow border-rounded p-1"><span>Panel 2</span></div>
    </div>
    <div class="flex gap-1 grow">
        <div class="w-2/3 border-rounded p-1"><span>Wide</span></div>
        <div class="grow border-rounded p-1"><span>Narrow</span></div>
    </div>
</div>

Modal Example

Important: Modals require full-screen mode. They are silently ignored in inline mode (WithInlineHeight). To show a modal from an inline app, call app.EnterAlternateScreen() first, then app.ExitAlternateScreen() when the modal closes.

<modal open={s.showConfirm} class="justify-center items-center" backdrop="dim">
    <div class="border-rounded p-2 flex-col gap-1 w-40">
        <span class="font-bold text-yellow">Confirm?</span>
        <button class="px-2 border-rounded focusable" onActivate={s.cancel}>Cancel</button>
        <button class="px-2 border-rounded focusable" onActivate={s.confirm}>OK</button>
    </div>
</modal>

Bottom sheet variant:

<modal open={s.showSheet} class="justify-end items-stretch" closeOnBackdropClick={false}>
    <div class="border-single p-1 flex gap-4 items-center justify-center">
        <span class="font-bold">Action?</span>
        <button class="px-2 border-rounded focusable" onActivate={s.cancel}>Cancel</button>
        <button class="px-2 border-rounded focusable" onActivate={s.doAction}>Go</button>
    </div>
</modal>

Testing

// MockTerminal captures rendering output
term := tui.NewMockTerminal(80, 24)

// MockEventReader provides scripted events
reader := tui.NewMockEventReader(
    tui.KeyEvent{Key: tui.KeyRune, Rune: 'a'},
    tui.KeyEvent{Key: tui.KeyEscape},
)

// Table-driven test pattern
type tc struct { /* fields */ }
tests := map[string]tc{ "name": { /* values */ } }
for name, tt := range tests {
    t.Run(name, func(t *testing.T) { /* test */ })
}

Element Option Functions (Go API)

When building elements programmatically (outside .gsx):

el := tui.New(
    tui.WithWidth(30),
    tui.WithHeight(10),
    tui.WithWidthPercent(50),
    tui.WithWidthAuto(),
    tui.WithMinWidth(10),
    tui.WithMaxWidth(60),
    tui.WithDirection(tui.Column),
    tui.WithJustify(tui.JustifyCenter),
    tui.WithAlign(tui.AlignCenter),
    tui.WithGap(2),
    tui.WithFlexGrow(1.0),
    tui.WithFlexShrink(0),
    tui.WithPadding(2),
    tui.WithMargin(1),
    tui.WithBorder(tui.BorderRounded),
    tui.WithBackground(tui.Cyan),
    tui.WithText("content"),
    tui.WithTextStyle(tui.NewStyle().Bold()),
    tui.WithTextAlign(tui.TextAlignCenter),
    tui.WithScrollable(tui.ScrollVertical),
    tui.WithFocusable(true),
    tui.WithHidden(true),
    tui.WithTruncate(true),
    tui.WithTextGradient(tui.NewGradient(tui.Cyan, tui.Magenta)),
    tui.WithBackgroundGradient(tui.NewGradient(tui.Blue, tui.Cyan)),
    tui.WithBorderGradient(tui.NewGradient(tui.Cyan, tui.Magenta)),
    tui.WithBorderTitle(" Title "),
    tui.WithBorderTitleAlign(tui.TextAlignLeft),
    tui.WithBorderTitleStyle(tui.NewStyle().Bold()),
    tui.WithFocusBorderStyle(tui.NewStyle().Foreground(tui.Magenta)),
    tui.WithCursorSource(func() (col, row int, visible bool) { return 0, 0, true }),
)

Input / TextArea Runtime API

Both Input and TextArea expose the same programmatic editing methods. Positions are grapheme-cluster indices (whole glyphs before the cursor), not byte or rune offsets.

w.CursorPos() int          // cluster index of the cursor
w.SetCursorPos(pos int)    // clamped to [0, ClusterCount(text)]
w.InsertText(s string)     // insert at cursor, advance past it
w.Text() string            // current text
w.SetText(s string)        // replace text, cursor to end

Text widgets drive the real terminal cursor by default and draw no glyph. Opt into the painted with WithInputVirtualCursor() or WithTextAreaVirtualCursor(), or disable framework cursor placement app-wide with tui.WithManualCursor().

tui.WithInputOnChange(func(string))      // Input: fires on every text change
tui.WithTextAreaOnChange(func(string))   // TextArea: fires on every text change

Resize a retained element between frames:

el.SetWidth(tui.Fixed(40))   // or tui.Percent(50), tui.Auto()
el.SetHeight(tui.Fixed(10))

Cross-Component Communication

// Producer component
type producer struct {
    bus *tui.Events[string]
}
func Producer() *producer {
    return &producer{bus: tui.NewEvents[string]("my.topic")}
}
func (p *producer) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.Rune('e'), func(ke tui.KeyEvent) { p.bus.Emit("event!") }),
    }
}

// Consumer component (separate, no direct reference to producer)
type consumer struct {
    bus      *tui.Events[string]
    messages *tui.State[[]string]
}
func Consumer() *consumer {
    c := &consumer{
        bus:      tui.NewEvents[string]("my.topic"), // same topic string
        messages: tui.NewState([]string{}),
    }
    c.bus.Subscribe(func(msg string) {
        c.messages.Set(append(c.messages.Get(), msg))
    })
    return c
}

Buffer / Rendering Internals

  • Double-buffered character grid (front/back buffers)
  • Diff-based rendering: only changed cells are written to terminal
  • Each cell stores: Rune, Style, Width (CJK support)
  • ANSI escape sequences for cursor movement, colors, text attributes
  • Automatic color capability detection and fallback