gitstack

grindlemire/go-tui code browser

18.2 KB markdown 563 lines 2026-04-03 · 2dcff81 raw

Built-in Elements

Overview

go-tui provides HTML-like elements (<div>, <span>, <p>, <ul>, <li>, <button>, <input>, <table>, <progress>, <hr>, <br>) that compile to tui.New() calls with appropriate defaults. Here's what each one does and when to use it.

Container Elements

<div> is the primary layout container. It renders as a flexbox container with Row direction by default. Use Tailwind classes or attributes to configure direction, alignment, gaps, and sizing:

// Horizontal layout (default)
<div class="flex gap-2">
    <span>Left</span>
    <span>Right</span>
</div>

// Vertical layout
<div class="flex-col gap-1 p-1 border-rounded">
    <span>Top</span>
    <span>Bottom</span>
</div>

// Nested layout
<div class="flex gap-1">
    <div class="flex-col border-single p-1" width={20}>
        <span>Sidebar</span>
    </div>
    <div class="flex-col grow border-single p-1">
        <span>Content</span>
    </div>
</div>

Every visible element in go-tui is ultimately a <div> with different default options applied.

Text Elements

span

<span> displays inline text. Use it for styled text content within a layout:

<span>Plain text</span>
<span class="text-cyan font-bold">Styled text</span>
<span class="text-[#ff6600]">Hex-colored text</span>

p

<p> renders paragraph text that wraps automatically when it exceeds the available width:

<p>{"This paragraph text wraps automatically when the content exceeds the available width. Use <p> for longer text blocks."}</p>

Use <span> for short inline labels and <p> for longer text that should word-wrap.

Separator Elements

hr

<hr> draws a horizontal rule across the container width. It's self-closing:

<div class="flex-col gap-1">
    <span>Above the line</span>
    <hr />
    <span>Below the line</span>
</div>

br

<br> inserts a blank line break. Also self-closing:

<div class="flex-col">
    <span>Line one</span>
    <br />
    <span>Line three (with a blank line above)</span>
</div>

List Elements

<ul> creates a list container and <li> renders list items with bullet markers. Nest them together for bulleted lists:

<ul class="flex-col p-1">
    <li><span>First item</span></li>
    <li><span>Second item</span></li>
    <li><span class="text-cyan">Third (styled)</span></li>
</ul>

Each <li> automatically prepends a bullet character. Put any content inside the <li>, typically a <span> with text, but it can contain other elements too.

Table Element

<table> acts as a flex container for tabular data. Build tables by composing <div> rows with fixed-width columns and an <hr> separator between the header and body:

<table class="flex-col p-1">
    // Header row
    <div class="flex gap-2">
        <span class="w-10 font-bold">Name</span>
        <span class="w-10 font-bold">Role</span>
        <span class="w-5 font-bold">Lvl</span>
    </div>
    <hr />
    // Data rows
    <div class="flex gap-2">
        <span class="w-10 text-cyan">Alice</span>
        <span class="w-10">Engineer</span>
        <span class="w-5 text-green">Sr</span>
    </div>
    <div class="flex gap-2">
        <span class="w-10 text-cyan">Bob</span>
        <span class="w-10">Designer</span>
        <span class="w-5 text-yellow">Jr</span>
    </div>
</table>

The fixed widths on each column (w-10, w-5) keep columns aligned across rows. Use gap-2 on the row <div> for spacing between columns.

Button Element

<button> renders a clickable button. Combine it with refs for mouse handling (see the Refs and Clicks guide):

<div class="flex gap-2">
    <button>{"Save"}</button>
    <button class="font-bold">{"Submit"}</button>
    <button disabled={true}>{"Disabled"}</button>
</div>

The disabled attribute visually dims the button. Wire up click handling through the MouseListener interface with HandleClicks and a Ref bound to the button.

Input Element

<input> is a single-line text input with cursor management and placeholder support. It is self-closing and focus-aware.

Bind value to a *State[string] for two-way binding. Typing updates the state, and changing the state updates the display:

<input value={s.name} placeholder="Type your name..." width={30} border={tui.BorderRounded} />

You can also set focusColor to change the border color when focused, or use focusGradient and borderGradient for gradient borders:

<input
    value={s.name}
    placeholder="Type here..."
    width={30}
    border={tui.BorderRounded}
    focusColor={tui.Magenta}
/>

// Gradient border that shifts color when focused
<input
    value={s.query}
    placeholder="Search..."
    border={tui.BorderRounded}
    borderGradient={tui.NewGradient(tui.Blue, tui.Cyan)}
    focusGradient={tui.NewGradient(tui.Cyan, tui.Magenta)}
/>

Wire up onSubmit for Enter key handling and onChange to react to each keystroke.

TextArea Element

<textarea> is a multi-line text input with word wrapping and cursor navigation. Like <input>, it is self-closing and focus-aware.

Bind value to a *State[string] for two-way binding. Set maxHeight to cap the visible rows, and use submitKey to control how Enter behaves:

<textarea
    value={s.note}
    placeholder="Write a note..."
    width={40}
    maxHeight={6}
    border={tui.BorderRounded}
    onSubmit={s.onSave}
    focusColor={tui.BrightRed}
/>

By default, Enter triggers onSubmit and Ctrl+J inserts a newline. Set submitKey to a different key (like tui.KeyCtrlS) to make Enter insert newlines instead.

The same border styling options from Input apply here: focusColor, borderGradient, and focusGradient.

Progress Bars

There's no built-in progress element yet, but a helper function does the job:

func progressBar(value, width int) string {
    filled := value * width / 100
    bar := ""
    for i := 0; i < width; i++ {
        if i < filled {
            bar += "█"
        } else {
            bar += "░"
        }
    }
    return bar
}

Then use it in your template with styling:

<div class="flex gap-2 items-center">
    <span class="font-dim w-10">Download:</span>
    <span class="text-cyan">{progressBar(e.progress.Get(), 25)}</span>
    <span class="text-cyan font-bold">{fmt.Sprintf("%d%%", e.progress.Get())}</span>
</div>

Color the bar with text-cyan, text-green, text-yellow, etc. to convey meaning (progress, success, warning).

Modal Dialogs

The <modal> element renders as a full-screen overlay. When open, it dims the background and closes on Escape and backdrop clicks by default. With trapFocus enabled (the default), it also traps Tab navigation and blocks unhandled keys from parents. Set trapFocus={false} to let keys pass through, or provide a keyMap for custom hotkeys.

Bind the open attribute to a *State[bool] to control visibility. Use onActivate on buttons to handle Enter key activation:

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

The modal container uses flexbox layout, so justify-center items-center centers the dialog, while justify-end items-stretch pins it as a bottom sheet. Tab and Shift+Tab cycle between focusable children. Enter triggers the focused element's onActivate callback. Mouse clicks on elements with onActivate also trigger it.

Key attributes:

Attribute Default Description
open (required) *State[bool] controlling visibility
backdrop "dim" "dim", "blank", or "none"
closeOnEscape true Escape key closes the modal
closeOnBackdropClick true Clicking outside the dialog closes it
trapFocus true Restrict Tab navigation and block unhandled keys from parents
keyMap Custom KeyMap bindings for modal hotkeys

Focusable elements with borders get an automatic cyan highlight when focused. The first focusable child receives focus when the modal opens.

Complete Example

This elements gallery demonstrates every built-in element type in a scrollable layout, including Input and TextArea with two-way value binding:

package main

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

type elementsApp struct {
    progress    *tui.State[int]
    scrollY     *tui.State[int]
    content     *tui.Ref
    name        *tui.State[string]
    note        *tui.State[string]
    selectedBtn *tui.State[string]
    btnRefs     *tui.RefMap[string]
}

func Elements() *elementsApp {
    return &elementsApp{
        progress:    tui.NewState(62),
        scrollY:     tui.NewState(0),
        content:     tui.NewRef(),
        name:        tui.NewState(""),
        note:        tui.NewState(""),
        selectedBtn: tui.NewState(""),
        btnRefs:     tui.NewRefMap[string](),
    }
}

func (e *elementsApp) onNoteSubmit(text string) {
    e.note.Set(text)
}

func greeting(name string) string {
    if name == "" {
        return "Hello, World!"
    }
    return fmt.Sprintf("Hello, %s!", name)
}

func (e *elementsApp) scrollBy(delta int) {
    el := e.content.El()
    if el == nil {
        return
    }
    _, maxY := el.MaxScroll()
    newY := e.scrollY.Get() + delta
    if newY < 0 {
        newY = 0
    } else if newY > maxY {
        newY = maxY
    }
    e.scrollY.Set(newY)
}

func (e *elementsApp) KeyMap() tui.KeyMap {
    return tui.KeyMap{
        tui.On(tui.KeyEscape, func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.Rune('q'), func(ke tui.KeyEvent) { ke.App().Stop() }),
        tui.On(tui.KeyTab, func(ke tui.KeyEvent) { ke.App().FocusNext() }),
        tui.On(tui.KeyTab.Shift(), func(ke tui.KeyEvent) { ke.App().FocusPrev() }),
        tui.On(tui.Rune('+'), func(ke tui.KeyEvent) {
            v := e.progress.Get() + 5
            if v > 100 {
                v = 100
            }
            e.progress.Set(v)
        }),
        tui.On(tui.Rune('-'), func(ke tui.KeyEvent) {
            v := e.progress.Get() - 5
            if v < 0 {
                v = 0
            }
            e.progress.Set(v)
        }),
        tui.On(tui.Rune('j'), func(ke tui.KeyEvent) { e.scrollBy(1) }),
        tui.On(tui.Rune('k'), func(ke tui.KeyEvent) { e.scrollBy(-1) }),
        tui.On(tui.KeyDown, func(ke tui.KeyEvent) { e.scrollBy(1) }),
        tui.On(tui.KeyUp, func(ke tui.KeyEvent) { e.scrollBy(-1) }),
    }
}

func (e *elementsApp) HandleMouse(me tui.MouseEvent) bool {
    switch me.Button {
    case tui.MouseWheelUp:
        e.scrollBy(-1)
        return true
    case tui.MouseWheelDown:
        e.scrollBy(1)
        return true
    }

    if me.Button == tui.MouseLeft && me.Action == tui.MousePress {
        for name, el := range e.btnRefs.All() {
            if el != nil && el.ContainsPoint(me.X, me.Y) {
                if name != "Disabled" {
                    e.selectedBtn.Set(name)
                }
                return true
            }
        }
    }

    return false
}

var buttonLabels = []string{"Save", "Cancel", "Submit", "Disabled"}

func progressBar(value, width int) string {
    filled := value * width / 100
    bar := ""
    for i := 0; i < width; i++ {
        if i < filled {
            bar += "█"
        } else {
            bar += "░"
        }
    }
    return bar
}

templ (e *elementsApp) Render() {
    <div
        ref={e.content}
        class="flex-col gap-1 h-full"
        scrollable={tui.ScrollVertical}
        scrollOffset={0, e.scrollY.Get()}
    >
        <span class="text-gradient-cyan-magenta font-bold">Built-in Elements</span>

        // Text Elements
        <div class="flex-col border-rounded p-1 gap-1">
            <span class="text-gradient-cyan-magenta font-bold">Text Elements</span>
            <p>{"Paragraph text (<p>) wraps automatically when the content exceeds the available width. This demonstrates how longer text content is displayed."}</p>
            <hr />
            <span class="text-cyan">{"This is a <span> element for inline styled text"}</span>
            <br />
            <span class="font-dim">{"<hr> above draws a line, <br> inserts a blank line"}</span>
        </div>

        // Lists and Table side by side
        <div class="flex gap-1">
            <div class="flex-col border-rounded p-1 gap-1">
                <span class="text-gradient-cyan-magenta font-bold">{"Lists (<ul> / <li>)"}</span>
                <ul class="flex-col p-1">
                    <li><span>First item</span></li>
                    <li><span>Second item</span></li>
                    <li><span>Third item</span></li>
                    <li><span class="text-cyan">Fourth (styled)</span></li>
                </ul>
            </div>
            <div class="flex-col border-rounded p-1 gap-1">
                <span class="text-gradient-cyan-magenta font-bold">Table</span>
                <table class="p-1">
                    <tr>
                        <th>Name</th>
                        <th>Role</th>
                        <th>Lvl</th>
                    </tr>
                    <hr />
                    <tr>
                        <td class="text-cyan">Alice</td>
                        <td>Engineer</td>
                        <td class="text-green">Sr</td>
                    </tr>
                    <tr>
                        <td class="text-cyan">Bob</td>
                        <td>Designer</td>
                        <td class="text-yellow">Jr</td>
                    </tr>
                </table>
            </div>
        </div>

        // Buttons
        <div class="flex-col border-rounded p-1 gap-1">
            <span class="text-gradient-cyan-magenta font-bold">Buttons</span>
            <div class="flex gap-2">
                for _, label := range buttonLabels {
                    if label == "Disabled" {
                        <button ref={e.btnRefs} key={label} class="font-dim" disabled={true}>{label}</button>
                    } else {
                        if label == e.selectedBtn.Get() {
                            <button ref={e.btnRefs} key={label} class="font-bold text-cyan">{label}</button>
                        } else {
                            <button ref={e.btnRefs} key={label}>{label}</button>
                        }
                    }
                }
            </div>
        </div>

        // Input & TextArea
        <div class="flex-col border-rounded p-1 gap-1">
            <span class="text-gradient-cyan-magenta font-bold">Input & TextArea</span>
            <div class="flex gap-2">
                <div class="flex-col gap-1 w-1/2">
                    <div class="flex gap-2 items-center">
                        <span class="font-dim">Name:</span>
                        <input
                            placeholder="Type your name..."
                            value={e.name}
                            width={30}
                            border={tui.BorderRounded}
                            focusGradient={tui.NewGradient(tui.Cyan, tui.Magenta)}
                        />
                    </div>
                    <span class="text-cyan font-bold" width={30}>
                        {greeting(e.name.Get())}
                    </span>
                </div>
                <div class="flex-col gap-1 w-1/2">
                    <div class="flex gap-2 items-center">
                        <span class="font-dim">Note:</span>
                        <textarea
                            placeholder="Write a note..."
                            width={30}
                            maxHeight={4}
                            border={tui.BorderRounded}
                            onSubmit={e.onNoteSubmit}
                            focusColor={tui.BrightRed}
                        />
                    </div>
                    if e.note.Get() != "" {
                        <span class="text-cyan font-bold">{fmt.Sprintf("Saved: %s", e.note.Get())}</span>
                    }
                </div>
            </div>
            <span class="font-dim">Tab to cycle focus | Esc to blur | Enter submits note</span>
        </div>

        // Progress bars
        <div class="flex-col border-rounded p-1 gap-1">
            <span class="text-gradient-cyan-magenta font-bold">Progress Bars</span>
            <div class="flex gap-2 items-center">
                <span class="font-dim w-10">Download:</span>
                <span class="text-cyan">{progressBar(e.progress.Get(), 25)}</span>
                <span class="text-cyan font-bold">{fmt.Sprintf("%d%%", e.progress.Get())}</span>
            </div>
            <div class="flex gap-2 items-center">
                <span class="font-dim w-10">Upload:</span>
                <span class="text-green">{progressBar(100, 25)}</span>
                <span class="text-green font-bold">{"100%"}</span>
            </div>
            <div class="flex gap-2 items-center">
                <span class="font-dim w-10">Build:</span>
                <span class="text-yellow">{progressBar(35, 25)}</span>
                <span class="text-yellow font-bold">{"35%"}</span>
            </div>
        </div>

        <span class="font-dim">tab focus input | +/- progress | j/k scroll | q quit</span>
    </div>
}

With main.go:

package main

import (
    "fmt"
    "os"

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

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

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

Generate and run:

tui generate ./...
go run .

Scroll through the gallery with j/k or arrow keys:

Built-in Elements screenshot

Next Steps