wui

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 1, 2026 License: MIT Imports: 13 Imported by: 0

README

wui

A Go UI framework that renders the same component tree as a terminal UI (TUI) or as semantic HTML in the browser via WebAssembly. One codebase, two build targets, zero canvas.

make run-counter      # run counter example in terminal
make serve-counter    # build + serve counter in browser (auto-picks a free port)
make tui-counter      # run counter in terminal AND serve it in the browser

How it works

wui follows the Elm Architecture: your application defines a Model (state), an Update function (events → new state), and a View function (state → element tree). wui drives the loop on the appropriate platform.

          ┌──────────────────────────────────────────────────────────┐
          │  Your application (shared)                               │
          │                                                          │
          │   Model ──Update(Msg)──► (Model, Cmd) ──► View() ──►    │
          │                                              Element     │
          └──────────────────────┬───────────────────────┬──────────┘
                                 │                       │
                    ┌────────────▼──────┐   ┌────────────▼──────────┐
                    │   TUI Renderer    │   │   HTML Renderer        │
                    │  (bubbletea +     │   │  (syscall/js DOM)      │
                    │   lipgloss)       │   │                        │
                    │                  │   │  <button>, <input>,    │
                    │  [ Increment ]   │   │  <form>, <table>, <a>  │
                    └───────────────────┘   └────────────────────────┘

The HTML renderer maps every element to the correct semantic HTML tag — not canvas, not <div> soup. This means native browser behaviours work out of the box: tab focus, form submission, scroll, accessibility, link navigation.

By default the WASM build also injects a small terminal-look stylesheet (monospace type, dark background, [ Button ] chrome, [x] checkboxes, - list markers, bordered tables, preserved whitespace so column-aligned text lines up like in a terminal) so the browser rendering visually matches the TUI. Disable it with wui.WithoutBaseCSS() if the host page brings its own styles.

Element → HTML mapping
wui element HTML output Native behaviour
Text <span>
Box(Row) <div style="display:flex;flex-direction:row">
Box(Column) <div style="display:flex;flex-direction:column">
Button <button> focusable, keyboard-activatable
Input <input type="text/password"> native caret, IME, autofill
Checkbox <label><input type="checkbox"> focusable, Space toggles, label click
Card <fieldset><legend>
Form <form> submit on Enter, preventDefault wired
List(false) <ul><li>…</li></ul>
List(true) <ol><li>…</li></ul>
Scroll <div style="overflow:auto"> native scroll
Link <a href="…"> right-click, open-in-new-tab, history
Table <table><thead><tbody>…
TextArea <textarea> native caret, multi-line editing
Select <select><option> native dropdown, keyboard picking
Progress <progress> native semantics, screen-reader value
Spinner <span>
Divider <hr> / labelled <div>
Spacer / Flex <span style="flex:…">
Empty (nothing)

Installation

go get github.com/suprbdev/wui

Requires Go 1.24+. No CGO.


Make targets

make help            # list all targets
make build           # build wui package (TUI)
make build-wasm      # build wui package (WASM)
make vet             # go vet both TUI and WASM targets
make test            # run tests
make run-NAME        # run example NAME in terminal
make tui-NAME        # run example NAME in terminal + serve its web build (auto port)
make wasm-NAME       # build example NAME → example/NAME/web/
make serve-NAME      # build + serve example NAME in the browser (auto port)
make clean           # remove built WASM binaries

NAME is any of the examples: counter, form, todo, timer, dashboard. Both serving targets auto-pick the first free port in 8765–8864 (loopback only); pin one with make serve-counter PORT=9000 (binds all interfaces).


Quick start

package main

import (
    "fmt"
    "github.com/suprbdev/wui"
)

// 1. Define your state.
type model struct{ count int }

// 2. Define your messages.
type incrementMsg struct{}

// 3. Implement wui.Model.
func (m model) Init() wui.Cmd { return nil }

func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
    if _, ok := msg.(incrementMsg); ok {
        m.count++
    }
    return m, nil
}

func (m model) View() wui.Element {
    return wui.Box(wui.Column,
        wui.Text(fmt.Sprintf("Count: %d", m.count)),
        wui.Button("Increment", func() wui.Msg { return incrementMsg{} }),
    )
}

func main() {
    wui.NewProgram(model{}).Run()
}

TUImake run-counter

Browsermake serve-counter, then open the printed URL

Renders as:

<div style="display:flex;flex-direction:column;…">
  <span>Count: 0</span>
  <button>Increment</button>
</div>

Core concepts

Model

Your application state. Must be a value type (struct). wui.Model requires three methods:

type Model interface {
    Init()           Cmd          // runs once on startup; return nil or a Cmd
    Update(Msg)      (Model, Cmd) // pure: old state + message → new state + optional Cmd
    View()           Element      // pure: state → element tree
}

Init and Update must be pure — no side effects. Side effects (HTTP calls, timers, file I/O) belong in a Cmd.

Msg

Any Go value. Define your own types:

type tickMsg time.Time
type loadedMsg struct{ data []Item }
type errMsg struct{ err error }

wui also emits built-in messages your Update can handle:

Type When
wui.KeyMsg{Key, Rune} Key press on both platforms (key names: "enter", "ctrl+c", "tab", "backspace", "up", "down", etc.). In the browser, keys aimed at a focused input/textarea/select stay with that element and are not dispatched
wui.ResizeMsg{Width, Height} Terminal or window resize
wui.InputMsg{ID, Value} Input value changed with no OnChange callback set
wui.ToggleMsg{ID, Checked} Checkbox toggled with no OnToggle callback set
wui.SubmitMsg{FormValues} Form submitted with no OnSubmit callback set
wui.ClickMsg{TargetID} Link clicked
wui.NavigateMsg{Path} Browser URL hash names a path — initial load or back/forward (WASM only; see wui.Pather)
Cmd

A func() Msg run asynchronously. Use it for side effects:

func fetchData() wui.Cmd {
    return func() wui.Msg {
        resp, err := http.Get("https://api.example.com/data")
        if err != nil {
            return errMsg{err}
        }
        // decode resp...
        return loadedMsg{data: items}
    }
}

func (m model) Init() wui.Cmd {
    return fetchData()
}

Return a Cmd from Init or Update. wui runs it in a goroutine and dispatches the resulting Msg back through Update.


Elements

Text
wui.Text("Hello, world!")

// With style:
wui.Text("Warning", wui.WithTextStyle(wui.Style{FG: "#ff0000", Bold: true}))

Renders as <span> in HTML.

Box

Flex container — wui.Row or wui.Column:

wui.Box(wui.Row,
    wui.Text("Left"),
    wui.Text("Right"),
)

wui.Box(wui.Column,
    wui.Text("Top"),
    wui.Text("Bottom"),
)

// With gap and style:
wui.BoxStyled(wui.Column, 8, wui.Style{Border: true},
    wui.Text("Item 1"),
    wui.Text("Item 2"),
)
Button
wui.Button("Save", func() wui.Msg { return saveMsg{} })

// Disabled:
wui.Button("Save", nil, wui.Disabled())

// With style:
wui.Button("Delete", onDelete, wui.WithButtonStyle(wui.Style{FG: "#ff0000"}))

// With an explicit identity (needed when several buttons share a label,
// e.g. a per-row "✕" delete button):
wui.Button("✕", onDelete, wui.WithID("del-"+itemID))

TUI: rendered as [ Label ], focusable via Tab, activated via Enter or Space. HTML: <button onclick=…>.

A button's focus identity is its WithID value when set, else its label. Buttons that share a label and have no ID share TUI focus and a DOM id — give repeated buttons IDs.

Input

Text inputs require a stable id string — wui uses it to maintain cursor/blink state in TUI and to identify the DOM element in HTML.

wui.Input("username",
    wui.WithValue(m.username),
    wui.WithPlaceholder("Username"),
    wui.WithOnChange(func(val string) wui.Msg {
        return usernameChanged{val}
    }),
)

// Password field:
wui.Input("password",
    wui.WithPlaceholder("Password"),
    wui.WithPassword(),
    wui.WithOnSubmit(func(val string) wui.Msg {
        return loginMsg{password: val}
    }),
)

OnChange fires on every keystroke. OnSubmit fires on Enter, on both platforms.

Input values are controlled with a twist: in-progress typing is never clobbered by re-renders, but when your app changes WithValue programmatically (e.g. clearing a field after submit), the new value is applied. This works identically in TUI and HTML.

Checkbox
wui.Checkbox("done-42", "Buy milk", item.Done, func(checked bool) wui.Msg {
    return toggleMsg{id: 42, done: checked}
})

// Without a callback, toggling emits wui.ToggleMsg{ID, Checked}:
wui.Checkbox("opt-in", "Subscribe", m.optIn, nil)

// Disabled:
wui.Checkbox("locked", "Read-only", true, nil, wui.CheckboxDisabled())

TUI: rendered as [x] Label / [ ] Label, focusable via Tab, toggled via Enter or Space. HTML: a real <label><input type="checkbox"> — Space toggles, clicking the label toggles, screen readers announce it. The base CSS hides the native box and draws the same [x] mark as the TUI.

The id must be stable and unique per view (like Input).

Card

A titled, bordered panel — the standard dashboard building block:

wui.Card("Weather", body)

// With style (Border is implied; Padding, Margin, Width, BorderColor apply):
wui.Card("Weather", body, wui.WithCardStyle(wui.Style{Padding: [4]int{0, 1, 0, 1}}))

TUI: rounded border with the bold title embedded in the top border line:

╭ Weather ─────────╮
│ 21.3°C  clear    │
╰──────────────────╯

HTML: <fieldset><legend>Weather</legend>…</fieldset> — the semantic element for a titled group, styled by the base CSS to match.

Form

Wraps inputs in a <form> (HTML) or a vertical container (TUI). The OnSubmit callback receives all input values by ID when the form is submitted.

wui.Form(
    func(vals map[string]string) wui.Msg {
        return loginMsg{user: vals["user"], pass: vals["pass"]}
    },
    wui.Input("user", wui.WithPlaceholder("Username")),
    wui.Input("pass", wui.WithPlaceholder("Password"), wui.WithPassword()),
    wui.Button("Log in", nil), // nil OnClick inside a Form = submit button
)

Submission works the same on both platforms:

  • A button with nil OnClick inside a Form acts as a submit button — like <button type="submit"> in HTML; in TUI, Enter on the focused button submits the form.
  • Enter in an input inside the form also submits it (unless the input has its own OnSubmit, which then takes precedence).
List
// Unordered:
wui.List(false,
    wui.Text("First item"),
    wui.Text("Second item"),
    wui.Button("Clickable item", onClick),
)

// Ordered:
wui.List(true,
    wui.Text("Step one"),
    wui.Text("Step two"),
)

Items can be any Element, not just text. Renders as <ul>/<ol> + <li> in HTML.

ScrollArea
wui.Scroll(
    wui.Box(wui.Column, items...),
    20, // max height in terminal rows, both platforms
)

TUI: lipgloss.MaxHeight. HTML: overflow:auto; max-height:Nlh (an em fallback covers browsers without lh).

wui.Link("Docs", "https://pkg.go.dev/github.com/suprbdev/wui")

HTML: real <a href="…"> — right-click works, opens in new tab, browser history, screen readers. TUI: underlined text, focusable via Tab.

Table
wui.Table(
    []string{"Name", "Version", "Status"},
    [][]string{
        {"bubbletea", "v1.3.10", "✓"},
        {"lipgloss",  "v1.1.0",  "✓"},
    },
)

TUI: lipgloss table with rounded borders. HTML: <table><thead><tbody>.

TextArea
wui.TextArea("notes",
    wui.WithAreaValue(m.notes),
    wui.WithAreaPlaceholder("what changed?"),
    wui.WithAreaRows(4),
    wui.WithAreaOnChange(func(v string) wui.Msg { return notesMsg{v} }),
)

Multi-line input. Enter inserts a newline rather than submitting (matching <textarea>), so a form containing one is submitted from its submit button. Like Input, in-progress typing survives re-renders and is only overwritten when the app changes the value itself.

Select
wui.Select("env",
    []wui.SelectOption{
        wui.Opt("dev", "Development"),
        wui.Opt("prd", "Production"),
    },
    m.env,
    func(v string) wui.Msg { return envMsg{v} },
)

HTML: a native <select>. TUI: a focusable < Development > field that cycles with ←/→ (or Space/Enter). wui.Options("a", "b") builds options whose labels are their values.

Progress and Spinner
wui.Progress(0.42, wui.WithProgressWidth(30), wui.WithProgressLabel())
wui.Spinner(m.frame, wui.WithSpinnerLabel("deploying"))

Progress clamps to 0–1. Spinner is deliberately stateless — the app owns the animation clock, so both renderers stay pure and the two platforms animate identically:

case tickMsg:
    m.frame++
    return m, wui.Tick(100*time.Millisecond, func() wui.Msg { return tickMsg{} })
Divider, Spacer, and Empty
wui.Divider("Section")              // ── Section ──────  /  labelled <hr>
wui.Spacer(2)                       // 2 cells (Row) or 2 lines (Column)
wui.Flex()                          // stretches, pushing siblings apart
wui.Empty()                         // renders nothing

Flex is the idiom for pinning a child to the far end of a row:

wui.BoxStyled(wui.Row, 0, wui.NewStyle().W(60),
    wui.Text("Deploy dashboard"),
    wui.Flex(),
    wui.Text(status),   // hard right
)
Conditionals and lists

Views stay single expressions instead of being assembled imperatively:

wui.If(m.err != "", wui.Text(m.err))            // or Empty()
wui.IfElse(m.loading, spinner, results)
wui.When(m.open, func() wui.Element { … })      // build called only if true

wui.List(false, wui.Map(m.items, func(i int, s string) wui.Element {
    return wui.Text(s)
})...)

Group, HStack, VStack, and Center cover the common layout shorthands.

Layouts

Ready-made application layouts, built entirely from Box plus the flex hints below — there are no dedicated layout element types:

wui.Fullscreen(children...)          // Column filling the terminal/viewport
wui.AppShell(header, body, footer)   // pinned header/footer, body takes the rest
wui.Centered(child)                  // centered both axes — login box, splash
wui.Sidebar(side, main)              // fixed side pane + growing main pane
wui.Split(a, b, ...)                 // equal side-by-side shares
wui.Grow(1, child)                   // child takes n shares of leftover space

Sidebar is responsive: when the viewport is too narrow for the main pane's minimum width, the panes collapse into a vertical stack (CSS flex-wrap in the browser; the TUI re-lays the row out as a column). Tune it with SidebarWidth(n), SidebarMinMain(n), SidebarGap(n), and SidebarRight().

The primitives underneath, usable on any element's Style / any Box:

  • wui.Fill as a Style Width/Height fills the available space — the terminal (minus the status bar) or the browser viewport.
  • Style.Grow gives a Box child a flex-grow share of leftover space along the Box's axis; Style.MinWidth floors its width.
  • BoxEl.Justify distributes children along the main axis (justify-content); BoxEl.Align remains the cross axis.
  • BoxEl.Wrap lets a Row collapse to a stack when its children don't fit.

Keyboard shortcuts

Bindings are declared once and drive three things: dispatch in Update, generated help text, and the status bar's hints — so help can never drift from what the keys actually do.

var (
    keyRun  = wui.NewBinding("run",  wui.Keys("r"),          wui.Help("r", "run job"))
    keyQuit = wui.NewBinding("quit", wui.Keys("q", "ctrl+c"), wui.Help("q", "quit"))
)

func (m model) keyMap() wui.KeyMap {
    return wui.NewKeyMap(
        // Disabled bindings match nothing and vanish from help.
        keyRun.SetEnabled(!m.running),
        keyQuit,
    )
}

func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
    switch v := msg.(type) {
    case wui.KeyMsg:
        name, ok := m.keyMap().Match(v)
        if !ok {
            return m, nil
        }
        switch name {
        case "run":
            return m, startJob()
        case "quit":
            return m, wui.Quit()
        }
    }
    return m, nil
}

Help renders from the same map:

m.keyMap().ShortHelp()          // "r run job • q quit"
m.keyMap().HelpView(wui.Column) // an Element, one row per binding

Register the map with wui.WithKeyMap(km) and the status bar shows the shortcuts automatically. wui never dispatches bindings for you — Update stays the single place state changes.

For table-driven dispatch, pair bindings with actions instead:

handlers := []wui.Handler{
    wui.Bind(keyRun,  func() wui.Msg { return startMsg{} }),
    wui.Bind(keyQuit, func() wui.Msg { return wui.QuitMsg{} }),
}
if out, ok := wui.Dispatch(v, handlers...); ok {
    return m, wui.Send(out)
}

Status line

Both platforms draw a one-line status strip: the bottom row in the TUI, a fixed bottom strip in the browser.

By default it shows the web URL (when WithWebServer is active) and the key map's short help. Supply your own with WithStatus:

wui.NewProgram(m,
    wui.WithKeyMap(m.keyMap()),
    wui.WithStatus(func(cur wui.Model) wui.Status {
        d := cur.(model)
        return wui.NewStatus().
            Left("env:" + d.env).
            Right(d.keyMap().ShortHelp())
    }),
)

Left- and right-aligned segments are pushed apart; when the terminal is too narrow for both, the right side is dropped before the left is truncated. The web link is prepended to whatever you return, so adding your own segments never costs you the link to your own web build.

Return wui.NewStatus().Hide() to suppress the bar for a frame, or pass wui.WithoutStatusBar() to turn it off entirely.


Platform-conditional content

Sometimes the same idea needs different wording — the TUI has a focus ring, the browser has clicks. Current(), IsTUI(), and IsWeb() are build-time constants, so branches cost nothing at runtime.

// Different wording, same instruction:
wui.Text(wui.PlatformText("press q to quit", "close this tab to quit"))

// Different elements:
wui.OnPlatform(
    wui.Text("Tab moves focus • Enter activates"),
    wui.Text("Click anything, or Tab through the page"),
)

// Only on one platform:
wui.TUIOnly(wui.Text("Ctrl+C also works"))
wui.WebOnly(wui.Link("Docs", "/docs"))

// Any value, not just Elements:
width := wui.PlatformValue(80, 1200)

Styling

wui.Style is a platform-agnostic style struct:

type Style struct {
    FG, BG      Color    // hex "#ff0000", CSS color name "red", or ANSI index "9"
    Bold        bool
    Italic      bool
    Underline   bool
    Faint       bool
    Strike      bool
    Padding     [4]int   // top, right, bottom, left — cells
    Margin      [4]int   // top, right, bottom, left — cells
    Width       int      // 0 = auto — cells
    Height      int      // 0 = auto — cells
    Border      bool
    BorderColor Color
    Align       Align    // text alignment within Width
}

Struct literals get verbose fast, so Style also composes. Every method returns a copy:

wui.NewStyle().SetBold(true).Fg("6").Pad(1, 2)   // CSS-style shorthand
wui.NewStyle().W(40).Aligned(wui.AlignCenter)
wui.NewStyle().Bordered("#3a3d46")

Pad and Mar take 1–4 values with CSS semantics (all / vertical-horizontal / top-horizontal-bottom / top-right-bottom-left).

Merge layers a variant over a base — useful for theme-ish reuse:

base   := wui.NewStyle().Pad(0, 1)
danger := base.Merge(wui.Style{FG: "9", Bold: true})

Color values:

  • Hex strings ("#ff0000") work on both platforms.
  • Named CSS colors ("red", "cornflowerblue") work on both platforms.
  • ANSI indices "0""15" are translated to hex for HTML, passed to lipgloss as-is for TUI.

Units: all sizing values (Width, Height, Padding, Margin, Box gap) are terminal cells. The HTML renderer translates them to the closest CSS analogues — ch horizontally and lh (line height, with an em fallback) vertically — so the same numbers produce visually similar layouts on both platforms.


Keyboard navigation (TUI)

Key Action
Tab Focus next focusable element
Shift+Tab Focus previous focusable element
Enter / Space Activate focused button, link, or checkbox; Enter submits a focused input (its OnSubmit, else the enclosing form)
/ Cycle a focused Select through its options (also /)
Esc Blur the focused element; the key is also forwarded to Update
Ctrl+C Quit (intercepted by wui, not forwarded to Update)

A focused element only consumes the keys it actually uses (text editing for inputs, Enter/Space for buttons and checkboxes, arrows for selects); everything else falls through to Update, so app-level shortcuts keep working while something is focused. Esc always blurs first — an input can never trap the keyboard.

Mouse clicks are also wired in the TUI: clicking a button, link, checkbox, or input activates it and moves keyboard focus to it, like the browser.

Focusable elements are collected in tree order: text inputs, text areas, selects, checkboxes, buttons, and links. Button focus keys derive from WithID when set, else from the label — give repeated buttons IDs.

Your Update receives wui.KeyMsg for any key that isn't consumed by focus management or input editing — in the TUI and in the browser alike (WASM builds listen on document and skip keys aimed at an editable element; alt/meta chords and unmapped named keys stay with the browser, and space, backspace, ', /, and tab have their page defaults suppressed so they behave like the TUI). Use it for global shortcuts:

case wui.KeyMsg:
    if v.Key == "q" {
        // return a Cmd that signals quit, or transition to a done state
    }

Commands (async work)

func (m model) Init() wui.Cmd {
    return wui.Tick(time.Second, func() wui.Msg { return tickMsg{} })
}

func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
    switch msg.(type) {
    case tickMsg:
        m.elapsed++
        // Reschedule.
        return m, wui.Tick(time.Second, func() wui.Msg { return tickMsg{} })
    }
    return m, nil
}

Each Cmd runs in its own goroutine. The returned Msg is dispatched back through Update on the main loop.

Helper Purpose
Tick(d, build) deliver a Msg once, after d elapses
Every(d, build) same, but aligned to the wall clock so repeated ticks don't drift
Batch(cmds…) run cmds concurrently; every resulting Msg is delivered
Sequence(cmds…) run cmds in order, when a later one depends on an earlier
Send(msg) wrap a value you already have as a Cmd
Do(f) run f in the background (the plain-function form)
Quit() end the program — restores the terminal properly, unlike os.Exit

Use Every rather than Tick for anything displaying a real clock: Tick drifts by however long Update takes, Every re-aligns each period.

Batch and Sequence deliver their results as a BatchMsg, which the runtime unpacks and feeds through Update one Msg at a time — so Update never has to know a batch happened, and batches nest safely.


Program options

Option Effect
WithWebServer(addr, dir) Serve a WASM build of the same app while the TUI runs, and link to it from the status bar. No effect in WASM builds, so it is safe to pass unconditionally
WithKeyMap(km) Register the app's key map so the status bar shows its shortcuts; also readable via Program.KeyMap()
WithStatus(f) Supply the status line content (see Status line)
WithoutStatusBar() Suppress the status line on both platforms
WithTitle(s) Set document.title (WASM) / the terminal window title (TUI)
WithoutBaseCSS() Skip wui's default stylesheet in WASM builds
WithoutAltScreen() Run the TUI inline so the final frame stays in scrollback after exit
WithoutMouse() Disable TUI mouse tracking, leaving click and text selection to the terminal

Running TUI and web together

A TUI program can serve the WASM build of the same app over HTTP while it runs:

wui.NewProgram(model{}, wui.WithWebServer(":8765", "example/counter/web")).Run()

// Or pass "" to pick a port automatically: binds loopback only, on the
// first free port in 8765-8864 (OS-assigned as a last resort).
wui.NewProgram(model{}, wui.WithWebServer("", "example/counter/web")).Run()

The examples expose this as a -serve flag via the wui.ServeFlag helper:

make tui-counter                        # builds the WASM bundle, then:
go run ./example/counter -serve         # auto-pick a free port (loopback only)
go run ./example/counter -serve=:8765   # explicit address

Note the = in the explicit form — because bare -serve is allowed, the flag package treats it as boolean-style, so -serve :8765 would not parse. In your own main:

serve := wui.ServeFlag("serve", "also serve the web build")
flag.Parse()
var opts []wui.Option
if serve.Enabled {
    opts = append(opts, wui.WithWebServer(serve.Addr, "path/to/web"))
}

While serving, the TUI always shows a status bar pinned to the bottom of the screen with the URL of the equivalent web page:

 web ⇒ http://localhost:8765/

WithWebServer is a no-op in WASM builds, so shared main code can pass it unconditionally.

Equivalent paths (Pather)

If your model implements wui.Pather, both platforms agree on where in the app you are:

func (m model) Path() string {
    if m.state == stateResult {
        return "/result"
    }
    return "/"
}
  • TUI: the status bar link includes the path — http://localhost:8765/#/result.
  • Browser: location.hash is kept in sync with Path() after every update, and a wui.NavigateMsg{Path} is dispatched on initial load and on hash changes (back/forward), so deep links and history work:
case wui.NavigateMsg:
    if v.Path == "/result" && m.message != "" {
        m.state = stateResult
    } else {
        m.state = stateForm
    }

WASM build

Quick start
make serve-counter   # builds WASM + copies wasm_exec.js + serves on a free port
make serve-form

Or manually:

GOOS=js GOARCH=wasm go build -o web/main.wasm .
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" web/
HTML harness
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
<script src="wasm_exec.js"></script>
<script>
  const go = new Go();
  WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
    .then(r => go.run(r.instance));
</script>
</body>
</html>

wui mounts a <div id="wui-root"> into document.body automatically.

Serving

make serve-counter and make serve-form handle the WASM MIME type automatically. For production or custom setups, ensure the server sets Content-Type: application/wasm for .wasm files — most servers (Caddy, nginx, Go's net/http) do this by default. For ad-hoc local serving:

# npx serve web
# caddy file-server --root web
Build constraints

All bubbletea/lipgloss/bubbles code is behind //go:build !js. All syscall/js DOM code is behind //go:build js. Standard Go build tags select the right renderer automatically — no manual configuration needed.


Architecture

wui/
├── element.go        Element interface + all concrete types + constructors
├── style.go          Style struct (shared, no platform deps)
├── msg.go            Msg, Cmd, and built-in message types
├── program.go        Model interface + Program (NewProgram / Run)
├── program_tui.go    //go:build !js — bubbletea adapter + key routing
├── program_wasm.go   //go:build js  — DOM event loop + dispatch
├── render_tui.go     //go:build !js — element tree → lipgloss string
├── render_wasm.go    //go:build js  — element tree → DOM nodes
├── focus_tui.go      //go:build !js — Tab ring (index-based)
├── focus_wasm.go     //go:build js  — Tab ring (delegates to browser)
├── key.go            Binding / KeyMap / help generation (shared)
├── cmd.go            Cmd helpers: Batch, Sequence, Tick, Every, Quit
├── status.go         Status line model + shared line layout
├── platform.go       Platform detection + conditional helpers (shared)
├── platform_tui.go   //go:build !js — currentPlatform = PlatformTUI
└── platform_wasm.go  //go:build js  — currentPlatform = PlatformWeb

The element.go, style.go, msg.go, program.go, key.go, cmd.go, status.go, and platform.go files have no build tags and no platform dependencies — they are the shared contract between your app and both renderers.

Re-render strategy

TUI: bubbletea calls View() on every state change and diffs the output string to minimise redraws.

HTML: v1 does a full replace of #wui-root on every state change. Before clearing the DOM, wui saves:

  1. document.activeElement.id — restored after the new tree is built, preserving keyboard focus.
  2. All <input> values by id — restored after the new tree, preventing in-progress text from being wiped by unrelated state changes.

This is efficient enough for typical app trees. VDOM diffing is a future enhancement.


Examples

Every example runs three ways:

make run-NAME     # terminal
make serve-NAME   # browser, auto-picked free port (printed on start)
make tui-NAME     # terminal + web server + status bar link
Counter (example/counter/)

Minimal example: increment/decrement/reset buttons, styled title, row layout.

Contact form (example/form/)

Two text inputs, validation, form submission via submit button or Enter, result view with a data table. Implements wui.Pather — the result screen lives at #/result in the browser and the TUI status bar links to it.

TUI interaction:

  • Tab to cycle between Name input, Email input, Submit button
  • Enter on the Submit button (or in an input) to submit
  • q or Back button to return to the form
Todo (example/todo/)

Add items via input + submit button (or Enter), remove items with per-item buttons, scrollable list, programmatic input clearing after submit.

Dashboard (example/dashboard/)

Exercises the framework features rather than a domain: declarative key bindings with generated help (including bindings that disable themselves mid-run), a custom status line, platform-conditional wording, and the layout/feedback elements — Divider, Flex, Progress, Spinner, Select, TextArea.

Timer (example/timer/)

A stopwatch: Cmd-driven self-rescheduling ticks, start/stop/reset, bordered display.


Dependencies

Package Used by Purpose
github.com/charmbracelet/bubbletea TUI only Event loop, alt-screen, key input
github.com/charmbracelet/lipgloss TUI only Layout, styling, borders, tables
github.com/charmbracelet/bubbles TUI only Text input widget (cursor, blink, echo modes)
syscall/js WASM only DOM manipulation (Go standard library)

The WASM binary includes none of the charmbracelet packages. The TUI binary includes no syscall/js code. Both are enforced by //go:build constraints.


Limitations (v1)

  • No VDOM diffing — HTML renderer does a full tree replace on each update. Works well for most apps; large frequently-updating trees may flicker.
  • Button focus keys derive from labels by default — two buttons with the same label and no WithID in one view share TUI focus; give repeated buttons IDs.
  • No theme system — style is applied per-element inline (Style.Merge helps compose variants); the WASM base stylesheet (see WithoutBaseCSS) is the only global styling hook.
  • TextArea editing is basic in the TUI — typing, Enter, and Backspace append and delete at the end of the buffer; there is no cursor movement within the text. The browser build uses a native <textarea> with full editing.
  • ANSI color fidelity — ANSI indices 0–15 map to fixed hex values in HTML; exact colours depend on the terminal's colour scheme in TUI.

License

MIT

Documentation

Index

Constants

View Source
const Fill = -1

Fill, given as Style.Width or Style.Height, sizes an element to the full available space: the terminal in the TUI (minus the status bar row), the viewport in the browser. Intended for top-level layout containers — see Fullscreen, AppShell, Centered.

Variables

This section is empty.

Functions

func AreaDisabled

func AreaDisabled() func(*TextAreaEl)

AreaDisabled marks a TextAreaEl as disabled.

func CheckboxDisabled

func CheckboxDisabled() func(*CheckboxEl)

CheckboxDisabled marks a CheckboxEl as disabled.

func Disabled

func Disabled() func(*ButtonEl)

Disabled marks a ButtonEl as disabled.

func IsTUI

func IsTUI() bool

IsTUI reports whether this is a terminal build.

func IsWeb

func IsWeb() bool

IsWeb reports whether this is a WASM build.

func PlatformText

func PlatformText(tui, web string) string

PlatformText returns tui in terminal builds and web in browser builds — the string form of the common case, where only the wording differs between platforms:

wui.Text(wui.PlatformText("press q to quit", "close this tab to quit"))

func PlatformValue

func PlatformValue[T any](tui, web T) T

PlatformValue returns tui in terminal builds and web in browser builds. It is the generic form of PlatformText, for any value type.

func RenderTUI

func RenderTUI(el Element) string

RenderTUI renders an element tree to the string the TUI renderer would draw, without running a Program: no focus, fresh input state, mouse-zone markers stripped. Intended for snapshot tests of Views. It assumes an 80x24 terminal; use RenderTUISized to pick the size.

func RenderTUISized

func RenderTUISized(el Element, width, height int) string

RenderTUISized is RenderTUI at an explicit terminal size. Use it to test how a view responds to width — Responsive variants, Sidebar collapse, Fill sizing — without running a Program.

func SelectDisabled

func SelectDisabled() func(*SelectEl)

SelectDisabled marks a SelectEl as disabled.

func SidebarGap

func SidebarGap(n int) func(*SidebarOpts)

SidebarGap sets the gap between the panes.

func SidebarMinMain

func SidebarMinMain(n int) func(*SidebarOpts)

SidebarMinMain sets the main pane width below which the layout collapses to a vertical stack.

func SidebarRight

func SidebarRight() func(*SidebarOpts)

SidebarRight places the sidebar on the right of the main pane.

func SidebarSnug

func SidebarSnug() func(*SidebarOpts)

SidebarSnug keeps the panes side by side at their natural widths rather than stretching the main pane across the whole terminal.

func SidebarWidth

func SidebarWidth(n int) func(*SidebarOpts)

SidebarWidth sets the sidebar's fixed width in cells.

func SpinnerFrames

func SpinnerFrames() int

SpinnerFrames returns the number of distinct spinner glyphs, for apps that want to keep their frame counter bounded.

func Walk

func Walk(el Element, fn func(Element) bool)

Walk calls fn for el and every descendant, depth-first in tree order. Returning false from fn skips that element's children.

func WithAreaOnChange

func WithAreaOnChange(f func(string) Msg) func(*TextAreaEl)

WithAreaOnChange sets the change handler on a TextAreaEl.

func WithAreaPlaceholder

func WithAreaPlaceholder(p string) func(*TextAreaEl)

WithAreaPlaceholder sets placeholder text on a TextAreaEl.

func WithAreaRows

func WithAreaRows(n int) func(*TextAreaEl)

WithAreaRows sets the visible line count of a TextAreaEl.

func WithAreaStyle

func WithAreaStyle(s Style) func(*TextAreaEl)

WithAreaStyle sets style on a TextAreaEl.

func WithAreaValue

func WithAreaValue(v string) func(*TextAreaEl)

WithAreaValue sets the current value of a TextAreaEl.

func WithButtonStyle

func WithButtonStyle(s Style) func(*ButtonEl)

WithButtonStyle sets style on a ButtonEl.

func WithCardStyle

func WithCardStyle(s Style) func(*CardEl)

WithCardStyle sets style on a CardEl. Border is implied; Padding, Margin, Width and BorderColor apply.

func WithCheckboxStyle

func WithCheckboxStyle(s Style) func(*CheckboxEl)

WithCheckboxStyle sets style on a CheckboxEl.

func WithDividerStyle

func WithDividerStyle(s Style) func(*DividerEl)

WithDividerStyle sets style on a DividerEl. Width bounds the rule; without it the rule fills the available width.

func WithID

func WithID(id string) func(*ButtonEl)

WithID gives a button an explicit stable identity. Without it the focus key derives from the label, so two buttons sharing a label in one view would share focus; an ID also lets the browser restore focus to the button across re-renders.

func WithOnChange

func WithOnChange(f func(string) Msg) func(*TextInputEl)

WithOnChange sets the change handler on a TextInputEl.

func WithOnSubmit

func WithOnSubmit(f func(string) Msg) func(*TextInputEl)

WithOnSubmit sets the submit handler on a TextInputEl (Enter key).

func WithPassword

func WithPassword() func(*TextInputEl)

WithPassword marks a TextInputEl as a password field.

func WithPlaceholder

func WithPlaceholder(p string) func(*TextInputEl)

WithPlaceholder sets placeholder text on a TextInputEl.

func WithProgressLabel

func WithProgressLabel() func(*ProgressEl)

WithProgressLabel appends a percentage readout after the bar.

func WithProgressRunes

func WithProgressRunes(filled, empty string) func(*ProgressEl)

WithProgressRunes overrides the TUI fill and track glyphs. It has no effect in HTML, which draws a real bar.

func WithProgressStyle

func WithProgressStyle(s Style) func(*ProgressEl)

WithProgressStyle sets style on a ProgressEl; FG colors the filled portion.

func WithProgressWidth

func WithProgressWidth(n int) func(*ProgressEl)

WithProgressWidth sets the bar width in cells (pixels-equivalent ch units in HTML).

func WithSelectStyle

func WithSelectStyle(s Style) func(*SelectEl)

WithSelectStyle sets style on a SelectEl.

func WithSpinnerLabel

func WithSpinnerLabel(label string) func(*SpinnerEl)

WithSpinnerLabel sets text shown next to the spinner glyph.

func WithSpinnerStyle

func WithSpinnerStyle(s Style) func(*SpinnerEl)

WithSpinnerStyle sets style on a SpinnerEl.

func WithTextStyle

func WithTextStyle(s Style) func(*TextEl)

WithTextStyle sets style on a TextEl built via Text.

func WithValue

func WithValue(v string) func(*TextInputEl)

WithValue sets the current value of a TextInputEl.

Types

type AddrFlag

type AddrFlag struct {
	Enabled bool
	Addr    string
}

AddrFlag is a flag.Value for optional-address flags such as -serve. It accepts three forms:

(absent)        Enabled=false — no web server
-serve          Enabled=true, Addr=""  — pick a free port automatically
-serve=:8765    Enabled=true, Addr=":8765"

Because the bare form is allowed, an explicit address must use the "=" syntax ("-serve=:8765", not "-serve :8765").

func ServeFlag

func ServeFlag(name, usage string) *AddrFlag

ServeFlag registers an AddrFlag named name on flag.CommandLine and returns it. Call before flag.Parse; afterwards, pass the result to WithWebServer when Enabled:

serve := wui.ServeFlag("serve", "also serve the web build (optionally -serve=:8765)")
flag.Parse()
var opts []wui.Option
if serve.Enabled {
	opts = append(opts, wui.WithWebServer(serve.Addr, "path/to/web"))
}

func (*AddrFlag) IsBoolFlag

func (f *AddrFlag) IsBoolFlag() bool

IsBoolFlag lets the flag package accept the bare form (-serve) with no value.

func (*AddrFlag) Set

func (f *AddrFlag) Set(s string) error

func (*AddrFlag) String

func (f *AddrFlag) String() string

type Align

type Align int

Align values for flex-like alignment.

const (
	AlignStart Align = iota
	AlignCenter
	AlignEnd
	AlignStretch
)

type BatchMsg

type BatchMsg struct {
	Msgs []Msg
}

BatchMsg carries several Msgs produced by Batch. The runtime unpacks it and feeds each Msg through Update in order.

type Binding

type Binding struct {
	// Name identifies the binding. It is the ID reported by KeyMap
	// lookups and is handy as a Msg payload.
	Name string
	// contains filtered or unexported fields
}

Binding is a named keyboard shortcut: one or more key names, a short label for help output, and an optional enabled predicate.

Bindings are declarative — they describe intent ("q quits") rather than hard-coding a switch in Update, which lets wui derive help text automatically and lets both platforms share one definition.

var quit = wui.NewBinding("quit", wui.Keys("q", "ctrl+c"), wui.Help("q", "quit"))

func (m model) Update(msg wui.Msg) (wui.Model, wui.Cmd) {
    switch v := msg.(type) {
    case wui.KeyMsg:
        if quit.Matches(v) {
            return m, wui.Quit()
        }
    }
    return m, nil
}

func NewBinding

func NewBinding(name string, opts ...BindingOption) Binding

NewBinding creates a named Binding.

func (Binding) Enabled

func (b Binding) Enabled() bool

Enabled reports whether the binding is active.

func (Binding) HelpDesc

func (b Binding) HelpDesc() string

HelpDesc returns the description for help output.

func (Binding) HelpKey

func (b Binding) HelpKey() string

HelpKey returns the key text for help output: the explicit Help key when set, else the binding's first key name.

func (Binding) KeyNames

func (b Binding) KeyNames() []string

Keys returns the key names the binding matches.

func (Binding) Matches

func (b Binding) Matches(msg KeyMsg) bool

Matches reports whether msg is one of the binding's keys. A disabled binding matches nothing, so a single `if b.Matches(k)` in Update respects context-sensitive availability without extra conditionals.

func (Binding) MatchesKey

func (b Binding) MatchesKey(key string) bool

MatchesKey reports whether the binding contains the given key name.

func (Binding) SetEnabled

func (b Binding) SetEnabled(v bool) Binding

SetEnabled returns a copy of the binding with its enabled state set. Bindings are values, so context-sensitive availability is expressed by storing the result — typically rebuilt in View or Update:

km.Undo = km.Undo.SetEnabled(len(m.history) > 0)

type BindingOption

type BindingOption func(*Binding)

BindingOption configures a Binding at construction time.

func BindingDisabled

func BindingDisabled() BindingOption

BindingDisabled constructs the Binding in a disabled state: it matches nothing and is hidden from help until SetEnabled(true).

func Help

func Help(key, desc string) BindingOption

Help sets the key text and description shown in help output. key may be a display form covering several bindings ("↑/k"); pass "" to show the binding's first key instead.

func Keys

func Keys(keys ...string) BindingOption

Keys sets the key names a Binding matches. Names use the same normalized vocabulary as KeyMsg.Key ("enter", "ctrl+c", "up", "q").

type BoxEl

type BoxEl struct {
	Direction Direction
	Gap       int
	Align     Align // cross-axis placement of children (align-items)
	Children  []Element
	Style     Style

	// Justify distributes children along the Box's main axis
	// (justify-content). It needs a resolved main-axis size to work
	// against — Style.Width for a Row, Style.Height for a Column —
	// and AlignStretch behaves as AlignStart.
	Justify Align

	// Wrap lets a Row collapse: in HTML it is flex-wrap, in the TUI
	// the Row re-lays itself out as a Column when its children do not
	// fit the available width. See Sidebar for the ready-made use.
	Wrap bool
}

type Breakpoint

type Breakpoint struct {
	// MinWidth is the smallest available width, in cells, at which
	// Content applies. The variant with the largest satisfied
	// MinWidth wins, so breakpoints read like CSS min-width media
	// queries: 0 is the fallback for the narrowest case.
	MinWidth int
	Content  Element
}

Breakpoint is one variant of a Responsive element: the content to use when at least MinWidth cells are available.

func At

func At(minWidth int, content Element) Breakpoint

At pairs a minimum available width with the content to show at or above it, for use with Responsive.

type ButtonEl

type ButtonEl struct {
	ID       string // optional; focus key falls back to the label
	Label    string
	OnClick  func() Msg
	Style    Style
	Disabled bool
}

type CardEl

type CardEl struct {
	Title string
	Child Element
	Style Style
}

type CheckboxEl

type CheckboxEl struct {
	ID       string
	Label    string
	Checked  bool
	OnToggle func(checked bool) Msg
	Style    Style
	Disabled bool
}

type ClickMsg

type ClickMsg struct {
	TargetID string
}

ClickMsg is sent when a Link is activated. Buttons instead invoke their own OnClick callback directly.

type Cmd

type Cmd func() Msg

Cmd is a function that produces a Msg asynchronously.

func Batch

func Batch(cmds ...Cmd) Cmd

Batch runs cmds concurrently and delivers every resulting Msg. nil cmds are skipped; Batch returns nil when nothing is left to run.

func Do

func Do(f func() Msg) Cmd

Do runs f in the background and delivers the Msg it returns. It is the plain-function form of a Cmd, for readability at call sites.

func Every

func Every(d time.Duration, build func(time.Time) Msg) Cmd

Every delivers the Msg from build once, after the wall clock reaches the next multiple of d. Unlike Tick, successive ticks stay aligned to the clock rather than drifting by the time Update takes — use it for anything displaying a real time.

func Quit

func Quit() Cmd

Quit returns a Cmd that ends the program.

func Send

func Send(msg Msg) Cmd

Send wraps a value as a Cmd that delivers it immediately. It is the bridge from "I have a Msg" to "Update wants a Cmd":

return m, wui.Send(savedMsg{})

func Sequence

func Sequence(cmds ...Cmd) Cmd

Sequence runs cmds one after another, delivering their Msgs in order. Use it when a later command depends on an earlier one having run.

func Tick

func Tick(d time.Duration, build func() Msg) Cmd

Tick delivers the Msg from build once, after d elapses. Reschedule from Update for a repeating timer:

case tickMsg:
    m.elapsed++
    return m, wui.Tick(time.Second, func() wui.Msg { return tickMsg{} })

type Color

type Color string

Color is a color value. In TUI it may be a hex string ("#ff0000"), a named ANSI color, or an ANSI index ("9"). In WASM it is passed through as CSS, with ANSI indices 0-15 translated to hex.

type Direction

type Direction int

Direction controls Box layout axis.

const (
	Row Direction = iota
	Column
)

type DividerEl

type DividerEl struct {
	Label string // optional text embedded in the rule
	Style Style
}

DividerEl is a horizontal rule.

type Element

type Element interface {
	// contains filtered or unexported methods
}

Element is the renderable unit. Every concrete element type satisfies it; renderers type-switch on the concrete type.

func AlignedX

func AlignedX(align Align, children ...Element) Element

AlignedX places children horizontally within the full width available — AlignStart (the default) left, AlignCenter middle, AlignEnd right — without taking vertical space. CenteredX is the AlignCenter case.

func AppShell

func AppShell(header, body, footer Element) Element

AppShell is the classic application frame: a header pinned to the top, a footer pinned to the bottom, and a body that takes every line in between. Pass nil (or Empty()) for a part you don't need.

func Box

func Box(dir Direction, children ...Element) Element

Box creates a flex container laid out along dir.

func BoxStyled

func BoxStyled(dir Direction, gap int, style Style, children ...Element) Element

BoxStyled creates a flex container with explicit style and gap.

func Button

func Button(label string, onClick func() Msg, opts ...func(*ButtonEl)) Element

Button creates a clickable button.

func Card

func Card(title string, child Element, opts ...func(*CardEl)) Element

Card wraps a child in a titled, bordered panel. The TUI draws the title embedded in the top border; HTML renders <fieldset><legend>.

func Center

func Center(width int, children ...Element) Element

Center horizontally centers children within width cells.

func Centered

func Centered(child Element) Element

Centered centers child both horizontally and vertically in the full terminal or viewport — the login-box / splash-screen layout.

func CenteredX

func CenteredX(children ...Element) Element

CenteredX centers children horizontally across the full width available, without claiming any vertical space. Unlike Centered it is safe inside an AppShell body: Centered fills the terminal height and would push the header and footer off screen.

Use it to center a banner, a title, or a hint line within whatever pane it sits in.

func Checkbox

func Checkbox(id, label string, checked bool, onToggle func(bool) Msg, opts ...func(*CheckboxEl)) Element

Checkbox creates a toggleable checkbox with a text label. id must be stable and unique — it identifies the checkbox in the focus ring and the DOM. onToggle receives the new checked state; pass nil to get a generic ToggleMsg instead.

func Children

func Children(el Element) []Element

Children returns el's child elements, or nil for leaves. It is the single place that knows the shape of the tree, so walkers (focus collection, form value gathering, lookups) do not each have to enumerate every container type.

func Constrain

func Constrain(maxWidth int, children ...Element) Element

Constrain caps content at an intended width and centers it in whatever space is left over. It is the fix for a layout that looks right at 100 columns and absurd at 400: panes stay together instead of drifting to opposite edges of the screen.

wui.AppShell(header, wui.Constrain(120, body), footer)

Children inside see the capped width, so Sidebar, Split and Responsive all lay out against the intended size rather than the terminal. Below the cap it is a no-op, so narrow terminals are unaffected.

func ConstrainBox

func ConstrainBox(maxWidth int, dir Direction, place Align, children ...Element) Element

ConstrainBox is Constrain with the placement and layout axis spelled out, for the cases where the content should hug an edge or run as a Row rather than being a centered Column.

func Divider

func Divider(label string, opts ...func(*DividerEl)) Element

Divider draws a horizontal rule. Pass "" for an unlabelled rule.

func Empty

func Empty() Element

Empty renders nothing. Use it as the "else" branch of a conditional view instead of returning nil.

func Find

func Find(el Element, pred func(Element) bool) (Element, bool)

Find returns the first element in tree order for which pred holds.

func Flex

func Flex() Element

Flex inserts stretchy empty space that pushes the siblings on either side apart — the idiom for pinning one child to the far end of a Row. In the TUI it expands to fill the Box's remaining width; in HTML it is flex:1.

func Form

func Form(onSubmit func(map[string]string) Msg, children ...Element) Element

Form creates a form container that collects input values on submit.

func Fullscreen

func Fullscreen(children ...Element) Element

Fullscreen lays children out top-to-bottom in a Column that fills the whole terminal (minus the status bar) or browser viewport. Use it as the outermost element of an app that should own the screen.

func Group

func Group(children ...Element) Element

Group combines children into one Element without adding layout of its own beyond stacking them vertically. Use it where a single Element is required but several must be returned.

func Grow

func Grow(n int, child Element) Element

Grow wraps child in a Box that takes n shares of the leftover space along the parent Box's axis. It is the building block AppShell and Split use; reach for it directly when composing custom layouts.

func HStack

func HStack(children ...Element) Element

Row is shorthand for Box(Row, ...) with a one-cell gap.

func If

func If(cond bool, el Element) Element

If renders el when cond holds, and nothing otherwise — so a view can stay a single expression:

wui.Box(wui.Column,
    wui.If(m.err != "", wui.Text(m.err)),
    wui.Text("ready"),
)

func IfElse

func IfElse(cond bool, yes, no Element) Element

IfElse renders yes when cond holds, else no.

func Input

func Input(id string, opts ...func(*TextInputEl)) Element

Input creates a text input field identified by id.

func Link(label, href string) Element

Link creates a navigable link.

func List

func List(ordered bool, items ...Element) Element

List creates a list of items, ordered or unordered.

func Map

func Map[T any](items []T, build func(i int, item T) Element) []Element

Map builds one Element per item — the loop that every View writes by hand, as an expression:

wui.List(false, wui.Map(m.items, func(i int, s string) wui.Element {
    return wui.Text(s)
})...)

func OnPlatform

func OnPlatform(tui, web Element) Element

OnPlatform renders tui in terminal builds and web in browser builds. Either may be nil to render nothing on that platform:

wui.OnPlatform(
    wui.Text("Tab/Shift+Tab to move, Enter to activate"),
    wui.Text("Click anything, or Tab through the page"),
)

func Progress

func Progress(value float64, opts ...func(*ProgressEl)) Element

Progress creates a determinate progress bar. value is clamped to 0..1.

func Responsive

func Responsive(variants ...Breakpoint) Element

Responsive picks the content that fits the width available to it.

Each variant applies from its MinWidth up to the next one; the widest satisfied variant wins. Include a MinWidth of 0 to give the narrowest case a definite answer — without one, a width below every breakpoint falls back to the smallest variant anyway, but stating it is clearer.

wui.Responsive(
    wui.At(0, wui.Text("NR")),           // very narrow
    wui.At(24, wui.Text("NETRUNNER")),   // roomier
    wui.At(80, bigAsciiLogo),            // full width
)

Variants are given in any order; ties on MinWidth keep the first.

func ResponsiveText

func ResponsiveText(style Style, variants ...TextVariant) Element

ResponsiveText is the common case of Responsive over plain strings: the same message written for progressively wider space.

wui.ResponsiveText(style, wui.TextAt(0, "err"), wui.TextAt(40, "connection failed"))

func Scroll

func Scroll(child Element, maxHeight int) Element

Scroll wraps a child in a scrollable area capped at maxHeight.

func Select

func Select(id string, options []SelectOption, value string, onChange func(string) Msg, opts ...func(*SelectEl)) Element

Select creates a single-choice picker. id must be stable and unique. In HTML it is a <select>; in the TUI it is a focusable field that cycles through options with left/right (or Space/Enter).

func Sidebar(side, main Element, opts ...func(*SidebarOpts)) Element

Sidebar places side at fixed width next to a main pane that grows to take the rest. When the viewport is too narrow for both — main would drop under its minimum width — the panes collapse into a vertical stack, sidebar first (browser: flex-wrap; TUI: the Row re-lays itself out as a Column).

func Spacer

func Spacer(n int) Element

Spacer inserts n cells (in a Row) or n blank lines (in a Column) of empty space between siblings.

func Spinner

func Spinner(frame int, opts ...func(*SpinnerEl)) Element

Spinner creates an activity indicator showing the given animation frame. Advance frame from a Tick Cmd:

case tickMsg:
    m.frame++
    return m, wui.Tick(100*time.Millisecond, func() wui.Msg { return tickMsg{} })

func Split

func Split(panes ...Element) Element

Split lays panes side by side, each taking an equal share of the full width. For unequal shares compose Grow directly:

wui.BoxEl{Direction: wui.Row, Style: wui.Style{Width: wui.Fill},
    Children: []wui.Element{wui.Grow(2, left), wui.Grow(1, right)}}

func TUIOnly

func TUIOnly(children ...Element) Element

TUIOnly renders its children only in terminal builds; browser builds render nothing.

func Table

func Table(columns []string, rows [][]string) Element

Table creates a tabular display.

func Text

func Text(s string, opts ...func(*TextEl)) Element

Text creates a text node.

func TextArea

func TextArea(id string, opts ...func(*TextAreaEl)) Element

TextArea creates a multi-line text input identified by id.

func VStack

func VStack(children ...Element) Element

VStack is shorthand for Box(Column, ...) with no gap.

func WebOnly

func WebOnly(children ...Element) Element

WebOnly renders its children only in WASM builds; terminal builds render nothing.

func When

func When(cond bool, build func() Element) Element

When renders the result of build when cond holds. Unlike If, build is only called when needed — use it when constructing the element is costly or would panic on the false branch.

type EmptyEl

type EmptyEl struct{}

EmptyEl renders nothing. It is what conditional helpers return for a false branch, so callers never have to deal with a nil Element.

type FormEl

type FormEl struct {
	Children []Element
	OnSubmit func(values map[string]string) Msg
	Style    Style
}

type Handler

type Handler struct {
	Binding Binding
	Do      func() Msg
}

Handler pairs a Binding with the action it triggers, for apps that prefer table-driven dispatch over a switch on binding names.

func Bind

func Bind(b Binding, do func() Msg) Handler

Bind is shorthand for a Handler.

type HelpEntry

type HelpEntry struct {
	Key  string
	Desc string
}

HelpEntry is one row of rendered help: the key text and what it does.

type InputMsg

type InputMsg struct {
	ID    string
	Value string
}

InputMsg is sent when a TextInput's value changes and it has no OnChange callback of its own.

type KeyMap

type KeyMap struct {
	// contains filtered or unexported fields
}

KeyMap is an ordered set of Bindings. It gives an app one place to declare its shortcuts, one call to dispatch them, and automatic help rendering.

km := wui.NewKeyMap(
    wui.NewBinding("inc", wui.Keys("+", "k"), wui.Help("+/k", "increment")),
    wui.NewBinding("quit", wui.Keys("q"), wui.Help("q", "quit")),
)

func KeyMapFrom

func KeyMapFrom(handlers ...Handler) KeyMap

KeyMapFrom builds a KeyMap from handlers, preserving order — so one handler table can drive both dispatch and help output.

func NewKeyMap

func NewKeyMap(bindings ...Binding) KeyMap

NewKeyMap creates a KeyMap from bindings, preserving their order — help output lists them as given.

func (KeyMap) Add

func (k KeyMap) Add(bindings ...Binding) KeyMap

Add returns a copy of the KeyMap with additional bindings appended.

func (KeyMap) Bindings

func (k KeyMap) Bindings() []Binding

Bindings returns the bindings in declaration order.

func (KeyMap) Get

func (k KeyMap) Get(name string) (Binding, bool)

Get returns the binding with the given name.

func (KeyMap) HelpEntries

func (k KeyMap) HelpEntries() []HelpEntry

HelpEntries returns a help row for every enabled binding that has a description. Bindings without Help text are skipped — they are implementation details, not user-facing shortcuts.

func (KeyMap) HelpView

func (k KeyMap) HelpView(dir Direction) Element

HelpView renders the key map as an Element: one dim "key desc" pair per binding, laid out along dir. Row gives a status-bar strip, Column a help panel.

func (KeyMap) Match

func (k KeyMap) Match(msg KeyMsg) (string, bool)

Match returns the name of the first enabled binding matching msg. Bindings are tested in declaration order, so earlier entries win when two bindings share a key.

func (KeyMap) SetEnabled

func (k KeyMap) SetEnabled(name string, enabled bool) KeyMap

SetEnabled returns a copy of the KeyMap with the named binding's enabled state set. Unknown names are ignored.

func (KeyMap) ShortHelp

func (k KeyMap) ShortHelp() string

ShortHelp renders the key map as a single line — "+/k increment • q quit" — suitable for a status bar.

type KeyMsg

type KeyMsg struct {
	Key  string
	Rune rune
}

KeyMsg represents a key press. Key is a normalized name such as "enter", "ctrl+c", "tab", "backspace", or a single printable rune.

type LinkEl

type LinkEl struct {
	Label string
	Href  string
	Style Style
}

type ListEl

type ListEl struct {
	Items   []Element
	Ordered bool
	Style   Style
}

type Model

type Model interface {
	Init() Cmd
	Update(Msg) (Model, Cmd)
	View() Element
}

Model is the user-implemented Elm Architecture model.

type Msg

type Msg interface{}

Msg is the message type for the Elm Update loop. Any value can be a Msg, including application-defined types — analogous to tea.Msg.

func Dispatch

func Dispatch(msg KeyMsg, handlers ...Handler) (Msg, bool)

Dispatch runs the first handler whose binding matches msg and returns its Msg. handled is false when nothing matched, so the caller can fall through to its own key handling:

case wui.KeyMsg:
    if out, ok := wui.Dispatch(v, handlers...); ok {
        return m, func() wui.Msg { return out }
    }
type NavigateMsg struct {
	Path string
}

NavigateMsg is sent in WASM builds when the browser URL hash names a path — on initial load with a "#/path" fragment and on every hashchange (back/forward navigation). TUI programs never receive it. See Pather.

type NoneMsg

type NoneMsg struct{}

NoneMsg is a no-op message.

type Option

type Option func(*config)

Option configures a Program at construction time.

func WithKeyMap

func WithKeyMap(km KeyMap) Option

WithKeyMap registers the app's key map with the runtime. wui does not dispatch it for you — Update stays the single place where state changes — but registering it means the status bar shows the shortcuts automatically, and Program.KeyMap can be consulted when building a help view.

func WithStatus

func WithStatus(f StatusFunc) Option

WithStatus supplies the status line content, replacing wui's default (the web URL plus the key map's short help). It is called on every frame with the current model:

wui.WithStatus(func(m wui.Model) wui.Status {
    return wui.NewStatus().
        Left(m.(model).file).
        Right(fmt.Sprintf("%d items", len(m.(model).items)))
})

Returning a Status with no items falls back to the default bar; return NewStatus().Hide() to suppress the bar for a frame.

The bar renders in both TUI and WASM builds. In the browser it is a fixed strip at the bottom of the viewport.

func WithTitle

func WithTitle(title string) Option

WithTitle sets the application title. In WASM builds it becomes document.title; in the TUI it sets the terminal window title.

func WithWebServer

func WithWebServer(addr, dir string) Option

WithWebServer serves dir — a WASM build of the same app (index.html, main.wasm, wasm_exec.js) — over HTTP for as long as the TUI runs, and displays a status bar at the bottom of the TUI linking to the equivalent web page.

addr is the listen address, e.g. ":8765" (all interfaces) or "127.0.0.1:8765". Pass "" to bind the loopback interface only, on the first free port in 8765–8864 (falling back to an OS-assigned port if the whole range is busy).

It has no effect in WASM builds, so it is safe to pass unconditionally from shared code.

func WithoutAltScreen

func WithoutAltScreen() Option

WithoutAltScreen runs the TUI inline in the terminal's normal buffer instead of the alternate screen, so the final frame stays in the scrollback after exit. It has no effect in WASM builds.

func WithoutBaseCSS

func WithoutBaseCSS() Option

WithoutBaseCSS disables injection of wui's default terminal-like stylesheet in WASM builds. Use it when the host page provides its own styling. It has no effect in TUI builds.

func WithoutMouse

func WithoutMouse() Option

WithoutMouse disables TUI mouse tracking, leaving click and scroll events to the terminal (so text selection works normally). It has no effect in WASM builds.

func WithoutStatusBar

func WithoutStatusBar() Option

WithoutStatusBar suppresses the status line on both platforms, even when WithWebServer or WithKeyMap would otherwise populate it.

type Pather

type Pather interface {
	Path() string
}

Pather is an optional interface a Model can implement to expose its current logical location as a path such as "/" or "/settings".

When the TUI runs with WithWebServer, the status bar link points at the equivalent web location ("http://host/#/settings"). In the browser, the path is mirrored into location.hash after every update, and a NavigateMsg is dispatched on initial load and whenever the hash changes (e.g. back/forward navigation), so both platforms stay addressable at the same locations.

type Platform

type Platform int

Platform identifies which renderer is running.

const (
	// PlatformTUI is a terminal build (bubbletea + lipgloss).
	PlatformTUI Platform = iota
	// PlatformWeb is a WASM build rendering to the DOM.
	PlatformWeb
)

func Current

func Current() Platform

Current reports which renderer this binary was built for. It is a build-time constant per binary, so branches on it cost nothing at runtime and the unused branch's data is still compiled in (both sides must type-check).

func (Platform) String

func (p Platform) String() string

type Program

type Program struct {
	// contains filtered or unexported fields
}

Program is the wui runtime. Construct with NewProgram and call Run.

func NewProgram

func NewProgram(m Model, opts ...Option) *Program

NewProgram creates a Program for the given model.

func (*Program) KeyMap

func (p *Program) KeyMap() KeyMap

KeyMap returns the key map registered with WithKeyMap, or the zero KeyMap when none was set.

func (*Program) Run

func (p *Program) Run() error

Run starts the program loop, blocking until the program exits.

type ProgressEl

type ProgressEl struct {
	Value      float64
	Width      int  // bar width in cells; 0 = default (20)
	ShowLabel  bool // append " 42%"
	Style      Style
	FilledRune string // TUI fill glyph; defaults to "█"
	EmptyRune  string // TUI track glyph; defaults to "░"
}

ProgressEl is a determinate progress bar. Value is clamped to 0..1.

type QuitMsg

type QuitMsg struct{}

QuitMsg asks the runtime to shut the program down. Return it from a Cmd (or use Quit) rather than calling os.Exit, so the terminal is restored properly. In WASM builds it stops the event loop; the page itself stays open.

type ResizeMsg

type ResizeMsg struct {
	Width  int
	Height int
}

ResizeMsg is sent when the terminal or window is resized.

type ResponsiveEl

type ResponsiveEl struct {
	// Variants is sorted by ascending MinWidth by Responsive, which
	// both renderers rely on.
	Variants []Breakpoint
}

ResponsiveEl renders the variant matching the available width. It is built by Responsive; the renderers select from Variants.

type ScrollAreaEl

type ScrollAreaEl struct {
	Child     Element
	MaxHeight int
	Style     Style
}

type SelectEl

type SelectEl struct {
	ID       string
	Options  []SelectOption
	Value    string
	OnChange func(value string) Msg
	Style    Style
	Disabled bool
}

SelectEl is a single-choice dropdown (HTML <select>) / cycling picker (TUI: left/right or space cycles the selection).

type SelectOption

type SelectOption struct {
	Value string
	Label string
}

SelectOption is one choice in a SelectEl.

func Opt

func Opt(value, label string) SelectOption

Opt builds a SelectOption. Pass an empty label to display the value.

func Options

func Options(values ...string) []SelectOption

Options builds a SelectOption slice from values, using each value as its own label.

type SidebarOpts

type SidebarOpts struct {
	Width   int  // sidebar width in cells; default 24
	MinMain int  // main pane width below which the layout collapses; default 40
	Gap     int  // cells between sidebar and main; default 2
	Right   bool // place the sidebar after the main pane

	// Snug keeps the main pane at its natural width instead of letting
	// it absorb the leftover space. Without it a wide terminal pushes
	// the two panes to opposite edges with a gulf between them; with
	// it they stay together and the slack falls outside the pair.
	Snug bool
}

SidebarOpts configures a Sidebar layout; set it through the With/Sidebar* option functions.

type SpacerEl

type SpacerEl struct {
	Size int
}

SpacerEl is flexible empty space inside a Box: Size cells/lines of blank space, or — when Size is 0 — a stretch that pushes the surrounding children apart.

type SpinnerEl

type SpinnerEl struct {
	Frame int
	Label string
	Style Style
}

SpinnerEl is an indeterminate activity indicator. Frame selects which glyph of the animation to draw — the app owns the animation clock, so the renderers stay pure. Drive it from a Tick Cmd.

type Status

type Status struct {
	Items []StatusItem

	// Separator is drawn between adjacent segments on the same side.
	// Empty means the default (" │ ").
	Separator string

	// Style overrides the bar's default appearance (reverse video in
	// the TUI, a bordered strip in HTML).
	Style Style

	// Hidden suppresses the bar entirely for this frame.
	Hidden bool
}

Status is the content of the bottom status line: a list of segments plus the separator drawn between them.

wui fills in defaults an app rarely wants to write itself — the web URL when WithWebServer is active, and the key map's short help when WithKeyMap is set — so a status line is usually one option, not a layout problem:

wui.NewProgram(m,
    wui.WithKeyMap(keys),
    wui.WithStatus(func(m wui.Model) wui.Status {
        return wui.NewStatus().Left(m.(model).filename).Right("ln 4")
    }),
)

func NewStatus

func NewStatus() Status

NewStatus returns an empty Status, as the head of a builder chain.

func (Status) Empty

func (s Status) Empty() bool

Empty reports whether the bar has nothing to draw.

func (Status) Hide

func (s Status) Hide() Status

Hide suppresses the bar for this frame.

func (Status) Item

func (s Status) Item(it StatusItem) Status

Item appends a fully specified segment.

func (Status) Left

func (s Status) Left(texts ...string) Status

Left appends left-aligned segments.

func (Status) Right

func (s Status) Right(texts ...string) Status

Right appends right-aligned segments.

func (Status) Styled

func (s Status) Styled(text string, style Style) Status

Styled appends a left-aligned segment with its own style.

func (Status) WithSeparator

func (s Status) WithSeparator(sep string) Status

WithSeparator sets the text drawn between adjacent segments.

func (Status) WithStyle

func (s Status) WithStyle(style Style) Status

WithStyle overrides the bar's default appearance.

type StatusFunc

type StatusFunc func(Model) Status

StatusFunc builds the status line for the current model state. It is called on every frame, after Update.

type StatusItem

type StatusItem struct {
	// Text is the segment's content.
	Text string
	// Style overrides the bar's default styling for this segment.
	Style Style
	// Right pins the segment to the right-hand end of the bar. Items
	// keep their declared order within each side.
	Right bool
}

StatusItem is one segment of the status line.

type Style

type Style struct {
	FG          Color
	BG          Color
	Bold        bool
	Italic      bool
	Underline   bool
	Faint       bool
	Strike      bool
	Padding     [4]int // top, right, bottom, left
	Margin      [4]int // top, right, bottom, left
	Width       int    // 0 = auto, Fill = all available width
	Height      int    // 0 = auto, Fill = all available height
	Border      bool
	BorderColor Color
	Align       Align // text alignment within Width; AlignStart = left

	// Grow is the element's flex-grow share when it is a direct child
	// of a Box: leftover space along the Box's axis is split between
	// growing children in proportion to their Grow values. 0 = fixed.
	Grow int

	// MinWidth floors the element's width in cells. With Grow it sets
	// the smallest acceptable size; in a wrapping Box it is the
	// threshold below which the row collapses to a column.
	MinWidth int

	// MaxWidth caps the element's width in cells, so a Fill layout
	// stops growing once it reaches its intended size instead of
	// stretching across a very wide terminal. It also caps the width
	// children see, so everything inside lays out against the cap
	// rather than the screen. 0 = uncapped.
	//
	// Pair it with Align to place the capped block in the leftover
	// space; Constrain is the ready-made centered version.
	MaxWidth int

	// MaxHeight caps the element's height in rows: the vertical
	// counterpart of MaxWidth. 0 = uncapped.
	MaxHeight int

	// PlaceX positions a MaxWidth-capped element within the space it
	// was given but did not use. It is distinct from Align, which
	// positions content *inside* the element: a block can be centered
	// on screen while its own text stays left-aligned. Ignored without
	// MaxWidth. AlignStart (the default) keeps the block flush left.
	PlaceX Align
}

Style holds presentation properties shared by both renderers. Width/Height are terminal cells in TUI and pixels in WASM.

The zero Style is "no styling". Build styles by composition rather than by filling the struct literal by hand:

wui.NewStyle().Bold().FG("6").Pad(1, 2)

func NewStyle

func NewStyle() Style

NewStyle returns the zero Style, as the head of a builder chain.

func (Style) Aligned

func (s Style) Aligned(a Align) Style

Aligned returns a copy with text alignment set. It positions content inside the style's Width, so it has no visible effect without one.

func (Style) Bg

func (s Style) Bg(c Color) Style

Bg returns a copy with the background color set.

func (Style) Bordered

func (s Style) Bordered(c Color) Style

Bordered returns a copy with a border in the given color. Pass "" to use the current foreground color.

func (Style) Fg

func (s Style) Fg(c Color) Style

Fg returns a copy with the foreground color set.

func (Style) Grown

func (s Style) Grown(n int) Style

Grown returns a copy with the flex-grow share set. It only has an effect on direct children of a Box.

func (Style) H

func (s Style) H(n int) Style

H returns a copy with the height set. Pass Fill for all available height.

func (Style) Mar

func (s Style) Mar(v ...int) Style

Mar returns a copy with margin set, using the same shorthand rules as Pad.

func (Style) MaxH

func (s Style) MaxH(n int) Style

MaxH returns a copy with the maximum height set.

func (Style) MaxW

func (s Style) MaxW(n int) Style

MaxW returns a copy with the maximum width set: the element stops growing at n cells however wide the terminal is.

func (Style) Merge

func (s Style) Merge(other Style) Style

Merge returns a copy of s with every non-zero field of other applied on top. Use it to layer a variant over a base style:

danger := base.Merge(wui.Style{FG: "9", Bold: true})

Boolean attributes are additive — Merge can set them but never clears one already set in s.

func (Style) MinW

func (s Style) MinW(n int) Style

MinW returns a copy with the minimum width set.

func (Style) Pad

func (s Style) Pad(v ...int) Style

Pad returns a copy with padding set CSS-style: one value applies to all sides, two are vertical/horizontal, three are top/horizontal/ bottom, four are top/right/bottom/left. Other counts are ignored.

func (Style) PlacedX

func (s Style) PlacedX(a Align) Style

PlacedX returns a copy with the placement of a MaxWidth-capped element within its leftover space set.

func (Style) SetBold

func (s Style) SetBold(v bool) Style

Bold returns a copy with bold set.

func (Style) SetFaint

func (s Style) SetFaint(v bool) Style

Faint returns a copy with faint (dim) set.

func (Style) SetItalic

func (s Style) SetItalic(v bool) Style

Italic returns a copy with italic set.

func (Style) SetStrike

func (s Style) SetStrike(v bool) Style

Strike returns a copy with strikethrough set.

func (Style) SetUnderline

func (s Style) SetUnderline(v bool) Style

Underline returns a copy with underline set.

func (Style) W

func (s Style) W(n int) Style

W returns a copy with the width set. Pass Fill for all available width.

type SubmitMsg

type SubmitMsg struct {
	FormValues map[string]string
}

SubmitMsg is sent when a Form is submitted and it has no OnSubmit callback of its own.

type TableEl

type TableEl struct {
	Columns []string
	Rows    [][]string
	Style   Style
}

type TextAreaEl

type TextAreaEl struct {
	ID          string
	Value       string
	Placeholder string
	Rows        int // visible lines; 0 = default (4)
	OnChange    func(string) Msg
	Style       Style
	Disabled    bool
}

TextAreaEl is a multi-line text input.

type TextEl

type TextEl struct {
	Content string
	Style   Style
}

type TextInputEl

type TextInputEl struct {
	ID          string
	Value       string
	Placeholder string
	Password    bool
	OnChange    func(string) Msg
	OnSubmit    func(string) Msg
	Style       Style
	Disabled    bool
}

type TextVariant

type TextVariant struct {
	MinWidth int
	Content  string
}

TextVariant is one string of a ResponsiveText.

func TextAt

func TextAt(minWidth int, content string) TextVariant

TextAt pairs a minimum available width with the string to show at or above it, for use with ResponsiveText.

type ToggleMsg

type ToggleMsg struct {
	ID      string
	Checked bool
}

ToggleMsg is sent when a Checkbox is toggled and it has no OnToggle callback of its own. Checked is the new state.

Directories

Path Synopsis
cmd
serve command
Command serve is a static file server for wui WASM web builds.
Command serve is a static file server for wui WASM web builds.
example
counter command
Command counter is a minimal wui example: buttons that adjust a count, rendering identically as a TUI or in a WASM build.
Command counter is a minimal wui example: buttons that adjust a count, rendering identically as a TUI or in a WASM build.
dashboard command
Command dashboard exercises the framework features that make wui pleasant to build with: declarative key bindings with generated help, a custom status line, platform-conditional content, and the layout and feedback elements (Divider, Spacer, Progress, Spinner, Select, TextArea).
Command dashboard exercises the framework features that make wui pleasant to build with: declarative key bindings with generated help, a custom status line, platform-conditional content, and the layout and feedback elements (Divider, Spacer, Progress, Spinner, Select, TextArea).
form command
Command form demonstrates wui text inputs, form submission, and rendering a results table — works identically as TUI or WASM.
Command form demonstrates wui text inputs, form submission, and rendering a results table — works identically as TUI or WASM.
layout command
Command layout demonstrates the layout helpers: an AppShell frame with a collapsing Sidebar, a Split pair of panels, and a Centered login screen — switchable at runtime to compare them.
Command layout demonstrates the layout helpers: an AppShell frame with a collapsing Sidebar, a Split pair of panels, and a Centered login screen — switchable at runtime to compare them.
timer command
Command timer is a stopwatch demonstrating Cmd-driven async work (self-rescheduling ticks) — identical in TUI and WASM builds.
Command timer is a stopwatch demonstrating Cmd-driven async work (self-rescheduling ticks) — identical in TUI and WASM builds.
todo command
Command todo demonstrates lists, scroll areas, per-item buttons, and clearing an input programmatically after submit — identical in TUI and WASM builds.
Command todo demonstrates lists, scroll areas, per-item buttons, and clearing an input programmatically after submit — identical in TUI and WASM builds.
Package webserver provides the listener and URL helpers wui uses to serve WASM web builds.
Package webserver provides the listener and URL helpers wui uses to serve WASM web builds.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL