Skip to content

Latest commit

 

History

8 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ZeroTUI

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.

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), 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.

Table of contents

Features

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.

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 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

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

Image
  • Click x on a panel to close it
  • Use the mouse to scroll
  • Press 1 / 2 / 3 to reopen Controls / Market / Table
  • Drag the dividers to resize each panel
  • Press q to quit
Image - Press `Ctrl+c` to quit

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.

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

The reference below covers every widget, layout primitive, and application-level API in ZeroTUI. Expand only the sections you need.

🎨 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:

widget.AlignLeft
widget.AlignCenter
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.


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.

⚙️ 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:

widget.IsFocused()

and:

widget.Focus(true)

The application can explicitly focus a widget with:

a.Focus(widget)

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

ZeroTUI uses four cooperating layers:

  1. Layout computes widget placement and can reuse retained placement storage.
  2. Widgets paint cells into a reusable back buffer.
  3. Damage tracking identifies the smallest screen regions that need repainting.
  4. 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.

Input and terminal behavior

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.

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, 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

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.

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.

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:

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.

ZeroTUI sparse rendering and trading workloads

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.

Editor benchmark results

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.

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

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages