Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZeroTUI

A retained-mode, low-allocation terminal UI library for Go.

Update data frequently, repaint only what changed, and keep the terminal renderer out of the allocation-heavy path.

Most terminal applications spend far more CPU time rebuilding text the user cannot even see change than they spend actually changing it — a whole frame's worth of fmt.Sprintf calls just to update one number in the corner of the screen. ZeroTUI takes a different approach: widgets draw into a retained cell buffer, a compositor tracks exactly which cells got dirty, and the terminal writer emits only the runs that actually changed. The rest of the screen is simply left alone, because it didn't need anything.

Latest recorded HFT stress results

The cross-framework results below are from the latest supplied 30-second run, using the same deterministic workload for ZeroTUI, Bubble Tea v2 + Lip Gloss v2, and Ratatui. The target was 1,000 logical ticks/sec with rendering capped at 60 FPS.

Benchmark host: Linux amd64, Intel Xeon E312xx (Sandy Bridge, IBRS update), root@ubuntu. Sustained container limits: 4 CPUs, 2 GB RAM.

go version go1.27.1 linux/amd64
rustc 1.98.0 (88d9e12ae 2026-08-18)
cargo 1.98.0 (797e8a9bc 2026-08-05)
github.com/ZeroGCDev/zerotui
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
ratatui v0.30.2

Scope of these numbers (read this first):

  • The sustained comparison is an idiomatic cross-framework comparison, not a controlled equivalent-code comparison. Each dashboard is written in its own library's natural style, so per-framework formatting strategy is a real part of the result.
  • Allocation accounting differs by runtime: Go targets report Go runtime TotalAlloc deltas; Ratatui reports a custom CountingAllocator's layout.size() sum. These are not the same definition. "No GC" does not mean "no allocation" — Rust frees deterministically via drop(), but format! still calls the allocator.
  • Bubble Tea's "worst density" tick counts are a counter artifact (see footnote), not a throughput measurement.
  • No framework recorded actual frames rendered, so "strict 60 FPS" is a requested cadence, not a verified one.
Metric ZeroTUI Bubble Tea v2 Ratatui
Realistic sustained ticks/sec (20 rows) 973.5 910.0 ~1,000
Realistic sustained ticks/sec (500 rows) 979.9 900.2 ~1,000
Realistic allocation rate (20 rows) 35.5 KB/s 26.3 MB/s 7.6 MB/s
Realistic allocation rate (500 rows) 35.3 KB/s 25.5 MB/s 7.5 MB/s
Full-frame render (20-row headless microbench) ~79.5 µs ~96.8 µs ~253.9 µs
Full-frame render (500-row headless microbench) ~79.6 µs ~88.0 µs ~258.5 µs
Go GC cycles (realistic sustained) 0 369 / 120 N/A
  • Throughput: Ratatui is the fastest raw sustained logical-tick processor in the recorded run. ZeroTUI stays close to the 1 kHz target in realistic mode and ahead of Bubble Tea on both realistic row counts. Bubble Tea's worst-density tick counters over-count by a factor of rows; corrected logical throughput is ~910 ticks/sec (20 rows) and ~894 ticks/sec (500 rows).
  • Allocation (definition caveat applies): ZeroTUI's realistic allocation traffic is roughly 99.87% lower than Bubble Tea and roughly 99.5% lower than Ratatui in these measurements. The ZeroTUI figure reflects its zero-allocation numfmt render path; the other two format with fmt.Sprintf / format! per tick. Ratatui's allocation is transient — freed deterministically by drop() — which is why it shows allocation traffic but zero GC pauses.
  • Rendering (headless microbench): ZeroTUI's full-frame render was about 18% faster than Bubble Tea at 20 rows and about 10% faster at 500 rows. Ratatui's measured frame time was substantially higher in this particular workload.
  • Worst-case 500-row allocation: ZeroTUI measured 15.3 MB/s, the same order as Ratatui's 14.3 MB/s, and about 71% below Bubble Tea's 52.7 MB/s.

Table of contents

Features

None of this means ZeroTUI is the right tool for every terminal app — a settings screen that redraws twice a minute doesn't need any of it. It earns its keep specifically when:

  • values change many times per second;
  • only small regions of the screen change between frames;
  • predictable memory behavior matters;
  • dashboards contain large tables or virtualized data sets;
  • terminal resizing and mouse interaction must stay responsive;
  • you want a small, Go-native layout and widget stack rather than a browser or CGO dependency.

Installation

go get github.com/ZeroGCDev/zerotui

Quick start

A ZeroTUI application needs three things:

  1. a widget,
  2. a layout root,
  3. an app.App.
Minimal example
package main

import (
    "github.com/ZeroGCDev/zerotui/app"
    "github.com/ZeroGCDev/zerotui/layout"
    "github.com/ZeroGCDev/zerotui/style"
    "github.com/ZeroGCDev/zerotui/widget"
)

func main() {
    hello := widget.NewLabel("Hello, ZeroTUI!")

    root := layout.Wrap(hello)

    app.New(root, style.NordTheme()).Run()
}

layout.Wrap converts a widget.Widget into a layout.Node. That distinction is important:

  • Widget — draws and optionally handles input.
  • Layout Node — decides where one or more widgets are placed.

Once Run() starts, ZeroTUI owns terminal input/rendering until the application exits.

A widget paints something:

label := widget.NewLabel("CPU: 42%")

A layout decides where that widget lives:

root := layout.FixedSize(
    layout.Wrap(label),
    30,
    3,
)
Interactive examples
package main

import (
    "fmt"

    "github.com/ZeroGCDev/zerotui/app"
    "github.com/ZeroGCDev/zerotui/layout"
    "github.com/ZeroGCDev/zerotui/style"
    "github.com/ZeroGCDev/zerotui/widget"
)

func main() {
    var enabled uint32

    status := widget.NewLabel("Notifications are OFF")
    toggle := widget.NewToggle("Notifications", &enabled)
    button := widget.NewButton("SHOW STATUS", func() {
        if enabled == 1 {
            status.SetText("Notifications are ON")
        } else {
            status.SetText("Notifications are OFF")
        }
    })

    root := layout.NewFlex(layout.Vertical,
        layout.Fix(layout.Wrap(widget.NewLabel("ZeroTUI")), 1),
        layout.Fix(layout.Wrap(toggle), 1),
        layout.Fix(layout.Wrap(button), 1),
        layout.Fix(layout.Wrap(status), 1),
    )

    if err := app.New(root, style.TokyoNightTheme()).Run(); err != nil {
        fmt.Println(err)
    }
}
package main

import (
	"fmt"
	"time"

	"github.com/ZeroGCDev/zerotui/app"
	"github.com/ZeroGCDev/zerotui/color"
	"github.com/ZeroGCDev/zerotui/layout"
	"github.com/ZeroGCDev/zerotui/style"
	"github.com/ZeroGCDev/zerotui/widget"
)

func main() {
	theme := style.CatppuccinMochaTheme()

	revenue := widget.NewStat("REVENUE", "$128.4K")
	revenue.Delta, revenue.Up = "+14.8%", true
	orders := widget.NewStat("ORDERS", "2,481")
	orders.Delta, orders.Up = "+8.2%", true
	latency := widget.NewStat("LATENCY", "3.8 ms")
	latency.Delta, latency.Down = "-0.6 ms", true

	cpu := widget.NewGauge("CPU")
	cpu.ValueFn = func() float64 { return 0.42 + 0.08*float64(time.Now().Unix()%5)/4 }
	cpu.WarnAt, cpu.DangerAt = .70, .90

	memory := widget.NewGradientBar(color.Cyan, color.Magenta, color.DimGray)
	memory.Value = .68

	traffic := widget.NewSparkline(40)
	for i := 0; i < 40; i++ {
		traffic.Push(40 + float64((i*i)%31))
	}

	health := widget.NewList([]string{
		"● API gateway       healthy",
		"● Database          healthy",
		"● Worker queue      healthy",
		"● Object storage    healthy",
		"● Search            healthy",
	})
	health.Background = &color.Panel

	badge := widget.NewBadge("LIVE")
	badge.Positive = true

	stats := layout.NewGrid(1, 3,
		layout.Wrap(revenue), layout.Wrap(orders), layout.Wrap(latency),
	)

	top := layout.BorderedRounded("KEY METRICS", stats, nil)
	chart := layout.BorderedRounded("TRAFFIC • LAST 40 SAMPLES", layout.NewFlex(
		layout.Vertical,
		layout.Fix(layout.Wrap(traffic), 4),
		layout.Fix(layout.Wrap(memory), 1),
	), nil)

	right := layout.BorderedRounded("SYSTEM HEALTH", layout.NewFlex(
		layout.Vertical,
		layout.Fix(layout.Wrap(badge), 1),
		layout.Flex1(layout.Wrap(health)),
		layout.Fix(layout.Wrap(cpu), 1),
	), nil)

	body := layout.NewSplit(layout.Horizontal, chart, right, .68)
	root := layout.NewFlex(layout.Vertical,
		layout.Fix(layout.Wrap(widget.NewLabel("  NOVA • OPERATIONS")), 1),
		layout.Fix(top, 4),
		layout.Flex1(body),
		layout.Fix(layout.Wrap(widget.NewLabel("  [q] quit")), 1),
	)

	if err := app.New(root, theme).Run(); err != nil {
		fmt.Println(err)
	}
}

Widgets & layout primitives

Widgets Layout primitives
Label · Button · Toggle · Slider · Gauge · Sparkline · Table · VirtualTable · List · VirtualList · Tabs · TextInput · Panel · PriceTicker · OrderBook · FastLogView · CommandPalette · Badge · Divider · Stat · Spinner · GradientBar · ScrollBar · ResizeHandle · CloseButton · Terminal · TextEditor · CodeEditor · MultiLineTextEditor · TreeView · HierarchicalTree · FilePicker · ShortcutHelpBar · TimeAndSales · Positions · Orders · PnL · LatencyMonitor · RiskMonitor · MarketStatus · OrderEntry Flex · Grid · Split · Stack · Responsive · Center · FixedSize · SizeBounds · Padding · Overlay · Modal · Bordered · BorderedRounded · ClosableRounded · Retained

Interactive widgets implement the focus and mouse contracts used by app.App, so applications do not need to build a separate routing layer for every component.

Examples

The fastest way to get a feel for ZeroTUI isn't reading about it — it's running it. Ten runnable showcases ship inside examples/. Each is a real, self-contained package main, so go run is all you need:

go run ./examples/showcase_dashboard
Image

showcase_dashboard is the one pictured above — a live operations console with closable panels, a scrolling order book, a price ticker, and a sparkline all updating at once:

  • Click x on a panel to close it
  • Use the mouse to scroll
  • Press 1 / 2 / 3 to reopen Controls / Market / Table
  • Press t to swap between the Nord and Tokyo Night themes live
  • Drag the dividers to resize each panel
  • Press q to quit
Image

The screenshot above is showcase_ide — the reference editor build described in the next section. Press Ctrl+c to quit.

The rest of the showcases are worth a look too — together they exercise almost every widget in the library:

Run it What it shows
go run ./examples/showcase_dashboard Closable panels, live order book, price ticker, sparkline, theme switching
go run ./examples/showcase_ide The full editor.Editor: file tree, tabs, syntax highlighting, embedded terminal
go run ./examples/showcase_trading Positions, orders, PnL, latency, risk, and market-status widgets side by side
go run ./examples/showcase_terminal Order book, price ticker, sparkline, and a live log feed in one operations layout
go run ./examples/showcase_layouts Panels and labels composed to demonstrate the layout primitives themselves
go run ./examples/showcase_controls A calm, single-accent screen of buttons, toggles, sliders, and text inputs
go run ./examples/showcase_settings A settings screen: toggles, sliders, badges, gradient bars, and dividers
go run ./examples/showcase_observability Gauges, stats, a spinner, a fast log view, and a command palette
go run ./examples/showcase_tasks Tables, tabs, a plain list, and a virtualized list working together
go run ./examples/showcase_static A quieter, mostly-static dashboard layout for lower-refresh-rate use cases

Every showcase quits with q or Ctrl+c unless noted otherwise on screen.

Editor & developer tooling

ZeroTUI includes an editor surface intended for real terminal-based development workflows, not just a text box. The high-level editor.Editor composes the reusable widget.CodeEditor with a file tree, tabs, terminal pane, search/settings panes, and resizable splits.

Code editing capabilities

  • File explorer: open, create, rename and delete files/folders, with a collapsible tree.
  • Tabs: multiple open files, active-tab switching, tab scrolling and close handling.
  • Editing: cursor movement, word movement, selections, cut/copy/paste, insertion/deletion, duplicate line, delete line, comment toggling, indentation/outdent, page navigation and goto-line.
  • History: document-level undo/redo with bounded history support.
  • Search: in-editor forward search and search-hit tracking.
  • Folding: brace-aware/code-aware fold ranges with hidden-line indexing.
  • Syntax highlighting: lightweight lexical highlighting without an AST; Go has a fused highlighter/fold scanner, while other language profiles use range-based scanning.
  • Large files: files at or above the large-document threshold use viewport token caching, so steady-state repaint work is proportional to the visible region instead of repeatedly highlighting the whole document.
  • Persistence: the editor.CodeViewer adapter saves through the host filesystem and preserves file permissions when available.
  • Embedded terminal: the editor can open a disposable PTY terminal beneath the code view, with resize and focus routing.
  • Mouse/keyboard interaction: the editor participates in ZeroTUI's normal focus and mouse contracts.

A clipboard note. Cut/copy/paste inside TextEditor, CodeEditor, and Terminal go through the clipboard package, which is intentionally process-local — it never touches the host OS clipboard, and never emits a clipboard escape sequence like OSC 52. Copying inside ZeroTUI and pasting into another terminal window will not work, and that's by design rather than an oversight. See Clipboard in the widgets reference for the two-function API.

Syntax coverage

The built-in profiles currently cover Go plus Python, JSON, YAML/YML, TOML, Rust, C/C++ headers and sources, Java, JavaScript/JSX, Bash/Shell, and Markdown. The syntax layer is intentionally presentation-oriented: it emits tokens for highlighting and folding rather than building an AST.

Documentation

What follows is the whole library, front to back: every widget, every layout primitive, and every application-level API, with runnable code next to each one rather than a bare function signature. It's organized so you can treat it like a reference, not a novel — expand only the sections you actually need, and skip the rest.

🎨 Colors, styles & theming

Colors

Create an RGB color:

red := color.RGB(220, 50, 47)

Use a named color:

blue := color.TokyoBlue

Styles: foreground, background and attributes

A style.Style contains:

type Style struct {
    Fg   color.Color
    Bg   color.Color
    Attr style.Attr
}

Create one:

s := style.New(color.White, color.Background)

Or directly:

s := style.Style{
    Fg: color.NordWhite,
    Bg: color.NordPanel,
}

Change foreground:

s = s.WithFg(color.NordCyan)

Change background:

s = s.WithBg(color.NordBackground)

Add attributes:

s = s.WithAttr(style.Bold)

Remove attributes:

s = s.WithoutAttr(style.Bold)

Supported terminal attributes:

style.Bold
style.Dim
style.Underline
style.Reverse
style.Blink
style.Italic

Built-in themes

Use a built-in theme:

theme := style.NordTheme()

Then:

app.New(root, theme).Run()

Other theme constructors:

style.TokyoNightTheme()
style.MatchaLatteTheme()
style.VaporwaveTheme()
style.MochaEspressoTheme()
style.DeepAbyssTheme()
style.NordTheme()
style.DraculaTheme()
style.CatppuccinMochaTheme()
style.RosePineTheme()
style.CyberpunkTheme()
style.AutumnTheme()
style.SynthwaveTheme()
style.SolarizedLightTheme()

Creating your own theme

Clone an existing theme:

theme := style.NordTheme().Clone()

Change selected color:

theme.Selected.Bg = color.RGB(70, 120, 190)
theme.Selected.Fg = color.White

Change panel:

theme.Panel.Bg = color.RGB(30, 34, 42)

Change title:

theme.Title.Fg = color.RGB(100, 200, 220)
theme.Title.Attr = style.Bold

Use it:

app.New(root, theme).Run()

ThemeOverride

Most standard visual widgets expose:

ThemeOverride *style.Theme

ZeroTUI lets a rendered component opt into its own *style.Theme through ThemeOverride. A component theme controls the visual palette for that component: text/foreground colours, backgrounds, borders, focus state, selection, semantic colours, titles and scrollbar roles. The application theme remains the default, so you only override the components that need a custom appearance.

TextEditor is the exception: it exposes ThemeOverride *style.EditorTheme, which embeds the normal style.Theme and adds editor/syntax roles. Use style.NewEditorTheme(...) or style.ZedEditorTheme() when customizing source-editor appearance.

base := style.TokyoNightTheme()
buttonTheme := *base
buttonTheme.Positive = buttonTheme.Positive.WithFg(color.TokyoGreen)
buttonTheme.Selected = buttonTheme.Selected.WithBg(color.TokyoBlue)

button := widget.NewButton("RUN", run)
button.ThemeOverride = &buttonTheme

Component typography can be controlled through the theme's foreground/background and terminal attributes such as Bold, Dim, Underline, Reverse and Blink.

Runtime theme switching

a.SetTheme(style.NordTheme())

For example:

a.OnKey = func(k input.Key) bool {
    if k.Type == input.KeyRune && k.Rune == 't' {
        a.SetTheme(style.DraculaTheme())
        return true
    }
    return false
}

This is a visual change only. Existing component ThemeOverride values continue to take precedence for components that intentionally use their own palette.

Widget background overrides

Many widgets expose:

Background *color.Color

nil means:

inherit the surface already behind the widget.

Example:

bg := color.RGB(30, 35, 45)

label := widget.NewLabel("Custom surface")
label.Background = &bg

For a shared color:

panelBg := color.NordPanel

label.Background = &panelBg
button.Background = &panelBg

This is preferable to making every component a different random color.

Foreground overrides

Widgets that explicitly support foreground overrides can use:

Foreground *color.Color

For example:

badge := widget.NewBadge("LIVE")

fg := color.NordCyan
badge.Foreground = &fg

For components without a dedicated Foreground field, use a style.Style where supported, or use a component ThemeOverride.

📐 Layout system

Padding

Padding reserves space around a child.

content := layout.Padding(
    layout.Wrap(widget.NewLabel("Hello")),
    2, // left
    1, // top
    2, // right
    1, // bottom
)

Exact component dimensions

Layout controls widget dimensions. Flex already supports fixed main-axis sizes with Fix(...) and flexible sizes with Flex1(...)/FlexN(...). It now also supports optional Item.Width and Item.Height cross-axis constraints. For an explicit width and height, use layout.FixedSize(...):

layout.Fix(layout.FixedSize(layout.Wrap(button), 32, 3), 3)
Fixed size

FixedSize clamps to the available terminal area and centers the component. It is a layout-time operation and therefore does not add render-loop allocations. Use FixedSize when a component should have a specific width and/or height.

box := layout.FixedSize(
    layout.Wrap(widget.NewLabel("Settings")),
    40,
    5,
)

Examples:

layout.FixedSize(child, 40, 0) // width 40, available height
layout.FixedSize(child, 0, 5)  // available width, height 5
layout.FixedSize(child, 40, 5) // exact 40x5 when space permits
SizeBounds

Use SizeBounds when a component needs minimum and maximum dimensions.

box := layout.SizeBounds(
    layout.Wrap(widget.NewLabel("Responsive")),
    20, // min width
    60, // max width
    3,  // min height
    8,  // max height
)

A zero maximum means unlimited.

layout.SizeBounds(child, 20, 0, 3, 0)

means:

minimum width  = 20
maximum width  = unlimited
minimum height = 3
maximum height = unlimited
Flex

Flex is the most useful general-purpose layout.

Create a vertical layout:

root := layout.NewFlex(
    layout.Vertical,
    layout.Fix(layout.Wrap(title), 1),
    layout.Flex1(layout.Wrap(body)),
)

Create a horizontal layout:

row := layout.NewFlex(
    layout.Horizontal,
    layout.Flex1(layout.Wrap(left)),
    layout.Flex1(layout.Wrap(right)),
)
Fixed Flex items
layout.Fix(node, 10)

The 10 is the size on the Flex main axis.

Horizontal:

┌──────────┬─────────────────────────┐
│ fixed 10 │ flexible                │
└──────────┴─────────────────────────┘

Vertical:

┌─────────────────────────────┐
│ fixed 3                     │
├─────────────────────────────┤
│ flexible                    │
│                             │
└─────────────────────────────┘
Flexible Flex items

One equal share:

layout.Flex1(node)

Weighted share:

layout.FlexN(node, 2)

Example:

row := layout.NewFlex(
    layout.Horizontal,
    layout.FlexN(layout.Wrap(left), 1),
    layout.FlexN(layout.Wrap(middle), 2),
    layout.FlexN(layout.Wrap(right), 1),
)

The remaining width is divided approximately:

left    = 25%
middle  = 50%
right   = 25%
Flex gaps

NewFlex defaults to a 1-cell gap.

form := layout.NewFlex(
    layout.Vertical,
    layout.Fix(layout.Wrap(a), 1),
    layout.Fix(layout.Wrap(b), 1),
)

Disable the gap:

form.Gap = 0

Use a larger gap:

form.Gap = 2

For compact terminal forms, Gap = 0 is often useful when the containing panel already provides grouping.

Width and height overrides inside Flex

A layout.Item can also have:

Width
Height

For example:

item := layout.Flex1(layout.Wrap(input))
item.Width = 40
item.Height = 3

This is useful when a flexible child should remain centered at a particular cross-axis size.

Grid layout

Grid creates equal-sized row/column cells.

grid := layout.NewGrid(
    2,
    3,
    layout.Wrap(a),
    layout.Wrap(b),
    layout.Wrap(c),
    layout.Wrap(d),
    layout.Wrap(e),
    layout.Wrap(f),
)

This produces:

┌──────┬──────┬──────┐
│  A   │  B   │  C   │
├──────┼──────┼──────┤
│  D   │  E   │  F   │
└──────┴──────┴──────┘

Use Grid for:

  • KPI cards
  • dashboards
  • control panels
  • fixed tile layouts
Split layout

Split creates two resizable panes.

split := layout.NewSplit(
    layout.Horizontal,
    layout.Wrap(left),
    layout.Wrap(right),
    0.5,
)

The final argument is the initial ratio for the first pane.

0.25 → first pane ~25%
0.50 → first pane ~50%
0.75 → first pane ~75%

The divider is mouse draggable.

Vertical split

Use:

layout.Vertical

for a top/bottom split:

split := layout.NewSplit(
    layout.Vertical,
    layout.Wrap(top),
    layout.Wrap(bottom),
    0.60,
)
Split minimum sizes

A Split exposes:

MinFirst
MinSecond

Example:

split.MinFirst = 20
split.MinSecond = 30

This prevents either pane becoming unusably narrow.

The default minimums are already conservative.

Stack

Stack places children on top of one another in the same area.

stack := layout.NewStack(
    layout.Wrap(background),
    layout.Wrap(content),
)

Later children are rendered above earlier children.

Use Stack for:

  • overlays
  • layered indicators
  • custom decorations
  • background + foreground compositions
Overlay

An Overlay displays a child conditionally.

overlay := layout.NewOverlay(
    func() bool {
        return modalVisible
    },
    layout.Wrap(modal),
)

Use it for:

  • popups
  • command palettes
  • temporary dialogs
  • contextual UI
Modal overlay

NewModal is a convenient centered overlay:

modal := layout.NewModal(
    func() bool { return modalVisible },
    layout.Wrap(dialog),
    60,
    15,
)

The child is displayed at the requested size with a dimming backdrop.

Centering

Use:

centered := layout.Center(
    layout.Wrap(widget.NewLabel("Centered")),
    40,
    5,
)

This creates a centered 40x5 area when the parent has enough space.

Bordered layout

Bordered is the layout-level way to put a titled border around a whole child layout.

panel := layout.Bordered(
    "Controls",
    layout.Padding(form, 2, 1, 2, 1),
    func() bool {
        return textInput.IsFocused()
    },
)

The third argument tells the border whether it should use the focused border style.

This is ideal when the contents are multiple widgets:

┌─ Controls ─────────────────────┐
│                                │
│  Toggle                        │
│  Slider                        │
│  Input                         │
│  [ APPLY ]                     │
│                                │
└────────────────────────────────┘
Rounded borders

Use:

layout.BorderedRounded(...)

when you want rounded corners.

Similarly:

layout.ClosableRounded(...)

creates a rounded closable panel.

Closable panels

A closable panel is useful for dashboards.

panel := layout.ClosableRounded(
    "Order Book",
    layout.Wrap(orderBook),
    func() bool {
        return orderBook.IsFocused()
    },
    func() {
        // optional close callback
    },
)

Close from code:

panel.Close()

Show again:

panel.Show()

Check state:

if panel.Visible() {
    // visible
}

When a panel closes, Flex/Split layouts can reclaim its space.

Responsive layouts

Use Responsive to choose between compact and expanded layouts.

root := layout.Responsive(
    100,
    compactLayout,
    expandedLayout,
)

Meaning:

terminal width < 100
    → compactLayout

terminal width >= 100
    → expandedLayout

This is useful for applications that should behave differently on:

  • laptop terminals
  • large monitors
  • SSH sessions
  • split terminal windows
Retained layout

For a large stable dashboard, use:

retained := layout.NewRetained(root)

A retained subtree caches its flattened placements until:

  • its geometry changes, or
  • it is explicitly invalidated.

Invalidate it when its layout structure changes:

retained.Invalidate()

Use retained layout for large stable scene trees where one hot widget changes frequently but the surrounding layout does not.

🧩 Widgets reference

Label

The simplest text widget:

label := widget.NewLabel("Hello")

Change it:

label.SetText("New text")

Style it:

label.Bold = true

Custom style:

s := style.Style{
  Fg: color.NordCyan,
  Bg: color.NordPanel,
  Attr: style.Bold,
}

label.Style = &s

Custom background:

bg := color.NordBackground
label.Background = &bg

Label with dynamic text

TextFn can provide text during rendering:

label.TextFn = func() string {
  return currentText
}

For high-frequency/concurrent data, the callback should read data from your own safe state.

Do not casually read ordinary mutable strings from another goroutine.

For a simple UI-owned value, SetText is usually clearer.


Button

Create:

button := widget.NewButton(
  "SAVE",
  func() {
      // action
  },
)

The callback runs when the button is activated by keyboard or mouse.

Danger button:

button.Danger = true

This uses the theme's negative semantic role.


Button background

bg := color.NordPanel
button.Background = &bg

For a custom button appearance, prefer a component theme:

buttonTheme := style.NordTheme().Clone()
buttonTheme.Positive.Fg = color.NordCyan
button.ThemeOverride = buttonTheme

Toggle

Create a toggle using an atomic uint32:

var enabled uint32

toggle := widget.NewToggle(
  "Notifications",
  &enabled,
)

The value convention is:

0 = off
1 = on

Customize displayed flags:

toggle.OnFlag = "ON"
toggle.OffFlag = "OFF"

Read it safely:

if atomic.LoadUint32(&enabled) == 1 {
  // enabled
}

The widget uses atomic operations internally.

This makes Toggle useful when a simple on/off state may also be read by another goroutine.


Slider

Create a slider:

var level uint32 = 50

slider := widget.NewSlider(
  "Alert",
  &level,
  0,
  100,
  1,
  widget.FormatInt("%"),
)

Arguments:

label
value pointer
minimum
maximum
step
formatter

The value is stored in an atomic uint32.

Keyboard and mouse interaction are supported.


Slider formatting

Integer format:

widget.FormatInt("x")

Example:

10x
20x
30x

Basis-point percentage:

widget.FormatBasisPointsPct('+')

This is intended for values such as:

+2.00%
+5.50%

The formatter uses a caller-owned scratch buffer so the steady-state render path can remain allocation-free.


Slider width

slider.TrackWidth = 24

Use this when you want a consistent control width.


Gauge

Gauge is a non-interactive progress/utilization bar.

gauge := widget.NewGauge("CPU")
gauge.Value = 0.42

Value is expected in:

0.0 → 1.0

Example:

CPU █████████░░░░░ 42%

Gauge thresholds

gauge.WarnAt = 0.70
gauge.DangerAt = 0.90

The gauge automatically switches semantic colors:

< 70%  → positive
>= 70% → warning
>= 90% → negative

You can also supply a custom style:

s := style.Style{
  Fg: color.NordCyan,
  Bg: color.NordPanel,
}

gauge.Style = &s

Gauge with concurrent data

If another goroutine updates the measurement, use your own atomic representation and ValueFn.

Conceptually:

var bits atomic.Uint64

gauge.ValueFn = func() float64 {
  return math.Float64frombits(bits.Load())
}

Then a producer can write:

bits.Store(math.Float64bits(value))

This avoids a data race.


Sparkline

Create:

spark := widget.NewSparkline(120)

Push values:

spark.Push(price)

The sparkline is a fixed-capacity ring buffer.

Push is O(1).

It is safe to push from another goroutine while the widget is rendering because Sparkline uses a mutex around its ring-buffer state.


Sparkline colors

By default it uses:

up   → theme.Positive
down → theme.Negative

Override them:

up := style.Style{
  Fg: color.NordGreen,
  Bg: color.NordPanel,
}

down := style.Style{
  Fg: color.NordRed,
  Bg: color.NordPanel,
}

spark.UpStyle = &up
spark.DownStyle = &down

PriceTicker

PriceTicker is designed for atomic fixed-point prices.

Suppose:

price = 12345
decimals = 2

means:

123.45

Create:

var price uint64 = 12345

ticker := widget.NewPriceTicker(
  "BTC-PERP",
  &price,
  2,
  2,
)

The price pointer is read atomically.

The widget detects price direction and uses:

Positive
Negative

styles when the value changes.


OrderBook

Create:

book := widget.NewOrderBook(
  2, // price decimals
  2, // size decimals
  2, // displayed decimals
)

Populate:

bids := []widget.Level{
  {Price: 10000, Size: 500},
  {Price: 9998, Size: 250},
}

asks := []widget.Level{
  {Price: 10002, Size: 400},
  {Price: 10004, Size: 700},
}

book.SetLevels(bids, asks)

Price and Size are fixed-point integers.

The exact scale is determined by:

Decimals
SizeDecimals

This avoids floating-point formatting in the hot render path.


Why OrderBook is optimized

OrderBook is intended for frequently changing market data.

SetLevels:

  • reuses backing storage where possible,
  • compares old/new data,
  • records dirty ranges,
  • recalculates side maxima,
  • avoids per-update snapshot allocations in the steady state.

The renderer then repaints the necessary regions rather than rebuilding the entire dashboard.


Table

For a normal in-memory table:

columns := []widget.Column{
  {Title: "SYMBOL", Width: 12},
  {Title: "PRICE", Width: 12, Align: widget.AlignRight},
  {Title: "STATUS", Width: 12},
}

table := widget.NewTable(columns)

table.Rows = [][]string{
  {"BTC-PERP", "78900.00", "ACTIVE"},
  {"ETH-PERP", "4200.00", "PENDING"},
}

Wrap it:

root := layout.Wrap(table)

Table column alignment

Columns support two alignments:

widget.AlignLeft
widget.AlignRight

For prices and quantities:

{Title: "PRICE", Width: 12, Align: widget.AlignRight}

For symbols:

{Title: "SYMBOL", Width: 12, Align: widget.AlignLeft}

Flexible table columns

A column with:

Width: 0

is flexible.

Use Weight:

columns := []widget.Column{
  {Title: "SYMBOL", Width: 12},
  {Title: "DESCRIPTION", Weight: 2},
  {Title: "STATUS", Weight: 1},
}

Fixed columns are allocated first; flexible columns share remaining space by weight.

For terminal dashboards, avoid using flexible weights for every column if the result creates huge empty spaces.

A common good design is:

fixed identity columns
+
fixed numeric columns
+
one flexible description column

Table selection

Tables support:

table.Selected

The selected row is visually highlighted when the table has focus.

Customize selection foreground:

fg := color.NordWhite
table.SelectionForeground = &fg

Customize selection background:

bg := color.NordBlue
table.SelectionBackground = &bg

Selection is applied across the complete row.


Zebra tables

Enable:

table.Zebra = true

This gives alternating row surfaces for dense data.

Use zebra styling carefully. A subtle difference is usually easier to read than strong alternating colors.


Per-row styling

Use:

table.RowStyle = func(row int) *style.Style {
  if row == 3 {
      s := style.Style{
          Fg: color.NordGreen,
          Bg: color.NordPanel,
      }
      return &s
  }
  return nil
}

For high-frequency tables, avoid constructing a new style.Style every callback.

Prefer prebuilt styles:

positive := style.Style{
  Fg: color.NordGreen,
  Bg: color.NordPanel,
}

table.RowStyle = func(row int) *style.Style {
  if row == 3 {
      return &positive
  }
  return nil
}

Per-cell styling

Use:

table.CellStyle = func(row, col int) *style.Style {
  if col == 2 {
      return &positive
  }
  return nil
}

This is useful for:

  • P&L
  • status
  • risk
  • warnings
  • semantic values

Selection styling is applied after cell styling so selected rows remain visually continuous.


VirtualTable

Use VirtualTable for large datasets.

table := widget.NewVirtualTable(
  columns,
  1_000_000,
  func(row, col int) string {
      return getCell(row, col)
  },
)

The table does not render one million rows.

It only asks for cells that intersect the visible viewport/damage region.

This is the right widget for:

  • order flow
  • large transaction history
  • log-like data
  • millions of records
  • database viewers
  • telemetry streams

VirtualTable callbacks

The callback:

Cell func(row, col int) string

should ideally return an existing string.

For maximum performance, avoid doing expensive work inside it.

Bad:

Cell: func(row, col int) string {
  return fmt.Sprintf("%.8f", databaseValue(row))
}

Better:

format data when it changes
or
use an allocation-free formatter
or
keep already formatted strings

The renderer should stay simple.


VirtualTable RowStyle and CellStyle

Same APIs as Table:

table.RowStyle = ...
table.CellStyle = ...

Only painted/visible cells are requested during clipped drawing.

This is important when the underlying table has hundreds of thousands or millions of rows.


VirtualTable scrollbar

Enable:

table.ShowScrollBar = true

Customize:

track := color.NordDimGray
thumb := color.NordCyan

table.ScrollTrack = &track
table.ScrollThumb = &thumb

Selection colors:

table.SelectionForeground = &fg
table.SelectionBackground = &bg

The selected scrollbar cell retains the scrollbar thumb glyph so the thumb stays visually continuous.


VirtualList

For a large one-column list:

list := widget.NewVirtualList(
  1_000_000,
  func(index int) string {
      return itemAt(index)
  },
)

This is preferable to building:

[]string

for a huge dataset when only a small viewport is visible.


VirtualList selection

list.Selected = 42

Handle selection:

list.OnSelect = func(index int) {
  // selected index
}

Customize:

list.ShowScrollBar = true
list.SelectionBackground = &selectionBg
list.SelectionForeground = &selectionFg

Normal List

Use List when the complete item collection is reasonably small:

items := []string{
  "Market",
  "Orders",
  "Positions",
  "Risk",
}

list := widget.NewList(items)

Keyboard:

Up / Down
j / k
Enter

Mouse selection is also supported.


Tabs

Create:

tabs := widget.NewTabs([]string{
  "POSITIONS",
  "ORDERS",
  "RISK",
})

Set the active tab:

tabs.Active = 1

React to changes:

tabs.OnChange = func(index int) {
  // switch content
}

Tabs draw the tab strip; your application decides what content belongs to the active tab.


TextInput

Create:

input := widget.NewTextInput("Symbol")

Enable a border:

input.Border = true

Set an initial value:

input.SetValue("BTC-PERP")

Read it:

value := input.String()

Submit callback:

input.OnSubmit = func(value string) {
  // use submitted text
}

Numeric TextInput

Set:

input.Numeric = true

This restricts input to digits and ..

Useful for:

  • quantities
  • prices
  • percentages
  • numeric parameters

TextInput styling

Background:

bg := color.NordBackground
input.Background = &bg

Theme:

input.ThemeOverride = style.NordTheme()

For forms, it is often best to use the panel surface as the normal background and a stronger border/focus style.


Panel widget

There are two ways to create a panel.

Widget-level Panel
panel := widget.NewPanel(
  "Status",
  label,
)

A widget.Panel contains one Widget.

Layout-level Bordered

For multiple children, prefer:

panel := layout.Bordered(
  "Status",
  formLayout,
  func() bool {
      return textInput.IsFocused()
  },
)

This distinction is useful:

widget.Panel
  → one child widget

layout.Bordered
  → arbitrary layout tree

Panel styling

panel.Background = &bg
panel.Rounded = true
panel.Focused = true

Or use:

panel.ThemeOverride = customTheme

For complex panels, layout-level Bordered is generally more flexible.


FastLogView

Use FastLogView for a high-volume log display.

log := widget.NewFastLogView(10_000)

Append:

log.Append("connected to exchange")
log.Append("received market update")

Follow tail:

log.FollowTail = true

The widget stores logs in a bounded ring.

This avoids unbounded memory growth.


CommandPalette

Create:

palette := widget.NewCommandPalette([]widget.Command{
  {
      Name: "Open Orders",
      Key:  "O",
      Execute: func() {
          // action
      },
  },
  {
      Name: "Quit",
      Key:  "Q",
      Execute: func() {
          // action
      },
  },
})

Update query:

palette.SetQuery("ord")

It performs subsequence-style fuzzy matching.

Use it as an overlay/modal when you want a command launcher.


Badge

Create:

badge := widget.NewBadge("LIVE")

Semantic mode:

badge.Positive = true

or:

badge.Negative = true

or:

badge.Info = true

Explicit colors:

fg := color.NordCyan
bg := color.NordPanel

badge.Foreground = &fg
badge.Background = &bg

Divider

Create a horizontal divider:

divider := widget.NewDivider(true)

Vertical:

divider := widget.NewDivider(false)

Use Divider instead of creating one-off strings of box-drawing characters when you want a reusable structural separator.


Stat

Create a KPI:

stat := widget.NewStat(
  "Latency",
  "1.2ms",
)

Optional delta:

stat.Delta = "-0.3ms"
stat.Down = true

Or:

stat.Delta = "+12%"
stat.Up = true

Use Stat for compact dashboard metrics.


Spinner

Create:

spinner := widget.NewSpinner("Loading")

Advance it:

spinner.Tick()

A spinner is useful for small local progress indications.

Do not create a new goroutine for every spinner. A single application update mechanism is usually better.


GradientBar

Create:

bar := widget.NewGradientBar(
  color.NordCyan,
  color.NordBlue,
  color.NordDimGray,
)

Set:

bar.Value = 0.65

It is useful for:

  • utilization
  • intensity
  • health
  • confidence
  • capacity

ScrollBar

ScrollBar is a lower-level visual component.

scroll := widget.ScrollBar{
  Total:    1000,
  Offset:   200,
  Viewport: 30,
}

Customize:

scroll.Track = &track
scroll.Thumb = &thumb
scroll.Background = &bg

It is primarily useful when implementing custom scrolling components.

Most users should use the scrollbar built into:

  • VirtualList
  • VirtualTable

ResizeHandle

ResizeHandle is normally created by Split.

You usually do not need to construct it manually.

If you do:

ratio := 0.5

handle := widget.NewResizeHandle(
  widget.ResizeVertical,
  &ratio,
)

You can control:

handle.MinRatio = 0.20
handle.MaxRatio = 0.80

It is pointer-oriented rather than keyboard-focus oriented.


CloseButton

CloseButton is also normally managed by layout.ClosableRounded.

Create directly:

close := widget.NewCloseButton(func() {
  // close
})

It is intentionally not part of the keyboard focus ring.

This prevents decorative panel controls from increasing focus complexity.

Terminal

Terminal is a real PTY-backed terminal widget. It uses term.PTYSession plus the built-in VT/ANSI emulator rather than executing each command through an ordinary pipe.

Create it with an optional working directory:

terminal := widget.NewTerminal(".")

Start the interactive shell:

if err := terminal.Start(); err != nil {
    // handle startup error
}

Useful controls include:

terminal.SetCWD("/tmp")
terminal.SetShell("/bin/bash")
terminal.SetMaxScrollback(10000)
terminal.RunCommand("go test ./...")
terminal.Stop()
terminal.Close()

Append is retained for programmatic/emulator-fed output:

terminal.Append("hello\n")

The terminal handles keyboard, paste, mouse wheel scrolling, resize, and PTY lifecycle. Use OnExit to observe shell termination and OnChange to wake the application when terminal content changes.


Clipboard

Terminal, TextEditor, and CodeEditor all cut/copy/paste through the small clipboard package rather than the host operating system:

clipboard.Copy("some text")
text, ok := clipboard.Paste()

It is process-local by design: the two functions above are a package-level, mutex-protected string — nothing more. There is no OS clipboard call and no OSC 52 escape sequence, so copying from ZeroTUI will not show up in another application's paste buffer, and pasting into ZeroTUI will not pick up something you copied elsewhere on the host. If your application needs real interop with the system clipboard, that integration has to happen at the host/application layer; ZeroTUI's editors will keep using their own internal buffer regardless.


TextEditor

TextEditor is the reusable plain-text editing surface. It provides:

  • line-numbered viewport rendering;
  • cursor movement and selection;
  • vertical and horizontal scrolling;
  • undo/redo;
  • search and goto-line modes;
  • optional read-only mode;
  • an editor-owned status bar;
  • host-provided persistence through OnSave;
  • a reusable Document model that can be shared by multiple editors.

Create one without filesystem coupling:

editor := widget.NewTextEditor()
editor.SetDocumentName("notes.txt")
editor.SetLanguage("text")
editor.SetText("hello\nworld")
editor.OnSave = func() bool {
    // save editor.Document.Text() using the host application
    return true
}

For embedded layouts, the status row can be disabled:

editor.ShowStatusBar = false

For a source editor with syntax/folding, use CodeEditor instead.


CodeEditor

CodeEditor specializes TextEditor with syntax highlighting and folding:

editor := widget.NewCodeEditor()
editor.Load("main.go", sourceBytes)

Load accepts source bytes and metadata but performs no filesystem I/O; the host is responsible for reading and saving files.

The highlighter and fold provider are pluggable:

editor.SetHighlighter(myHighlighter)
editor.SetFoldProvider(myFoldProvider)

Set either provider to nil to disable that feature. Built-in syntax profiles cover Go, Python, JSON, YAML/YML, TOML, Rust, C/C++, Java, JavaScript/JSX, Bash/Shell, and Markdown.

Large documents (100,000+ lines or 4 MiB+) use a viewport token cache so steady-state syntax work stays scoped to visible source lines.


MultiLineTextEditor

MultiLineTextEditor is a smaller plain-text editor for notes, configuration forms, command buffers, and snippets:

editor := widget.NewMultiLineTextEditor("line one\nline two")
editor.TabWidth = 4
editor.ReadOnly = false

It exposes Text()/SetText(), cursor/scroll state, and an OnChange callback. It intentionally does not include the heavier document/syntax/folding machinery of TextEditor.


TreeView

TreeView renders a hierarchy through an application-owned TreeModel:

tree := widget.NewTreeView(model)
tree.OnActivate = func(node widget.TreeNode) {
    // activate node
}

The model supplies roots, children, and expansion changes:

type TreeModel interface {
    Roots() []widget.TreeNode
    Children(parentID string) []widget.TreeNode
    SetExpanded(id string, expanded bool) bool
}

The widget handles selection, keyboard navigation, expansion/collapse, scrolling, and optional custom row rendering. It never performs filesystem I/O.


HierarchicalTree

HierarchicalTree is the simpler value-based tree API:

tree := widget.NewHierarchicalTree([]widget.TreeItem{
    {
        ID: "src",
        Label: "src",
        Expanded: true,
        Children: []widget.TreeItem{
            {ID: "main", Label: "main.go"},
        },
    },
})

Use TreeView when the application already has a model or needs lazy children; use HierarchicalTree when the complete hierarchy can live directly in the widget.


FilePicker

FilePicker indexes a root directory and filters supported text/code/log files:

picker := widget.NewFilePicker(".")
picker.OnSelect = func(path string) {
    // open path
}
picker.OnCancel = func() {
    // dismiss picker
}
picker.SetQuery("editor")

It skips .git, vendor, and node_modules directories and does not expose arbitrary binary files.


ShortcutHelpBar

Use ShortcutHelpBar for a compact keyboard legend:

help := widget.NewShortcutHelpBar(
    widget.Shortcut{Key: "Ctrl+S", Label: "Save"},
    widget.Shortcut{Key: "Ctrl+F", Label: "Search"},
)

The separator can be customized with Separator.


Trading widgets

ZeroTUI also includes focused market/operations widgets:

TimeAndSales
trades := widget.NewTimeAndSales(100, 2, 2, 2)
trades.AddTrade(widget.Trade{Price: 10025, Size: 3, Buy: true})
Positions
positions := widget.NewPositions(100, 2, 2, 2)
positions.SetRows([]widget.Position{
    {Symbol: "BTC-PERP", Qty: 2, AvgPrice: 10000, PnL: 125},
})
Orders
orders := widget.NewOrders(100, 2, 2)
orders.SetRows([]widget.Order{
    {ID: "42", Symbol: "BTC-PERP", Side: "BUY", Price: 10000, Qty: 1, Status: "OPEN"},
})
PnL
pnl := widget.NewPnL(2, 2)
LatencyMonitor
latency := widget.NewLatencyMonitor()

Its exported Stats field contains atomic Min, Max, Last, and Count counters.

RiskMonitor
risk := widget.NewRiskMonitor(2, 2)
MarketStatus
status := widget.NewMarketStatus("CME", "BTC-PERP")
status.SetState(widget.MarketLive)
OrderEntry
entry := widget.NewOrderEntry()
entry.Side = "BUY"

These widgets are intentionally small presentation components: the application owns the market/order model and decides how updates are synchronized.


Editor support types

Document is the reusable text model shared by TextEditor/CodeEditor instances:

doc := widget.NewDocument("hello\nworld")
doc.SetName("notes.txt")
doc.SetLanguage("text")
doc.SetLine(0, "updated")

if doc.Modified() {
    // persist doc.Text() in the host application
}

The model also provides line/byte-position conversion, subscriptions, bounded undo/redo, and range mutation APIs. DocumentChange, SyntaxToken, SyntaxHighlighter, RangeSyntaxHighlighter, FoldProvider, Command, Shortcut, Column, and the trading model structs are supporting data/API types rather than standalone rendered widgets.

🔢 Number formatting (numfmt)

Every trading widget in this library — PriceTicker, OrderBook, Positions, Orders, PnL — has the same problem every frame: turn a fixed-point integer into digits on screen without asking the Go allocator for anything. fmt.Sprintf("%.2f", price) allocates. strconv.FormatFloat allocates. Neither is acceptable on a hot render path ticking many times a second. numfmt is the small package those widgets use internally to avoid that entirely, and it is exported so your own custom widgets can use the same trick.

The whole package is five functions that append to a caller-owned []byte, exactly the way strconv.AppendInt works — no formatting verbs, no reflection, no allocation once the scratch buffer is warm.

Plain integers

buf := make([]byte, 0, 32)
buf = numfmt.AppendUint(buf, 42)   // "42"
buf = buf[:0]
buf = numfmt.AppendInt(buf, -42)   // "-42"

Fixed-point values

Trading data is usually stored as an integer scaled by a power of ten (a uint64 price of 7890012345 at 9 decimals means 7.890012345) specifically so the render path never touches a float64. AppendFixed renders that scaled integer with its full decimal precision:

buf = buf[:0]
buf = numfmt.AppendFixed(buf, 7890012345, 9)
// buf == "7.890012345"

Most displays don't want all nine digits, though — a compact ticker might store prices at high internal precision but only show 2. AppendFixedPrec renders a chosen number of visible decimals instead of the full stored precision:

buf = buf[:0]
buf = numfmt.AppendFixedPrec(buf, 1234567, 2, 2)
// buf == "12345.67"  (stored at 2 decimals, shown at 2)

For signed fixed-point values (P&L, deltas, funding rates), use AppendSignedFixedPrec:

buf = buf[:0]
buf = numfmt.AppendSignedFixedPrec(buf, -1234567, 2, 2)
// buf == "-12345.67"

Padding a rendered number

PadLeft left-pads the digits it just appended, rather than taking a length up front. Record len(dst) before appending, then pad after:

buf = buf[:0]
from := len(buf)
buf = numfmt.AppendUint(buf, 42)
buf = numfmt.PadLeft(buf, from, 5, '0')
// buf == "00042"

This order — append, then pad from the recorded start — is what lets PadLeft work on any of the append functions above without knowing in advance how many digits a value will produce.

The pattern widgets actually use

The idiom every built-in widget follows is a small [N]byte scratch array reused across frames:

type Row struct {
    scratch [32]byte
}

func (r *Row) renderPrice(price uint64) []byte {
    buf := numfmt.AppendFixed(r.scratch[:0], price, 2)
    return buf
}

r.scratch[:0] reuses the backing array's capacity every call, so a widget redrawing thousands of times a second never asks the garbage collector for a new byte slice just to show a number.

⚙️ Application, focus & input

Focus

Interactive widgets implement the focus contract.

Examples:

  • Button
  • Toggle
  • Slider
  • List
  • VirtualList
  • Table
  • VirtualTable
  • Tabs
  • TextInput
  • CommandPalette

The application routes keyboard input to the focused widget.

A concrete focusable widget exposes:

btn.IsFocused()

and:

btn.Focus(true)

The application can explicitly focus a widget with:

a.Focus(btn)

where appropriate.


Mouse interaction

Mouse-aware widgets implement:

HandleMouse(...)

ZeroTUI supports:

  • mouse clicks
  • wheel scrolling
  • dragging
  • resize handles
  • scrollbar dragging
  • button activation
  • table/list selection

You normally do not need to route mouse events manually.

app.App handles the routing.


Global keyboard shortcuts

Use:

a.OnKey = func(k input.Key) bool {
  if k.Type == input.KeyRune {
      switch k.Rune {
      case 'q':
          // handled by QuitKeys normally
      case 'r':
          // reset
          return true
      }
  }
  return false
}

Returning true means:

the event has been consumed.

Global handlers run before normal focus routing.


Quit keys

Default:

q

You can configure:

a.QuitKeys = []rune{'q', 'x'}

Ctrl+C also causes the application to exit.


Application target FPS

App exposes:

TargetFPS int

For live/interactive rendering:

a.TargetFPS = 60

or:

a.TargetFPS = 30

The scheduler is event-driven when idle.

This means a static application does not need to wake continuously just to redraw an unchanged screen.


Live rendering

For continuously changing UI:

a.RequestLive()

This tells the application that a live rendering source exists.

For a local animation region, use:

a.RequestLiveRect(rect)

and later:

a.DropLiveRect(rect)

This is preferable to making the entire screen live when only a small widget changes.


Invalidation

When data changes and the application needs a redraw:

a.Invalidate()

For a known rectangle:

a.InvalidateRect(rect)

For widgets:

a.InvalidateWidgets(
  priceTicker,
  orderBook,
)

Targeted invalidation is preferable when the change is localized.


Batching updates

If several related values change together:

a.BeginBatch()

// update several widgets/data sources

a.InvalidateWidgets(price, book, status)

a.EndBatch()

This coalesces a burst of updates into one render wakeup.

A good real-time pattern is:

market update arrives
     ↓
update related state
     ↓
BeginBatch
     ↓
invalidate affected widgets
     ↓
EndBatch
     ↓
one render opportunity

UI queue

Use Queue when a background worker needs to hand a short widget-state mutation back to the application's UI/render goroutine:

ok := a.Queue(func() {
    status.SetText("market data refreshed")
    a.InvalidateWidgets(status)
})

The queue is bounded and non-blocking. Queue returns false when the queue is full, so producers can coalesce or retry instead of blocking the rendering path. Queued callbacks are not run concurrently with drawing or input dispatch.


Explicit relayout and interactive mode

Normal invalidation does not require a full layout pass. When layout structure or geometry changes, use:

a.InvalidateLayout()

or explicitly:

a.Relayout()

For mouse-drag or other continuous interaction, the application can enter interactive mode:

a.SetInteractive(true)
// update geometry while dragging
a.SetInteractive(false)

Interactive mode allows the scheduler to keep producing frames while the interaction is active.


Retained-state inspection

For diagnostics and performance instrumentation:

total, dirty := a.RetainedState()

This reports the current retained placement count and the number of retained placements marked dirty.


Resize and synchronized output

OnResize runs after the application rebuilds geometry:

a.OnResize = func(width, height int) {
    // update application-owned state
}

SynchronizedOutput defaults to true and wraps changed terminal frames in DEC synchronized-output mode 2026. Disable it when a terminal compatibility constraint requires ordinary output:

a.SynchronizedOutput = false

Concurrent data updates

ZeroTUI deliberately uses different synchronization techniques depending on the data.

Examples:

Toggle / Slider / PriceTicker

Use atomic scalar storage.

Sparkline / OrderBook

Use internal mutex-protected ring/snapshot state.

Your own data

You are responsible for synchronization.

Do not do:

var price float64

// goroutine A
price = 123.4

// renderer
fmt.Println(price)

without synchronization.

Use an atomic value, mutex, channel, or another safe ownership model.

Rendering architecture

Every frame passes through four cooperating layers, and only the last one ever talks to the actual terminal:

Layout          →   Widgets          →   Damage tracking     →   Renderer
placement           paint into a         finds the smallest      diffs vs. the front
math only           reusable back        dirty regions            buffer, writes only
                     buffer                                        the changed runs
  1. Layout computes widget placement and can reuse retained placement storage instead of recomputing a tree of rectangles every frame.
  2. Widgets paint cells into a reusable back buffer — the same backing array, frame after frame, not a fresh one each time.
  3. Damage tracking identifies the smallest screen regions that actually need repainting, cell by cell.
  4. The renderer diffs those cells against the front buffer and emits only the terminal escape sequences for runs that changed.

A normal live update therefore never needs to clear and redraw the entire terminal — most frames touch a handful of cells, and a handful of cells is all that gets written.

Input and terminal behavior

The input parser keeps a fast ASCII path — the common case for keystrokes — while still supporting UTF-8 runes, SGR mouse events, and Kitty/progressive keyboard reports for terminals that offer them.

The renderer can optionally wrap changed frames in DEC synchronized-output mode 2026, which tells a supporting terminal to buffer a batch of writes and present them atomically instead of painting partial frames the eye can catch mid-update. It is enabled by default by app.New and can be disabled with the application's SynchronizedOutput field when terminal compatibility or application policy requires it.

Performance

The benchmark suite is part of the project, not an afterthought. Run it on the machine that matters to you:

chmod +x benchmarks/run.sh
./benchmarks/run.sh

Or use the standard Go command directly:

go test ./benchmarks -run '^$' -bench . -benchmem -count=5

Most operations report 0 B/op and 0 allocations/op, and a targeted widget update (~95 ns) is roughly 1,000× cheaper than a full dashboard redraw (~92–93 µs) on the test machine below — which is why small changes can stay small in ZeroTUI.

Full benchmark results

The following values are from the latest in-repository compatibility run during this review on Linux amd64, Intel Xeon E312xx (Sandy Bridge, IBRS update).

The focused benchmark pass used a 200 ms measurement window and -benchmem. The values below are the mean of those samples, not the first sample.

Operation Latest review-run result
App.Invalidate 19.1 ns/op, 0 B/op, 0 allocs/op
App.InvalidateWidgets 28.6 ns/op, 0 B/op, 0 allocs/op
Sparse retained buffer render (20 rows) ~136.3 ns/op, 0 B/op, 0 allocs/op
Sparse retained buffer render (500 rows) ~127.7 ns/op, 0 B/op, 0 allocs/op
Full buffer render (20 rows) ~79.5 µs/op, 0 B/op, 0 allocs/op
Full buffer render (500 rows) ~79.6 µs/op, 0 B/op, 0 allocs/op
Flex horizontal layout ~402 ns/op, 0 B/op, 0 allocs/op
Split layout ~32.9 ns/op, 0 B/op, 0 allocs/op
Grid layout ~267 ns/op, 0 B/op, 0 allocs/op
Responsive layout ~60.7 ns/op, 0 B/op, 0 allocs/op
High-frequency market-update scenario ~24.2 µs/op, 0 B/op, 0 allocs/op
OrderBook tick / 100 levels (single-level tick) ~1.88 µs/op, 0 B/op, 0 allocs/op

The widget pass covered the shipped drawing and interaction benchmarks. Representative results (mean of 5 samples) include PriceTicker ~121.7 ns/op, Table ~9.57 µs/op, VirtualTable ~11.88 µs/op, OrderBook ~19.11 µs/op, and FastLogView ~7.93 µs/op, all at 0 B/op and 0 allocs/op in this run.

The editor-specific benchmarks measured (mean of 5 samples):

Editor operation Latest review-run result
TextEditor draw / 500k-line source ~49.2 µs/op, 0 B/op, 0 allocs/op
TextEditor search / 500k-line source ~572.7 ns/op, 0 B/op, 0 allocs/op
CodeEditor viewport draw / 10k-line source ~83.8 µs/op, 0 B/op, 0 allocs/op
CodeEditor search / 10k-line source ~105.4 ns/op, 0 B/op, 0 allocs/op
CodeEditor edit + syntax update ~2.27 ms/op, 1.28 MB/op, 11 allocs/op
Complete editor.Editor draw / ~1,000 source lines ~174.5 µs/op, 16 B/op, 1 alloc/op

HFT sustained benchmark — latest supplied run — cpu: Intel Xeon E312xx (Sandy Bridge, IBRS update)

The sustained comparison uses a 120×45 pseudo-terminal, a 19-row visible viewport, 20- and 500-row datasets, a 1 kHz logical tick target, and a strict 60 FPS render ceiling. Updates are queued/batched between frames; realistic density changes one row per logical tick, while worst density changes every row.

All three are run inside the same podman run --cpus=4 --memory=2g container, through the same script -qec pty. Ratatui uses CrosstermBackend; ZeroTUI and Bubble Tea use their own pty writers. GOMAXPROCS=4 for the two Go targets.

Framework Rows Density Ticks completed Effective ticks/sec Allocation rate GC cycles GC pause total
ZeroTUI 20 realistic 28,252 973.5 35.5 KB/s 0 0.00 ms
ZeroTUI 20 worst 28,222 972.6 0.64 MB/s 5 2.60 ms
ZeroTUI 500 realistic 28,433 979.9 35.3 KB/s 0 0.00 ms
ZeroTUI 500 worst 27,774 957.0 15.3 MB/s 36 9.30 ms
Bubble Tea v2 20 realistic 26,610 910.0 26.3 MB/s 369 140.30 ms
Bubble Tea v2 20 worst 534,240 † 18,214.4 † 27.5 MB/s 413 141.38 ms
Bubble Tea v2 500 realistic 26,313 900.2 25.5 MB/s 120 167.68 ms
Bubble Tea v2 500 worst 13,127,500 † 446,734.3 † 52.7 MB/s 121 215.23 ms
Ratatui 20 realistic 29,012 ~1,000 7.6 MB/s N/A N/A
Ratatui 20 worst 29,010 ~1,000 7.8 MB/s N/A N/A
Ratatui 500 realistic 29,015 ~1,000 7.5 MB/s N/A N/A
Ratatui 500 worst 29,001 ~1,000 14.3 MB/s N/A N/A

† Bubble Tea worst-density counter is a counting artifact, not a throughput measurement. Bubble Tea's applyTick increments its tick counter once per fixture application, and in worst mode one logical tick applies rows fixture entries — so the counter over-counts logical ticks by a factor of rows. The underlying work Bubble Tea performs is one logical tick per queued token, the same as ZeroTUI and Ratatui; only the reporting differs. Correcting for the artifact:

  • Bubble Tea 20 worst: 534,240 ÷ 20 = 26,712 logical ticks~910 ticks/sec
  • Bubble Tea 500 worst: 13,127,500 ÷ 500 = 26,255 logical ticks~894 ticks/sec

These two rows are retained here only because they appear in the raw run data, not as valid ticks/sec figures.

Allocation definition caveat. The Go targets (ZeroTUI, Bubble Tea) report deltas in Go runtime TotalAlloc — bytes allocated on the Go heap. Ratatui reports a custom CountingAllocator that sums layout.size() on every alloc call. These are not the same definition and are not directly comparable; cross-framework allocation ratios in this table should be read as order-of-magnitude signals, not precise equivalents. "No GC" is not "no allocation": Ratatui allocates because its dashboard calls format! per tick, but Rust frees that memory deterministically via drop(), so no GC pauses appear. ZeroTUI is the only framework here that is both GC-free and essentially allocation-free in the hot render path.

Layer-1 state mutation and frame rendering — cpu: Intel Xeon E312xx (Sandy Bridge, IBRS update)

The supplied headless cross-framework microbenchmarks report the following central measurements (mean of 5 runs for Go targets, mean of criterion samples for Ratatui):

Framework State mutation, 20 rows Full frame, 20 rows State mutation, 500 rows Full frame, 500 rows
ZeroTUI ~368 ns ~79.5 µs ~359 ns ~79.6 µs
Bubble Tea v2 ~362 ns ~96.8 µs ~352 ns ~88.0 µs
Ratatui ~233 ns ~253.9 µs ~237 ns ~258.5 µs

ZeroTUI does not win the isolated state-mutation test; Ratatui is faster there (233 ns vs 368 ns). ZeroTUI's advantage in this suite is concentrated in low-allocation rendering and retained/sparse update behavior.

Corrected rendering claim: ZeroTUI's full-frame render is ~18% faster than Bubble Tea at 20 rows (96.8 ÷ 79.5 = 1.218) and ~10% faster at 500 rows (88.0 ÷ 79.6 = 1.105).

ZeroTUI sparse rendering and trading workloads

The supplied ZeroTUI microbenchmarks measured:

  • sparse 60 FPS frame path: ~136.3 ns/op, 0 B/op, 0 allocs/op at 20 rows; ~127.7 ns/op at 500 rows;
  • full 60 FPS frame path: ~79.5 µs/op at 20 rows; ~79.6 µs/op at 500 rows;
  • OrderBook full update + draw: approximately 6.05 µs / 10 levels, 13.85 µs / 25, 27.28 µs / 50, and 32.0 µs / 100, all with 0 B/op and 0 allocs/op in the supplied runs.

Note on OrderBook numbers. The BenchmarkOrderBookTick/100 in the main benchmarks package reports ~1.88 µs/op, while the HFT target's BenchmarkZeroTUIOrderBookTick/100 reports ~32.0 µs/op. These measure different operations: the former is a single-level tick, the latter is a full 100-level update plus draw. Do not conflate them.

The current benchmark suite also keeps concurrent Sparkline and OrderBook paths separate from ordinary single-threaded measurements, so synchronization overhead is visible rather than hidden inside a generic widget benchmark.

Editor benchmark results

An editor-specific benchmark set was added for viewport rendering, search, edit/highlight behavior, and the complete editor composition. A local compatibility validation on Linux amd64 / Intel Xeon E312xx (Sandy Bridge, IBRS update) measured (mean of 5 samples):

Editor operation Result Allocations
CodeEditor viewport draw, 10,000-line source ~83.8 µs/op 0 B/op, 0 allocs/op
CodeEditor search, 10,000-line source ~105.4 ns/op 0 B/op, 0 allocs/op
CodeEditor edit + syntax update ~2.27 ms/op 1.28 MB/op, 11 allocs/op
Complete editor.Editor draw, ~1,000 source lines ~174.5 µs/op 16 B/op, 1 alloc/op

For code-heavy workloads, the important distinction is between steady-state viewport work and content-changing work: repainting an already-highlighted viewport is allocation-free in the benchmark, while an edit can legitimately invalidate syntax/fold state and therefore does more work.

Why retained rendering matters

A market-data producer can receive many updates while the terminal only needs to present the latest state at the next render opportunity. ZeroTUI can coalesce changes and emit only the final damaged cells instead of rebuilding every visible string for every update.

The same design helps the editor: changing one line does not inherently require rebuilding every visible line, and large-document syntax work can be scoped to the viewport. This is why the editor benchmarks distinguish zero-allocation viewport redraw/search from the more expensive edit-and-highlight path.

License

MIT License © 2026 ZeroGCDev

About

High-performance Terminal User Interface (TUI) library for Go, engineered with almost zero heap allocations and zero GC pauses.

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages