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
),
)
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.
↓
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.
↓
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.
↓
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.
The spatial properties that control how components are sized and positioned.
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.
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.
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.
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.
↓
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),
)
})
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.
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
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")
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)))