Skip to content
01
Requires Go 1.21 or later.
$ mkdir myapp && cd myapp $ go mod init myapp $ go get github.com/kungfusheep/glyph@latest
The dot import is optional, but used throughout these examples. It brings all glyph identifiers into scope. Write VBox() instead of glyph.VBox(). A namespaced import works equally well; the dot form keeps examples shorter.
package main import . "github.com/kungfusheep/glyph"
02
State lives in plain Go variables. Any type, any structure. Pass a pointer and the framework reads current values on each update. Keyboard handlers are plain functions.
package main import . "github.com/kungfusheep/glyph" func main() { count := 0 app := NewInlineApp() app.SetView( VBox( Text(&count), Text("↑/↓ to count, enter to quit"), ), ). Handle("<Up>", func() { count++ }). Handle("<Down>", func() { count-- }). Handle("<Enter>", app.Stop). ClearOnExit(true). Run() println("You selected", count) }
NewInlineApp() renders at the cursor position without taking over the screen. SetView() compiles the declarative tree once into a flat list of operations. At runtime, glyph walks that list and dereferences pointers to read current state. The frame cost is proportional to what's visible, not to what changed.
Text(&count) takes a pointer. glyph dereferences it on every frame.
Handle(key, fn) registers a global key binding. Plain characters ("q"), special keys ("<Up>", "<Esc>", "<Enter>"), modifiers ("<C-c>"). The callback runs on keypress and glyph re-renders.
03
Panels & Grow
VBox stacks children vertically, HBox places them side by side. Both measure and distribute space in a single pass.
HBox( VBox.Grow(1).Border(BorderRounded).Title("left")( Text("panel one"), ), VBox.Grow(2).Border(BorderRounded).Title("right")( Text("panel two takes 2/3 width"), ), )
.Grow(n) controls how remaining space is distributed. Grow(2) gets twice the space of Grow(1). Without Grow, elements shrink to fit their content.
Borders, Gap & Space
.Border(style) wraps the container in a box. Built-in styles are BorderRounded, BorderSingle, and BorderDouble. .Title(s) labels the top edge. .Gap(n) adds spacing between children. Space() expands to fill remaining space; use it to push siblings to opposite ends.
HBox.Gap(2)( Text("ready").FG(Green), Text("3 tasks").Bold(), Space(), Text("q: quit").FG(BrightBlack), )
Custom Layout
Arrange gives you full control over layout. Provide a function that receives child sizes and available space, return exact positions.
grid := Arrange(func(children []ChildSize, w, h int) []Rect { cols := 3 cellW := w / cols cellH := 3 rects := make([]Rect, len(children)) for i := range children { rects[i] = Rect{ X: (i % cols) * cellW, Y: (i / cols) * cellH, W: cellW, H: cellH, } } return rects }) grid( VBox.Border(BorderRounded)(Text("alpha")), VBox.Border(BorderRounded)(Text("beta")), VBox.Border(BorderRounded)(Text("gamma")), VBox.Border(BorderRounded)(Text("delta")), VBox.Border(BorderRounded)(Text("epsilon")), VBox.Border(BorderRounded)(Text("zeta")), )
04
Conditional
If takes a pointer to a bool. glyph dereferences it each frame and picks the matching branch.
online := true If(&online).Then( Text("● connected").FG(Green), ).Else( Text("● offline").FG(Red), )
.Eq() matches a specific value. Works with any comparable type. IfOrd adds .Gt(), .Lt(), .Gte(), .Lte() for ordered types.
If(&status).Eq("active"). Then(Text("online").FG(Green)). Else(Text("offline").FG(Red)) IfOrd(&cpu).Gt(80). Then(Text("CRIT").FG(Red).Bold()). Else(IfOrd(&cpu).Gt(50). Then(Text("WARN").FG(Yellow)). Else(Text("OK").FG(Green)), )
Inline Values
If works anywhere a value is expected, not only as a child in a container. Use it to drive layout properties, dimensions, and styling at render time.
// dynamic height based on state VBox.Height( If(&expanded).Then(30).Else(10), )( Text("content"), ) // dynamic width on a progress bar Progress(&val).Width( If(&detailed).Then(40).Else(20), )
Matching
Switch branches on a value with named cases. Default catches unmatched values.
Switch(&mode). Case("edit", Text("editing")). Case("preview", Text("previewing")). Default(Text("idle"))
Match is first-match-wins with operators. Cases evaluate top-to-bottom; the first true case renders. Use Eq, Gt, Lt, Gte, Lte, Ne for comparisons and Where for predicates.
Match(&cpu, Gt(90.0, Text("CRITICAL").FG(Red)), Gt(70.0, Text("WARNING").FG(Yellow)), Lte(70.0, Text("OK").FG(Green)), ) Match(&query, Eq("", Text("type to search")), Where(func(q string) bool { return len(q) < 3 }, Text("keep typing..."), ), Where(func(q string) bool { return len(q) >= 3 }, Text("searching"), ), )
Iteration
ForEach iterates a slice. The callback receives a pointer to each item. Conditions work per-item. Style, show, or hide based on each element's state.
ForEach(&items, func(item *Todo) any { return HBox.Gap(2)( Checkbox(&item.Done, ""), If(&item.Done).Then( Text(&item.Title).Dim(), ).Else( Text(&item.Title), ), ) })
05
List takes a pointer to a slice. .OnSelect() fires when the user presses enter. The app below lets you pick a file, then exits and prints it.
package main import ( "fmt" . "github.com/kungfusheep/glyph" ) func main() { app := NewApp() files := []string{"main.go", "go.mod", "README.md", "config.toml", "Makefile"} var choice string app.SetView( VBox.Border(BorderRounded).Title("open")( List(&files).OnSelect(func(f *string) { choice = *f app.Stop() }).BindVimNav(), ), ).Run() fmt.Println("opening", choice) }
.BindVimNav() adds j/k, <C-d>/<C-u> to page, g/G for start/end. FilterList wraps the same API with a built-in fuzzy search input.
06
app.Handle(key, fn) registers global key bindings, active regardless of focus. Plain characters, special names, and modifier combos all work.
app.Handle("q", app.Stop) app.Handle("<C-c>", app.Stop) app.Handle("<Esc>", app.Stop) app.Handle("a", func() { /* add item */ }) app.Handle("d", func() { /* delete item */ }) app.Handle("<C-s>", func() { /* save */ })
List.Handle(key, fn) fires with the selected item already resolved. No index to track.
List(&tasks).Render(func(t *Task) any { return HBox.Gap(1)( If(&t.Done).Then(Text("[x]")).Else(Text("[ ]")), Text(&t.Title), ) }).Handle("<Space>", func(t *Task) { t.Done = !t.Done }).BindVimNav()
.BindVimNav() wires j/k, <C-d>/<C-u>, and g/G in a single call. For custom keys, or for components like AutoTable that support page-level jumps, use .BindNav(down, up) and .BindPageNav(down, up).
AutoTable(&procs).Scrollable(20). BindNav("<C-n>", "<C-p>"). BindPageNav("<C-d>", "<C-u>")
07
Form auto-aligns labels, manages focus between fields, and wires validation. Each Field pairs a label with an input component.
name := "Pete" email := "[email protected]" role := 0 agree := true Form.LabelBold().OnSubmit(register)( Field("Name", Input(&name). Validate(VRequired, VOnBlur)), Field("Email", Input(&email). Validate(VEmail, VOnBlur)), Field("Role", Radio(&role, "Admin", "User", "Guest")), Field("Terms", Checkbox(&agree, "I accept")), )
Validation
Built-in validators: VRequired, VEmail, VMinLen, VMaxLen, VMatch, VTrue. Trigger on VOnBlur (field loses focus) or VOnSubmit (form submitted). Errors surface inline next to the field.
08
AutoTable takes a pointer to a slice of structs. Columns are inferred from field names.
type Service struct { Name string Status string CPU string } services := []Service{ {"api", "running", "12%"}, {"worker", "running", "8%"}, {"cache", "stopped", "0%"}, {"db", "running", "23%"}, } AutoTable(&services). Sortable(). Scrollable(10). BindVimNav()
.Sortable() adds column-click sorting. .Scrollable(n) sets the visible row count with a scrollbar. Column formatting is automatic; override with .Format() for custom display.
09
Colours & Attributes
Styles chain directly on elements.
Text("error").FG(Red).Bold() Text("muted").FG(BrightBlack).Dim() Text("success").FG(Green) Text("warning").FG(Yellow).Bold()
.FG(color) and .BG(color) accept the standard ANSI names (Red, Green, Yellow, Blue, Magenta, Cyan, White, Black) and their Bright variants. For true colour: Hex() and RGB().
Text("branded").FG(Hex(0xc44040)) Text("custom").BG(RGB(20, 20, 40))
.Bold(), .Dim(), .Italic(), and .Underline() set text attributes. Any combination is valid; glyph merges them into a single ANSI sequence per cell.
Themes
.CascadeStyle() propagates a base style to all children. Set it on a container; glyph applies it to every nested widget on each render pass. Three built-in themes are provided (ThemeDark, ThemeLight, ThemeMonochrome), each with Base, Muted, Accent, Error, and Border slots.
theme := ThemeDark VBox.CascadeStyle(&theme.Base).Border(BorderRounded).BorderFG(theme.Border.FG)( Text("normal text"), Text("muted").Style(theme.Muted), Text("accent").Style(theme.Accent), Text("error!").Style(theme.Error), )
Custom themes use the same ThemeEx struct.
myTheme := ThemeEx{ Base: Style{FG: White}, Muted: Style{FG: BrightBlack}, Accent: Style{FG: Cyan, Attr: AttrBold}, Error: Style{FG: Red}, Border: Style{FG: BrightBlack}, }
.BorderFG(color) colours the border independently.
VBox.Border(BorderRounded).BorderFG(Cyan).Title("status")( Text("all systems go").FG(Green), )
Custom Rendering
Widget is the escape hatch for fully custom rendering. Provide a measure function and a render function. glyph calls them during layout and draw.
VBox.Border(BorderRounded).Title("dashboard")( HBox.Gap(2)( Text("metrics").Bold(), Space(), Text("live").FG(Green), ), HRule(), HBox( VBox.Grow(1).Border(BorderRounded).Title("chart")( Widget( func(availW int16) (w, h int16) { return availW, 4 }, func(buf *Buffer, x, y, w, h int16) { // draw bar chart directly into the buffer }, ), ), VBox.Grow(1).Border(BorderRounded).Title("status")( Leader("uptime", "99.9%"), Leader("errors", "0"), Leader("latency", "12ms"), ), ), )