A retained-mode, low-allocation terminal UI library for Go.
ZeroTUI is designed for applications where terminal rendering is part of a real-time workload: market-data terminals, trading dashboards, operations consoles, telemetry viewers, risk monitors, and dense interactive tools.
The core idea is simple: update data frequently, repaint only what changed, and keep the terminal renderer out of the allocation-heavy path.
Many terminal applications spend far more work rebuilding text than the user can actually see changing. ZeroTUI takes a different approach: widgets draw into a retained cell buffer, the compositor tracks damage, and the terminal writer emits only changed cells.
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), Ubuntu 24.04 / Killercoda. 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 v1.0.3
charm.land/bubbletea/v2 v2.0.9
charm.land/lipgloss/v2 v2.0.6
ratatui v0.30.2
| Metric | ZeroTUI | Bubble Tea v2 | Ratatui |
|---|---|---|---|
| Realistic sustained ticks/sec (20 rows) | 936.5 | 900.5 | ~1,000 |
| Realistic sustained ticks/sec (500 rows) | 934.8 | 917.8 | ~1,000 |
| Realistic allocation rate (20 rows) | 33 KB/s | 25.0 MB/s | 7.6 MB/s |
| Realistic allocation rate (500 rows) | 31 KB/s | 25.0 MB/s | 7.6 MB/s |
| Full-frame render (20-row headless microbench) | 64.2 µs | 68.0 µs | 189.7 µs |
| Full-frame render (500-row headless microbench) | 59.6 µs | 71.6 µs | 189.5 µs |
| Go GC cycles (realistic sustained) | 0 | 334 / 117 | N/A |
- Throughput: Ratatui remains the fastest raw sustained logical-tick processor in the recorded run. ZeroTUI stays close to the 1 kHz target and ahead of Bubble Tea on both realistic row counts.
- Allocation: ZeroTUI's realistic allocation traffic is roughly 99.6–99.9% lower than the other two implementations in these measurements.
- Rendering: In the supplied headless frame benchmark, ZeroTUI's full-frame render was about 6% faster than Bubble Tea at 20 rows and about 17% faster at 500 rows, while Ratatui's measured frame time was substantially higher in this particular workload.
- Worst-case 500-row allocation: ZeroTUI measured 14.89 MB/s, essentially the same order as Ratatui's 14.80 MB/s, and about 70.7% below Bubble Tea's 50.78 MB/s.
- Features
- Installation
- Quick start
- Widgets & layout primitives
- Examples
- Editor & developer tooling
- Documentation
- Rendering architecture
- Input and terminal behavior
- Performance
- License
ZeroTUI is a particularly good fit 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.
go get github.com/ZeroGCDev/zerotuiA ZeroTUI application needs three things:
- a widget,
- a layout root,
- 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 example (toggle, button, dynamic label)
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)
}
}| 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.
- Click
xon a panel to close it - Use the mouse to scroll
- Press
1/2/3to reopen Controls / Market / Table - Drag the dividers to resize each panel
- Press
qto quit
- Press `Ctrl+c` to quit
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.
- 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.CodeVieweradapter 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.
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.
The reference below covers every widget, layout primitive, and application-level API in ZeroTUI. Expand only the sections you need.
🎨 Colors, styles & theming
Create an RGB color:
red := color.RGB(220, 50, 47)Use a named color:
blue := color.TokyoBlueA 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.ItalicUse 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()Clone an existing theme:
theme := style.NordTheme().Clone()Change selected color:
theme.Selected.Bg = color.RGB(70, 120, 190)
theme.Selected.Fg = color.WhiteChange panel:
theme.Panel.Bg = color.RGB(30, 34, 42)Change title:
theme.Title.Fg = color.RGB(100, 200, 220)
theme.Title.Attr = style.BoldUse it:
app.New(root, theme).Run()Most standard visual widgets expose:
ThemeOverride *style.ThemeZeroTUI 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 = &buttonThemeComponent typography can be controlled through the theme's foreground/background and terminal attributes such as Bold, Dim, Underline, Reverse and Blink.
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.
Many widgets expose:
Background *color.Colornil means:
inherit the surface already behind the widget.
Example:
bg := color.RGB(30, 35, 45)
label := widget.NewLabel("Custom surface")
label.Background = &bgFor a shared color:
panelBg := color.NordPanel
label.Background = &panelBg
button.Background = &panelBgThis is preferable to making every component a different random color.
Widgets that explicitly support foreground overrides can use:
Foreground *color.ColorFor example:
badge := widget.NewBadge("LIVE")
fg := color.NordCyan
badge.Foreground = &fgFor components without a dedicated Foreground field, use a style.Style where supported, or use a component ThemeOverride.
📐 Layout system
Padding reserves space around a child.
content := layout.Padding(
layout.Wrap(widget.NewLabel("Hello")),
2, // left
1, // top
2, // right
1, // bottom
)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)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 permitsUse 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 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)),
)layout.Fix(node, 10)The 10 is the size on the Flex main axis.
Horizontal:
┌──────────┬─────────────────────────┐
│ fixed 10 │ flexible │
└──────────┴─────────────────────────┘
Vertical:
┌─────────────────────────────┐
│ fixed 3 │
├─────────────────────────────┤
│ flexible │
│ │
└─────────────────────────────┘
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%
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 = 0Use a larger gap:
form.Gap = 2For compact terminal forms, Gap = 0 is often useful when the containing panel already provides grouping.
A layout.Item can also have:
Width
HeightFor example:
item := layout.Flex1(layout.Wrap(input))
item.Width = 40
item.Height = 3This is useful when a flexible child should remain centered at a particular cross-axis size.
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 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.
Use:
layout.Verticalfor a top/bottom split:
split := layout.NewSplit(
layout.Vertical,
layout.Wrap(top),
layout.Wrap(bottom),
0.60,
)A Split exposes:
MinFirst
MinSecondExample:
split.MinFirst = 20
split.MinSecond = 30This prevents either pane becoming unusably narrow.
The default minimums are already conservative.
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
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
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.
Use:
centered := layout.Center(
layout.Wrap(widget.NewLabel("Centered")),
40,
5,
)This creates a centered 40x5 area when the parent has enough space.
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 ] │
│ │
└────────────────────────────────┘
Use:
layout.BorderedRounded(...)when you want rounded corners.
Similarly:
layout.ClosableRounded(...)creates a rounded closable panel.
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.
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
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
The simplest text widget:
label := widget.NewLabel("Hello")Change it:
label.SetText("New text")Style it:
label.Bold = trueCustom style:
s := style.Style{
Fg: color.NordCyan,
Bg: color.NordPanel,
Attr: style.Bold,
}
label.Style = &sCustom background:
bg := color.NordBackground
label.Background = &bgTextFn 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.
Create:
button := widget.NewButton(
"SAVE",
func() {
// action
},
)The callback runs when the button is activated by keyboard or mouse.
Danger button:
button.Danger = trueThis uses the theme's negative semantic role.
bg := color.NordPanel
button.Background = &bgFor a custom button appearance, prefer a component theme:
buttonTheme := style.NordTheme().Clone()
buttonTheme.Positive.Fg = color.NordCyan
button.ThemeOverride = buttonThemeCreate 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.
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.
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.TrackWidth = 24Use this when you want a consistent control width.
Gauge is a non-interactive progress/utilization bar.
gauge := widget.NewGauge("CPU")
gauge.Value = 0.42Value is expected in:
0.0 → 1.0
Example:
CPU █████████░░░░░ 42%
gauge.WarnAt = 0.70
gauge.DangerAt = 0.90The 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 = &sIf 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.
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.
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 = &downPriceTicker 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.
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.
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.
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)Columns support:
widget.AlignLeft
widget.AlignCenter
widget.AlignRightFor prices and quantities:
{Title: "PRICE", Width: 12, Align: widget.AlignRight}For symbols:
{Title: "SYMBOL", Width: 12, Align: widget.AlignLeft}A column with:
Width: 0is 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
Tables support:
table.SelectedThe selected row is visually highlighted when the table has focus.
Customize selection foreground:
fg := color.NordWhite
table.SelectionForeground = &fgCustomize selection background:
bg := color.NordBlue
table.SelectionBackground = &bgSelection is applied across the complete row.
Enable:
table.Zebra = trueThis gives alternating row surfaces for dense data.
Use zebra styling carefully. A subtle difference is usually easier to read than strong alternating colors.
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
}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.
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
The callback:
Cell func(row, col int) stringshould 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.
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.
Enable:
table.ShowScrollBar = trueCustomize:
track := color.NordDimGray
thumb := color.NordCyan
table.ScrollTrack = &track
table.ScrollThumb = &thumbSelection colors:
table.SelectionForeground = &fg
table.SelectionBackground = &bgThe selected scrollbar cell retains the scrollbar thumb glyph so the thumb stays visually continuous.
For a large one-column list:
list := widget.NewVirtualList(
1_000_000,
func(index int) string {
return itemAt(index)
},
)This is preferable to building:
[]stringfor a huge dataset when only a small viewport is visible.
list.Selected = 42Handle selection:
list.OnSelect = func(index int) {
// selected index
}Customize:
list.ShowScrollBar = true
list.SelectionBackground = &selectionBg
list.SelectionForeground = &selectionFgUse 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.
Create:
tabs := widget.NewTabs([]string{
"POSITIONS",
"ORDERS",
"RISK",
})Set the active tab:
tabs.Active = 1React to changes:
tabs.OnChange = func(index int) {
// switch content
}Tabs draw the tab strip; your application decides what content belongs to the active tab.
Create:
input := widget.NewTextInput("Symbol")Enable a border:
input.Border = trueSet an initial value:
input.SetValue("BTC-PERP")Read it:
value := input.String()Submit callback:
input.OnSubmit = func(value string) {
// use submitted text
}Set:
input.Numeric = trueThis restricts input to digits and ..
Useful for:
- quantities
- prices
- percentages
- numeric parameters
Background:
bg := color.NordBackground
input.Background = &bgTheme:
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.
There are two ways to create a panel.
panel := widget.NewPanel(
"Status",
label,
)A widget.Panel contains one Widget.
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.Background = &bg
panel.Rounded = true
panel.Focused = trueOr use:
panel.ThemeOverride = customThemeFor complex panels, layout-level Bordered is generally more flexible.
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 = trueThe widget stores logs in a bounded ring.
This avoids unbounded memory growth.
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.
Create:
badge := widget.NewBadge("LIVE")Semantic mode:
badge.Positive = trueor:
badge.Negative = trueor:
badge.Info = trueExplicit colors:
fg := color.NordCyan
bg := color.NordPanel
badge.Foreground = &fg
badge.Background = &bgCreate 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.
Create a KPI:
stat := widget.NewStat(
"Latency",
"1.2ms",
)Optional delta:
stat.Delta = "-0.3ms"
stat.Down = trueOr:
stat.Delta = "+12%"
stat.Up = trueUse Stat for compact dashboard metrics.
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.
Create:
bar := widget.NewGradientBar(
color.NordCyan,
color.NordBlue,
color.NordDimGray,
)Set:
bar.Value = 0.65It is useful for:
- utilization
- intensity
- health
- confidence
- capacity
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 = &bgIt is primarily useful when implementing custom scrolling components.
Most users should use the scrollbar built into:
VirtualListVirtualTable
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.80It is pointer-oriented rather than keyboard-focus oriented.
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 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.
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
Documentmodel 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 = falseFor a source editor with syntax/folding, use CodeEditor instead.
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 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 = falseIt exposes Text()/SetText(), cursor/scroll state, and an OnChange callback. It intentionally does not include the heavier document/syntax/folding machinery of TextEditor.
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 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 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.
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.
ZeroTUI also includes focused market/operations widgets:
trades := widget.NewTimeAndSales(100, 2, 2, 2)
trades.AddTrade(widget.Trade{Price: 10025, Size: 3, Buy: true})positions := widget.NewPositions(100, 2, 2, 2)
positions.SetRows([]widget.Position{
{Symbol: "BTC-PERP", Qty: 2, AvgPrice: 10000, PnL: 125},
})orders := widget.NewOrders(100, 2, 2)
orders.SetRows([]widget.Order{
{ID: "42", Symbol: "BTC-PERP", Side: "BUY", Price: 10000, Qty: 1, Status: "OPEN"},
})pnl := widget.NewPnL(2, 2)latency := widget.NewLatencyMonitor()Its exported Stats field contains atomic Min, Max, Last, and Count counters.
risk := widget.NewRiskMonitor(2, 2)status := widget.NewMarketStatus("CME", "BTC-PERP")
status.SetState(widget.MarketLive)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.
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.
⚙️ Application, focus & input
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:
widget.IsFocused()and:
widget.Focus(true)The application can explicitly focus a widget with:
a.Focus(widget)where appropriate.
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.
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.
Default:
qYou can configure:
a.QuitKeys = []rune{'q', 'x'}Ctrl+C also causes the application to exit.
App exposes:
TargetFPS intFor live/interactive rendering:
a.TargetFPS = 60or:
a.TargetFPS = 30The scheduler is event-driven when idle.
This means a static application does not need to wake continuously just to redraw an unchanged screen.
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.
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.
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
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.
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.
For diagnostics and performance instrumentation:
total, dirty := a.RetainedState()This reports the current retained placement count and the number of retained placements marked dirty.
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 = falseZeroTUI deliberately uses different synchronization techniques depending on the data.
Examples:
Use atomic scalar storage.
Use internal mutex-protected ring/snapshot state.
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.
ZeroTUI uses four cooperating layers:
- Layout computes widget placement and can reuse retained placement storage.
- Widgets paint cells into a reusable back buffer.
- Damage tracking identifies the smallest screen regions that need repainting.
- The renderer diffs those cells against the front buffer and emits changed terminal runs.
A normal live update therefore does not need to clear and redraw the entire terminal.
The input parser keeps a fast ASCII path while supporting UTF-8 runes, SGR mouse events, and Kitty/progressive keyboard reports.
The renderer can optionally wrap changed frames in DEC synchronized-output mode 2026. 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.
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.shOr use the standard Go command directly:
go test ./benchmarks -run '^$' -bench . -benchmem -count=5Most 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, AMD EPYC 9V74 80-Core Processor.
The focused benchmark pass used a 200 ms measurement window and -benchmem.
| Operation | Latest review-run result |
|---|---|
App.Invalidate |
7.13 ns/op, 0 B/op, 0 allocs/op |
App.InvalidateWidgets |
12.46 ns/op, 0 B/op, 0 allocs/op |
| Sparse retained buffer render | 59.12 ns/op, 0 B/op, 0 allocs/op |
| Full buffer render | 31.69 µs/op, 6 B/op, 0 allocs/op |
| Flex horizontal layout | 292.0 ns/op, 0 B/op, 0 allocs/op |
| Split layout | 29.45 ns/op, 0 B/op, 0 allocs/op |
| Grid layout | 160.2 ns/op, 0 B/op, 0 allocs/op |
| Responsive layout | 45.71 ns/op, 0 B/op, 0 allocs/op |
| High-frequency market-update scenario | 15.47 µs/op, 0 B/op, 0 allocs/op |
| OrderBook tick / 100 levels | 1.25 µs/op, 0 B/op, 0 allocs/op |
The widget pass covered the shipped drawing and interaction benchmarks. Representative results include PriceTicker 78.94 ns/op, Table 5.24 µs/op, VirtualTable 6.56 µs/op, OrderBook 12.02 µs/op, and FastLogView 6.05 µs/op, all at 0 B/op and 0 allocs/op in this run.
The editor-specific benchmarks measured:
| Editor operation | Latest review-run result |
|---|---|
TextEditor draw / 500k-line source |
36.35 µs/op, 0 B/op, 0 allocs/op |
TextEditor search / 500k-line source |
517.9 ns/op, 0 B/op, 0 allocs/op |
CodeEditor viewport draw / 10k-line source |
67.60 µs/op, 0 B/op, 0 allocs/op |
CodeEditor search / 10k-line source |
69.02 ns/op, 0 B/op, 0 allocs/op |
CodeEditor edit + syntax update |
108.1 µs/op, 6.08 KB/op, 12 allocs/op |
Complete editor.Editor draw / ~1,000 source lines |
143.8 µs/op, 16 B/op, 1 alloc/op |
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.
| Framework | Rows | Density | Ticks completed | Effective ticks/sec | Allocation rate | GC cycles | GC pause total |
|---|---|---|---|---|---|---|---|
| ZeroTUI | 20 | realistic | 27,178 | 936.5 | 33 KB/s | 0 | 0.00 ms |
| ZeroTUI | 20 | worst | 27,069 | 933.0 | 0.60 MB/s | 5 | 0.08 ms |
| ZeroTUI | 500 | realistic | 27,126 | 934.8 | 31 KB/s | 0 | 0.00 ms |
| ZeroTUI | 500 | worst | 26,999 | 930.4 | 14.89 MB/s | 35 | 0.81 ms |
| Bubble Tea v2 | 20 | realistic | 26,357 | 900.5 | 25.05 MB/s | 334 | 8.52 ms |
| Bubble Tea v2 | 20 | worst | 531,340 | 18,109.3* | 26.07 MB/s | 370 | 8.72 ms |
| Bubble Tea v2 | 500 | realistic | 26,836 | 917.8 | 25.05 MB/s | 117 | 2.97 ms |
| Bubble Tea v2 | 500 | worst | 12,284,500 | 418,957.5* | 50.78 MB/s | 119 | 3.30 ms |
| Ratatui | 20 | realistic | 28,020 | ~1,000 | 7.59 MB/s | N/A | N/A |
| Ratatui | 20 | worst | 28,006 | ~1,000 | 7.86 MB/s | N/A | N/A |
| Ratatui | 500 | realistic | 28,021 | ~1,000 | 7.60 MB/s | N/A | N/A |
| Ratatui | 500 | worst | 28,014 | ~1,000 | 14.80 MB/s | N/A | N/A |
*Bubble Tea's extreme worst-density counter has different internal work/counting behavior in that stress path and is not treated as an apples-to-apples ordinary market-tick throughput number.
The supplied headless cross-framework microbenchmarks report the following central measurements:
| Framework | State mutation, 20 rows | Full frame, 20 rows | State mutation, 500 rows | Full frame, 500 rows |
|---|---|---|---|---|
| ZeroTUI | 347.1 ns | 64.2 µs | 337.2 ns | 59.6 µs |
| Bubble Tea v2 | 273.3 ns | 68.0 µs | 279.5 ns | 71.6 µs |
| Ratatui | 167.5 ns | 189.7 µs | 200.9 ns | 189.5 µs |
ZeroTUI does not win the isolated state-mutation test; Ratatui is faster there. ZeroTUI's advantage in this suite is concentrated in low-allocation rendering and retained/sparse update behavior.
The supplied ZeroTUI microbenchmarks measured:
- sparse 60 FPS frame path: 98.4 ns/op, 0 B/op, 0 allocs/op at 20 rows; 95.4 ns/op at 500 rows;
- full 60 FPS frame path: 64.2 µs/op at 20 rows; 59.6 µs/op at 500 rows;
- OrderBook update: approximately 3.86 µs / 10 levels, 9.14 µs / 25, 18.06 µs / 50, and 21.51 µs / 100, all with 0 B/op and 0 allocs/op in the supplied runs.
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.
A new 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 Platinum 8370C @ 2.80 GHz measured:
| Editor operation | Result | Allocations |
|---|---|---|
CodeEditor viewport draw, 10,000-line source |
123.7 µs/op | 0 B/op, 0 allocs/op |
CodeEditor search, 10,000-line source |
118 ns/op | 0 B/op, 0 allocs/op |
CodeEditor edit + syntax update |
1.20 ms/op | 683 KB/op, 19 allocs/op |
Complete editor.Editor draw, ~1,000 source lines |
258 µ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.
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.
MIT License © 2026 ZeroGCDev