| name | zeichenwerk |
| description | Build terminal UIs with the zeichenwerk Go library — widgets, themes, events, and both Builder and Compose APIs. |
| user-invocable | false |
Zeichenwerk — terminal UI library for Go
Module: github.com/tekugo/zeichenwerk
Sub-packages:
core — Widget, Container, Theme, Style, Event, Handler, alignment / flag constants, Find, MustFind, FindAll, FindAt, Layout, Traverse, LayoutForm, StyleForm
widgets — concrete widget types (List, Button, …), event helpers (OnActivate, OnChange, OnKey, OnMouse, …), Redraw, Relayout, per-widget *Form types (StaticForm, FlexForm, BreadcrumbForm, …) for the inspector / designer
inspector — designer state machine: Designer, Kind, WidgetForm, ContainerForm, ModeBuilder / ModeCompose; codegen via GenerateFragment / GenerateFile
themes — pre-built themes: TokyoNight(), Nord(), MidnightNeon(), GruvboxDark(), GruvboxLight(), Lipstick()
compose — functional alternative to the Builder
values — reactive Value[T], Setter[T] interface, Update[T](container, id, value)
Tutorial: doc/tutorial/README.md
Full widget reference: widgets.md
Designer guide: doc/designer/README.md — covers *-form.go files, the inspector package, codegen, and the cmd/designer-poc driver.
Two APIs — choose one per file
Builder API — fluent method chain:
import (
. "github.com/tekugo/zeichenwerk"
"github.com/tekugo/zeichenwerk/core"
"github.com/tekugo/zeichenwerk/themes"
)
ui := NewBuilder(themes.TokyoNight()).
VFlex("root", core.Stretch, 0).
Static("hello", "Hello, World!").
End().
Build()
ui.Run()
The Builder offers HFlex(id, alignment, spacing) and VFlex(id, alignment, spacing) shortcuts — they wrap NewFlex(id, class, alignment, spacing) and pre-set FlagVertical. There is no Flex builder method that takes a horizontal bool.
Compose API — Option functions:
import (
"github.com/tekugo/zeichenwerk/core"
. "github.com/tekugo/zeichenwerk/compose"
"github.com/tekugo/zeichenwerk/themes"
)
ui := UI(themes.TokyoNight(),
VFlex("root", "", core.Stretch, 0,
Static("hello", "", "Hello, World!"),
),
)
ui.Run()
Both APIs produce identical widget trees. Don't mix them in the same file.
Theme
import (
. "github.com/tekugo/zeichenwerk/core"
. "github.com/tekugo/zeichenwerk/themes"
)
t := NewTheme()
AddUnicodeBorders(t)
AddNerdStrings(t)
AddDefaultStyles(t)
t.SetColors(map[string]string{
"$bg0": "#1a1b26",
"$fg0": "#c0caf5",
"$blue": "#7aa2f7",
})
t.AddStyles(
NewStyle("").WithColors("$fg0", "$bg0").WithMargin(0).WithPadding(0),
NewStyle("button").WithColors("$bg0", "$blue").WithBorder("none").WithPadding(0, 2),
NewStyle("button:focused").WithColors("$fg0", "$blue"),
)
t.SetStrings(map[string]string{
"collapsible.expanded": "▼ ",
"collapsible.collapsed": "▶ ",
})
Style builder: WithColors(fg, bg), WithForeground, WithBackground,
WithBorder(name), WithMargin(…), WithPadding(…), WithFont(font), WithCursor(style).
Selector syntax: "button" · "button.dialog" · "button:focused" ·
"button.dialog:focused" · "table/grid" · "table/grid:focused"
Sub-part comes before class/state with a /. Component selectors resolve in
fallback order: exact → part → state → default. Theme.Get never returns nil.
Events
| Constant | Payload |
|---|
EvtActivate | int (index) or string (Combo, …) — Button always sends 0 |
EvtChange | varies — see widget entry |
EvtSelect | int or int, int (Table cellNav) or *TreeNode |
EvtAccept | string (Typeahead suggestion accepted) |
EvtEnter | string (Enter pressed in Input) |
EvtFocus / EvtBlur | — |
EvtShow / EvtHide | — |
EvtClose | — (popup about to close) |
EvtKey | *tcell.EventKey |
EvtMouse | *tcell.EventMouse |
EvtClick | — (mouse button-1 single click) |
EvtMode | string (Canvas mode change) |
EvtMove | int, int (cursor / highlight position) |
EvtPaste | string |
EvtHover | *tcell.EventMouse |
Handler signature: func(source Widget, event Event, data ...any) bool —
return true to stop propagation. Handlers run in reverse registration
order (newest first).
widget.On(widgets.EvtActivate, handler)
widgets.OnActivate(button, func(idx int) bool { … })
widgets.OnSelect (list, func(idx int) bool { … })
widgets.OnChange (input, func(value string) bool { … })
widgets.OnEnter (input, func(value string) bool { … })
widgets.OnKey (widget, func(*tcell.EventKey) bool { … })
widgets.OnMouse (widget, func(*tcell.EventMouse) bool { … })
OnChange only fires for string-valued change events (Input, Select,
Typeahead). For Checkbox (bool) or Tabs (int), use the raw form and
type-assert data[0].
Flags
core.Flag constants — pass to widget.Flag(name) / widget.SetFlag(name, value):
| Constant | Effect |
|---|
FlagChecked | Checkbox checked state |
FlagDisabled | Non-interactive; renders :disabled style |
FlagFocusable | Eligible for keyboard focus |
FlagFocused | Currently holds keyboard focus |
FlagGrid | Table: render inner grid lines |
FlagHidden | Invisible to renderer / hit-test / focus traversal; layout slot preserved |
FlagHorizontal | Viewport: restrict to horizontal scrolling only |
FlagHovered | Mouse cursor over widget |
FlagMasked | Input: hide text behind a mask character |
FlagPressed | Mouse button held on widget |
FlagReadonly | Prevents user edits; focus & selection still work |
FlagRight | Right-align content (Digits, Static) |
FlagSearch | List: enable incremental search-as-you-type |
FlagSkip | Excluded from Tab/Shift-Tab traversal even if focusable |
FlagVertical | Flex: vertical orientation. Viewport: restrict to vertical scrolling only |
widget.SetFlag(core.FlagDisabled, true)
widget.Flag(core.FlagFocused)
State priority for :state selectors: disabled > pressed > focused > hovered.
Widget quick-reference
| Widget | Constructor | Key events |
|---|
| BarChart | NewBarChart(id, class) | EvtSelect(int) EvtActivate(int) |
| Box | NewBox(id, class, title) | — |
| Breadcrumb | NewBreadcrumb(id, class) | EvtSelect(int) EvtActivate(int) |
| Button | NewButton(id, class, text) | EvtActivate(int) (always 0) |
| Canvas | NewCanvas(id, class, pages, w, h) | EvtChange EvtMove(int,int) EvtMode(string) |
| Card | NewCard(id, class, title) | — |
| Checkbox | NewCheckbox(id, class, text, checked) | EvtChange(bool) |
| Clock | NewClock(id, class, interval, params...) | — |
| Collapsible | NewCollapsible(id, class, title, expanded) | EvtChange(bool) |
| Combo | NewCombo(id, class, items) | EvtChange(string) EvtActivate(string) |
| CRT | NewCRT(id, class) | — |
| Deck | NewDeck(id, class, render, itemHeight) | EvtSelect(int) EvtActivate(int) |
| Dialog | NewDialog(id, class, title) | EvtClose |
| Editor | NewEditor(id, class) | EvtChange |
| Filter | NewFilter(id, class) | EvtChange(string) EvtAccept(string) |
| Flex | NewFlex(id, class, alignment Alignment, spacing int) | — |
| Form | NewForm(id, class, title, data any) | — |
| FormGroup | NewFormGroup(id, class, title, horizontal, spacing) | — |
| Grid | NewGrid(id, class, rows, cols, lines) | — |
| Grow | NewGrow(id, class, horizontal bool) | — |
| Heatmap | NewHeatmap(id, class, rows, cols) | — |
| Input | NewInput(id, class, params...) | EvtChange(string) EvtEnter(string) |
| List | NewList(id, class, items []string) | EvtSelect(int) EvtActivate(int) |
| Marquee | NewMarquee(id, class) | — |
| Progress | NewProgress(id, class, horizontal) | — |
| Radio | NewRadio(id, class, val, lbl, …) | EvtChange(string) |
| Rule | NewHRule(class, style) / NewVRule(class, style) | — |
| Scanner | NewScanner(id, class, width, charStyle) | — |
| Select | NewSelect(id, class, val, lbl, …) | EvtChange(string) |
| Shimmer | NewShimmer(id, class) | — |
| Shortcuts | NewShortcuts(id, class, pairs ...string) | — |
| Slider | NewSlider(id, class) | EvtChange(int) |
| Sparkline | NewSparkline(id, class) | — |
| Spinner | NewSpinner(id, class, sequence) | — |
| Static | NewStatic(id, class, text) | — |
| Styled | NewStyled(id, class, text) | — |
| Switcher | NewSwitcher(id, class) | EvtShow EvtHide (when connect=true) |
| Table | NewTable(id, class, provider, cellNav bool) | EvtSelect(int,int) EvtActivate(int,[]string) |
| Tabs | NewTabs(id, class) | EvtChange(int) EvtActivate(int) |
| Terminal | NewTerminal(id, class) | — |
| Text | NewText(id, class, lines, follow, max) | — |
| Tiles | NewTiles(id, class, render, tileW, tileH) | EvtSelect(int) EvtActivate(int) |
| Tree | NewTree(id, class) | EvtSelect(*TreeNode) EvtActivate(*TreeNode) EvtChange(*TreeNode) |
| TreeFS | NewTreeFS(id, class, root, dirsOnly) | inherits Tree |
| Typeahead | NewTypeahead(id, class, params...) | EvtChange(string) EvtAccept(string) |
| Typewriter | NewTypewriter(id, class) | EvtChange(bool) EvtActivate(bool) |
| Viewport | NewViewport(id, class, title) | — |
Flex sizing rules
Hint(w, h) values per child:
- Positive → fixed cells
- Zero → natural
Hint() size
- Negative → fractional share of remaining space (
-1 = 1 share, -2 = 2 shares)
To fill the available area, at least one child must have a negative hint on
the axis of the Flex (height for vertical, width for horizontal). Without it the
Flex shrinks to the sum of its children.
To right-align trailing content in a horizontal Flex, place a Spacer with
Hint(-1, 0) before the right-hand widgets — it expands to absorb all remaining space:
HFlex("header", core.Center, 0).
Static("title", "App").
Spacer().Hint(-1, 0).
Button("quit", "Quit").
End()
Grid sizing rules
Same hint semantics as Flex apply to both Rows and Columns:
- Positive → fixed cells
- Zero → auto-size from content
- Negative → fractional share of remaining space
To fill the available area, at least one row size and at least one column
size must be negative. A Grid where all sizes are fixed or zero will not expand.
Cell(x, y, w, h) (Builder) sets the position for the next widget. Skipping
a Cell call defaults to (0, 0, 1, 1).
Rendering is not clipped
Widget Render methods draw directly to the screen and are not automatically
clipped to the widget's bounds. A widget can draw outside its allocated area.
This is intentional for overlapping effects (borders, dropdowns, popups) but
means that oversized content from one widget can visually overwrite a neighbour.
Design widget sizes and content lengths defensively.
Finding widgets after build
import "github.com/tekugo/zeichenwerk/core"
ui := builder.Build()
btn := core.Find(ui, "my-button").(*widgets.Button)
btn := core.MustFind[*widgets.Button](ui, "my-button")
widgets.OnActivate(btn, func(idx int) bool { ; return true })
Other helpers:
core.FindAll[T](container) []T — every descendant of type T
core.FindAt(container, x, y) — deepest widget at screen coordinates
core.Traverse(container, fn) — depth-first walk
widgets.FindRoot(widget) Root — walk up to the *UI (or root container)
Pushing data into widgets
values.Update[T](container, id, value) looks up a widget by id and calls
Set(value) on it if it implements values.Setter[T]. No type assertion
needed:
values.Update(ui, "tables", []string{"authors", "books", "loans"})
values.Update(ui, "result", widgets.NewArrayTableProvider(cols, rows))
values.Update(ui, "status", "Ready")
Important: Table.Set(provider) does not trigger a redraw on its
own (unlike List.Set). Always call core.Find(ui, id).Refresh() after
updating a Table's provider.
Commands palette
cmds := ui.Commands()
cmds.Register("File", "Save", "ctrl+s", func() { })
cmds.Register("File", "Open", "ctrl+o", func() { })
cmds.SetMaxItems(8)
cmds.SetWidth(60)
cmds.Open()
Style keys: "commands" "commands/input" "commands/item" "commands/item:focused"
"commands/shortcut" "commands/shortcut:focused" "commands/group".
Animation widgets
Clock, Marquee, Progress, Scanner, Shimmer, Spinner, Typewriter
all embed Animation. Start and stop them explicitly; pair with
EvtShow/EvtHide on the enclosing Switcher (with connect=true) so they
only run while visible:
container.On(widgets.EvtShow, func(_ Widget, _ Event, _ ...any) bool {
tw.Start(30 * time.Millisecond)
return true
})
container.On(widgets.EvtHide, func(_ Widget, _ Event, _ ...any) bool {
tw.Stop()
return true
})
widgets.Spinners is a map[string]string of built-in sequences:
"bar" "dots" "dot" "arrow" "circle" "bounce" "braille".
Clock.Start() takes no argument — the interval is fixed at construction time.
CRT container
CRT wraps any single child and plays a power-on / power-off animation.
During normal operation it is a zero-overhead pass-through container.
crt := widgets.NewCRT("root", "")
crt.Add(myRootWidget)
ui := zeichenwerk.NewUI(theme, crt)
crt.Start(16 * time.Millisecond)
crt.PowerOff(16*time.Millisecond, ui.Quit)
Updating widget state
| Helper | Use when |
|---|
widgets.Redraw(w) | Visual change only; widget's Hint() didn't change |
widgets.Relayout(w) | Change affects size — re-runs layout starting at nearest container |
ui.Refresh() | Full screen repaint — always correct, rarely needed |
Many widgets call the right helper internally (List.Set, Static.Set,
Editor keystrokes, …). You only need these helpers when you mutate widget
state directly.
Error sentinels
core.ErrScreenInit
core.ErrChildIsNil
Designer & per-widget forms
zeichenwerk ships a runtime designer / inspector. Three layers:
widgets/*-form.go — every supported widget kind has a
*Form struct that implements inspector.WidgetForm
(Name/Group/Help, Load/Store, New, Validate, Emit).
Each form embeds ComponentForm for the standard
id / class / hint / flags / style snapshot, then declares
kind-specific fields with struct tags
(group, label, control, options, width, line,
readonly).
inspector package — central state holder
inspector.Designer, type registry (Kind), and the codegen
walker (GenerateFragment, GenerateFile). Forms register a
Kind{Type, Make} pair; Designer.FormFor(w) returns a fresh
form pre-loaded from the widget. ContainerForm.LayoutForm
surfaces per-child Add-params (e.g. Grid cell coordinates).
cmd/designer-poc/main.go — reference popup driver
(Ctrl+Space opens; tabs General / Layout / Style / Info).
Apply writes form → live widget; Generate writes Builder-mode
source via Designer.GenerateFile(ModeBuilder, …).
Adding a form for a custom widget:
- Create
mywidget-form.go next to mywidget.go. Embed
ComponentForm, declare fields with struct tags, implement the
seven required methods. Load/Store MUST call the embedded
f.ComponentForm.Load/Store(&w.Component) first.
Emit SHOULD use EmitFrame(out, mode, body) so the standard
class prefix and trailing Hint/Flag/style chain happen
automatically. Only bypass when the constructor name itself is
computed (e.g. HFlex vs VFlex).
- Properties without a chained Builder setter MUST emit a
// TODO: Setter(value) — no Builder setter comment after the
frame.
- Register the form in
cmd/designer-poc/main.go's
registerKinds(d) table:
register(reflect.TypeOf((*MyWidget)(nil)),
func() inspector.WidgetForm { return &MyWidgetForm{} })
Slice fields (List items, Tabs names, Combo items, Tree segments,
Shortcuts pairs) MUST be exposed as comma-separated strings; use
the package-level parseItems helper, or parseSelectOptions /
parseShortcutPairs for key:value pairs. Typed enums
(time.Duration, core.Level, core.Alignment) MUST round-trip
as strings with parseXxx / xxxConst helpers — see
ClockForm, IndicatorForm, FlexForm.
See doc/designer/forms.md for
the full per-form recipe and
doc/designer/codegen.md for
the chain-element convention.
Invariants
| Rule | Detail |
|---|
Deck itemHeight | ≥ 1 — panics otherwise |
Tiles tile dims | Both ≥ 1 — panics otherwise |
Form data | Must be pointer-to-struct |
| Single-child containers | Box, Card, Collapsible, Viewport, Dialog, CRT, Form — second Add() replaces the previous child |
compose.Build() | Panics with clear message if no widget added |
Builder.Columns() / Rows() | No-op with warning outside Grid context |
NewUI | Returns *UI — no error return; signature is NewUI(theme *Theme, root Container) *UI |
Layout MUST not paint | Compute bounds via SetBounds; no draw calls allowed |
Render MUST not mutate | Geometry is already resolved; render is read-only |
Set(...) on a widget | MUST never fire EvtChange (reserved for user-driven changes) |
ItemRender focused param | true when the host widget itself has focus (use to distinguish focus from selection) |
Terminal default size | 80×24 — call SetHint or let Render resize to content area |
Terminal.Write | Thread-safe; call from any goroutine (pty, subprocess, test) |
Single-child container Add followed by sibling | Replaces the child silently — close with End() before adding siblings (bug magnet for Form/Box) |
OnKey(ui, …) | Currently dispatched (root-container exclusion was lifted); attach global shortcuts there |