TermMosaic
A terminal UI framework for Go: a cell-buffer renderer with two-tier diffing and 24 ready-to-use widgets. v1.0.0.
A terminal UI framework for Go
TermMosaic is a cell-buffer renderer plus a catalog of 24 widgets. The renderer double-buffers cells, diffs at two tiers, and tracks dirty rectangles; the catalog is the part that is the point — including the five measurement widgets that comparable Go TUIs do not ship.
v1.0.0 is the first release that makes a stability promise. The public API freezes there and Semantic Versioning applies in earnest — a behaviour change means a minor, not a quiet patch — with one documented exception:
widgets/widgettest, the test harness, is excluded from that promise, because it must evolve with the framework. Everything on this site is accurate as of v1.0.0 (released 2026-10-06). Read Limitations before you rely on any of it — the honest list is short, specific, and load-bearing, and it names what is still open at this release.
go get github.com/serkanalgur/termmosaic@v1.0.0
Then read the quickstart, or run the example:
go run github.com/serkanalgur/termmosaic/examples/markets@v1.0.0
What’s new in v1.0.0
The first release that makes a stability promise — and the reason it is v1.0.0 rather than v0.8.0 is a behaviour change below.
- The public API freezes here. From v1.0.0 the project follows Semantic
Versioning in earnest: a behaviour change means a minor, not a quiet patch —
with one documented exception:
widgets/widgettest, the test harness, is excluded from the promise, because it must evolve with the framework. Every release before this was a pre-release under a break-without-notice policy, and v1.0.0 retires that paragraph. One honesty note the release carries itself: the gate’s SemVer criterion is recorded PARTIALLY MET, because v0.5.1, v0.5.2 and v0.6.1 were patch numbers that carried behaviour changes. The promise starts at v1.0.0; it is not retroactive. - The colour quantiser was replaced, and that is a behaviour change. PR #18
audited the 256/16-colour selection with CIEDE2000 and found the “redmean”
mapping inert —
rmean/256and(255-rmean)/256divide to zero inuint8arithmetic, so both weights were identically 2 and the formula was fixed2*dr²+4*dg²+2*db²in gamma-space RGB, flipping the hue of plausible UI colours. Measured selection error before: 256 rung max 21.201 (19.35% above the just-noticeable difference), 16 rung max 36.821 (37.50% above), withmarkets.downcollapsing to grey at the 16 rung and colliding withmarkets.flat. PR #19 replaced the quantiser with Lab-space (CIEDE2000) selection through the existingbuffer.Quantiserhook: selection error is 0.000 on both rungs, 0 of 281,216 colour-rungs regressed, and the frame path went 224.8 → 6.6 ns/op at 0 allocs. The bytes a program emits at the 256 and 16 colour rungs change. The colour model moved PROPOSED → DECIDED on measurement — decided because it was measured, not asserted. - Every widget now has a runnable example. 74
func Examplefunctions covering all 24 catalog widgets, in the eightwidgets/*/example_test.gofiles — each rendered throughwidgets/widgettest, so its// Outputcomment is the cell grid the renderer produced. This closes the framework’s largest unmet criterion (“every widget has a runnable example”). Test-only: no behaviour change. - Platform claim narrowed to Linux and macOS (ADR 0001). Windows is a
deliberate loud-error stub, out of scope for v1.0.0 — every console
operation fails loudly instead of half-working. The Windows CI test leg
was dropped: the full test suite on a Windows runner spent minutes
asserting only that the stub returns its documented error. CI is now
11 required checks:
test (ubuntu-24.04),test (macos-15),gofmt,golangci-lint,zero-allocation diff, and sixcross-compilelegs (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64) — Windows is still build-verified, just not test-run. - Two CI correctness fixes:
-count=1on test runs (a manual re-run could otherwise serve a cached PASS instead of re-executing) andcache: trueon the setup-go steps that did not declare it.
Open at this release — decided by nobody. v1.0.0 does not close these, and
reading the release as closure would be reading the maintainer’s mind: the
macOS test leg still runs and a further reduction has been discussed but not
done; and deleteBranchOnMerge is false at repo level. One item that was
undecided here has since been settled — widgets/widgettest is excluded
from the v1.0.0 stability promise (decided 2026-10-06), because a test harness
must evolve with the framework. The full list is on
Limitations.
What’s new in v0.7.0
A minor bump, and the reason is a fourth example: examples/search —
search and results on real Wikipedia data, no API key and nothing to sign
up for. A form.TextInput query field, a data.Table of results, and a detail
pane fed by the article-summary endpoint. --offline runs the whole screen on a
transcribed capture, so it needs no network at all.
- It is the first example with a focusable widget in the focus ring, and that
is what makes it worth reading rather than just running.
helloandmarketscompose screens out of widgets that hold no keyboard focus — their focus ring is an integer the screen owns. HereTextInputandTableboth implementFocusableand both have key contracts of their own, so this is the first place the catalog andkeymapmeet under real conditions. TableoverList, deliberately. Word counts span two orders of magnitude, and a right-aligned fixed column lets the eye find the longest article by shape; aListrenders one string per item and would have had the spacing built in by hand.- Nine golden files and 74 tests, all asserting on cells through the headless harness. No escape-sequence assertion anywhere.
- No capture on this site moved, and that is expected: the search example is not in the widget catalog, so no widget’s rendering changed.
Two findings worth knowing if you build a screen with real focus:
context-dependence is expressed with Command.Enabled rather than ScopeFocus
(no catalog widget implements Commandable, so a focus-scoped binding would mean
the application declaring keys on a widget’s behalf); and because
Registry.SetFocus still does not exist, Describe(ScopeFocus) is
over-inclusive rather than incomplete — fine for a help screen, wrong for a
hint line. Both are in full on the Limitations page.
What’s new in v0.6.1
A minor bump, and the reason is that the first application to actually use
keymap found a shape the specification had not considered.
keymap.Registry.DescribeGrouped(scope)— oneEntryper command, carrying every chord in scope for it.Describestays one per chord, which is what ADR 0009 §9 specifies for a command palette, where a row consumesChords[0]. The difference is the consumer:form.KeyHint.SetEntriesjoins an entry’s chords into one label, so a hint line fedDescribeprinted a three-chord command’s description three times.examples/helloworked around it with its own merge; that is now a framework function.examples/hellodispatches throughkeymap— six commands, twelve chords, and both the pinned hint line and the?overlay render from the registry. A binding and its description are written once; the hand-written hint string and the test that checked it againstHandleare both gone. This retires risk 5 of ADR 0009.
Two visible consequences, if you took hello as near-enough-correct. The
pinned hint line’s text changed — it now reads
[Q q Esc Ctrl+c] quit · [?] toggle the keys — and the ? help overlay went
from two rows to three, because Describe spells every chord in full rather
than saying “arrows”. The key contract did not otherwise change: same keys, same
behaviour, and Widget.Handle claims nothing.
What’s new in v0.6.0
A minor bump, and the reason is the longest-deferred item in the project:
keymap shipped, after slipping v0.3.0 and
v0.4.0.
If you are on v0.5.x, the upgrade is additive. Widget, Event, Key and
Mouse are unchanged, and Widget.Handle’s interface is byte-identical — only
its doc comment changed, to state that events reach a widget after your keymap has
declined them. There is no migration to perform, and a program that never
touches keymap behaves exactly as before.
- Named commands, and a key is one way to invoke one. A new package holding
Command,CommandID,Binding,Entry,Ctxand a 16-byte comparableChord, withParseChord/ChordOfas the single notation function in both directions. - Resolution by context specificity: focus, then screen, then global, with no
numeric priority.
Dispatchwalks a pre-built candidate slice in rank order, which is what lets bothEnabledandRundecline and fall through. Ties break on registration order. Dispatchis 0 allocs/op on every event kind, measured and pinned across all eleven paths in ADR 0009 §2’s table — miss, match,Enablednil,Enablednon-nil,Rundeclining, paste, resize and each mouse case. 91 ns/op for a hit, 29.5 ns/op for a miss, both zero-alloc.Describeas the single source of discoverability data, plusChords, andKeyHint.SetEntriesso help renders from the registry rather than from a hand-maintained second list.- ADR 0009 gained five corrections where its code did not compile or
contradicted itself. The two worth naming:
Ctxis 152 bytes, not the 128 the prose claimed, pinned by a test with the arithmetic in the comment; and the precedence table contradicted itself on overrides, where specificity wins and an override is the within-scope tiebreak — the only reading under which a user’s globalEscdoes not steal a dialog’s.
What this release does not do, plainly: there is no palette. ADR 0009 §9 puts
a Ctrl+K palette in scope and explicitly not in that ADR. There is also no
catalog widget implementing the optional Commandable or Clickable, and a
key the keymap consumes shadows a widget’s own switch without the registry
being able to report it — it is told a widget’s bounds and published chords,
never what its Handle does. The full list is on
Limitations.
What’s new in v0.5.2
A minor bump, and the reason is a decision with a number attached: ADR 0010 settles who receives a mouse event — and the answer exposed three widgets that were getting it wrong.
form.Tabs,form.Selectandform.Radioconsumed a wheel notch regardless of where the pointer was.Tabs.Handletested the wheel before theswitch ev.Kind, so a notch never reached the bounds check its click path already used;SelectandRadioreached the same place through the sharedoptionlisthelper. A tab row, a select or a radio group took the wheel from whatever sat beneath it. Per ADR 0010, a widget handles a pointer event only when the pointer is inside itsBounds()— with two stated exemptions: a release ends a drag wherever the pointer is, and a drag continues outsideBoundsonce a press has claimed it.examples/marketsbehaviour changes. Its tab row is first in the focus ring, so it swallowed every notch in the app. A notch over a KPI tile, which no widget in the ring owns, is now consumed by nobody rather than scrolling the tab row by three.
The ADR also states which widgets decline a wheel outright: Button, Checkbox,
Toggle and Split have nothing to scroll, and TextInput/TextArea decline
by decision — TextArea wheel-to-scroll is plausible, but adding it under a
routing ADR would answer a different question.
What’s new in v0.5.1
A minor bump, and the reason is seven defects: the style a widget computes for one cell was stopping where two code paths diverged, and never reaching the text beside it. Three of the five releases before this existed because of this one class.
Dialog:ChoiceFocusStylenever reached the choice label — the row was filled and marked in the focus style while its text was written in the unfocused style. With default styles the focused choice renderedattr=noneon anattr=reverserow: unreadable dark-on-dark, on exactly the row the reader is meant to look at.Button:FocusStyleandDisabledStylereached the ring and the fill but not the label, so a disabled button rendered blue brackets around default-coloured text. The field’s own documentation already promised otherwise.Table:SelectedStyleandItemStylefilled the row but never reached the cell text, andHeaderStylewas documented as “patched withItemStyle” and never was. Both now do what their docs said. The trade, stated plainly: on the selected row a cell’s ownStyleand its column’sCellStyleno longer show, because the row style now overrides a single-span cell — a multi-span cell keeps its own. And the header now carriesItemStyle’s foreground as well as its background, becausePatchtakes both.Menu: the check glyph and the submenu arrow were drawn inItemStyleon the selected row, where the row is reversed — the one unreadable thing on it.Radio: the focus gutter was styled as a focus mark on every row, so an unfocused group painted a reverse-video stripe down its left edge. The column still exists on every row so labels align; only the rendition was wrong.BarChart: axis labels overran their column — a computed centring offset was discarded with_ = lx, so a label wider than its column overwrote the next category’s.
Every one of these renders differently from v0.4.x because it now renders correctly. If your app looks different after upgrading, that is this release working. The full detail is on the Limitations page.
What’s new in v0.5.0
A minor bump, and the reason is the first item: five exported fields are now private, so a program that assigns them will not compile. Start here if you are arriving at v0.5.x from v0.4.x.
Breaking — Pager.Status, Split.Spacing, Meter.ShowValue,
ProgressBar.Label and ProgressBar.Percentage are no longer exported fields.
Each already had a working setter, so migration is mechanical:
| Before | After |
|---|---|
p.Status = on |
p.SetStatus(on) |
s.Spacing = n |
s.SetSpacing(n) |
m.ShowValue = on |
m.SetShowValue(on) |
p.Label = s |
p.SetLabel(s, st) |
p.Percentage = on |
p.SetPercentage(on) |
Read-only accessors exist too: Status(), Spacing(), ShowValue(),
Percentage(), Label(). The reason is in
ADR 0007 §3: a widget caches its derived layout
keyed on Bounds(), so a field that changes without an Invalidate() produces a
stale layout that nothing ever repairs. A doc comment saying “call Invalidate
after assigning” is a rule with no enforcement, no compile error and no reminder.
- Eight widgets kept a stale layout cache after a documented setter — found by
the new cache-audit mode, not by review:
Pager.SetStatus,Select.SetMarker,BarChart.SetData,Meter.SetShowValue,ProgressBar.SetLabel/SetLabelSpans/SetPercentage,Sparkline.SetValues, andSplit.Spacingvia direct assignment. Each now drops the cached derivation. - A cache-audit mode, specified as ADR 0007 §3’s deferred “expensive half”: it
corrupts a widget’s cached derivation after a
Drawand asserts the next frame is byte-identical, so this defect class is caught mechanically. Opt-in viarender.Config.CacheAudit, and zero-allocation when disabled. widgets/cacheauditnow fails the build when a widget in the catalog is flagged, on all three platforms via the existinggo test ./... -racejob.
The gate covers the transitions it names, so the exported raw fields that remain —
Select.Marker, Gauge.ShowValue, BarChart.ShowValue/Vertical/Data,
Sparkline.Values/Braille, TextInput.Placeholder, Checkbox.TriState — are
the same shape but produce no finding today. See
Limitations.
What’s new in v0.4.1
A patch, and a dependency-only one: golang.org/x/term v0.27.0 → v0.29.0 and
golang.org/x/sys v0.28.0 → v0.30.0. No library code changed, so there is
nothing to adapt to, and the Go 1.23 floor is preserved — the pin still keeps
x/term off the releases that would raise it. That is the whole release; it
answers framework issue #4.
What’s new in v0.4.0
A minor bump, and the reason is one fix: styling you set on a
Tree used to be silently discarded, and is now honoured.
That is a behavioural change, so it is a minor bump and not a patch.
If your tree looks different after upgrading, this is why — and if you had worked around the old behaviour by compensating in your own styles, remove that compensation now, because it is double-counting.
Treeignored per-node and selected-row styling on the label. The row painter computed the right style for each node — the node’sStyle, falling back toItemStyle, andSelectedStyleoutright when the row was selected — and then applied it only to the expander glyph, writing the label through a call that took no style at all. So the label was drawn with whatever the terminal default happened to be, and only the little marker beside it was styled. All three now apply to the label, and the selected style wins on the selected row. A deliberately multi-span label keeps its per-span styles, and the fill covers the label region only — it cannot reach the marker or the indent.
This closes the defect class rather than opening one. v0.3.0 fixed List and
Table; Tree was missed because its row painter computed the style for one
cell and then took a different call for the text beside it, so the computed
style stopped exactly where the two code paths diverged.
The rest of this release is CI and tooling, not the library: every GitHub
Actions action moved to a Node 24 major (checkout v4→v7, setup-go v5→v7,
golangci-lint-action v7→v9, github-script v7→v9) and every runner image is
pinned by name instead of tracking -latest (ubuntu-24.04, macos-15,
windows-2025). All twelve checks then ran green on the v0.4.0 pull request,
and the post-merge run on main was green as well. What that run still does not
cover is recorded on the Limitations page rather than glossed.
No capture on this site moved, and that is expected: the generator’s tree entry sets no styles, so the fix is inert there. The full rationale is on the Limitations page.
What’s new in v0.3.0
A minor bump, and the reason is the two fixes below: styling you set on a
List or a Table used to be silently
discarded, and is now honoured. That is a behavioural change, so it is a minor
bump and not a patch.
If your widgets look different after upgrading, this is why — and if you had worked around the old behaviour by compensating in your own styles, remove that compensation now, because it is double-counting.
Listignored per-item and selected-row styling. The style computed for each row was never passed to the paint call, so it was computed and thrown away.SelectedStyle, documented as the selected row’s rendition, reached only the background fill and never the glyphs.ItemStyleandSelectedStylenow both apply, with the selected style winning on the selected row. A deliberately multi-span row keeps its per-span styles — flattening one would mean allocating on the frame path.Tablediscarded every cell style. The sentinel meaning “write these spans verbatim” was a resolved style rather than an unset one, so the guard never fired and each cell’s ownStylewas overwritten with the terminal default, along with every column’sCellStyle. Both now render as given.HeadingStylewas never affected and still works.
Both were documented as taking effect already, so these are fixes to behaviour that contradicted the documentation. The full rationale is on the Limitations page, and the captures on this site have been regenerated to show the corrected rendering.
What’s new in v0.2.0
A minor bump, and the reason is the framework fix below: Renderer.Post now
wakes the frame pacer, which is a behavioural change. If you are on v0.1.0 and
your app updated the screen from Post, it was almost certainly frozen.
Fixed
Renderer.Postnever woke the pacer, so async apps froze.Postqueued work, callbacks only ran insideRender, andRenderwas gated onNeedsFrame()— which cannot become true until the callback runs. An app that updates the screen fromPost, which ADR 0003 documents as the safe way to mutate widget state, painted one frame and idled forever.examples/marketshit it: live runs painted one empty frame and stopped. A regression test now covers it.- The diff emitted a cursor move before every wide glyph. Run suppression assumed one cell per rune, but a wide glyph advances the cursor by two. On 6,000 wide glyphs that was 6,000 cursor moves against the narrow scene’s 30, and 68,832 bytes against 6,233 — about 11× — for identical output. It now advances by the glyph’s cell width: 60 moves, 19,443 bytes, 3.12×. The ASCII path is unchanged at ~7,200 ns/op, 0 allocs.
BarChartdrew every horizontal category label at absolute column 0 — a widget writing outside its own rectangle.
Added
Menu— a navigable tree with submenus to arbitrary depth.Dialog— a modal with info, confirm and choice variants that traps keys and restores focus on close.examples/markets— a live Grafana-style finance dashboard: ECB FX from Frankfurter and crypto from CoinGecko, no API key, and--offlinefor bundled sample data.- Keyboard and mouse in both examples.
hellogains a focus ring and a?help overlay;marketsgains a two-entry focus ring, per-panel key routing, wheel and click, pause, and a help overlay. - ADR 0009 — the command and keymap layer.
Specified, not implemented: there is no
keymappackage and no command palette yet, and widgets still dispatch their own keys. (Accurate as of v0.2.0. The package shipped in v0.6.0; the palette still does not exist.)
Changed
examples/hellois genuinely responsive. It usedMax(46)inside twoFill(1)s, so it shrank but never grew — a 200×60 terminal still drew a 46×9 block floating in the middle. It now spans the terminal, re-arranges across four bands, and below 38×8 says so in one line rather than clipping.- The catalog is 24 widgets, up from 22.
Full detail, including the Known Limitations section, is in the framework’s CHANGELOG.md.
What is measured, and what is not
These are the only performance figures on this site. They come from the
framework’s own benchmarks and its docs/STATUS.md, and nothing here is an
estimate.
| Property | Measured | Source |
|---|---|---|
| Two-tier diff vs a full repaint, 200×60 scene that is 99% static chrome | 141 bytes written where a full repaint writes 19,979 — about 141× fewer, at ~7,133 ns/op, 0 allocs/op | ADR 0001, ADR 0002 |
| Allocations on the frame path | 0 per frame, asserted by a test rather than benchmarked | ADR 0008 |
List render cost, 10,000 items → 100,000 items |
13,320 ns → 14,242 ns — 7% for ten times the data, both at zero allocations | widgets/data benchmarks |
Table render cost, 10,000 items → 100,000 items |
16,801 ns → 17,885 ns, same shape | widgets/data benchmarks |
| The key-decoding path | 0 allocations, pinned by test | ADR 0005 |
tcell’s flush, same one-row-dirty workload |
280,814 ns/op against our 7,133 ns/op — the measurement that settled ADR 0001 | ADR 0001 |
| Colour-quantiser selection, steady state (v1.0.0) | Nearest256 224.8 → 6.611 ns/op, Nearest16 16.02 → 7.126 ns/op, both 0 allocs — the Lab/CIEDE2000 replacement selects through a per-colour memo, so only the first use of a colour pays for the exhaustive search |
Framework buffer benchmarks, recorded in the v1.0.0 release notes |
There are no benchmarks on this site for the widgets’ visual output, for
frame pacing under load, or for drag-resize. The resize costs in
ADR 0007 are derived from existing code and
from ADR 0002/0003’s numbers; no resize has ever been observed against a real
terminal being dragged. docs/STATUS.md says so itself and this site does not
paper over it.
cmd/capture through
widgettest.Capture: colour at 3 widths
(40, 80, 120 columns), and the exact plain-text cell grid at
the widest of them.
MinSize() returns 8 × 6 cells.
That is the whole widget including its own chrome, and it is the
widget's assertion about itself — below it, Draw clips rather
than blanks, and what to do about that is the application's decision.
40 columns × 11 rows
╭ allocations per frame ───────────────╮ │ 412 968 1284 2140 356 │ │ █ │ │ █ │ │ ▃ █ │ │ ▂ █ █ │ │ █ █ █ │ │▄ █ █ █ ▂ │ │█ █ █ █ █ │ │-buffer--render-widgets-headle…-input-│ ╰──────────────────────────────────────╯
80 columns × 11 rows
╭ allocations per frame ───────────────────────────────────────────────────────╮ │ 412 968 1284 2140 356 │ │ █ │ │ █ │ │ ▃ █ │ │ ▂ █ █ │ │ █ █ █ │ │▄ █ █ █ ▂ │ │█ █ █ █ █ │ │-----buffer----------render---------widgets--------headless---------input-----│ ╰──────────────────────────────────────────────────────────────────────────────╯
120 columns × 11 rows
╭ allocations per frame ───────────────────────────────────────────────────────────────────────────────────────────────╮ │ 412 968 1284 2140 356 │ │ █ │ │ █ │ │ ▃ █ │ │ ▂ █ █ │ │ █ █ █ │ │▄ █ █ █ ▂ │ │█ █ █ █ █ │ │---------buffer------------------render-----------------widgets----------------headless-----------------input---------│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Plain text — the exact cell grid, trimmed per line. This is the honest form: Braille and block-element glyphs can take a different advance width in a web font than a terminal gives them, which breaks the alignment they depend on.
# BarChart — Categorical magnitudes as horizontal or vertical bars, with an axis and per-bar values. # package: widgets/viz # constructor: viz.NewBarChart(r buffer.Rect) *viz.BarChart # 120 columns x 11 rows, rendered through widgettest.Capture ╭ allocations per frame ───────────────────────────────────────────────────────────────────────────────────────────────╮ │ 412 968 1284 2140 356 │ │ █ │ │ █ │ │ ▃ █ │ │ ▂ █ █ │ │ █ █ █ │ │▄ █ █ █ ▂ │ │█ █ █ █ █ │ │---------buffer------------------render-----------------widgets----------------headless-----------------input---------│ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
What you are looking at. This is the cell grid the renderer produced, in a web page. It is not a screenshot of a terminal, and it cannot show interaction, resize or animation — a capture is one settled frame after N frames of settling. Braille and Block Elements can take a different advance width in your browser's font than in a terminal, so if a gauge or a sparkline above looks wrong, the plain-text form below it is the exact output and the colour one is the decoration. The colour frames are at 40, 80, 120 columns; the plain-text frame is at 120. More on this.
That is a BarChart at three widths, rendered by the framework’s own capture
tool from the cells the renderer produced.
About the pictures on this site — read this once
Every capture on this site is a cell grid, not a screenshot of anyone’s
terminal. It is produced by cmd/capture in the framework repo, which runs each
widget through widgettest.Capture — the same path the 1,186 top-level test functions assert
on — and converts MemorySink.Cells() to HTML. That is what makes it trustworthy: the
docs cannot show something no test pins.
It is also what it is, and the limits are not closable:
- No interaction. A capture is one settled frame after N frames of settling. It cannot show a keypress, a selection moving, a cell flickering, a pager scrolling or a tree expanding. Every widget page says so.
- No animation, no timing, no frame pacing.
render.Pacerand the 30–60 fps budget are invisible in a picture. - No live resize. Instead, every widget page shows three fixed widths — 40, 80 and 120 columns — side by side. That is more informative than a resize demo, because each frame is exact and the widths are named.
- No terminal fidelity. You are not seeing your font, your colour scheme, your background, your terminal’s cell aspect ratio, or its wide-glyph and ligature behaviour.
- Braille and Block Elements may misalign in a web font.
GaugeandSparklineare Braille (U+2800–28FF);ProgressBar,Meter,GaugeandBarChartuse Block Elements (U+2580–259F). If those glyphs take a different advance width in your browser than in a terminal, the alignment those five widgets depend on is destroyed. That is exactly why the plain-text capture sits beside every colour capture on every widget page. The plain-text form is exact; the colour form is the persuasive one.
Full statement: Captures are cell grids, not terminal screenshots.
The four examples
Every one runs with go run github.com/serkanalgur/termmosaic/examples/<name>.
All four are keyboard- and mouse-driven, and all four take --offline where
they have a network path.
| Example | What it demonstrates |
|---|---|
markets |
A real dashboard: three reflowing bands, live ECB FX from Frankfurter and crypto from CoinGecko, no API key, a two-entry focus ring and per-panel key routing. Walkthrough |
hello |
The smallest complete program: a bordered panel that survives a resize, a focus ring, and a ? help overlay — now both rendered from a keymap registry rather than hand-written strings. |
search |
Search and results on real Wikipedia data, no API key: a TextInput query field, a Table of results and a detail pane. The first example with a focusable widget in the focus ring, which is why it is the one to read if you want the catalog and keymap to meet under real conditions. |
dashboard |
An older program that overlaps markets heavily. Whether to keep it or retire it is undecided, so this site points new readers at markets and does not present the two as equally recommended. It has not been removed. |
The 24 widgets
| Group | Widgets |
|---|---|
| Core | Block Text Paragraph Split |
| Forms | TextInput TextArea Select Checkbox Radio Toggle Tabs Button KeyHint |
| Data | List Table Tree Pager |
| Visualization | ProgressBar Gauge Meter Sparkline BarChart |
| Navigation & modality | Menu Dialog |
That is 24, counted: every exported type with a New… constructor that
satisfies termmosaic.Widget. buffer.Buffer is deliberately not on the list —
it has Invalidate() but no Bounds, Draw or Handle, so it is not a widget,
it is what widgets draw into. widgets/form/optionlist.go is an unexported
helper behind Select, Tabs and KeyHint, not a widget of its own.
There is no Form container widget, on purpose. A Form type would be a
second way to do what ADR 0004’s constraint solver and
the layout package already do. See Forms.
Start here
- Install — Go 1.23+,
CGO_ENABLED=0, what is and is not released. - Quickstart — a working program in one file.
- Your first app — terminal, renderer, input and loop, end to end.
- Concepts — the twelve ideas the framework is built from. Start with Renderer and diff and Widgets and focus.
- Widgets — the catalog, one page per widget, with captures.
- Architecture decisions — the ten ADRs, verbatim, with the rejected alternatives and the risks.
Why it exists
The Go TUI ecosystem has one dominant framework and it leaves room:
- OpenTUI is a strong engineering artifact with a deliberately unfinished surface. At 13.4k stars and running in production, it ships no progressbar, gauge, meter, sparkline or bar chart, and no list, tree, pager or virtual scroll.
- Ink clears and repaints the whole screen on each update, which shows up as input lag once the application gets large.
The bet is narrow: the widget catalog is the product. A framework nobody can build a real dashboard on is a toy, however elegant its renderer.
Honest status, in one paragraph
The renderer, the input layer, the layout solver, the full 24-widget catalog and
the keymap package are built and tested: 28 packages, 1,186 top-level test functions, a zero-allocation frame
path. Alongside that: Windows is a stub that returns a loud error from every
console operation, there is still no command palette, no catalog widget
implements the optional Commandable/Clickable, a key the keymap consumes
shadows a widget’s own switch and the registry cannot report it, there is
no IME or preedit, tmux DCS passthrough is
missing, TextArea has no rendered selection, and there is no theme —
by decision, argued in
ADR 0008, not by omission. One decision made at
v1.0.0 is recorded as open rather than settled: whether the macOS test leg
should also go. (widgets/widgettest’s compatibility promise was in that
list too; it has since been decided — the package is excluded from the
v1.0.0 stability promise, 2026-10-06.) Everything in that list is on
Limitations with the reason.
Elsewhere
- Source: github.com/serkanalgur/termmosaic
- API reference: pkg.go.dev/github.com/serkanalgur/termmosaic — which is never out of date, because it is generated from the source
- The ten architecture decision records, verbatim
CHANGELOG.md, hand-maintained, with a Known Limitations section
MIT licensed. Informed by Ratatui, Bubble Tea, Textual and OpenTUI.
Pages in this section
- Getting started Three pages: install it, run a program in one file, then build a real application end to end.
- Concepts The twelve ideas TermMosaic is built from, and why each one was decided rather than defaulted into.
- Widgets All 24 widgets, with a captured frame at three widths for each.
- Guides Composition, forms, data display, dashboards, performance and migration — task-shaped rather than widget-shaped.
- Reference Generated from the framework's own capture manifest — nothing here is hand-maintained.
- Architecture decisions The ten ADRs, verbatim — what was decided, what was rejected, and why.
- FAQ The questions a reader arrives with, answered without hedging into uselessness.
- Search Full-text search over every page of this site. Indexed by Pagefind from the built HTML.
- Limitations What TermMosaic does not do, every item traceable to the framework's own status document. Read this before you rely on anything.