flatte

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 20 Imported by: 0

README

Flatte logo

Flatte

ci Go Reference Live demo

Flatte is a Go TUI foundation built around one mutable state struct, direct state mutation, and pure views. It is intentionally not a Bubble Tea clone: apps do not define messages, commands, or component update trees. State lives in your app, Handle mutates it, and View renders it.

A montage of Flatte sample apps running in the terminal

A glimpse of the sample apps — Snake, a Docker-style TUI, a three-pane workspace, styling, tables, progress — all built from the primitives below. Try Flatte live in your browser → · regenerate the reel with cmd/record-demo.sh.

The module path is currently:

go get github.com/lunguini/flatte

The root package name is flatte, so ordinary imports use the flatte identifier:

import "github.com/lunguini/flatte"

Minimal App

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/lunguini/flatte"
)

type State struct {
	count int
}

func Handle(s *State, ev flatte.Event, fx flatte.Effects[State]) {
	key, ok := ev.(flatte.KeyEvent)
	if !ok {
		return
	}
	switch key.Key {
	case flatte.KeyEscape:
		fx.Quit()
	case flatte.KeyCharacter:
		switch key.Rune {
		case '+':
			s.count++
		case '-':
			s.count--
		case 'q', 'Q':
			fx.Quit()
		}
	}
}

func View(s *State, ctx flatte.RenderContext) flatte.Frame {
	return flatte.Frame{Content: fmt.Sprintf("count: %d\n\n+/- change  q quit", s.count)}
}

func main() {
	if err := flatte.Run(context.Background(), flatte.App[State]{
		State:  &State{},
		Handle: Handle,
		View:   View,
	}); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Mental Model

  • State is the single source of truth.
  • Handle(state, event, effects) mutates state directly.
  • View(state, context) returns a full flatte.Frame.
  • Async work goes through named helpers: flatte.Go, flatte.Every, flatte.Stream, and flatte.Latest.
  • Widgets in flatui are opt-in state structs. They own no goroutines and no key policy.
  • Tests use normal field assertions plus flatest golden/harness helpers.

Packages

  • github.com/lunguini/flatte (flatte) - runtime, events, effects, frame, cursor, clipboard, exec, file selection.
  • github.com/lunguini/flatte/flatui - stateful UI helpers such as TextField, Textarea, Viewport, List, Table, Tree, FocusRing, Paginator, Progress, Spinner, Timer, and Stopwatch.
  • github.com/lunguini/flatte/flatui/layout - flexbox-style layout engine (Row, Col, Text, Spacer) that solves a frame tree once into composed content plus per-ID rects for geometry-based hit-testing.
  • github.com/lunguini/flatte/flatest - deterministic app driver, golden assertions, frame rendering, and replay helpers.

Navigation Patterns

Use plain state:

  • Multiple full screens: store a screen enum in State and switch in Handle and View. See cmd/flat-pages.
  • Multiple focusable sections on one screen: store a flatui.FocusRing. See cmd/flat-workspace.
  • Modal/overlay state: store modalOpen bool and modal-specific fields. See cmd/flat-modal.

Useful Commands

The library and samples are separate modules:

go test ./...
go vet ./...

cd cmd
go test ./...
go run ./flat-pages
go run ./flat-workspace

Thanks

Flatte stands on a lot of excellent open-source work:

  • Charm — Flatte is the deliberate inverse of Bubble Tea, but it happily reuses Charm's MIT-licensed substrate: Lip Gloss for styling, Ultraviolet for input parsing and cell-buffer rendering, and x/ansi and colorprofile underneath. Bubble Tea also serves as our honest benchmark — the cmd/bubble-* comparison apps keep our claims grounded. Thank you, Charm team.
  • rivo/uniseg for correct Unicode grapheme segmentation, so cursors and widths behave.
  • The Go team for the language, golang.org/x/term, and first-class WebAssembly support that powers the browser demo in cmd/flat-landing.

Further Reading

  • Quick Reference maps common TUI needs to Flatte APIs.
  • Examples maps every sample app to the capability it demonstrates.
  • Contributing covers Conventional Commits, the quality bar, and the golden-snapshot policy.
  • Changelog is generated from commit messages on each release.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrNoSelection = errors.New("flat: no file selected")

ErrNoSelection is returned by SelectFile when the selector command exits successfully without printing a selected path.

Functions

func ApplyUpdate

func ApplyUpdate[S any](s *S, tracer Tracer, update StateUpdate[S])

func Async

func Async[S, T any](
	ctx context.Context,
	updates chan<- StateUpdate[S],
	spawn func(func()),
	name string,
	work func(context.Context) (T, error),
	fold func(*S, T, error),
)

func Cancel

func Cancel[S any](fx Effects[S], name string)

Cancel stops in-flight Latest work under name, if any. It does not affect Every tickers — those stop through the Scope that started them (see Every's doc).

func Every

func Every[S any](fx Effects[S], name string, interval time.Duration, fold func(*S, time.Time))

Every sends a named update on a fixed interval until the loop context is cancelled. Timing comes from the Clock (real ticker by default; a fake clock drives it deterministically under test).

Every has no name-based cancellation of its own — Cancel only stops Latest work. To stop or re-arm a ticker (pause/resume, changing the interval), start it through a Scope (ScopeEvery) and cancel the scope; flat-game dogfoods exactly this for pause/resume/restart.

func Exec

func Exec[S any](fx Effects[S], name string, cmd *exec.Cmd, fold func(*S, error))

Exec releases the terminal (cooked mode, main screen), runs cmd attached to it, restores the terminal, and applies the named fold with the command's error — all synchronously on the loop goroutine: the TUI is paused while cmd runs, exactly like shelling out to $EDITOR. cmd's stdin/stdout default to Run's input/output and stderr to os.Stderr, each only when unset. Loop-goroutine-only, like all effects; no-op on a zero Effects value.

func Go

func Go[S, T any](fx Effects[S], name string, work func(context.Context) (T, error), fold func(*S, T, error))

Go runs work off-loop and folds its result back into state as one named update. It is Async spelled through Effects.

func Latest

func Latest[S, T any](fx Effects[S], name string, work func(context.Context) (T, error), fold func(*S, T, error))

Latest is Go with supersede-by-name semantics: starting new work under a name cancels any in-flight work under the same name, and a superseded result is dropped even if it was already queued. This replaces manual generation counters for request/response races.

On a zero Effects value (no registry) it degrades to Go.

func LoadState

func LoadState[S any](path string, defaultState S) S

LoadState gob-decodes state from path, returning defaultState if the file is missing or fails to decode. Struct shape changes during iteration will invalidate old state — the decode-error fallback handles this gracefully instead of crashing.

func Run

func Run[S any](ctx context.Context, app App[S], opts ...Option) error

func SaveState

func SaveState[S any](path string, state S) error

SaveState gob-encodes state to path. The state struct must contain only gob-serializable fields — paths not open file handles, queries not DB connections. Anything live gets reopened on boot via a rehydrate step.

func ScopeEvery

func ScopeEvery[S any](scope *Scope, fx Effects[S], interval time.Duration, fold func(*S, time.Time))

ScopeEvery sends a named update on a fixed interval until the scope is cancelled. Timing comes from the Clock (real ticker by default; fake clock under test).

func ScopeGo

func ScopeGo[S, T any](scope *Scope, fx Effects[S], work func(context.Context) (T, error), fold func(*S, T, error))

ScopeGo runs work off-loop using the scope's context. When the scope is cancelled, the context passed to work is cancelled. The fold runs on the loop goroutine as a named update.

func ScopeStream

func ScopeStream[S, T any](scope *Scope, fx Effects[S], source func(context.Context, func(T)), fold func(*S, T))

ScopeStream runs a long-lived source that emits many values over time. Each emitted value becomes one named update. When the scope is cancelled, both the source's context and the send-channel select see the cancellation.

func SelectFile

func SelectFile[S any](fx Effects[S], name string, cmd *exec.Cmd, fold func(*S, FileSelection))

SelectFile releases the terminal, runs cmd as an external file selector, restores the terminal, and applies fold with the selected path. The selected path is read from stdout and trimmed. If cmd already has Stdout, output is still forwarded there while also being captured for the selection result.

This is intentionally terminal-delegated rather than an in-TUI file browser: apps can plug in fzf, yazi, ranger, or another command while Flatte owns the terminal handoff.

func Stream

func Stream[S, T any](fx Effects[S], name string, source func(context.Context, func(T)), fold func(*S, T))

Stream runs a long-lived source that emits many values over time; each emitted value becomes one named update. The source must return when its context is cancelled.

Types

type App

type App[S any] struct {
	State  *S
	Init   func(*S, Effects[S])
	Handle func(*S, Event, Effects[S])
	View   func(*S, RenderContext) Frame
	Tracer Tracer
	// OnExit is called after the loop ends, on every exit path (clean quit,
	// context cancel, signal). Use it to persist state before the process
	// exits. It fires before terminal restoration.
	OnExit func(*S)
}

type ClipboardEvent

type ClipboardEvent struct{ Text string }

ClipboardEvent delivers the terminal's answer to fx.ReadClipboard (OSC52, system selection). Unsupported terminals never answer — treat the event as optional and do not wait for it.

type Clock

type Clock interface {
	// Tick calls cb on each interval until ctx is cancelled. Real
	// implementations own a goroutine; fake ones fire synchronously.
	Tick(ctx context.Context, interval time.Duration, cb func(time.Time))
}

Clock abstracts the timing source for interval effects so tests can drive them deterministically. The real clock uses time.Ticker; flatest provides a fake one.

type Cursor

type Cursor struct {
	X, Y  int
	Style *CursorStyle
}

Cursor is a hardware-cursor position in frame cell coordinates.

type CursorShape

type CursorShape int
const (
	CursorShapeDefault CursorShape = iota
	CursorShapeBlock
	CursorShapeUnderline
	CursorShapeBar
)

type CursorStyle

type CursorStyle struct {
	Shape CursorShape
	Blink bool
	Color color.Color
}

CursorStyle configures the terminal hardware cursor when supported. A nil style leaves the terminal default in place.

type Effects

type Effects[S any] struct {
	Context context.Context
	Updates chan<- StateUpdate[S]
	// contains filtered or unexported fields
}

func NewEffects

func NewEffects[S any](ctx context.Context, updates chan<- StateUpdate[S], quit func()) Effects[S]

NewEffects builds an Effects value with an observable quit callback. Run uses it internally; tests use it to assert quit requests.

func NewHarnessEffects

func NewHarnessEffects[S any](ctx context.Context, updates chan<- StateUpdate[S], quit func(), dispatch func(func()), clock Clock) Effects[S]

NewHarnessEffects builds an Effects wired for deterministic testing: dispatch controls how async bodies are spawned (nil = real goroutine) and clock controls time-based effects (nil = real clock). For flatest; not app API.

func (Effects[S]) Ctx

func (fx Effects[S]) Ctx() context.Context

Ctx returns the loop's context, defaulting to context.Background() if none is set (e.g., in tests using the zero Effects value). Use this to derive child contexts for scoped async work — context.WithCancel(fx.Ctx()) gives you a cancellable handle for per-screen or per-selection goroutines.

func (Effects[S]) Print

func (fx Effects[S]) Print(s string)

Print writes s into the terminal's scrollback above the live frame, then repaints the frame below it — the Claude-Code "message stream + pinned input" model: emitted lines flow into the real terminal's history (which the user scrolls with the terminal/mouse) while the frame stays put at the bottom. Requires inline rendering (WithInline); in alt-screen mode it is a no-op, because the prepended lines would be overwritten by the next frame. The content is the app's to format (no trailing newline is added). Newlines in s produce multiple scrollback lines. Loop-goroutine-only, like Quit. Safe on a zero Effects value.

func (Effects[S]) Printf

func (fx Effects[S]) Printf(format string, args ...any)

Printf is Print with fmt.Sprintf formatting.

func (Effects[S]) Quit

func (fx Effects[S]) Quit()

Quit requests a clean exit of the Run loop. Safe on a zero Effects value. Call it from Init, Handle, or a fold — they all run on the loop goroutine; calling it from an app-spawned goroutine is a data race.

func (Effects[S]) ReadClipboard

func (fx Effects[S]) ReadClipboard()

ReadClipboard asks the terminal for its clipboard content via OSC52. A supporting terminal answers with a ClipboardEvent; unsupported terminals never answer — treat the event as optional and do not wait for it. Loop-goroutine-only, like Quit. Safe on a zero Effects value.

func (Effects[S]) SetClipboard

func (fx Effects[S]) SetClipboard(text string)

SetClipboard writes text to the system clipboard via OSC52 on the next flush. Loop-goroutine-only, like Quit. Terminals without OSC52 support ignore it. Safe on a zero Effects value.

func (Effects[S]) Suspend

func (fx Effects[S]) Suspend()

Suspend releases the terminal (cooked mode, main screen, cursor visible), suspends the process like the shell's Ctrl-Z would (SIGTSTP to the process group), and on resume (fg/SIGCONT) restores the terminal and repaints. On platforms without job control it is a release/restore round trip. The framework never binds a key to this — apps decide what (if anything) triggers it. Loop-goroutine-only, like Quit. Safe on a zero Effects value.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is the closed set of terminal inputs the loop delivers to Handle. It is sealed: the framework defines every implementation, apps only consume them with a type switch. This is not TEA — events are terminal inputs, never app-defined messages; async results remain StateUpdates.

type FileSelection

type FileSelection struct {
	Path string
	Err  error
}

FileSelection is the result of a terminal-delegated file picker.

type FocusEvent

type FocusEvent struct{ Focused bool }

FocusEvent reports terminal focus changes (see WithReportFocus).

type Frame

type Frame struct {
	// Content is the styled frame text.
	Content string
	// Cursor places the hardware cursor, in frame cell coordinates
	// ((0,0) is the frame's top-left). nil hides the cursor.
	Cursor *Cursor
	// Title sets the terminal window title when non-empty. It is emitted
	// only when it changes, and reset on exit if it was ever set.
	Title string
}

Frame is what View returns: rendered content plus terminal metadata. The zero value is a blank frame with no cursor and no title.

type Key

type Key int
const (
	KeyUnknown Key = iota
	KeyUp
	KeyDown
	KeyEnter
	KeyCtrlC
	KeyBackspace
	KeyCharacter
	KeyTab
	KeyEscape
	KeyLeft
	KeyRight
	KeyDelete
	KeyHome
	KeyEnd
	KeyPageUp
	KeyPageDown
)

type KeyEvent

type KeyEvent struct {
	Key  Key
	Rune rune
	Mod  Mod
}

KeyEvent is a key press. Rune is set when Key is KeyCharacter.

type Mod

type Mod int

Mod is a bitmask of key modifiers.

const (
	ModShift Mod = 1 << iota
	ModAlt
	ModCtrl
)

func (Mod) Contains

func (m Mod) Contains(mods Mod) bool

type MouseAction

type MouseAction int
const (
	MousePress MouseAction = iota
	MouseRelease
	MouseMotion
)

type MouseButton

type MouseButton int
const (
	MouseNone MouseButton = iota
	MouseLeft
	MouseMiddle
	MouseRight
	MouseWheelUp
	MouseWheelDown
)

type MouseEvent

type MouseEvent struct {
	X, Y   int
	Button MouseButton
	Action MouseAction
	Mod    Mod
}

MouseEvent is a mouse press/release/motion/wheel; X/Y are zero-based cell coordinates from the top-left of the frame (see WithMouse).

type MouseMode

type MouseMode int

MouseMode selects which mouse events the terminal reports.

const (
	// MouseModeNone reports no mouse events (the default).
	MouseModeNone MouseMode = iota
	// MouseModeCellMotion reports clicks, releases, wheel, and drag motion.
	MouseModeCellMotion
	// MouseModeAllMotion additionally reports motion with no button held.
	MouseModeAllMotion
)

type NoopTracer

type NoopTracer struct{}

func (NoopTracer) Event

func (NoopTracer) Event(Event)

func (NoopTracer) Update

func (NoopTracer) Update(string)

type Option

type Option func(*runConfig)

Option configures Run behaviour.

func WithInline

func WithInline() Option

WithInline renders below the shell prompt instead of in the alternate screen: the frame occupies exactly its own lines, the terminal's scrollback stays intact, and on exit the final frame remains visible with the prompt landing below it.

func WithInput

func WithInput(in io.Reader) Option

WithInput sets the event source. Default: os.Stdin.

func WithMouse

func WithMouse(mode MouseMode) Option

WithMouse enables terminal mouse reporting; events arrive as MouseEvent.

func WithOutput

func WithOutput(out io.Writer) Option

WithOutput sets the render sink. Default: os.Stdout.

func WithReportFocus

func WithReportFocus() Option

WithReportFocus enables focus reporting; terminal focus changes arrive as FocusEvent. Some terminals and multiplexers need configuration to report focus (tmux: focus-events).

func WithoutBracketedPaste

func WithoutBracketedPaste() Option

WithoutBracketedPaste disables bracketed paste mode. It is on by default: without it a paste arrives as a flood of individual key events instead of one PasteEvent.

func WithoutDefaultQuit

func WithoutDefaultQuit() Option

WithoutDefaultQuit delivers Ctrl-C to the app instead of exiting the loop. The app must call fx.Quit(), close the input, or cancel the context to exit.

type PasteEvent

type PasteEvent struct{ Text string }

PasteEvent is a bracketed paste (paste mode is on by default; see WithoutBracketedPaste).

type RenderContext

type RenderContext struct {
	Width        int
	ColorProfile colorprofile.Profile
}

func RenderContextFor

func RenderContextFor(out io.Writer) RenderContext

RenderContextFor reports the terminal facts available to View. It mirrors renderer color-profile detection so apps can pick theme styles without owning terminal probing.

type ResizeEvent

type ResizeEvent struct {
	Width  int
	Height int
}

ResizeEvent reports the terminal size in cells. The loop delivers one at startup and one per SIGWINCH; sizes fall back to 72×24 when the output is not a terminal.

type Scope

type Scope struct {
	// contains filtered or unexported fields
}

Scope groups async work under a shared cancellable context. Cancel() cancels all in-flight work started through the scope's Go, Stream, and Every helpers. This closes the "Every/Stream can't be cancelled by name" gap that the flat-docker dogfood found (Task 4 — Logs streaming needed a 30-line hand-rolled goroutine because flatte.Stream runs for the app's entire loop lifetime).

Usage:

scope := flatte.NewScope(fx, "logs")
scope.Stream(fx, func(ctx context.Context, send func(string)) { ... }, fold)
// On selection change or screen leave:
scope.Cancel()
scope = flatte.NewScope(fx, "logs")  // fresh scope for new selection

func NewScope

func NewScope[S any](fx Effects[S], name string) *Scope

NewScope creates a scope whose context is derived from fx.Ctx(). The name prefixes the Named updates produced by the scope's helpers.

func (*Scope) Cancel

func (s *Scope) Cancel()

Cancel cancels all in-flight work started through this scope. The scope's context is cancelled, which causes every goroutine spawned by ScopeGo/ScopeStream/ScopeEvery to see ctx.Done() and exit.

func (*Scope) Ctx

func (s *Scope) Ctx() context.Context

Ctx returns the scope's cancellable context. Use this to derive further child contexts if needed.

type StateUpdate

type StateUpdate[S any] interface {
	Apply(*S)
	Name() string
}

func Named

func Named[S any](name string, apply func(*S)) StateUpdate[S]

type Tracer

type Tracer interface {
	Event(Event)
	Update(string)
}

type UpdateTracer

type UpdateTracer func(string)

func (UpdateTracer) Event

func (UpdateTracer) Event(Event)

func (UpdateTracer) Update

func (f UpdateTracer) Update(name string)

Directories

Path Synopsis
Package flatest is a deterministic, synchronous test harness for flat apps: it drives an App through scripted events and controlled time, exercising real async folds without goroutine races, real clocks, or a terminal.
Package flatest is a deterministic, synchronous test harness for flat apps: it drives an App through scripted events and controlled time, exercising real async folds without goroutine races, real clocks, or a terminal.
layout
Package layout is a flexbox-style layout engine for Flatte.
Package layout is a flexbox-style layout engine for Flatte.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL