1. A TUI in 20 lines
May 15, 2026 · View on GitHub
Before we explain anything, let's just look at what a small zeichenwerk app looks like. You'll meet most of the framework's load-bearing ideas in one go; the rest of the tutorial unpacks them.
What we're building
A task list with three widgets and one wired-up button:
- a title in cyan bold,
- a scrollable list of tasks,
- two buttons at the bottom — one marks the highlighted task done, the other quits.
It's about 35 lines including imports.
The code
package main
import (
. "github.com/tekugo/zeichenwerk"
"github.com/tekugo/zeichenwerk/core"
"github.com/tekugo/zeichenwerk/themes"
"github.com/tekugo/zeichenwerk/widgets"
)
func main() {
tasks := []string{
"Read the zeichenwerk teaser",
"Build a tiny TUI",
"Wire up an event handler",
"Try a different theme",
}
ui := NewBuilder(themes.TokyoNight()).
VFlex("root", core.Stretch, 0).Padding(1, 2).
Static("title", "My First TUI").Font("bold").Foreground("$cyan").
HRule("thin").
List("tasks", tasks...).Hint(0, -1).
HRule("thin").
HFlex("actions", core.End, 2).
Button("done", "Mark Done").
Button("quit", "Quit").
End().
End().
Build()
list := core.MustFind[*widgets.List](ui, "tasks")
widgets.OnActivate(core.MustFind[*widgets.Button](ui, "done"), func(_ int) bool {
i := list.Selected()
if i >= 0 {
items := list.Items()
items[i] = "✓ " + items[i]
list.Set(items)
}
return true
})
widgets.OnActivate(core.MustFind[*widgets.Button](ui, "quit"), func(_ int) bool {
ui.Quit()
return true
})
ui.Run()
}
Run it:
go run ./examples/01-teaser
Use ↑/↓ to move through the list, Enter to "click" a button, Tab to switch
between the list and the buttons, and q or Ctrl-Q to quit.
What just happened
Reading top-to-bottom:
Theme. themes.TokyoNight() returns a fully-populated *Theme —
colours, borders, default styling for every widget. Other themes:
themes.Nord(), themes.MidnightNeon(), themes.GruvboxDark(),
themes.GruvboxLight(), themes.Lipstick(). Try swapping it.
Builder. NewBuilder(theme) returns a fluent builder. Every widget
method (VFlex, Static, List, Button, …) adds a widget and returns
the same builder, so calls chain. Container widgets (VFlex, HFlex,
Grid, Box, …) push onto a stack; End() pops back to the parent.
Layout. VFlex stacks children vertically; HFlex stacks them
horizontally. core.Stretch is a cross-axis alignment — children fill the
full width here. Hint(0, -1) says "auto-width, take all remaining
height" — that's how the list grows to fill the middle while the title and
buttons stay at their natural size.
Styling per widget. .Font("bold") and .Foreground("$cyan") apply
inline. The $cyan is a theme variable — switch themes and the colour
follows. Literal colours ("red", "#ff6347") work too but lock you to one
palette.
Build, then wire. Build() returns the *UI. After construction we
fish widgets out with core.MustFind[T] (a generic typed lookup that panics
if the ID is missing or the type is wrong) and attach handlers with
widgets.OnActivate. Buttons fire EvtActivate when Enter is pressed; the
typed helper unpacks the event for you.
Quitting. ui.Quit() ends the event loop cleanly. The framework also
binds q, Q, Ctrl-Q, and Ctrl-C to quit globally, so you don't have
to wire those yourself.
What you didn't have to write
Compare against a hand-rolled tcell program — these are all free:
- the tcell screen lifecycle and event poller,
- a layout engine (Flex, Grid, padding, hints),
- focus traversal with Tab / Shift-Tab,
- mouse hit-testing and click routing,
- a redraw / refresh pipeline that only repaints what changed,
- theme-aware styling for every widget,
- a built-in inspector you can pop open with
Ctrl-D(see Debugging).
That's the entire teaser. The next chapter explains the mental model behind what you just wrote — the four layers, the frame lifecycle, and why we build the tree first and wire events afterwards.