01
Every glyph view goes through two distinct phases. Build happens once when you call SetView() — the declarative tree is compiled into a flat array of operations via reflection and type switches. Execute happens every frame — it walks the compiled ops and dereferences pointers to read current state. No tree rebuilds, no diffing, no allocation in steady state.
Once
SetView()
declarative tree in
Compile
compile()
type switch (~50 cases), reflection
Store
[]Op + []Geom
flat arrays, pointer offsets
Each frame
Execute()
deref pointers → buffer
Flush
Screen.Flush()
diff → single syscall
The composable API surface — VBox.Gap(2).Border(BorderRounded) — is purely a build-time convenience. At runtime, the template is just a flat array walk with pointer reads. Values exist in three variants: static (embedded literal), pointer (dereferenced each frame), and offset (ForEach element base + unsafe.Pointer arithmetic).
SetView( // build: compiled once VBox.Gap(2).Border(BorderRounded)( Text(&title), // pointer: read each frame Text("static label"), // static: embedded in op ), )
02
Rendering is a four-phase pipeline that runs on every frame, designed for terminal workloads. Each phase operates directly on flat Op and Geom arrays — no tree rebuilds, no node allocation. Components outside the viewport are culled before rendering, and the final flush diffs against the previous frame to emit only the cells that actually changed.
↓ top-down
Width Distribution
Parent distributes available width to children. Fixed widths and percentages first, then remaining space split by flex grow. HBox horizontal flex is fully resolved here.
↑ bottom-up
Layout
Deepest nodes first. Leaf nodes measure content height; containers sum children plus gaps, borders, margin. Each node saves its natural ContentH before any flex expansion.
↓ top-down
Flex Grow
Children with Grow() expand along the parent's main axis to fill remaining space — vertically in a VBox, horizontally in an HBox. Siblings are repositioned to account for the new sizes.
↓ top-down
Render
Walk the ops from root, write cells to the buffer at computed absolute positions. Borders, text, fills — one pass with final x/y/w/h coordinates.
Flex grow is a separate phase because it needs the parent's final size, which flows top-down from the screen dimensions. layout() runs once, saves each node's natural size, and flex grow distributes the remaining space without re-running layout.
03
The spatial properties that control how components are sized and positioned.
VBox — vertical
Child A
Child B
Child C
HBox — horizontal
A
B
C
Margin is outside the border — the space between this component and its siblings. Border wraps the content area. Gap is the spacing a container inserts between its children.
parent container
margin
border
Title
Text("hello")
↕ gap
Text("world")
↕ gap
Text("!")
VBox.Margin(1).Border(BorderRounded).Title("Title").Gap(1)( Text("hello"), Text("world"), Text("!"), )
Grow controls how remaining space is distributed after fixed-size children are measured. Without Grow(), a component takes exactly its content size. With it, the component expands into whatever space is left over. The number you pass is a ratio, not a pixel value.
One child with Grow(1) — it gets all the remaining space:
Text("header") 1 row
List(&items).Grow(1) all remaining
Text("footer") 1 row
VBox( Text("header"), List(&items).Grow(1), Text("footer"), )
Two children with equal Grow(1) — they split the remaining space 50/50:
Text("header") 1 row
List(&items).Grow(1) 50%
LayerView(&log).Grow(1) 50%
VBox( Text("header"), List(&items).Grow(1), LayerView(&log).Grow(1), )
Unequal ratios — Grow(1) vs Grow(2) — the space is split proportionally. Grow(2) gets twice as much of the remaining space as Grow(1):
Text("header") 1 row
sidebar.Grow(1)
content.Grow(2)
VBox( Text("header"), HBox.Grow(1)( sidebar.Grow(1), // 1 part content.Grow(2), // 2 parts ), )
The number doesn't mean anything on its own — Grow(1) and Grow(1) is the same split as Grow(100) and Grow(100). It's the ratio between siblings that matters. Think of it as "parts of the remaining pie."
FitContent() is the inverse — it tells a container to shrink to its children's measured size instead of filling available space. WidthPct(0.5) sets width as a fraction of the parent. Size(w, h) sets both dimensions explicitly.
04
There's no reactivity in glyph. No subscriptions, no change detection. When the framework renders, it dereferences your pointers and reads whatever value is there right now. That's it.
title := "Hello" app.SetView(Text(&title)) // captures the pointer, not the value title = "World" // next render shows "World"
Pass a *string and the text updates when the string changes. Pass a string literal and it's static forever. Same pattern for *int, *bool, *[]T — anything behind a pointer is live, anything without one is baked in at compile time.
There's no state diffing, no change detection. You mutate your variables directly, and the next render reads current values through pointers. Renders happen when you return from a key handler, or when you call RequestRender() from a goroutine. That's the whole model.
Because there is no propagation mechanism, problems like render thrashing, cascading updates, and stale intermediate states do not apply. Nothing stops you building reactive patterns on top - an observer that calls RequestRender() when a channel fires is a few lines of code. glyph just doesn't ship that complexity by default.
05
ForEach, If, and Switch each compile to exactly one Op in the parent's flat array. Their content lives in separate sub-templates — self-contained *Template instances with their own ops, geom, and byDepth arrays. From the parent's perspective, they're opaque single-slot ops.
Parent ops[]
... OpText OpForEach OpText ...
one slot per dynamic node
Sub-template
own ops[] + geom[] + byDepth
self-contained mini-template
ForEach is the most interesting. The render function is called once at compile time against a dummy element. Any pointers into that dummy get converted to offset-based ops via unsafe.Pointer arithmetic — the offset from the element base address to the field pointer is stored instead of the pointer itself.
compile
Dummy element
Create a temp T, call render function once. Pointers within the dummy's memory range become OpTextOff with offset = ptr - dummyBase. One sub-template compiled, shared across all items.
each frame
Layout per item
Read live slice header (Data, Len) via unsafe.Pointer. For each item: swap elemBase to the real element, run distributeWidths + layout on the sub-template. Store position in iterGeoms[i] — the sub-template's geom is scratch.
render
Offset resolve
OpTextOff resolves to (*string)(elemBase + offset) for each element. Same compiled template, different data. Per-item positions read from iterGeoms.
If compiles both branches into separate sub-templates at build time. At runtime the condition is evaluated and only the active branch is laid out and rendered. The inactive branch costs nothing — it's not even visited.
Switch creates N+1 sub-templates (one per case + default). getMatchIndex() linear-scans to find the matching case. Only the winner is laid out.
None of these participate in the parent's byDepth layout pass directly. They're measured inline by their parent container when it encounters them during the child scan in layoutContainer. The sub-templates run their own complete layout pipeline internally — distributeWidths + layout — but this is scoped to the sub-template's own ops array, not the parent's.
ForEach(&items, func(item *Item) any { return HBox.Gap(2)( Text(&item.Name), Text(&item.Status), ) })
06
Input is managed by riffkey, a router stack that supports vim-style multi-key sequences, modal layers, and count prefixes. Components declare their bindings as data via BindNav(), BindToggle(), etc. — these are wired at compile time, not at runtime.
A keypress follows this path:
keystroke
Router (top of stack)
pattern match
handler()
render
The router stack enables modal input. Push a new router for a dialog; it captures all keys. Pop it when done — the previous context resumes exactly where it was. Each named view gets its own router, swapped atomically by Go().
Dialog router
← active (captures all keys)
View "editor" router
paused
App base router
paused
app.Handle("q", app.Stop) // single key app.Handle("gg", goToTop) // multi-key sequence app.Handle("j", focusDown) // chord + key app.Handle("dd", deleteItem) // modal: push router for confirm dialog app.Push(confirmRouter) // ...later app.Pop()
07
Jump labels bring vim-easymotion to the TUI. Press a trigger key and every jump target in the view gets a short label overlay. Type the label and the target's callback fires. Any component can be a target.
Trigger
JumpKey("space")
enter jump mode
Render
labels appear
targets collect, labels assigned
Select
type "a" → callback()
exit jump mode
Wrap any component with Jump() to make it a target. The framework handles label assignment and positioning during the render pass.
app.JumpKey("space") app.SetView( VBox.Gap(1)( Jump(Text("Inbox"), func() { section = "inbox" }), Jump(Text("Drafts"), func() { section = "drafts" }), Jump(Text("Sent"), func() { section = "sent" }), ), )
app.SetJumpStyle() controls how labels look. For dynamic targets that aren't wrapped with Jump(), use app.AddJumpTarget() with coordinates, a callback, and a style during a render callback.
08
Applications with multiple screens use named views. Each view has its own compiled template and its own riffkey router — switching views swaps both atomically. The compiled template and key handlers swap atomically.
Define
app.View("list", ...)
template + handlers
Define
app.View("detail", ...)
template + handlers
Switch
app.Go("detail")
swap template + router
For overlays and dialogs, PushView() layers a modal on top of the current view. The modal's handlers take precedence. PopView() peels it off — the previous view and its input context resume immediately.
"confirm" (modal)
← PushView
"detail"
← Go("detail")
"list"
RunFrom("list")
app.View("list", VBox( Text("Items"), List(&items).BindNav("j", "k"), )).Handle("n", func() { app.Go("detail") }) app.View("detail", VBox( Text(&detail), )).Handle("b", func() { app.Go("list") }) app.RunFrom("list")
09
When content is taller than the viewport, Layer provides a pre-rendered off-screen buffer with scroll management. Content is rendered into the full-size buffer once, then the visible portion is blitted to the screen each frame. The layer only re-renders when the viewport width changes.
Buffer
line 0: package main
line 1: import . "glyph"
line 2:
line 3: func main() {
line 4: app, _ := NewApp()
line 5: app.SetView(
line 6: VBox(
line 7: Text("hello"),
line 8: ),
line 9: )
line 10: }
Viewport (scroll=0, height=4)
package main
import . "glyph"
func main() {
ScrollDown(n) / ScrollUp(n) shift the window. ScreenCursor() translates buffer coordinates to screen position.
LogC extends Layer by reading lines from an io.Reader in the background. Auto-scroll follows new content by default. FilterLogC adds live fzf-style filtering on top. Both re-render lazily — only when content changes or the viewport width changes.
Write
io.Reader → LogC
background line ingestion
Store
Layer buffer
full content, off-screen
Blit
viewport slice
visible rows → screen buffer
Flush
diff + write
only changed cells
layer := NewLayer() layer.Render = func() { buf := layer.Buffer() for i, line := range lines { buf.WriteString(0, i, line, Style{}) } } app.SetView(VBox(LayerView(layer).Grow(1)))
Install
Get Started
$ go get github.com/kungfusheep/glyph@latest copied