2. Architecture & lifecycle

May 15, 2026 · View on GitHub

This chapter is conceptual — no new code. It gives you the mental model so the rest of the tutorial reads as "here's the next layer," not "here's another magic incantation."

The four layers

Zeichenwerk is built as four packages, each with a clear role:

┌──────────────────────────────────────────────────────────────┐
│  builder / compose      ← DSLs you call from your app code    │
├──────────────────────────────────────────────────────────────┤
│  widgets                ← concrete widgets: List, Button, …   │
├──────────────────────────────────────────────────────────────┤
│  core                   ← Widget / Container / Theme contract │
├──────────────────────────────────────────────────────────────┤
│  renderer               ← thin tcell wrapper, drawing primitives │
└──────────────────────────────────────────────────────────────┘


                       github.com/gdamore/tcell
LayerWhat lives thereWhen you touch it
rendererScreen, Renderer, drawing primitives over tcellAlmost never — only when writing very low-level custom widgets.
coreWidget and Container interfaces, Theme, Style, Event, alignment / flag constants, geometry helpers (Find, Layout, Traverse, MustFind)Often — core.Stretch, core.MustFind, core.Debug, etc.
widgetsConcrete widgets and event helpers (OnActivate, OnChange, …)Often — both for widget types in MustFind[*widgets.List] and for typed event helpers.
builder (root package) and composeThe two DSLs for assembling a treeAlmost every line of UI code.

The themes and values sub-packages are smaller add-ons:

  • themes — pre-built *Theme values (themes.TokyoNight(), etc.) plus helpers to register Unicode borders and default styles.
  • values — a tiny reactive layer for binding form fields and pushing data into widgets (values.Update(ui, "tables", names)).

A typical import block

You'll see this shape over and over:

import (
    . "github.com/tekugo/zeichenwerk"     // NewBuilder, Builder, UI
    "github.com/tekugo/zeichenwerk/core"  // Stretch, MustFind, Debug, …
    "github.com/tekugo/zeichenwerk/themes"
    "github.com/tekugo/zeichenwerk/widgets"
)

The dot-import of the root package gives you NewBuilder, Builder, and UI directly. Everything else is qualified — that keeps it obvious whether a name comes from core, widgets, or your own code.

The frame lifecycle

A zeichenwerk app passes through these phases:

                ┌──────────────────────────┐
                │  NewBuilder(theme).…       │   you build the tree
                │  …Build()                 │
                └──────────────┬────────────┘


                ┌──────────────────────────┐
                │  ui.Run()                 │
                │   ├── tcell screen init  │
                │   ├── Layout()           │   ⇦ first layout pass
                │   ├── Draw()             │   ⇦ first paint
                │   └── event loop         │
                └──────────────┬────────────┘

                  ┌────────────┴───────────┐
                  ▼                        ▼
          tcell.EventKey           tcell.EventResize
          tcell.EventMouse                 │
                  │                        ▼
                  ▼                  Layout() + Draw()
          dispatch to widget


          handler returns

       ┌──────────┴───────────┐
       ▼                      ▼
   Redraw(w)              Relayout(w)
   (cheap repaint        (size hint changed,
    of one widget)         re-layout subtree)

Two principles fall out of this:

Layout() never paints; Render() never mutates. The layout pass computes bounds (SetBounds(x, y, w, h)) for every widget. The render pass reads those bounds and pushes cells to the screen. They're separate phases on purpose: rendering is allowed to skip cleanly when nothing changed.

Use the cheapest update. When you change a widget's contents but not its size, call widgets.Redraw(w) — only that widget repaints. When the change affects size (a list grew taller, a label's text widened), call widgets.Relayout(w) — the framework re-runs layout starting at the nearest container that cares. ui.Refresh() repaints the whole screen and is always correct but rarely necessary.

Build declaratively, wire imperatively

This is the most important pattern in zeichenwerk. The teaser already used it; now it has a name.

// 1) Build the whole tree in one expression.
ui := NewBuilder(themes.TokyoNight()).
    VFlex("root", core.Stretch, 0).
        List("tasks", tasks...).
        Button("done", "Mark Done").
    End().
    Build()

// 2) Find widgets by ID and attach behaviour.
list := core.MustFind[*widgets.List](ui, "tasks")
widgets.OnActivate(core.MustFind[*widgets.Button](ui, "done"), func(_ int) bool {
    // …mutate `list`…
    return true
})

// 3) Run.
ui.Run()

Why this split?

  • The tree describes structure and looks like the screen does. Mixing event closures into it makes a 30-line builder block into 200 lines of spaghetti.
  • Handlers often need references to other widgets (the button's handler reaches the list). Building the tree first means every widget exists by the time you wire callbacks.
  • Tests can construct the tree without running it.

MustFind[T] is a generic typed lookup. core.MustFind[*widgets.List](ui, "tasks") returns a *widgets.List directly, panicking with a clear error if the ID is wrong or the widget isn't a *widgets.List. There's also core.Find(container, id) Widget (untyped, returns nil when not found) if you want to handle absence gracefully.

IDs are like DOM IDs

Widget IDs are how MustFind finds things. The framework does not check uniqueness — duplicates silently let Find return whichever appears first in depth-first order. Treat them like HTML element IDs: short, stable, and unique within the UI. Empty IDs are fine for cosmetic widgets you'll never look up (separators, spacers, decorative labels).

Ahead

With the model in mind, the next three chapters cover the three things every TUI developer fights with: layout (where things go), styling (how they look), and events (what they do).

Next: Layout