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,
walking the compiled ops and dereferencing pointers to read current state.
The frame cost is constant regardless of what changed.
Once
SetView()
declarative tree in
→
Compile
compile()
type switch (~50 cases), reflection
→
Store
[]Op + []Geom
flat arrays, pointer offsets
→
Each update
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 a flat array walk with pointer reads.
Values exist in three variants. Static (embedded literal),
pointer (dereferenced each update), and offset
(ForEach element base + unsafe.Pointer arithmetic).
SetView( // build: compiled once
VBox(
Text(&title), // pointer: read each update
Text("static label"), // static: embedded in op
),
)
02
Rendering is a four-phase pipeline that runs on every update. Each phase operates directly on flat Op and Geom
arrays. 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 and horizontally in an HBox. Siblings are repositioned to
account for the new sizes.
↓
↓ top-down
Render
Walk the ops from root, writing cells to the buffer at their computed positions. One pass covering borders, text, and fills.
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. Padding is the space between the border
and the children. Gap is the spacing a container inserts between its children.
parent container
margin
border
Title
padding
Text("hello")
↕ gap
Text("world")
↕ gap
Text("!")
VBox stacks children vertically
VBox.Gap(1)(
Text("hello"),
Text("world"),
Text("!"),
)
HBox places them side by side
HBox.Gap(2)(
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) gets all the remaining space:
Text("header")
1 row
List(&items).Grow(1)
all remaining
Text("footer")
1 row
VBox(
Text("header"),
VBox.Grow(1).Border(BorderRounded)(
List(&items),
),
Text("footer"),
)
Two children with equal Grow(1) split the remaining space 50/50:
Text("header")
1 row
List(&items).Grow(1)
50%
LayerView(&log).Grow(1)
50%
VBox(
Text("header"),
VBox.Grow(1).Border(BorderRounded).Title("list")(
List(&items),
),
VBox.Grow(1).Border(BorderRounded).Title("log")(
Text("started on :8080").FG(BrightBlack),
),
Text("footer"),
)
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)(
VBox.Grow(1).Border(BorderRounded).Title("sidebar")(
Text("nav one"),
Text("nav two"),
),
VBox.Grow(2).Border(BorderRounded).Title("content")(
Text("main content area"),
Text("with more detail"),
),
),
Text("footer"),
)
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 is no reactivity in glyph. State is plain Go. The framework dereferences your pointers at frame time
and reads whatever is there. Mutations take effect on the next render without any
subscription or notification mechanism.
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 read each update; anything without one is fixed at compile time.
Mutate your variables directly. The next render reads current values through the pointers
you registered. Renders happen when you return from a key handler, or when you call
RequestRender() from a goroutine.
Nothing stops you building reactive patterns on top. An observer that calls
RequestRender() when a channel
fires is often just a few lines of code.
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 update
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.
Switch creates N+1 sub-templates (one per case + default).
getMatchIndex() linear-scans to find the matching case. Only the matching case is laid out and rendered.
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).FG(
Switch(&item.Status).
Case("running", Yellow).
Case("healthy", Green).
Case("stopped", Red).
Default(BrightBlack),
),
)
})
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, swapped atomically when you call
Go().
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 update. The layer only re-renders
when the viewport width changes.
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)))
10
ScreenEffect declares full-screen post-processing passes. Place it anywhere in the
view tree. It takes zero layout space. After all content renders into the cell buffer, each
effect runs in declaration order, mutating cells in-place before the diff flush.
Execute
tree renders
content → cell buffer
→
Collect
ScreenEffect nodes
gathered during tree walk
→
Apply
effects run in order
mutate cells in-place
→
Flush
diff + write
only changed cells
Each effect receives the full cell buffer and a
PostContext containing terminal
dimensions and the terminal's detected foreground/background colours.
// subtle colour grade + edge darkening
ScreenEffect(
SETint(Hex(0xFF6600)).Strength(0.1),
SEVignette().Strength(0.5),
)
Multiple effects compose by chaining in a single node or across separate
ScreenEffect
nodes. They apply left-to-right, top-to-bottom.
Built-in effects fall into two categories. Polish effects add ambient character:
SEVignette darkens edges with quadratic falloff, SETint colour-grades
toward a target hue, SEDesaturate strips saturation, SEContrast boosts
contrast, and SEBloom bleeds bright cells into neighbours. Modal effects draw
attention: SEFocusDim dims everything outside a referenced node,
SEDropShadow radiates darkening outward from a focus region, and
SEGlow samples and spills colour outward.
Custom per-cell transforms.
EachCell is a per-cell callback that
receives coordinates, the cell, and the post-processing context. It runs in parallel across quadrants of
the buffer. This is the fragment-shader equivalent for terminal cells.
// diagonal flip band with brand gradient
ScreenEffect(
EachCell(func(x, y int, c Cell, ctx PostContext) Cell {
diag := float64(x)/float64(ctx.Width) + float64(y)/float64(ctx.Height)
t := diag / 2.0
c.Style.FG = lerpGradient(t) // #682850 → #c44040 → #ff6060
dist := math.Abs(diag - 0.7)
if dist < 0.2 {
if f, ok := flipMap[c.Rune]; ok {
c.Rune = f
}
}
return c
}),
)
// brand gradient across columns: #c44040 → #ff6060 → #983848 → #682850
ScreenEffect(
EachCell(func(x, y int, c Cell, ctx PostContext) Cell {
t := float64(x) / float64(ctx.Width-1)
c.Style.FG = lerpBrandGradient(t)
return c
}),
)
// wave — shift each row by a sine offset (full-buffer effect)
type waveEffect struct{ phase *float64 }
func (w waveEffect) Apply(buf *Buffer, ctx PostContext) {
for y := range ctx.Height {
offset := int(4 * math.Sin(float64(y)*0.7 + *w.phase))
shiftRow(buf, y, offset, ctx.Width)
}
}
// collapse — radial block fill from center
ScreenEffect(
EachCell(func(x, y int, c Cell, ctx PostContext) Cell {
dx := (float64(x) - float64(ctx.Width)/2) / float64(ctx.Width/2)
dy := (float64(y) - float64(ctx.Height)/2) / float64(ctx.Height/2)
dist := math.Sqrt(dx*dx + dy*dy)
if dist < radius {
blocks := []rune{' ', '░', '▒', '▓', '█'}
c.Rune = blocks[int((1-dist/radius)*4)]
}
return c
}),
)
Blend modes wrap any effect with compositing:
WithBlend(BlendScreen, effect) applies Photoshop-style blending (Multiply, Screen,
Overlay, Add, SoftLight, ColorDodge, ColorBurn). WithQuantize snaps RGB values
to step-size buckets, reducing unique colours and shrinking diff output.
Focus and Dodge. Most effects accept
.Focus(&ref) and
.Dodge(&ref) to target or exclude a specific node. Bind a
NodeRef to any component, then pass its pointer. Focus constrains the effect to
radiate from that node. Dodge leaves it untouched while the rest of the screen is affected.
var selRef NodeRef
VBox(
List(&items).SelectedRef(&selRef).BindVimNav(),
ScreenEffect(SEFocusDim(&selRef)),
)
Effects toggle conditionally with
If(). When the condition is false, the
ScreenEffect node is skipped during tree execution and contributes nothing.
dimmed := false
app.SetView(
VBox(
Text("content"),
If(&dimmed).Then(
ScreenEffect(
SEDesaturate(),
SEDimAll(),
),
),
),
)
app.Handle("d", func() { dimmed = !dimmed })
11
Animation is declared inline. Wrap any property value with
Animate and
the framework interpolates toward it whenever it changes. The animation is part of
the property declaration, not a separate system.
// any property that accepts a pointer can animate
VBox.Height(Animate(&targetHeight))
// when targetHeight changes, the height interpolates smoothly
Configure duration and easing.
Animate returns a reusable function,
so you define an animation style once and apply it to any property.
smooth := Animate.Duration(300 * time.Millisecond).Ease(EaseOutCubic)
VBox(
sidebar.Width(smooth(&sidebarW)),
content.Height(smooth(&contentH)),
Text("status").FG(smooth(&statusColor)),
)
Conditions compose with animations. A single expression declares the values,
the condition, and the transition between them.
// sparkline expands to 26 rows or collapses to 1, animated
Sparkline(data).Height(
Animate.Duration(200 * time.Millisecond).Ease(EaseOutCubic)(
If(&expanded).Then(int16(26)).Else(int16(1)),
),
)
From sets an initial value so the animation starts immediately on first render.
OnComplete fires once when the target is reached.
// toggle a vignette on and off with a smooth transition
vignetteOn := false
SEVignette().Strength(
Animate.Ease(EaseOutCubic)(
If(&vignetteOn).Then(0.8).Else(0.0),
),
)
app.Handle("v", func() { vignetteOn = !vignetteOn })
Built-in easing functions cover Quad, Cubic, Quart, Quint,
Sine, Expo, Circ, Back, and Bounce. Each has In, Out, and InOut variants. Linear
is the default. Any
func(float64) float64 works as a custom easing.
Colors and styles interpolate. An animated
FG lerps through RGB space.
An animated Style interpolates foreground, background, and fill colours simultaneously.
Install
Get Started
$
go get github.com/kungfusheep/glyph@latest
copied