TermMosaic v1.0.0

v1.0.0 is the first release that makes a stability promise: the public API freezes here and Semantic Versioning applies in earnest — with one documented exception: widgets/widgettest is excluded from that promise, because it must evolve with the framework. Read the limitations before you rely on anything here.

Getting started

Your first app

Terminal, renderer, input, focus and the frame loop — end to end, with the failure modes named.

Your first app

The quickstart gave you a panel and a loop. This page adds the two things a real application needs and the quickstart deliberately omits: a widget that takes keys, and a layout with more than one thing in it.

Everything here is written against v1.0.0, the first release that makes a stability promise. The limitations page applies in full.

The shape of a TermMosaic program

There is no framework object, no App.Run() and no message algebra. Five pieces, each owned by you:

Piece What it is
term.Terminal Size, raw mode, alternate screen, capability probing, reading. Two narrow interfaces — Terminal and Sink — and a direct x/sys implementation.
render.Renderer Owns the double-buffered cells, the dirty rectangles, the two-tier diff and the pacer. You give it a root widget.
Your widget tree Plain Go values implementing four methods. No registration, no reconciler.
input.Source One ordered channel carrying decoded keys, mouse events and resizes.
Your loop A quit channel, a goroutine per source, and pacer.Run(quit).

Two design decisions shape all of it and are worth knowing before you write anything:

  • The renderer is hybrid, not retained-with-reconciler. You keep the widget tree and invalidate rectangles. There is no virtual tree and no Elm-style loop, because requiring widget authors to implement correct incremental invalidation is a discipline Go cannot enforce. The cost is that Draw runs for every widget every frame — about 81 µs for 12,000 cells — with no lever to pull if that ever stops being cheap. ADR 0003.
  • The terminal layer is ours. tcell’s flush costs 280,814 ns/op on a one-row-dirty workload where TermMosaic’s two-tier diff costs 7,133 ns/op, and tcell’s headless backend cannot expose the cell buffer that widget tests need. That measurement is what settled ADR 0001, and it is why there is no compatibility layer with any other TUI.

A form, with focus and keys

Three widgets, a solved layout, and a focus order you move yourself.

package main

import (
	"fmt"
	"os"

	"github.com/serkanalgur/termmosaic"
	"github.com/serkanalgur/termmosaic/buffer"
	"github.com/serkanalgur/termmosaic/layout"
	"github.com/serkanalgur/termmosaic/render"
	"github.com/serkanalgur/termmosaic/term"
	"github.com/serkanalgur/termmosaic/widgets/block"
	"github.com/serkanalgur/termmosaic/widgets/button"
	"github.com/serkanalgur/termmosaic/widgets/form"
	"github.com/serkanalgur/termmosaic/widgets/keyhint"
)

// screen owns the widget tree and the focus order.
//
// Both are plain values. Focus is an index into a slice this type owns, not a
// tree walk, because the framework offers events to the focused widget and then
// to the tree and has no opinion about which of your widgets that should be.
type screen struct {
	bounds buffer.Rect

	name  *form.TextInput
	agree *form.Checkbox
	save  *button.Button
	hint  *keyhint.KeyHint

	// focus is an index into focusable, not a pointer. A slice keeps the order
	// visible and makes "Tab moves here" a statement rather than an accident.
	focusable []termmosaic.Focusable
	focus     int
}

func newScreen(r buffer.Rect) *screen {
	s := &screen{bounds: r}

	s.name = form.NewTextInput(buffer.Rect{})
	s.agree = form.NewCheckbox(buffer.Rect{}, "I have read the limitations")
	s.save = button.NewButton(buffer.Rect{}, "Save")
	s.save.Disabled = true // enabled once both the name and the box are set

	s.hint = keyhint.NewKeyHint(buffer.Rect{}, []keyhint.Binding{
		{Key: "tab", Help: "next field"},
		{Key: "enter", Help: "save"},
		{Key: "q", Help: "quit"},
	})

	s.focusable = []termmosaic.Focusable{s.name, s.agree, s.save}
	s.setFocus(0)
	return s
}

// setFocus moves focus and tells both the old and the new widget. Implementations
// invalidate themselves; you do not have to.
func (s *screen) setFocus(i int) {
	if s.focusable[s.focus] != nil {
		s.focusable[s.focus].SetFocused(false)
	}
	s.focus = i
	s.focusable[s.focus].SetFocused(true)
	s.Invalidate()
}

// Bounds satisfies termmosaic.Widget.
func (s *screen) Bounds() buffer.Rect { return s.bounds }

// Invalidate satisfies termmosaic.Widget.
//
// It marks the whole rectangle. Returning the layout-derived rects as well would
// be marginally cheaper and would need keeping in step with the layout, which is
// not a trade worth making by hand — see the sizing helper below.
func (s *screen) Invalidate() {}

// Handle satisfies termmosaic.Widget.
//
// Keys reach the focused widget first, so if it consumes them they never arrive
// here. What reaches here is everything the widget set did not take — which is
// exactly where application-level keys like Tab and q belong.
func (s *screen) Handle(ev termmosaic.Event) bool {
	if ev.Kind != termmosaic.EventKey {
		return false
	}
	switch {
	case ev.Key == termmosaic.KeyTab:
		s.setFocus((s.focus + 1) % len(s.focusable))
		return true
	case ev.Key == termmosaic.KeyEscape, ev.Rune == 'q' && ev.Mod == 0:
		return false // let the loop quit
	}

	// A widget that changed something the rest of the screen depends on has to
	// say so. There is no change event; polling after the fact is the pattern,
	// and on a form with four widgets it is free.
	if s.agree.State() == form.Checked {
		s.save.Disabled = false
		s.Invalidate()
	}
	return false
}

// Draw satisfies termmosaic.Widget.
//
// Everything is drawn every frame. What is NOT done every frame is the arithmetic
// and the parsing: the constraint list is built once per size change, and each
// widget caches whatever it derived from its own rect.
func (s *screen) Draw(buf *buffer.Buffer) {
	// One Block owns the border, the title and the background for the whole
	// screen. Its Interior() is what the layout is solved against, which is how
	// the content stops needing to know that a border and a padding exist.
	chrome := block.New(s.bounds)
	chrome.SetBorder(buffer.BorderRounded)
	chrome.SetPadding(1)
	chrome.SetTitleString("New project", buffer.NewStyle(fg, bg, buffer.AttrBold))
	chrome.Draw(buf)

	r := chrome.Interior()

	// The constraint vocabulary, in full:
	//   layout.Length(n)      exactly n cells
	//   layout.Min(n)         at least n cells
	//   layout.Max(n)         at most n cells
	//   layout.Percentage(p)  a percentage of what is available
	//   layout.Ratio(n, d)    n/d of what is available
	//   layout.Fill(w)        share what is left, in proportion to w
	//
	// Solve returns the SIZE of each constraint, and layout.Rect turns a resolved
	// size list into the rectangle child i occupies. That pair is the composition
	// rule: nesting is not a second engine, it is Solve on a sub-rectangle.
	xs := layout.Solve(layout.Horizontal,
		layout.Fill(1),    // the field takes the slack
		layout.Length(12), // the button is fixed
		2,                 // two cells of spacing between them
		r.W,
	)
	ys := layout.Solve(layout.Vertical,
		layout.Fill(1),   // everything above the hint bar
		layout.Length(1), // the hint bar is one row
		1,                // one row of spacing
		r.H,
	)

	field := layout.Rect(r, layout.Horizontal, xs, 2, 0)
	cta := layout.Rect(r, layout.Horizontal, xs, 2, 1)
	agree := field
	agree.Y = field.Y + 1
	hint := layout.Rect(r, layout.Vertical, ys, 1, 1)

	s.name.SetBounds(field)
	s.agree.SetBounds(agree)
	s.save.SetBounds(cta)
	s.hint.SetBounds(hint)

	s.name.Draw(buf)
	s.agree.Draw(buf)
	s.save.Draw(buf)
	s.hint.Draw(buf)
}

func main() {
	t, err := term.Open(os.Stdin, os.Stdout, os.Getenv)
	if err != nil {
		fmt.Fprintln(os.Stderr, "app:", err)
		os.Exit(1)
	}
	defer t.Close()

	w, h := t.Size()
	r := render.New(term.NewSink(os.Stdout), render.Config{
		Width:   w,
		Height:  h,
		Caps:    t.Capabilities(),
		NoColor: render.NoColorFromEnv(os.Getenv),
	})

	root := newScreen(buffer.Rect{X: 0, Y: 0, W: w, H: h})
	r.SetRoot(root)

	if err := t.EnterRawMode(); err != nil {
		fmt.Fprintln(os.Stderr, "app:", err)
		os.Exit(1)
	}
	if err := t.EnterAltScreen(); err != nil {
		_ = t.LeaveRawMode()
		fmt.Fprintln(os.Stderr, "app:", err)
		os.Exit(1)
	}
	if err := r.Reset(); err != nil {
		fmt.Fprintln(os.Stderr, "app:", err)
		os.Exit(1)
	}

	quit := make(chan struct{})
	// … input goroutine and pacer exactly as in the quickstart …
	pacer := render.NewPacer(r)
	_ = pacer.Run(quit)
}

The elided tail is the same three blocks as the quickstart: a sync.Once-guarded stop, the goroutine reading src.Events() and switching on EventKey and EventResize, and close(stopBeat) / src.Close() / LeaveAltScreen / LeaveRawMode on the way out. Copy that file rather than retyping it — it is examples/hello/main.go.

Six rules that will save you a day

1. Your space is Bounds(), never buf.Size()

buf.Size() is the screen. Bounds() is your rectangle. A widget that reads buf.Size() will happily paint over its neighbours. This is rule 1 of ADR 0007 §1 and every widget in the catalog obeys it.

2. Repaint your whole rect before drawing into it

The renderer diffs and never clears. So if a widget does not repaint its Bounds() before drawing content, shrinking leaves stale cells on screen. A Block in Background does this for you, which is the main reason to compose one. ADR 0007 §1 rule 3.

3. Degenerate sizes are a contract, not an accident

No panic, ever. Clip, never blank. A 0×0 rect is valid and writes zero bytes. A rect below MinSize() draws its minimum layout clipped. Draw must be total for every rect, and there are tests that hold every widget to it — but that is the framework’s widgets. Yours are your responsibility, and this is the single most common way a TermMosaic widget crashes on a resize.

4. Cache against the rect, and drop the cache when anything else changes

Draw must not allocate. buffer.Wrap and buffer.Truncate allocate and are banned from Draw, so build them in a size-change check and read the result in Draw.

And Invalidate() now means both “mark dirty” and “drop everything you have cached”. A widget that caches column widths on Bounds() and is then handed Header = true renders the old layout permanently — nothing will produce a different rect to repair it. This is ADR 0007’s 2026-10-04 amendment, it is the sharpest edge in the framework, and it applies to your setters too: any setter that writes a field Draw reads must invalidate.

5. Build styles with buffer.NewStyle

A composite literal would silently leave a colour channel at opaque black. NewStyle is the constructor that cannot do that. There is no theme — see No theme, and why.

6. Move focus yourself, and offer events focused-first

Focusable is optional and discoverable by a type assertion. Events go to the focused widget, then to the tree. So a widget that consumes Tab while focused will trap the user — which is why none of them do. If your screen-level Handle wants Tab, the widgets under it must not.

Where to go next

  • Concepts — the twelve ideas behind all of this.
  • Forms — the nine form widgets as one workflow.
  • Composing with Block — layout, borders and composition.
  • Headless testing — how to test a widget without a terminal, which is the reason the headless sink exists.
  • Widgets — the catalog, with captures.