Go API
The runtime is one Go package, github.com/abdul-hamid-achik/tuimark, with no CGO. A host program uses it to load a document, fill the data store, handle the actions the document names, and run it in the terminal. Layout, painting, focus, and terminal handling are internal and not importable.
go get github.com/abdul-hamid-achik/tuimark@latestThe functions
func Load(path string) (*App, error)
func LoadFS(fsys fs.FS, name string) (*App, error) // 0.3
func Parse(r io.Reader) (*App, error)
func (a *App) Bind(path string, v any) error
func (a *App) Set(path string, v any) error
func (a *App) Get(path string) (any, bool) // 0.3
func (a *App) Batch(fn func(b *Batch) error) error // 0.3
func (a *App) On(action string, h Handler)
func (a *App) Catalog() []ActionSpec
func (a *App) Dump(cols, rows int) (*Dump, error)
func (a *App) Validate() []Diagnostic
func (a *App) Play(opts PlayOptions, steps ...string) (*PlayResult, error) // 0.3
func (a *App) Run(w io.Writer) error
func (a *App) RunContext(ctx context.Context, w io.Writer) error // 0.4
func (a *App) RunWith(ctx context.Context, w io.Writer, opts RunOptions) error // 0.4
func (a *App) Quit() // 0.4
func (a *App) Every(d time.Duration, fn func(b *Batch) error) (stop func()) // 0.4
func (a *App) Derive(path string, fn func(get func(path string) (any, bool)) (any, error)) error // 0.4That is the whole surface, plus the types they use: Event, Handler, ActionSpec, Dump, DumpNode, Diagnostic, ErrQuit, and, since 0.3, Batch, PlayOptions, PlayEvent, PlayResult, since 0.3b, PlayFrame, and, since 0.4, RunOptions.
Loading
Load reads a .tui file; <style src> paths resolve relative to it. Parse reads a document from an io.Reader, with src paths relative to the working directory.
LoadFS (0.3) reads a document from an fs.FS. Its main use is shipping the view inside the binary with go:embed:
//go:embed ui/app.tui ui/theme.tcss
var views embed.FS
ui, err := tuimark.LoadFS(views, "ui/app.tui") // <style src="theme.tcss"/> is ui/theme.tcssA relative <style src> resolves inside the file system, next to the document. A src that climbs out of it is a V006 diagnostic.
None of them fails because of what is in the document: a document with errors still loads. The error is only for I/O. Read the problems with Validate(), or from a dump's Errors.
Data
The store is JSON-shaped: null, booleans, numbers, strings, arrays, and objects. Both Bind and Set convert what you pass (structs with json tags, maps, slices, numbers) to that shape, and reject what has no JSON form, such as channels, functions, NaN, and infinities.
Bind(path, v)storesvat a dotted path; the empty path replaces the whole store.Set(path, v)does the same for a running app, schedules a redraw, and is safe to call from any goroutine, such as a ticker that refreshes data every second.
_ = ui.Bind("", data) // the whole store
_ = ui.Set("status", "deploying") // one member
_ = ui.Set("selected_ticket", tickets[0]) // a struct, as JSON
_ = ui.Set("steps.2.status", "done") // host paths may index arraysA host path may use a number to reach into an array, as above; markup paths cannot, and reach arrays through each.
Get(path) (0.3) reads a value back as a copy: changing what it returns never changes the store. It uses the same paths, and @focus, @screen, and @theme return the app's current focus ("#id"), screen, and theme.
Batch (0.3) applies several writes as one change. Use it to publish one sample of live data, so no frame shows half of it:
err := ui.Batch(func(b *tuimark.Batch) error {
_ = b.Set("cpu", cpu)
_ = b.Set("memory", mem)
return b.Set("processes", procs)
})The writes are applied together and in order, and they cause a single redraw. If any of them fails, or fn returns an error, none is applied. fn runs without holding the app's lock, so it can call Get to compute what to write.
The returned error (0.3b). When something fails, Batch returns every part joined with errors.Join: first each failed Set's own error, in call order, then fn's own error, unless it is the same error as one already listed. The result is always a joined value, even for one failure, so err == someSentinel and a type switch on err no longer match — this changed in 0.3b and is a behavior break for code written against 0.3 or earlier. Use errors.Is/errors.As to test for a specific error, and err.(interface{ Unwrap() []error }).Unwrap() (or a plain range over errors.Join's result) to walk every part:
var errBudget = errors.New("over budget")
err := ui.Batch(func(b *tuimark.Batch) error {
if cpu > limit {
return errBudget
}
return b.Set("cpu", cpu)
})
if errors.Is(err, errBudget) { // still finds it, wrapped inside the join
…
}
// err == errBudget no longer holds, even when errBudget was fn's only error.Reserved paths
Three paths starting with @ are not in the store. They let the host drive the runtime:
| Path | Value | Does |
|---|---|---|
@focus | "#id" | moves focus to a node; into an inactive tab, it activates the tab |
@screen | "id" | switches the active screen |
@theme | "dark", "light", "auto" | sets the theme, over the document's theme (TUIMARK_THEME still wins in Run) |
An invalid value returns an error and changes nothing.
Actions
On registers the handler for an action name:
type Handler func(Event) error
type Event struct {
Action string // the action name
Source string // the id of the node that fired it
Keys map[string]any // the each-aliases of the row involved: {"t": "t-12"}
Value any // an input's text, a new checked array, a tab id, or nil
}ui.On("open", func(ev tuimark.Event) error {
id, _ := ev.Keys["t"].(string)
return ui.Set("current", lookup(id))
})
ui.On("search", func(ev tuimark.Event) error {
q, _ := ev.Value.(string)
return ui.Set("tickets", filter(all, q))
})A handler that returns ErrQuit stops Run cleanly; any other error stops it and is returned. The built-in quit action (a keymap row, or ctrl+c when nothing claims it) stops Run by itself when no quit handler is registered; register one to confirm first, and return ErrQuit when it should really quit.
Handlers run on the runtime's goroutine, one at a time. Long work belongs in a goroutine of its own that reports back with Set.
Catalog
Catalog() lists every action the document names, with where it is used (Sources), whether a handler is Registered, and whether it is a built-in (Builtin). Use it in a test to check that every action the view fires has a handler.
Dump and Validate
Dump(cols, rows) renders the current state as a frame (the same data as tuimark dump --format json), and Validate() returns every diagnostic, laying the document out at 40, 80, and 120 columns. Both are pure: they never move focus, fire events, or change what Run starts from, so you can call them at any size, in any order, as often as you like. Dump uses the document's theme (or @theme) and ignores the environment.
That makes them the natural base for Go tests of a view:
func TestTasksFit(t *testing.T) {
ui, err := tuimark.Load("app.tui")
if err != nil {
t.Fatal(err)
}
if err := ui.Bind("", map[string]any{
"project": "launch checklist",
"selected": "t1",
"tasks": []map[string]any{{"id": "t1", "title": "write the release notes"}},
}); err != nil {
t.Fatal(err)
}
for _, d := range ui.Validate() {
if d.Severity == "error" {
t.Errorf("validate: %s", d)
}
}
frame, err := ui.Dump(48, 10)
if err != nil {
t.Fatal(err)
}
if !frame.OK {
t.Fatalf("dump errors: %v", frame.Errors)
}
for _, n := range frame.Nodes {
if n.ID == "tasks" && n.H != 3 {
t.Errorf("tasks is %d rows tall, want 3 (one row and its border)", n.H)
}
}
}Play
Play (0.3) is Run without a terminal, for Go tests. Each step is one step of tuimark play: a key, text:…, paste:…, set:…, focus:…, resize:…, click:X,Y, or wheel-up:X,Y/wheel-down:X,Y. A step may contain spaces ("text:hello world").
res, err := ui.Play(tuimark.PlayOptions{Cols: 120, Rows: 30}, "7", "/", "text:fire", "enter", "K")
if err != nil {
t.Fatal(err)
}
// res.Events: the actions that fired, in order, with their step, keys, and value
// res.Dump: the final frame, as Dump returns it
// res.Quit: whether a quit ended the callPlaydrives the app itself and calls the handlers you registered withOn, exactly asRunwould. The state carries over to the nextPlayorRun, so a test can play a few keys, check the store withGet, and play more.- Every step is checked before any runs: a bad step returns an error and changes nothing. A handler that returns
ErrQuitends the call withQuit: true. Any other handler error ends the call and is returned together with the events so far. - Every call starts by rendering and settling what changed since the last one, so
ui.Play(opts)with no steps picks up writes a handler made from another goroutine. - With
NoHandlers: trueon a freshly loaded app,Playgives exactly whattuimark playprints. The CLI andPlayshare one engine. PlayandRunexclude each other: calling one while the other is active returns an error.
Frame-by-frame (0.3b). PlayOptions.Frames: true makes Play also fill PlayResult.Frames with one PlayFrame per applied step, step 0 (the live frame before any step) included:
res, err := ui.Play(tuimark.PlayOptions{Cols: 100, Rows: 30, Frames: true}, "/", "text:cpu", "enter")
for _, f := range res.Frames {
fmt.Printf("step %d %q: %d events\n", f.Step, f.Input, len(f.Events))
}Each PlayFrame is {Step, Input, Events, Dump}: Step is the step's number, Input is the step's text as given ("" for step 0), Events holds only the events that step fired, and Dump is that step's settled frame — built with Styles/Cells exactly as PlayResult.Dump is. A step that ends the call (a quit, or a handler error) still gets an entry, holding the event that ended it; a step that could not be applied does not. This is the same data tuimark play --frames prints, member for member. Without Frames, PlayResult.Frames is nil and nothing extra is built.
Run
Run(w) puts the terminal in raw mode, reads keys (and, when the document turns it on, the mouse) from stdin, and draws frames to w, usually os.Stdout, until the app quits. On the way it:
- asks the terminal what it supports (colors, synchronized output, its background for
theme="auto") and paints only the cells that changed; - delivers pastes as one edit, and never lets a paste or a terminal reply trigger a key binding;
- restores the terminal (cooked mode, main screen, visible cursor) however it ends.
It returns nil on quit. On SIGINT, SIGTERM, or SIGHUP it restores the terminal first and returns an error naming the signal, so the program can exit with a failure status. It reads the TUIMARK_* variables described in environment variables.
Context, errors, and Quit (0.4)
Run(w) is RunContext(context.Background(), w), and RunContext is RunWith with no options.
Cancellation. A context that is already done when RunWith is called returns an error at once, without touching the terminal. A cancellation during the session acts as a quit:
- the loop takes it before the next input or between two events;
- the terminal is restored;
RunWithreturns an error for whicherrors.Is(err, ctx.Err())holds.
A handler that blocks keeps the session until it returns, so pass the context on to handlers that wait.
Quit() ends the active session from any goroutine, the same way, and Run returns nil. Called from a handler, it takes effect when the handler returns, as ErrQuit does. Outside Run it does nothing, and Play ignores it.
RunOptions.OnError keeps a session alive when a handler fails. It is called on the loop's goroutine with each error that a handler, an Every function, or a Derive function returns, other than a quit. ev is the failed event, or nil for Every and Derive. What it returns decides what follows:
OnError returns | The session |
|---|---|
nil | goes on; the failed handler counts as having returned nil |
a quit (ErrQuit) | ends as a quit, and RunWith returns nil |
| any other error | ends with that error |
err := app.RunWith(ctx, os.Stdout, tuimark.RunOptions{
OnError: func(ev *tuimark.Event, err error) error {
_ = app.Set("status", "error: "+err.Error()) // show it and go on
return nil
},
})Without OnError, an error that is not a quit ends the session, as in Run.
Timers: Every (0.4)
Every(d, fn) calls fn every d while Run is active. Each call gets a fresh *Batch, whose writes apply as one batch with one redraw:
stop := app.Every(time.Second, func(b *tuimark.Batch) error {
return b.Set("clock", time.Now().Format("15:04:05"))
})
defer stop()- Where
fnruns. On the loop's goroutine, between inputs, as a handler does. A slowfndelays keys. - Late ticks. A tick that falls due while the loop is busy runs once when the loop is free. Ticks are never queued, as with
time.Ticker. - Lifetime. Registrations persist across
Runcalls untilstopis called.stopis safe from any goroutine,fnincluded, and never waits. - Errors. An error from
fnis a handler error with no event. It goes toOnError, or it endsRunwithout it. - Testing.
Dump,Validate, andPlaynever callfn, so a test drives time-based state withset:steps.
Derived values: Derive (0.4)
Markup has no expressions. Derive keeps a computed value in the store instead: a filtered list, a count, or a label. The markup then reads it as any other path:
err := app.Derive("visible", func(get func(string) (any, bool)) (any, error) {
all, _ := get("items")
q, _ := get("query")
return filter(all, q), nil
})When fn runs. Once at registration, then inside every change to the store:
Set,Bind, andBatch(once per batch, after all its writes);- every write the runtime makes: an input's text, a cursor, a checked array, or a tab.
Nothing ever sees a stale value: not Get, Dump, a frame, a handler, or a Play step. Derived values are recomputed in registration order, so a later one reads the earlier ones' new values.
get reads the store as the write left it, and returns deep copies. It returns (nil, false) for a missing path and for the reserved paths @focus, @screen, and @theme.
No App methods inside fn. fn runs while the app holds its lock, so a call to any method of the App from inside it never returns.
A derived path is read-only.
Set,Bind, orb.Seton a derived path, or on a path inside one, returns an error. A batch holding such a write applies none of its writes.- A write to an ancestor is allowed, but it fails as a whole when the recomputation could not write the derived path.
- A widget bound to a derived path does not change and fires no
on:changeoron:select. A built-in keymap row that targets it does not match.
Errors. When fn returns an error, null is written at the path and the error is reported once:
- during
Run, on the loop, as a handler error with no event; - during
Play, as an error of the step being applied.
The write that caused it returns nil.
A complete host
The getting started page builds one step by step. The repository's examples show larger ones: examples/inbox (search and selection), examples/dashboard (screens and a confirm modal), examples/monitor (a live collector, sorting, filtering, settings that switch the mouse and the theme), and examples/agent (a coding-agent chat with streaming replies and tool approvals). None of them has any layout, width, or color code: see Examples.