Skip to content
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.
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)))
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