Cati Documentation Index
Welcome to the Cati developer documentation. The following resources cover our project guides, code rules, and architecture:
- System Documentation — Rendering pipeline, design decisions, viewer core consolidation, line-width invariant, offline website asset generation, and licensing.
- Render Pipelines — Step-by-step Mermaid diagrams and spatial character art pixel flows for each render mode (half-block, quad-block, sextant, sparkline).
- Video & Audio Pipeline — FPS/dimension probing, rawvideo pipe, one-frame-per-tick loop, play-once vs loop, input resilience, audio via ffplay.
- Interactive Grid Browser — Page layouts, composite thumbnail grids, mouse tracking, async thumb loading, space-pan, raw terminal swaps.
- Terminal Input System —
spec/input.yamldecision tree,internal/inputpackage, SGR 1006 mouse protocol, UTF-8 tokenization, move vs drag,--input-testTUI. Read before touching input handling orspec/input.yaml. - Spec System — Authoritative Reference — Spec-as-code philosophy, file map, key dispatch pipeline, quality invariants, agent rules, integrity tests, and change checklist. Read this before touching any
spec/file or its Go loaders. - Spec System & Browser Design — The
spec/YAML-driven config system: template engine (renderTpl/if()), color system, button/label/view pipeline, full hint-bar variable table (meta.*,ssim,last_key, …), scrollbar, dense-mode grid, split-screen preview. - Quad-Block Pixel Art — Half-block vs. quad-block layout math, the 2× horizontal stretch aspect-ratio correction, neighbour-aware colour quantisation, and the quadrant character lookup table.
- Sparkline Pixel Art — Sparkline layout math, horizontal and vertical orientation, optimal-split character and color selection, and the test helper suite.
- Render Experiment Lessons — Short notes from the removed sextant search and geomshape render experiments.
- Rendering Bug & Golden-Change Playbook — How to diagnose visual/geometry bugs (prove the root cause with numbers first) and change golden images safely. Read before fixing any rendering bug or touching
testdata/goldens. - Go Library API — Import and use Cati’s high-performance renderers in your own Go applications and TUIs.
- Go Conventions — Development guidelines for writing Go code, state management, error handling, CLI verbs, and testing.
- Make Conventions — Standardized Makefile structures, target phony declarations using the sentinel
⚙️trick, and self-documenting rules.
Issues
Tracked in ../issues/ — concrete bugs, design problems, and features with resolution notes.
System Documentation
title: System Documentation weight: 10
Project Cati — System Documentation
This document captures the architecture, core design decisions, lessons learned, and utility systems of the Cati terminal image rendering utility.
1. Architecture & Rendering Pipeline
Cati is a lightweight terminal image and animation viewer written in Go. Its core logic is divided into CLI commands (cmd/) and the public rendering libraries under v1/ (v1/halfblock/, v1/quadblock/, v1/sextant/, and v1/sparkline/), utilizing core types defined in v1/core/ and terminal size detection utility in v1/term/.
graph TD
CLI[CLI entry: cmd/root.go] -->|Dir expansion & sorting| Files[List of file paths]
Files -->|Single frame| Render[halfblock.RenderFile]
Files -->|Animation play mode| Play[cmd/play.go]
Play -->|Pre-load all frames| Frames[Memory Buffer]
Play -->|Raw TTY Mode| Keyboard[Keyboard Ticker: q/ESC/Ctrl+C]
Play -->|Ticker Loop| RenderFrame[v1/halfblock.Render]
RenderFrame -->|Cursor Restore & Clear| Term[Terminal Output]
“Two Pixels, One Cell” Encoding
Cati encodes two vertical pixels into a single terminal cell using Unicode half-block characters. This effectively doubles the vertical resolution of standard terminal dimensions.
| Character | Visual Representation | Target Pixels | Color Source |
|---|---|---|---|
▀ | Top half filled | Top pixel color, Bottom transparent | Foreground color = top; Background color = none |
▄ | Bottom half filled | Top transparent, Bottom pixel color | Foreground color = bottom; Background color = none |
█ | Fully filled | Top & Bottom identical colors | Foreground color = top/bottom |
| Empty/Transparent | Both pixels transparent | None |
This is combined with 24-bit ANSI true-color escape sequences (\x1b[38;2;R;G;Bm for foreground and \x1b[48;2;R;G;Bm for background) to render full color images.
Sub-System Documentation
For detail on specific components, refer to:
- Video & Audio Pipeline — Probes video streams, decodes rawvideo frames via ffmpeg pipe at display FPS, audio via ffplay.
- Interactive Grid Browser — Renders paged thumbnails, decodes mouse/key navigation, and dynamically scales image grid layouts.
- Terminal Input System —
spec/input.yamltokenizer decision tree,internal/inputpackage, SGR 1006 mouse, UTF-8 handling,--input-testTUI. - Spec System & Browser Design — Spec-as-code YAML system, template engine, hint bar variables (
meta.*,ssim,last_key, …).
2. Crucial Design Decisions & Lessons Learned
Artifact-Free Animation Playback
- The Problem: Early versions left artifacts when drawing frame sequences at high speed in play mode.
- The Solution: Standardizing line updates. Before drawing each frame row, Cati prefixes the line with
\x1b[2K\r(Clear Line + Carriage Return) to ensure no characters or artifacts from previous frames remain in the terminal columns. - Tty Raw Mode: For playback, TTY raw mode is temporarily entered to enable non-blocking keyboard reads. This allows users to immediately quit using
q,Q,ESC, orCtrl+Cwhile maintaining perfect control over terminal state restoration.
Offline-First Website Compatibility
- CORS Tainting: The website visualizes how the pixel grid encodes pixels using a JavaScript visualizer. Reading PNG pixels directly using canvas
getImageData()throws aSecurityErrorin modern browsers if the website is opened directly from the local disk using thefile://protocol. - Static Inlining: The pixel grid now bypasses the canvas entirely at runtime. The raw pixel colors are pre-extracted and inlined directly in
website/index.html. - Asset Generator: A dedicated Go script (
scripts/generate_pixels.go) is provided to parse the logo image and automate this inlining workflow inside the HTML via marker comments:// PIXELS_START const pixelColors = [ ... ]; // PIXELS_END
3. Tooling & Licensing
Internal Package Decoupling (June 2026)
The quality metrics, image-geometry helpers, and pixel-art pre-scalers were extracted from cmd/ into dedicated internal/ packages. The key learnings:
- Pure math lives in
internal/— anything that depends only onimage,image/color, andmathshould not sit incmd/. It creates import coupling, bloats the UI package, and makes unit testing harder. - Extracted packages:
internal/metrics— SSIM, luminance, Sobel, box/pyramid downscale, blockiness, edge continuity. Zero project deps (stdlib only).internal/imgutil—FitPixelDims(aspect-ratio fit, no upscale),CropImage(zero-copy SubImage for RGBA). Zero project deps.internal/pixelart—Scale2x/Scale3x,Sharpen/Sharpen05/Sharpen10. Already extracted but had no tests (now has 9 tests).
RenderQualitystays incmd/— the orchestrator that wires renderers + metrics together. Only the pure sub-computations were moved.- Remove dead code during extraction.
BlockMeanReconstruct(block-colour quantisation model) was carried over fromcmd/ssim.gobut had zero callers. Extracting is a natural moment to prune. - Functions used only within the package stay unexported.
metrics.Lumawas exported initially, but no caller outsideinternal/metricsreferenced it. Unexporting avoids committing to a public API that may change. - Avoid package-name redundancy in exported names.
metrics.QualityGridKreads as “metrics quality grid K” — theQualityprefix is noise.metrics.GridKis shorter and unambiguous.
Public Library Reorganization (July 2026)
To expose Cati’s rendering algorithms as a public library, the core rendering modules were reorganized under v1/:
v1/core— Defines standard typescore.Cellandcore.Gridshared by all renderers.v1/term— Implements cross-platform terminal size detection utilities (term.TermWidth()andterm.TermHeight()).v1/halfblock,v1/quadblock,v1/sextant,v1/sparkline— Implement the public Go APIs:Render(w io.Writer, img image.Image, cols int, opts Options) errorRenderToGrid(img image.Image, cols int, opts Options) (*core.Grid, error)
Viewport Geometry Extraction (June 2026)
The viewport geometry math (term cells → renderer pixels → fit → zoom → clamp → crop) is centralized in internal/viewgeom and consumed through thin app-layer wrappers:
Spec.ViewportDims/Spec.Dims— computes derived pixel dimensions from source size, terminal size, zoom, and renderer geometry.Dimsis the preferred named result for callers that need to share the same geometry across pan, crop, reference generation, and rendering.Dims.ClampPan— clamps pan offsets to the scaled image bounds. Panning must move the viewport origin (panX,panY) only; do not add mode-specific “frame” panning or snap grids unless a separate phase-control feature is being designed.Dims.SrcCrop— maps viewport pixel coords back to source image coords. Used bybuildReffor SSIM reference generation and by the hint-bar formeta.src_res(now shows the visible crop region when zoomed/panning instead of always showing full source resolution).PanAnchor/Spec.PanFromAnchor/Spec.PanByCells— shared drag and keyboard-pan primitives. Individual render modes provide only their cell footprint viaviewSpec().
Cell-Quantum Zoom Model (June 2026, revised June 2026)
The stable zoom and viewport helpers now live in internal/viewgeom. The app layer keeps thin wrappers, while the core model uses a cell quantum where each renderer declares how many source-pixel units one cell represents.
- The stable user-facing unit is
src px/cell, notk.kis an internal ladder parameter; the hint bar should report the actual source pixels represented by one terminal cell. - The common geometry is
n : 2nsource pixels per cell footprint.nis renderer-specific and must stay configurable so future glyph families can plug into the same math. - Zoom should step through distinct rendered footprints, not through linear arithmetic in
k. Any candidate state that collapses to the same visible output after rounding is dead weight and should be dropped from the ladder. - Mode changes must preserve source-space center and aspect. Switching between halfblock, quad, and future modes should recenter from the source rectangle, not reuse the old viewport coordinates verbatim.
- Subcell phase shifts are a separate axis from zoom. They belong in dedicated controls later; they should not be conflated with the zoom ladder itself.
Ladder, not linear steps. Zoom changes should move through distinct rendered footprints, not through arbitrary arithmetic increments in k. The step generator should derive candidate cell footprints from the image dimensions and render quantum, convert them to src px / cell, and drop states that do not change the actual output after rounding. This keeps small images from accumulating useless tail states and gives every mode one geometry path.
Mode separation. Zoom changes size only. Sampling phase / subcell offsets are a separate axis for later testing-only controls such as quadshift. SSIM and other quality metrics should compare through a common analysis grid so new glyph families can still be evaluated against the same baseline.
Render-mode identity. renderCfg{} is halfblock and id 0 must stay
halfblock. CLI startup canonicalizes the flag-derived renderer into the active
cycle entry so display names, geometry, metrics, and r/R cycling all agree.
The main app cycle is currently halfblock → quad/splithalf → quad/edge-snap → spark/quad → spark/best → sextant/2x3; --mode=h starts at
halfblock, --mode=qs starts at quad/splithalf, --mode=qe starts at
quad/edge-snap, --mode=sq starts at spark/quad, --mode=sb starts at
spark/best, and --mode=xs starts at sextant/2x3.
Panning invariant. Pan state is the upper-left origin of the visible viewport in the scaled image. Halfblock, quad, spark, and sextant all use the same state and clamp path. Mode-specific code may translate terminal-cell deltas to viewport pixels through viewSpec(), but it must not pan a renderer-local output frame independently of the source viewport.
Render-size invariant. The interactive renderer validates terminal-cell size
before emitting ANSI. The expected footprint is derived from the untrimmed
source crop and zoom ladder first: columns are ceil(cropW / k), rows are
ceil(cropH / (2k)). Renderer-specific lattice details, such as quad’s even
pixel crop or spark’s 4×8 glyph block, are applied after that and then
normalized back to the same terminal-cell footprint. Pressing r must not
change the image size; render-size mismatches after viewport construction are
hard errors, not best-effort renders.
Static source-aspect invariant. Static fitting validates the source aspect
at the center of the shared render pipeline before ANSI is emitted. The check
compares the source rectangle against the renderer viewport after applying the
mode’s aspect correction and allows only one render-cell of quantization error
in each axis. A mode that would render a square 32×32 source into a squashed
viewport fails with a render aspect mismatch error instead of silently
producing output. Playback and static CLI paths call the checked pipeline
directly; test-only convenience callers panic on the same invariant so
regressions cannot pass unnoticed.
The sextant family keeps exactly one shipped algorithm: xs /
sextant/2x3. It uses a fixed 2×3 sample lattice and the rational sextant
aspect correction while the original zoom/pan view geometry remains shared with
the rest of the app. The experimental sextant search aliases and diagonal
geomshape family were removed; see RenderExperimentLessons.md for the short
postmortem.
The 2×3 glyph set covers 60 of the 64 possible bit masks. The empty (0) and
full (63) cells render as a space (with background fill); the remaining two —
the pure left column (1·3·5, mask 0b101010) and right column (2·4·6, mask
0b010101) — have no dedicated sextant rune because Unicode reuses the existing
half-block characters ▌ (U+258C) and ▐ (U+2590). sextantRuneByMask maps
those two masks to the half-block glyphs explicitly; without them displayMask
returns 0 and the renderer emits rune(0) (a zero-width NUL) that shifts the
row and leaves the right edge unfilled (see issue #020).
Decoupled step generation. zoomSteps(mz, srcW) []float64 returns a descending slice of zoom values. Handlers (inc_zoom, dec_zoom, scroll wheel) consume it via stepIdx(zoom, steps) int and never compute steps directly.
Spec-driven levels (June 2026). k-values come from spec/zoom_levels.yaml:
levels— fixed fractional k-values near 1 (e.g.0.5, 0.75, 1.25)extend— strategy enumhalves/quarters/adaptivefor generating k from 1.0 up tosrcW
The loader (loadZoomLevels) now delegates to the typed spec loader in spec/load.go (spec.LoadZoomLevels()), which uses gopkg.in/yaml.v3 and still returns defaults on read/parse error through sync.Once lazy init. The app layer keeps the zoom ladder normalization thin and mode-agnostic. See docs/Spec.md for spec system conventions.
Minimum rendered width: 1 cell. Both the levels list and the extension loop are capped at k ≤ srcW. This guarantees the rendered image is never smaller than 1 terminal cell wide, regardless of what the spec contains. The adaptive extension widens its k jumps as the image gets larger so zooming out of small images does not feel linear and slow at high k.
maxZoom (mz) is computed dynamically:
zCol = cellCols × srcW / scaledW (cellCols = 1 halfblock, 2 quad)
zRow = srcH / scaledH
maxZoom = max(min(zCol, zRow), 1.0)
This caps zoom at the 1-source-pixel-per-cell-column limit regardless of terminal resize or render-mode switch.
Convergence at k=1. When each cell shows 1×2 source pixels, all halfblock modes produce identical output. Quad modes also converge provided each 2×2 block has ≤ 2 colours (verified by TestMaxZoomQuadConvergence).
viewRows consistency. The --zoom 1:1 flag must open at k=1.0, and --zoom 0 / key 0 must fit the viewport. The old bug (opening at k≈1.03) was caused by initialZoomRatio using the full termRows in its maxZoom computation while zoomLevel and the render viewport used termRows - 2 (reserving 2 rows for the viewer chrome). Fix: define viewRows = max(1, termRows - viewerChromeRows) once and use it consistently in initialZoomRatio, zoomSteps, zoomLevel, and all event-handler zoom calls. For image/video viewers, an explicit CLI --height H means viewRows == H; the terminal row budget is resolved as H + viewerChromeRows internally. Oversized interactive --width / --height values are clamped to the current terminal, so --height never requests more image rows than the terminal can display after viewer chrome.
Step index invariant. stepIdx(zoom, steps) returns the first index where steps[i] ≤ zoom. The sequence must be strictly descending — stepIdx assumes ascending clamped behaviour (zoom above steps[0] returns 0, zoom below steps[last] returns last). Building steps from a deduplicated map of k-values follows this pattern:
- Collect k-values into a
map[float64]bool(dedup) - Iterate map keys into a slice, then
sort.Float64Slice(ks).Sort()(ascending k) - Forward-iterate the sorted ks:
steps[i] = mz / k(ascending k → descending zoom)
The naive steps[len-1-i] reversed-index pattern is wrong — it produces ascending zoom, breaking stepIdx.
Zoom level display. The normal hint bar shows the nearest zoom ladder value using %.3g format (e.g. src px/cell=0.75, src px/cell=1.25). It is based on the rendered terminal-cell width, not the physical terminal width: small images can render narrower than the terminal, and a 32×32 image rendered as 32×16 cells reports src px/cell=1, not 0.4 in an 80-column terminal. The Info action (i) reports raw crop ratio, nearest ladder value, crop, aligned view size, trim, rendered cell size, and source size separately.
Renderer reconstruction for quality metrics. SSIM, blockiness, and edge continuity compare the ideal source crop against a reconstruction of what the terminal renderer actually emits. Halfblock is represented by the viewport image itself, quad uses quadblock.RenderToImage, and spark uses sparkline.RenderToImage. The rendered reconstruction is normalized to the common metrics.GridK × metrics.GridK per-terminal-cell quality grid: smaller outputs are nearest-neighbour upscaled, while denser outputs are pyramid-downscaled. Never compare spark quality against the raw NN viewport; that scores the sampler, not the glyph renderer.
Viewer Core Consolidation (June 2026)
interactiveWithChan (image viewer) and interactiveVideo (video viewer) shared ~80% of their logic as independent duplicates. Every fix — zoom, pan, render-mode switch, show_info, preserveZoomForMode — had to be applied twice. The solution is cmd/viewer_core.go, a thin coordinator struct that both callers delegate to:
viewerCorestruct holds all shared mutable state:rc renderCfg,state viewState,drag dragState,curQ RenderQuality,modeName,lastNonHBID,buttons,activeAction,status,infoVisible,lastKey,lastVP,src image.Image.rerender func()is a callback set by the owning viewer: for images it builds fromorig, for video it builds fromlastRawFrame. The callback updatesvc.lastVPandvc.curQonly — it never writes to screen.handleAction(action, tok)owns all shared spec actions (inc_zoom,dec_zoom,zoom_k,cycle_render*,toggle_gray,toggle_halfblock,copy_viewport,show_info,go_back,quit). Returns(false, false)for viewer-specific actions so the caller can handle them.handleKey/handleMousereturn(quit, changed bool, unhandledAction string). AnunhandledActionis a spec action that the core did not claim — the owning viewer handles it (toggle_panfor image viewer,toggle_play_pausefor video viewer).switchMode(oldRC)callspreserveZoomForMode+recenterForModein one step; replaces the inline duplicate in the image viewer and theswitchVideoModeclosure in the video viewer.
What stays local to each viewer: spacePan/toggle_pan (image), paused/videoEnded/restartStream/setPaused/statusClearAt (video). The ticker loop, frame channel, audio player, and input splitter remain in interactiveVideo.
Net result: interactiveWithChan 447→95 lines, interactiveVideo 553→115 lines, four dead wrapper functions deleted (viewportDims, srcCrop, visibleCrop, renderView).
Line-Width Invariant (June 2026)
Every output path must emit at most termCols visible characters per terminal row. A silent overrun wraps to the next row, corrupting the layout without any error.
- Button bar (
drawBottomMenu): enforced viafitMenuItems(items, maxCols).maxColsiswriterTermCols(w)withtermColsas fallback for non-tty writers (tests, pipes). - Hint bar (
drawHintBar): enforced viatruncateANSI(text, cols-2). Same fallback logic. - Pixel renders (
rc.render): pre-validated byvalidateRenderSizebefore ANSI emission; actual output width is verified in tests vialineCapWriter.
lineCapWriter (in cmd/linecap_test.go) is an io.Writer that decodes ANSI CSI sequences (skipping escape bytes) and counts visible UTF-8 runes per line, recording the maximum column reached. Tests call lc.AssertFits(t, 80) for all render modes and UI components.
The termCols fallback parameter was added to both drawBottomMenu and drawHintBar in the same step as the test addition, closing the gap where non-tty enforcement was silently skipped.
Phony Sentinels in Makefiles
To keep targets phony without polluting the Makefile with lists of names, a sentinel target ⚙️ is used:
.PHONY: ⚙️
target: ⚙️ ## Description
The Unicode emoji target acts as a phony trigger since no such file will exist on disk, keeping the Makefile clean.
REUSE Licensing Specification
Cati is fully compliant with the FSFE REUSE 3.3 specification:
- Standard license texts reside under the
LICENSES/directory. - The project uses
REUSE.tomlannotations with wildcard matches (path = ["**"]) to define license (AGPL-3.0-or-later) and copyright (2026 Uwe Jugel) for all repository files. This completely removes the need to put license headers at the top of code/media assets.
Render Pipelines
title: Render Pipelines parent: System.md weight: 1
Render Pipelines
This document details the terminal rendering pipelines in Cati. It covers the layout geometry, pixel-to-cell mapping, aspect-ratio corrections, and character selection logic for each rendering mode.
1. General Pipeline Architecture
Cati takes a source image or video frame and runs it through a pipeline that scales the image (preserving aspect ratio), performs color quantisation, matches sub-pixel groups to specific Unicode character sets, and formats the output into 24-bit ANSI true-color cells.
graph TD
Src[Source Frame / Image] --> Scale[Resizing & Aspect-Ratio Fit]
Scale --> Mode{Mode Selection}
Mode -->|halfblock| HB[Half-block Pipeline]
Mode -->|quadblock| QB[Quad-block Pipeline]
Mode -->|sextant| SX[Sextant Pipeline]
Mode -->|sparkline| SL[Sparkline Pipeline]
HB --> ANSI[ANSI Output formatting]
QB --> ANSI
SX --> ANSI
SL --> ANSI
ANSI --> Term[Terminal Display]
Key Entry Points and API Links
- Half-block: halfblock.ScaleToFit & halfblock.Render
- Quad-block: quadblock.ScaleToFit & quadblock.RenderOpts
- Sextant: sextant.Render
- Sparkline: sparkline.ScaleToFit & sparkline.RenderOpts
2. Half-block Pipeline (halfblock)
The half-block mode divides each terminal cell vertically into a top and a bottom pixel.
Process Flow
graph LR
Src[ScaleToFit Image] --> Loop[Row Iteration]
Loop --> Pair[Group 1x2 Vertical Pixels]
Pair --> Color[Extract Top & Bottom Colors]
Color --> Render[Match Unicode Glyphs: ▀ / ▄ / █ / Space]
Spatial Block Flow
Each terminal cell has a 1:2 (W:H) physical screen aspect ratio. By grouping a vertical pair of pixels into one cell, we maintain correct 1:1 visual proportion.
src (2x4 pixels)
# .
. #
# #
. .
src grouped 1x2 cell blocks terminal output (2x2 cells)
# . [#][.] ▀ ▄
. # --step--> [.][#] --step--> █
# # [#][#]
. . [.][.]
#represents a foreground/colored pixel..represents a background/transparent pixel.- The final output characters are:
▀(U+2580): Top pixel colored, bottom transparent.▄(U+2584): Bottom pixel colored, top transparent.█(U+2588): Both pixels colored identically.(Space): Both pixels transparent.
3. Quad-block Pipeline (quadblock)
The quad-block mode divides each terminal cell into a 2x2 sub-pixel grid using Unicode quadrant block characters (▗, ▖, ▝, ▞, ▟, ▘, ▚, ▙, ▀, ▜, ▛, █).
Process Flow
graph TD
Src[Source Image] --> Stretch[2x Horizontal Stretch]
Stretch --> Fit[ScaleToFit and Crop]
Fit --> CellLoop[Segment into 2x2 sub-pixels]
CellLoop --> Quant[Pick Best Pair of Colors]
Quant --> Mask[Build 4-bit Mask]
Mask --> Glyph[Lookup Quadrant Rune]
Spatial Block Flow
Since a terminal cell is 1:2 (W:H) on screen, dividing it into a 2x2 grid would make each sub-pixel 1:2 (squeezed). To keep output pixels square, the image is stretched 2x horizontally before rendering.
src (2x2 pixels)
# .
# #
src stretched (4x2) grouped 2x2 sub-pixels terminal output (2x1 cells)
# . # # . . [# # / # #] █ ▄
# # --step--> # # # # --step--> [. . / # #] --step-->
- The first cell has all 4 sub-pixels filled (
#), rendering as a full block█. - The second cell has the top two sub-pixels empty (
.) and bottom two filled (#), rendering as a bottom half block▄. - Neighbor-Aware Quantisation: In pickBestPair, if a cell has 3 or more colors, we quantise it to 2 colors using a score weighted by exact pixel coverage (4x) and color continuity with left/above cells (1x).
4. Sextant Pipeline (sextant)
The sextant mode divides each terminal cell into a 2x3 sub-pixel grid, mapping to the Unicode sextant block glyphs (U+1FBF0–U+1FBF9) and utilizing fallback half-blocks where needed.
Process Flow
graph LR
Src[Source Image] --> Aspect[Sextant Aspect Correction]
Aspect --> Fit[ScaleToFit and Crop]
Fit --> Group[Group 2x3 sub-pixels]
Group --> Quant[Two-color Quantisation]
Quant --> Mask[Build 6-bit Mask]
Mask --> Lookup[Lookup Glyph / Fallback half-block]
Spatial Block Flow
A single terminal cell is mapped to a 2x3 grid. The 6 sub-pixel masks determine which of the 64 glyph configurations is drawn.
src (2x3 pixels)
# .
# .
# .
src 2x3 sub-pixel mask terminal output (1 cell)
# . [#][.] ▌
# . --step--> [#][.] --step-->
# . [#][.]
▌(U+258C) represents the left-half filled cell, which acts as the exact representation or closest Hamming-1 approximation for this 6-bit mask.
5. Sparkline Pipeline (sparkline)
Sparkline mode analyzes a dense 4x8 pixel block per terminal cell. It is optimized to represent scalar gradients and 2D features with minimal reconstruction error.
Process Flow
graph TD
Src[Source Image] --> Fit[Fit & Snap to half-cell boundaries]
Fit --> Blocks[Partition into 4x8 Pixel Blocks]
Blocks --> Candidates[Generate Candidate Masks: 1D splits & 2D quads]
Candidates --> SSE[Evaluate Sum of Squared Errors + Transparent Cost]
SSE --> Tie[Apply Tiebreaker: Prefer Solid/Space Cells]
Tie --> Render[Emit best-match unicode rune]
Spatial Block Flow
In vertical sparkline mode (spark/vert), the 4x8 grid is evaluated against 8 vertical bar fill levels (1/8 to 8/8) to find the level that minimizes SSE.
src (4x8 pixel block)
. . . .
. . . .
. . . .
# # # #
# # # #
# # # #
# # # #
# # # #
src 4x8 evaluation terminal output (1 cell)
. . . . [Top 3 rows empty] ▅
. . . . [Bottom 5 rows filled]
. . . .
# # # # --step--> --step-->
# # # # [SSE optimal]
# # # # [best level: 5/8]
# # # #
# # # #
- The optimal split level is chosen using pickBestLevel, returning
bestK(0 to 7) corresponding to Unicode characters▂▃▄▅▆▇█. spark/quadcombo: Inspark/quad, the renderer additionally evaluates 2D quadrant/half masks upsampled to 4x8 blocks. The candidate with the lowest SSE (plus a transparent-pixel penalty and a solid-color tiebreaker) is rendered.
Video & Audio Pipeline
title: Video & Audio Pipeline parent: System.md weight: 2
Video & Audio Pipeline
This document describes the video probing, decoding, streaming, and audio playback pipeline in Cati.
1. Video Detection & Probing
Cati detects video files by checking file extensions against a fixed set:
var VideoExts = map[string]bool{
".mp4": true, ".webm": true, ".mkv": true, ".mov": true, ".avi": true,
}
Three ffprobe helpers live in internal/halfblock/video.go:
| Function | ffprobe query | Returns |
|---|---|---|
ProbeVideoFPS | stream=r_frame_rate | Native FPS as float64 (parses num/den) |
ProbeVideoDuration | format=duration | Duration in seconds |
ProbeVideoDimensions | stream=width,height | (w, h int) for rawvideo frame sizing |
FPS probing happens at stream-open time. If ffprobe is unavailable or fails, playback falls back to 15 fps.
2. Streaming Architecture
Frames are decoded by a background goroutine and sent over a buffered channel so the main loop never blocks on ffmpeg I/O.
OpenVideoStream(ctx, path, displayFPS)
│
├─ ProbeVideoDimensions → (w, h)
│
├─ ffmpeg -v quiet -i path
│ [-vf fps=N -threads 4] ← rate limit + thread cap
│ -f rawvideo -pix_fmt rgba pipe:1
│
└─ goroutine: io.ReadFull(stdout, buf[w*h*4])
→ image.NewRGBA, copy(img.Pix, buf)
→ ch <- img
Why rawvideo instead of PNG pipe
The original pipeline used -f image2pipe -vcodec png pipe:1 and png.Decode in Go. PNG encoding (ffmpeg side) and decoding (Go side) both consumed significant CPU — ffmpeg routinely spawned 50+ threads at 600%+ CPU on a home video.
Rawvideo (-f rawvideo -pix_fmt rgba) eliminates all compression/decompression: ffmpeg copies pixels directly to the pipe, Go reads a fixed w*h*4 byte block per frame with io.ReadFull. Per-frame memory is the same (both approaches yield an uncompressed image.Image); the difference is CPU.
Current caveat: frames are piped at source resolution (e.g. 1920×1080 = 8.3 MB/frame). At 30 fps that is ~250 MB/s through the pipe, which Linux handles comfortably (loopback pipe bandwidth ≫ 1 GB/s), but is wasteful. The planned fix is ffmpeg-side scaling (-vf scale=W:H) so frames arrive pre-scaled to terminal dimensions. See issue 005.
FPS rate limiting
-vf fps=N tells ffmpeg’s fps filter to select the nearest source frame for each output timestamp. A 30 fps source at displayFPS=15 emits every 2nd frame in the same real time — natural playback speed is preserved. -threads 4 caps the decoder thread pool.
Without rate limiting ffmpeg decodes at full CPU speed regardless of the consumer’s tick rate, causing the stale-frame accumulation described in §3.
3. Playback Loop — One Frame Per Tick
Both playVideos and interactiveVideo use a ticker at displayFPS. The key rule: consume exactly one frame per ticker tick.
case <-ticker.C:
select {
case img, ok := <-frames:
if !ok { /* handle end */ }
lastFrame = halfblock.ScaleToFit(img, cols, rows)
default:
// no frame yet — keep showing lastFrame
}
if lastFrame != nil {
halfblock.Render(os.Stdout, lastFrame)
}
Why this matters
ffmpeg does not pace its output in real time — it decodes and pipes frames as fast as the CPU allows, then blocks when the channel buffer (size 8) fills. A separate frames case in the outer select would drain the buffer between ticks, advancing lastFrame 8+ frames per tick period and causing apparent fast-forward. The old “stale drain” loop (reading len(frames)-1 extras after each render) had the same effect.
The non-blocking inner select gives the ticker exclusive control over frame advancement. ffmpeg’s buffer fills, it blocks, and consumption naturally paces to displayFPS.
4. Play-Once vs Loop
cati -p video.mp4 plays each video in the argument list exactly once, then exits. There is no implicit looping. With multiple files, they play sequentially; videoIdx advances without wrap-around.
cati -i video.mp4 (interactive mode) loops by default — when the frame channel closes (!ok), restartStream() reopens the stream. But if the video ends while paused, the stream is set to nil (disabling that select case), the last frame is held, and videoEnded = true is set. The next play action (space or play button) calls restartStream().
5. Input Resilience in Interactive Video
Mouse events flood the inputs channel (cap 32, ~800 bytes). Two safeguards:
- Full drain at loop top — a labeled
for/selectempties the entire buffer before entering the blocking select. A single-token drain allowedframesto starveinputson a burst. - Buffer-full abort — if
len(inputs) == cap(inputs)at the top of the loop, the function returns an error. This means the goroutine was blocked (sending tokens with nowhere to put them) long enough to fill 32 slots — a genuine hang, not a burst.
6. Audio Playback
Audio is handled by the internal/audio package.
Probing
audio.HasAudio(path) // ffprobe -select_streams a:0 -show_entries stream=...
Returns true if the file contains at least one audio stream.
Playback backend: ffplay
Audio is played via ffplay -v quiet -nodisp -vn -autoexit path.
Why ffplay, not ffmpeg→aplay: when cati holds the terminal in raw mode, the process runs without a controlling TTY. aplay (and similar ALSA tools) fail silently in this context. ffplay manages its own audio session and works correctly as a subprocess of a raw-terminal process.
Lifecycle in playVideos
openAudio(path) → audio.Open(ctx, path) → ffplay subprocess
stopAudio(p) → p.Stop() → Kill + wait
Video advances → stopAudio(current), openAudio(next)
Audio is not yet wired into interactiveVideo (cati -i video.mp4).
7. Render-Pipeline Optimizations for Playback
Throttled invariant checks (renderCheckGate)
renderChecked validates every rendered frame by walking the full ANSI output string to count cell widths — an O(output-length) operation that is unnecessary after the first frame passes with stable dimensions.
renderCheckGate (in cmd/render_output.go) tracks the last check time and the last frame dimensions. renderCheckedGated calls the ANSI walk and validateRenderSize only when:
- The gate has never fired (first frame always checked), or
- The rendered cell dimensions changed (resize or mode switch), or
- More than
gate.interval(1 s) has elapsed since the last check.
Both playImages and playVideos create a gate with interval = time.Second.
interactiveVideo uses renderValidatedGated (which also gates validateRenderSize) via the same mechanism.
Skipping quality metrics while playing (skipQuality)
viewerCore.skipQuality disables the expensive per-frame quality pipeline in interactiveVideo:
buildRef(pyramid downscale of the source region)computeQuality(render-to-image + SSIM + Sobel + blockiness)
vc.skipQuality is true while the video is playing. On pause, setPaused(true) immediately runs a single quality computation so the hint bar shows accurate SSIM/blockiness values. While playing the hint bar displays the last computed value (frozen), which is acceptable because quality metrics are not meaningful at video frame rates.
vc.skipQuality is reset to false when the video ends or when the user toggles pause.
Interactive Grid Browser
title: Interactive Grid Browser parent: System.md weight: 3
Interactive Image & Video Browser
This document describes the design and implementation of the multi-image interactive grid browser in Cati.
1. Screen Layout & Boundaries
The browser occupies the entire terminal window. It dynamically partitions terminal lines into three zones based on terminal rows and columns resolved via TIOCGWINSZ.
+-------------------------------------------------------------+ -- row 1
| Title & Page Indicator (e.g. Page 1/3 (1-6 of 15)) |
+-------------------------------------------------------------+ -- row 2
| |
| +-------------------+ +-------------------+ |
| | | | | |
| | Thumbnail | | Thumbnail | |
| | | | | |
| +-------------------+ +-------------------+ |
| | [ filename.png ] | | filename.jpg | |
| +-------------------+ +-------------------+ |
| |
+-------------------------------------------------------------+ -- row (termRows-2)
| [◀ Prev] [Next ▶] [ℹ About] [✖ Quit] | -- row (termRows-1) (Buttons)
+-------------------------------------------------------------+ -- row (termRows) (Status)
| Quick Help / Key Bindings Bar |
+-------------------------------------------------------------+
Dynamic Grid Sizing
- Columns: Automatically drops from
3to2or1columns if the terminal width is too narrow (< 60or< 40characters). - Rows: Automatically shifts from
2rows to1row if the terminal height is limited (< 14lines). - Cell Width & Height:
WherecellW = (termCols - (gridCols-1)*gapX) / gridCols cellH = (gridRowsLimit - (gridRows-1)*gapY) / gridRowsgridRowsLimit = termRows - marginTop - marginBottom.
2. Thumbnail Composition & Anti-Flicker Rendering
Rendering individual grid cells using multiple cursor jumps and partial redraws causes screen tearing and massive blinking. Cati resolves this by rendering on a single composite canvas.
+---------------------------------------------+
| Composite Canvas |
| (image.RGBA) |
| +-----------+ +-----------+ +-----------+ |
| | Thumb 1 | | Thumb 2 | | Thumb 3 | |
| +-----------+ +-----------+ +-----------+ |
| +-----------+ +-----------+ +-----------+ |
| | Thumb 4 | | Thumb 5 | | Thumb 6 | |
| +-----------+ +-----------+ +-----------+ |
+---------------------------------------------+
│
▼
halfblock.Render()
│
▼
stdout (Single Write)
Thumbnail Caching
To keep panning, window resizing, and page flipping instant, Cati caches scaled thumbnails using a compound key:
type thumbKey struct {
path string
w, h int
}
If the terminal is resized, new thumbnail dimensions are calculated, and the cache scales the original images to the new target sizes on demand.
Composite Image Painting
- Initialize a blank
image.RGBAwith dimensionstermColswide andgridRowsLimit * 2high. - Retrieve or build the thumbnail for each page item, scaled to fit
cellWcolumns and(cellH - 1) * 2pixel rows. - Draw each thumbnail onto the composite canvas at its computed pixel offset
(left, top * 2). - Move the cursor to
(1, marginTop + 1)and runhalfblock.Renderon the composite canvas. - Render the filename labels and page title directly to terminal stdout via standard character positioning.
3. Double-Buffered Raw Mode
The browser supports opening selected items directly in the full-screen interactive view. Each viewer owns its own terminal mode — the browser restores cooked mode before handing off, and re-enters raw mode after:
+------------------+
| Grid Browser | (Raw Mode Active, Mouse Tracking On)
+------------------+
│
▼ (Item Clicked / Enter Pressed)
1. term.Restore → cooked mode
2. Disable mouse tracking & show cursor
3. Invoke interactiveWithChan() or interactiveVideo()
└─ Both call term.MakeRaw internally (raw mode during viewing)
└─ Both restore terminal state via defer on exit
│
▼ (Viewer exits — q/ESC/^C/video-end)
4. Drain browser's sigs channel (propagate any SIGINT received during viewing)
5. term.MakeRaw → raw mode for Grid Browser
6. Hide cursor & enable mouse tracking
7. Call redraw() to reconstruct the grid
│
▼
+------------------+
| Grid Browser | (Restored)
+------------------+
Critical invariant: every viewer must call term.MakeRaw itself
The browser calls term.Restore (cooked mode) before invoking any viewer, so the shared stdin
goroutine is in cooked-mode blocking (waits for a full line before Read returns). If a viewer
does not call term.MakeRaw, single-key presses like q and ESC appear non-functional
because they are buffered by the line-discipline and never forwarded to the goroutine.
Both interactiveWithChan and interactiveVideo call term.MakeRaw at their top and restore
via defer term.Restore — this must be maintained for any future viewer added.
SIGINT propagation
Go’s signal.Notify delivers a signal to all registered channels. Both browser() and each
viewer register for SIGINT. When the user presses ^C inside a viewer, the viewer’s channel
fires and it returns — but the browser’s sigs channel also buffered the signal. To ensure
^C always exits the app, the Enter handler explicitly drains sigs after the viewer returns:
select {
case <-sigs:
shouldQuit = true
return
default:
}
4. Input & Coordinate Mappings
SGR Mouse Coordinate Decoding
Clicking buttons or grid cells maps terminal-coordinate hits directly to actions:
- Buttons (Row
termRows-1): Checks if clicked columncolis withinbtn.colandbtn.col + btn.width. - Cells: Checks if clicked cursor coordinates
(c, r)reside within a cell’s bounding box:
If true, the indexc >= left && c < left+cellW && r >= top && r < top+cellHitemIdxis selected and immediately opened.
Keyboard fallbacks
All mouse-driven actions have full keyboard equivalents to support headless/keyboard-only operations:
- Arrow keys navigate the selected cell highlight.
Enter/Spaceopens the selected item.[/]/Page Up/Page Downtrigger page transitions.atoggles the About page overlay.
5. Async Thumbnail Loading & Priority Queue
Thumbnails (images and video preview frames) are loaded asynchronously so the browser grid renders immediately with placeholders and fills in progressively.
Architecture
Browser goroutine thumbQueue Worker goroutines (N = CPU/2)
───────────────── ────────── ─────────────────────────────
redraw()
└─ getThumbnail(item, w, h)
├─ cache hit → return frame
└─ cache miss → tq.submit()──→ [job, job, job …]──→ thumbWorker
├─ LoadImage / LoadVideoFrameAt
└─ results chan ──→ browser select
└─ cache + redraw
Priority Re-ordering
When the user scrolls, newly visible items move to the front of the job queue without interrupting in-progress workers:
tq.prioritize(currentVisibleKeys) // called inside redraw()
The queue is protected by a sync.Mutex + sync.Cond; workers block on cond.Wait and are
woken by cond.Signal on each new submission.
Video Preview Frames
For video items, loadVideoThumbs uses ffprobe to measure duration, then extracts N
evenly-spaced frames via ffmpeg -ss <offset>. The frames are stored as a []image.Image
slice in the cache and cycled as a one-shot animation.
Settings
| Config key | Default | Description |
|---|---|---|
preview_videos | true | Whether to extract video preview frames |
max_jobs | CPU/2 | Parallel thumbnail worker count (0 = auto; overridden by -j/--jobs) |
video_frames | 10 | Number of frames extracted per video thumbnail |
One-Shot Animation
When a video thumbnail scrolls into view (or the cursor moves onto it), startVisibleAnimations
triggers a one-shot playback of its cached frames at 300 ms/frame. The animation stops at the
last frame and does not loop, keeping the UI calm when browsing.
6. Space-Pan Mode in the Image Viewer
The full-screen image viewer (interactiveWithChan) supports a Space-pan mode as an
alternative to left-button drag:
| Action | Effect |
|---|---|
Space (first press) | Enter pan mode — status bar shows hint |
| Move mouse | Image follows cursor (grab-and-pull) |
Space (second press) | Exit pan mode |
| Left-button drag | Always available regardless of pan mode |
Implementation
Pressing Space toggles a spacePan bool flag and switches mouse tracking:
Space ON → \x1b[?1003h\x1b[?1006h (any-motion: reports bare mouse moves)
Space OFF → \x1b[?1002h\x1b[?1006h (button-event: reports moves only while button held)
The first motion event after entering pan mode sets an anchor (dragState). Subsequent events
compute pan as an absolute delta from that anchor — identical math to left-button drag. The
translation from terminal-cell deltas to viewport pixels is owned by internal/viewgeom, so
halfblock, quad, and spark modes all pan the same source-space region through their own cell
footprint:
state.panX, state.panY = geom.PanFromAnchor(anchor, col, row)
Any-motion tracking emits pure move events (IsMove) as well as drag events.
Space-pan must accept both; otherwise entering pan mode enables the terminal
protocol but ignores the bare mouse motion it asked for.
The pan values always describe the upper-left origin of the visible viewport in
the scaled image. Renderers receive a cropped image and must respect its
Bounds().Min; panning should not be reimplemented inside a renderer as
movement of an output frame or background.
The anchor resets each time pan mode is toggled on, so re-entering always anchors to the current cursor position.
ANSIMouseOff includes ?1003l so cleanup correctly disables any-motion tracking even if the
viewer exits while pan mode is active.
Go Library API
title: “Go Library API” weight: 12
Using Cati as a Go Library
Cati can be imported as a Go library to render images or videos directly in your terminal-based applications and TUI frameworks (like Bubbletea or tcell).
Installation
go get codeberg.org/ubunatic/cati
Usage Example
Below is a complete, compileable example of using Cati as a library:
package main
import (
"fmt"
"image"
"image/color"
"os"
"codeberg.org/ubunatic/cati/v1/halfblock"
"codeberg.org/ubunatic/cati/v1/quadblock"
)
func main() {
// Create a simple test image (a diagonal red line on blue background)
img := image.NewRGBA(image.Rect(0, 0, 40, 40))
for y := 0; y < 40; y++ {
for x := 0; x < 40; x++ {
if x == y {
img.Set(x, y, color.RGBA{R: 255, G: 0, B: 0, A: 255})
} else {
img.Set(x, y, color.RGBA{R: 0, G: 0, B: 255, A: 255})
}
}
}
fmt.Println("--- Example 1: Rendering ANSI directly to Stdout ---")
// Render using the halfblock algorithm at 20 terminal columns width.
// Width is mandatory (20). Height is unconstrained (Opts.Rows = 0).
err := halfblock.Render(os.Stdout, img, 20, halfblock.Options{})
if err != nil {
fmt.Fprintf(os.Stderr, "Error rendering: %v\n", err)
os.Exit(1)
}
fmt.Println("\n--- Example 2: Rendering to a core.Grid (for TUIs) ---")
// Render using the quadblock algorithm with edge-snap enabled.
opts := quadblock.Options{
EdgeSnap: true,
}
grid, err := quadblock.RenderToGrid(img, 20, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error rendering to grid: %v\n", err)
os.Exit(1)
}
// Print out the grid cell runes (ignoring colors for simplicity in stdout)
for y, row := range grid.Cells {
fmt.Printf("Row %02d: ", y)
for _, cell := range row {
fmt.Printf("%c", cell.Ch)
}
fmt.Println()
}
}
Terminal Input System
title: Terminal Input System weight: 20
Cati — Spec-Driven Terminal Input System
Architecture, design decisions, and pitfalls for the internal/input package and spec/input.yaml.
1. Motivation
Before this system, terminal input handling was scattered across three files:
cmd/browser.go—resolveKeyName(),tokenizeInput(), ad-hoc escape parsingcmd/interactive.go—parseSGRMouse(),sgrIsScroll(),sgrIsDrag(),sgrButton(),sgrScrollDir()- Hard-coded byte slices in switch statements
These duplicated the same state machine logic with subtle divergences. The spec-driven approach consolidates everything into:
spec/input.yaml ← single source of truth for all terminal input rules
internal/input/ ← Go package that loads and executes those rules
cmd/input_tester.go ← --input-test TUI for live verification
2. spec/input.yaml — Structure
input:
key_aliases: # named key → terminal byte sequence
esc: "\x1b"
tab: "\x09"
up: "\x1b[A"
<c-c>: "\x03"
# ... 30+ aliases including F1-F12
ctrl_pattern: # how to compute Ctrl-A through Ctrl-Z
prefix: "c-"
base_char: "a"
base_code: 1
signals: # OS signals and their event type
- name: SIGWINCH
event: resize
terminal_sequences: # fixed sequences → named events
- seq: "\x1b[I"
event: focus
- seq: "\x1b[O"
event: defocus
mouse: # SGR 1006 extended mouse protocol constants
prefix: "\x1b[<"
press_suffix: "M"
release_suffix: "m"
btn_button_mask: 3
btn_motion_flag: 32
btn_scroll_flag: 64
btn_no_button: 3 # SGR btn field value when no button held during move
tokenizer:
rules: # ordered decision tree — first match wins
- name: sgr_mouse
match: starts_with
prefix: "\x1b[<"
scan_until: "Mm"
emit: mouse
- name: csi_sequence
match: starts_with
prefix: "\x1b["
scan_class: alpha_tilde
emit: key
- name: bare_escape
match: starts_with
prefix: "\x1b"
emit: key
- name: utf8_multibyte
match: utf8_lead # byte ≥ 0x80, consume full codepoint
emit: key
- name: any_char
match: any
emit: key
The match types in the tokenizer:
starts_with— literal prefix match; scans to end-of-prefix or to a terminator set (scan_until)utf8_lead— byte ≥ 0x80: consume a complete UTF-8 codepoint viautf8.DecodeRuneInStringany— fallback, consumes one byte
3. internal/input Package API
// Load parses spec/input.yaml from the given FS. Falls back to DefaultSpec() on error.
func Load(fsys fs.FS) (*Spec, error)
// DefaultSpec returns a hardcoded baseline matching current terminal conventions.
// Used as fallback if the YAML cannot be read at runtime.
func DefaultSpec() *Spec
// Tokenize splits a raw byte buffer into terminal event tokens using the spec's
// ordered tokenizer rules. Each token is one complete event.
func (s *Spec) Tokenize(raw string) []string
// Classify returns the EventType and structured data for a token.
func (s *Spec) Classify(tok string) Event
// ParseMouse extracts SGR 1006 mouse fields from a token.
func (s *Spec) ParseMouse(tok string) (MouseEvent, bool)
// ResolveKeyAlias maps <esc>, <c-c>, <up>, etc. to terminal byte sequences.
func (s *Spec) ResolveKeyAlias(name string) string
// KeyName returns the human-readable name for a key sequence.
// Order: alias map → printable ASCII → Backspace → Ctrl- prefix → UTF-8 → hex.
func (s *Spec) KeyName(seq string) string
// MouseName returns "Scroll Up", "Press Left", "Drag Right", "Move", etc.
func MouseName(m MouseEvent) string
// EventName returns a single human-readable string for any Event.
func (s *Spec) EventName(ev Event) string
Event types: EventKey, EventMouse, EventFocus, EventDefocus, EventResize, EventQuit, EventUnknown.
4. Mouse: SGR 1006 Protocol
The SGR extended mouse protocol encodes all mouse events as:
ESC [ < btn ; col ; row M (press or motion)
ESC [ < btn ; col ; row m (release)
The btn field is a bitmask:
| Bit(s) | Mask | Meaning |
|---|---|---|
| 0–1 | 0x03 | Button (0=left, 1=middle, 2=right, 3=no-button) |
| 2 | 0x04 | Shift held |
| 3 | 0x08 | Meta/Alt held |
| 4 | 0x10 | Ctrl held |
| 5 | 0x20 | Motion event |
| 6 | 0x40 | Scroll event |
Move vs Drag — a critical distinction:
btn=35 (0x20 | 0x03): motion=true, button=3 (no button held) → IsMove()
btn=32 (0x20 | 0x00): motion=true, button=0 (left held) → IsDrag()
Before this system, IsDrag() checked only the motion flag, misidentifying pure moves as drags. The fix: IsDrag() requires Motion && !Scroll && Button != 3. IsMove() requires Motion && !Scroll && Button == 3.
Enable sequences:
\x1b[?1002h\x1b[?1006h— button events only\x1b[?1003h\x1b[?1006h— all mouse events including motion
5. UTF-8 and Alias Ordering — Two Fixed Bugs
Bug 1: Tab shown as “Ctrl-I”
Tab = \x09 falls in the ctrl range (bytes 1–26 → Ctrl-A through Ctrl-Z). KeyName was applying the ctrl heuristic before the alias map lookup.
Fix: alias map is consulted FIRST in KeyName:
alias map → printable ASCII (0x20–0x7e) → Backspace (0x7f) → Ctrl-X → UTF-8 → hex
This ensures Tab→“Tab”, Enter→“Enter”, Esc→“Esc” regardless of their byte values.
Bug 2: Multi-byte chars (ö, €) split into two tokens
The any_char tokenizer rule consumed one byte at a time. UTF-8 sequences like ö = \xc3\xb6 produced two separate tokens.
Fix: the utf8_multibyte tokenizer rule (placed before any_char) detects lead bytes ≥ 0x80 and consumes a full codepoint via utf8.DecodeRuneInString. The same logic applies in KeyName to display the character directly instead of hex.
6. Integration with Browser and Viewer
Key resolution chain
spec/buttons.yaml keys: ["<up>", "q"]
→ ResolveKeyAlias("<up>") → "\x1b[A"
→ loadButtonKeyDefs(inputSpec) → map[buttonName]buttonKeyDef{action, keys}
→ buildViewKeyMaps(viewRows, defs) → per-view map[key]action
→ viewKeyAction(tok) → action string
→ Go switch handler
resolveKeyName() in cmd/browser.go was deleted. loadButtonKeyDefs now takes *input.Spec and calls inputSpec.ResolveKeyAlias.
Tokenization
In the browser and both viewers, all stdin reads pass through inputSpec.Tokenize(raw). Mouse events use inputSpec.ParseMouse(tok) returning a typed input.MouseEvent with IsScroll(), IsDrag(), IsMove(), Button, Col, Row, etc. The old ad-hoc string parsing was deleted.
last_key template variable
Every input loop tracks:
lastKey = inputSpec.EventName(inputSpec.Classify(tok))
This is passed as "last_key" in the vars map to drawHintBar. The spec/labels.yaml hint templates use { last_key | dim } to show the last input event (e.g. "j", "Up", "Scroll Up", "Resize").
7. --input-test TUI
cati --input-test
Hidden flag. Opens a raw-terminal TUI that:
- Captures all input (keyboard, mouse, focus, resize)
- Shows event type, human-readable name, hex-escaped token sequence, and coverage status
- Marks tokens not matched by any spec rule as
← unexpected - Collects and prints a summary of unexpected tokens on exit
- Writes a log to
/tmp/cati-input-test-TIMESTAMP.log - Exits only on Ctrl-C (hardcoded safeguard independent of spec)
The hexEscape helper renders valid UTF-8 printable codepoints (ö, €) directly and escapes raw bytes as \xNN.
8. Pitfalls
DefaultSpec()must stay in sync withspec/input.yaml: it’s the fallback when the file cannot be read at runtime. Any new tokenizer rule or key alias added to the YAML should be reflected inDefaultSpec().\x03Ctrl-C is always hardcoded in every keyboard handler as a last-resort quit safeguard, independent of the spec. Do not remove it even if thequitbutton’s<c-c>key is loaded from spec.- Tokenizer rule order matters:
utf8_multibytemust precedeany_charor multi-byte chars are split.sgr_mousemust precedecsi_sequenceor the\x1b[<prefix gets misidentified. scan_class: alpha_tildefor CSI sequences: scans until an alphabetic character or~. This correctly terminates\x1b[5~(PgUp) at~and\x1b[AatA.
Spec System Reference
title: Spec System Reference weight: 30
Cati Spec System — Authoritative Reference
The spec/ directory is application code, not configuration. Treat it with the same rigour as Go source: every change must be intentional, every object must be used, and the spec must always be readable by the running app.
1. Core Rule
Spec files are the single source of truth. Go code must not duplicate or shadow spec values.
Violations:
- Hardcoded fallback maps in Go that mirror spec content (
loadButtonsreturning Go-default labels) - Keys hardcoded in switch cases that are already in
buttons.yaml keys: - Actions hardcoded in Go that no spec button references
If the spec file cannot be read at runtime, the app degrades gracefully (raw key names shown instead of labels, no crash) — it does not fall back to a Go-maintained copy of the spec content.
2. File Map
| File | Schema | Role |
|---|---|---|
spec/style.yaml | schemas/style.schema.json | All visual tokens: colors, borders, caps, scrollbar |
spec/labels.yaml | schemas/labels.schema.json | Non-button strings: icons, hints, URLs, titles |
spec/buttons.yaml | schemas/buttons.schema.json | Button text, action bindings, keyboard shortcuts |
spec/views.yaml | schemas/views.schema.json | Per-view layout rows; hidden_keys: for invisible bindings |
spec/theme.yaml | schemas/theme.schema.json | Semantic style tokens (primary, secondary, danger, …) |
spec/controls.yaml | schemas/controls.schema.json | Settings form fields with type/min/max/values |
spec/config.yaml | schemas/config.schema.json | App config defaults loaded before user config |
spec/about.yaml | — | About page content (title, content, controls) |
Every YAML file has a companion JSON Schema in spec/schemas/ for editor validation and auto-complete.
3. Key Concepts
3.1 spec/buttons.yaml — the action registry
Every user-facing action is defined here. An action not listed here does not exist.
buttons:
quit:
text: "{ 'Q' | bold | light }uit" # supports full renderTpl syntax
style: danger # theme token
action: quit # Go action name — must be in schema enum
keys: ["q", "Q", "<c-c>"] # named aliases resolved by resolveKeyName()
Named key aliases (resolved by resolveKeyName in Go):
| Alias | Terminal sequence |
|---|---|
<esc> | \x1b |
<bs> | \x7f |
<c-c> | \x03 |
<cr> | \x0d |
<space> | " " |
<up> <down> <left> <right> | \x1b[A … \x1b[D |
<pgup> <pgdn> | \x1b[5~ \x1b[6~ |
Hidden buttons — key-binding-only entries with no visible label:
nav_up:
text: "" # empty → not rendered in button bar
style: secondary
action: nav_up
keys: ["<up>"]
These must be placed in a hidden_keys: row in spec/views.yaml, not in a visible row:.
3.2 spec/views.yaml — layout rows
Three row types per view:
views:
browser:
- area: grid # content fill area
- row: "{ prev } { next } | { quit }" # visible button bar
- hidden_keys: "{ nav_up } { nav_down }" # key-only bindings (not rendered)
- row: "{ hint_browser }" # hint bar (contains hint_ label)
row:— first non-hint row drivesdrawBottomMenu; hint rows drivedrawHintBarhidden_keys:— contributes to key maps vialoadViewKeyRows()but is invisible
3.3 Key dispatch pipeline
spec/buttons.yaml keys:
→ resolveKeyName() → resolved byte sequences
→ loadButtonKeyDefs() → map[buttonName]buttonKeyDef{action, keys}
→ buildViewKeyMaps(loadViewKeyRows(), defs)
→ per-view map[key]action
→ viewKeyAction(tok) in keyboard switch default: case
→ action handler in Go switch
Structural keys not driven by any button (e.g. \x0d Enter to open, \t Tab in settings, arrow-pan in image viewer) remain as explicit case entries in Go with a comment marking them as structural.
\x03 (Ctrl-C) is always kept as an explicit hardcoded case in every keyboard handler as a last-resort quit safeguard — independent of whether quit button’s <c-c> key is loaded from spec.
4. Quality Invariants — “the spec compiles”
The spec is considered clean when all of the following hold:
- No stale actions — every
action:value inbuttons.yamlappears in the schema enum AND has a handler in Go - No unused buttons — every button defined in
buttons.yamlappears in at least one view row orhidden_keys:row inviews.yaml - No schema drift — every property used in any YAML file is declared in its companion schema; no extra properties exist in schema that no YAML uses
- No Go fallbacks — Go loading functions (
loadButtons,loadButtonKeyDefs, etc.) do not contain hardcoded copies of spec content; the spec file is the only source - Keys are specced — every key that triggers an action must have a
keys:entry inbuttons.yamlon the button that owns the action; undocumented hardcoded keys are a bug - Labels are complete — every label key referenced in any view row or hint bar template exists in
labels.yaml
5. Agent Rules
When working with spec files, agents must:
- Read the spec before editing Go — understand which actions, keys, and labels exist before writing handlers
- Update spec and Go together — adding a new action means: schema enum, buttons.yaml entry, views.yaml placement, Go handler, test
- Run schema validation after any spec change:
make validate-spec(or equivalent) - Write tests that assert spec integrity (see §6)
- Never add Go fallback copies of spec content — if a fallback is needed, add a failing test that catches the divergence
- Close the loop on removals — removing a button means removing it from views.yaml, removing its action from the schema enum if unused, and removing its Go handler
6. Tests for Spec Integrity
Spec tests live in cmd/ alongside the loaders. Each test function should be named TestSpec<Thing>.
Required coverage:
| Test | What it checks |
|---|---|
TestSpecButtonsLoad | loadButtonKeyDefs() returns non-empty map; all buttons have non-empty action |
TestSpecButtonsAllUsed | every button name in buttons.yaml appears in some view’s row or hidden_keys: |
TestSpecActionsAllHandled | every action: in buttons.yaml has a case in viewKeyAction dispatch (or is structural-documented) |
TestSpecViewsLoad | loadViewButtonRows() and loadViewKeyRows() return entries for all expected views |
TestSpecKeyResolve | resolveKeyName maps all documented aliases correctly |
TestSpecNoGoFallback | loadButtons("") returns only labels sourced from spec (not hardcoded) |
7. Change Checklist
When modifying the spec, work through this list:
- Added
action:to schema enum if new - Added button to
buttons.yamlwith text, style, action, keys - Placed button in a
row:orhidden_keys:inviews.yaml - Added Go handler for the action in the relevant keyboard
default:switch - Added Go handler for the action in the relevant mouse click switch
- Updated
docs/Design.mdsection 3 if the data flow changed - No stale entries remain (removed button removed from all view rows)
-
go vet ./...clean,go test ./...green,make installsucceeds
Spec System & Browser Design
title: Spec System & Browser Design weight: 40
Cati Browser — Spec System & Browser Design
Architecture and design decisions for the spec-driven style/layout system and the interactive grid browser.
1. Config Key & Temporary Height Adjustment
heightin~/.config/cati/configis renamed tomax_preview_height.+/=incrementscfgHeightby 1 row (clamped totermRows);-decrements (clamped to 10).- Changes are memory-only — not persisted — to avoid polluting saved preferences.
2. Dynamic Grid Density (Dense Mode)
- Trigger: directory contains no images/videos, or the current page has only folders.
- Layout:
gridCols = termCols/20,gridRows = gridRowsLimit,cellH = 1, no thumbnails. - Result: up to 10× more items per page (e.g. 60+ folders vs. 6).
3. Spec System (spec/)
All user-facing configuration, styling, labelling, and layout lives in spec/. Every YAML file has a companion JSON Schema in spec/schemas/ for editor validation and autocomplete.
3.1 File map
| File | Schema | Purpose |
|---|---|---|
spec/style.yaml | schemas/style.schema.json | Colors, borders, grid style, header bar, scrollbar |
spec/labels.yaml | schemas/labels.schema.json | Non-button strings: icons, hints, header template |
spec/buttons.yaml | schemas/buttons.schema.json | Button text + action bindings (single source of truth) |
spec/views.yaml | schemas/views.schema.json | Declarative button-row layouts per view |
spec/theme.yaml | schemas/theme.schema.json | Semantic style tokens (primary, secondary, active, …) |
spec/controls.yaml | schemas/controls.schema.json | Tunable runtime controls with get/set action names |
spec/config.yaml | schemas/config.schema.json | App config defaults — read by loadSpecConfigDefaults() as the base layer before user config |
spec/about.yaml | — | About page content (loaded by spec.LoadYamlView() via parseYamlView) |
All of these files are loaded through typed helpers in spec/load.go. The cmd/ package keeps only thin adapters such as loadViewButtonRows() and loadViewKeyRows() so the browser logic still works with simple string templates, but the spec content itself is no longer line-parsed in Go.
3.2 Color values
All color fields accept:
null— transparent / terminal default#rrggbb— 24-bit hex#rgb— 3-digit hex (expanded to#rrggbb)dark/light— ANSI 16-color palette entries (\x1b[90m/\x1b[97m); these adapt to the user’s terminal theme (Solarized, Gruvbox, Nord, etc.) unlike fixed hex- Named colors:
black/blk,white/wht,red,green/grn,blue/blu,yellow/yel,orange/org,purple/pur,pink/pnk,cyan/cyn,magenta/mag,brown/brn,gray/grey/gry,navy/nav,lime,aqua,teal,maroon,olive,silver/slv
3.3 spec/style.yaml sections
app: # App window background and border
buttons: # Button fg/bg/active colors; left_cap/right_cap applied at load time
preview: # Image cell background
control_bar: # Bottom area (button row + hint row) bg/fg
header_bar: # Top status bar: fg, bg, bold
grid: # item_fg/bg, selected_fg/bg/bold/marker, image_border
scroll_bar: # thumb_char, rail_char, width, thumb_fg, rail_fg, rail_bg
No hex colors are hardwired in Go. loadStyle() only has structural defaults (chars, booleans). Every color comes from spec/style.yaml. The page_title section (fg/bold) styles the title line in drawAboutPage and drawSettingsPage.
3.4 spec/labels.yaml — non-button strings only
Button text does not live here. It lives in spec/buttons.yaml.
app_name: # used in header template as {app_name}
header: # header bar template (supports { key | mod } expressions)
folder_icon: # icon prefix for directory entries
file_icon: # icon prefix for files in list/preview mode
hint_browser: # hint bar text for browser/grid view
hint_settings: # hint bar text for settings view
hint_about: # hint bar text for about view
hint_viewer: # hint bar text for image/video viewer
settings_title: # header shown at top of settings page
settings_hint_tab: # settings page Tab-to-cycle instruction
settings_hint_adjust: # settings page ↑/↓ instruction
settings_hint_save: # settings page Enter/Esc instruction
website_url: # URL opened by the open_website action
Template variables available per hint:
| Variable | Available in | Description |
|---|---|---|
active_file | hint_browser | Filename of the currently selected item |
active_setting | hint_settings | Name of the focused settings field |
preview_state | hint_browser | Thumbnail status: img, vid, Nf (N frames), … (loading), "" |
queue_size | hint_browser | Pending thumb-load jobs: ↻N or "" when idle |
last_key | all hints | Human-readable name of last input event ("j", "Up", "Scroll Up", …) |
ssim | hint_viewer | SSIM quality score as "0.823" |
render_mode | hint_viewer | Current rendering mode name ("halfblock", "spark/quad", "quad/splithalf", "quad/edge-snap", …) |
zoom_level | hint_viewer | Nearest ladder source pixels per rendered terminal cell, e.g. "src px/cell=1.25"; raw crop ratio is shown by the Info action |
meta.name | browser + viewer | Base filename |
meta.name_short | hint_viewer | Base filename shortened with ... to fit the hint bar |
meta.ext | browser + viewer | Lowercase extension without dot |
meta.size | browser + viewer | Human-readable file size ("3.2 MB") |
meta.modified | browser + viewer | File modification date ("2024-01-15") |
meta.src_w | browser + viewer | Source pixel width ("1920") |
meta.src_h | browser + viewer | Source pixel height ("1080") |
meta.src_res | browser + viewer | "1920×1080" or "" if unknown |
meta.disp_w | browser + viewer | Display area width in chars |
meta.disp_h | browser + viewer | Display area height in chars |
meta.disp_mode | browser + viewer | "half", "quad", or "spark" |
meta.disp_res | browser + viewer | "80×24 half" or "" |
meta.duration | browser + viewer | "1:23", "45s", or "" |
meta.fps | browser + viewer | Frame rate ("29.97") or "" |
meta.vcodec | browser + viewer | Video codec ("h264") or "" |
meta.acodec | browser + viewer | Audio codec ("aac") or "" |
meta.bitrate | browser + viewer | "5.2 Mbps" or "" |
meta.container | browser + viewer | Container format ("mp4") or "" |
meta.title | browser + viewer | Title tag from file metadata |
meta.author | browser + viewer | Artist/author tag |
meta.date | browser + viewer | Capture date from tags |
meta.location | browser + viewer | GPS string from tags |
meta.camera | browser + viewer | Device/camera model from tags |
meta.comment | browser + viewer | Comment tag |
All meta.* keys are always present in the vars map (empty string when unknown), so templates never fall back to showing the raw key name. In the browser, meta.* values are loaded asynchronously per-path — the hint shows whatever is cached so far, updating on the next redraw after the load completes. In viewers, meta is loaded synchronously at file open.
3.5 spec/buttons.yaml — button definitions (single source)
Cap characters come from style.yaml buttons.left_cap/right_cap and are applied at load time by loadButtons(leftCap, rightCap). Button text supports the full template engine syntax including inline { 'literal' | mod } styling.
Each button also declares prio for narrow terminals. Lower-priority buttons collapse to compact labels first, then are hidden first if the compact row still does not fit. Key maps are built from the full view row before responsive layout, so hotkeys remain active even when a visual button is hidden.
buttons:
quit:
text: "{ 'Q' | bold | light }uit"
style: danger # theme token (not yet wired to rendering)
action: quit # Go action name matched in button click handler
keys: ["q", "Q", "\x03"] # keyboard shortcuts that fire this action
settings:
text: "{ 'S' | bold | light }ettings"
style: secondary
action: open_settings
keys: ["s", "S"]
The keys: field lists key sequences (escape sequences as Go string literals) that trigger the same action as clicking the button. loadKeyActions() builds a map[string]string (key → action name) from this field. In the grid keyboard handler these drive a spec-dispatched default: case, replacing the previously hardcoded character switch arms.
Context-specific keys (Escape for go_back/quit depending on view, Enter to open files, Space, arrow keys, Tab) are not in keys: — they stay hardcoded in Go because they change meaning with view context.
The flow: loadButtons → merged into labels map at startup → drawBottomMenu reads from labels[key].
3.6 spec/theme.yaml — semantic tokens
Defines reusable named styles referenced by buttons.yaml. Not yet wired into button rendering — currently only the style: field is stored, not applied.
primary: { fg: wht, bold: true }
secondary: { fg: gry }
active: { fg: wht, bg: gry, bold: true }
danger: { fg: red }
3.7 spec/views.yaml — layout declarations
Each view is a list of stacked rows. The first non-hint row: per view drives drawBottomMenu; hint rows are rendered by drawHintBar.
views:
browser:
- area: grid
- row: "{ prev } { next } { back } | { settings } { mode } { about } | { quit }"
- row: "{ hint_browser }"
video_player:
- area: canvas
- row: "{ halfblock } { gray } { zoom_in } { zoom_out } { if(playing, pause, play) } { info } { copy_viewport } { render } { back } { quit }"
- row: "{ hint_viewer }"
Template syntax:
{ key }— resolves to button widget or label string{ key | mod1 | mod2 }— with style modifiers: color names,bold,dim,italic,underline{ 'literal' | mod }— quoted literal string with styling (not a label lookup){ if(cond, trueKey, falseKey) }— conditionally picks a button key at render time- Literal text between
{ }blocks (including|separators) is rendered withcontrol_barstyling
3.8 Template engine (renderTpl)
Used for headers, hint bars, and button text. Lives in cmd/browser.go.
renderTpl(tpl, vars, baseAnsi) string
tplWidth(tpl, vars) int — visual width without ANSI escapes
tplResolve(key, vars) string — resolves key: quoted literal, vars map, or fallback
if() conditional — resolved in drawBottomMenu before label lookup:
{ if(playing, pause, play) }
→ looks up conditions["playing"]
→ if true: renders labels["pause"], if false: renders labels["play"]
Hint bar vars — passed by the redraw function. See §3.4 for the full table per view.
3.9 spec/controls.yaml — runtime controls
Loaded by loadControls() → []ControlSpec. Drives the settings form:
- Field labels come from
settingsFieldLabel(key)(snake_case → Title Case) - Tab cycles through
len(controls)fields ↑/↓callapplySettingsDelta(c, ±1, &tempCfg)which usesc.Min/c.Maxfor int fields andc.Valuesfor enum fields
controls:
preview_height:
type: int
min: 10
max: 200
default: 40
set: set_preview_height # not yet wired — action name for future use
get: get_preview_height
Adding a new control to controls.yaml with a known key (one handled in applySettingsDelta) is enough to add it to the settings form.
4. Bottom Bar Rendering
The bottom two terminal rows are owned by the spec system:
row (effHeight-1): button bar — drawBottomMenu()
row (effHeight): hint bar — drawHintBar()
drawBottomMenu(w, termRows, termCols, viewMode, activeAction, style, labels, viewBtnRows, conditions):
- Reads the button row template from
viewBtnRows[viewName] - Resolves
if()conditionals using theconditionsmap - Renders literal content between
{ }blocks withctrlAnsistyling - Returns
[]menuButtonwith{label, action, col, width}for click detection termColsis a fallback column budget used whenwriterTermCols(w)returns 0 (non-tty); production callers pass the live terminal width, test callers pass their test budget (e.g. 80)
drawHintBar(w, termRow, termCols, label, vars, style):
- Calls
renderTpl(label, vars, ctrlAnsi) varsprovides runtime values likeactive_fileandactive_setting
5. Draggable Scroll Bar & Navigation
+--------------------------+
| Item 1 █ | -- thumb
| Item 2 ▒ | -- rail
| Item 3 ▒ |
+--------------------------+
handleHeight = max(1, visibleRows² / totalRows)handleTop = (currentRow × (visibleRows − handleHeight)) / (totalRows − visibleRows)- Drag: click-press on scrollbar column, drag vertically to shift viewport proportionally.
- Configurable via
scroll_barsection inspec/style.yaml.
6. Preview Mode Split-Screen
m/M toggles between Grid and Split-Screen Preview:
+-----------------------+-----------------------------+
| folder1 | |
| **file1.png** | Selected Preview |
| file2.jpg | (Scaled) |
+-----------------------+-----------------------------+
- Left pane: ~40% width, text list
- Right pane: ~60% width, scaled thumbnail of selected item
- State held in memory; saveable to
~/.config/cati/configasview_mode=preview|grid
7. Interactive Viewers (cmd/interactive.go)
interactiveWithChan (image) and interactiveVideo accept style, labels, viewBtnRows from the browser:
- Image viewer: renders image in
viewRows = termRows - viewerChromeRows; explicit CLI--heightsetsviewRows, not total terminal rows. The current chrome uses a button bar attermRows-1and a hint bar attermRows. Button actions:zoom_in,zoom_out,back,quit. - Video viewer: same layout. Adds
paused bool; Space bar toggles.conditions["playing"] = !pausedpassed todrawBottomMenuso{ if(playing, pause, play) }resolves at render time. Mouse tracking is enabled on entry (the browser disables it before calling the viewer).
Viewer quality values compare a reconstruction of the terminal glyph output
against a common quality-grid source reference. Quad uses
quadblock.RenderToImage; spark uses sparkline.RenderToImage; halfblock uses
the viewport image directly.
8. Audio (internal/audio)
New package for audio playback. play_video and pause_video button actions are stubbed in the browser event loop, ready to call into this package. The conditions["playing"] flag in drawBottomMenu already reflects the video player’s paused state and will extend naturally to audio.
Quad-Block Pixel Art
title: Quad-Block Pixel Art weight: 50
Quad-Block Pixel Art in the Terminal
This document captures the design, aspect-ratio math, and implementation decisions
for the internal/quadblock package, which renders images using Unicode quadrant
block characters (U+2596–U+259F).
Core idea: one cell, four pixels
Unicode quadrant characters divide a terminal cell into a 2×2 pixel grid:
| Position | Name |
|---|---|
| UL (upper-left) | bit 3 — value 8 |
| UR (upper-right) | bit 2 — value 4 |
| LL (lower-left) | bit 1 — value 2 |
| LR (lower-right) | bit 0 — value 1 |
fg colour fills the marked quadrants; bg fills the rest.
The same two-colour-per-cell constraint as half-block applies.
Character lookup table (4-bit mask → rune)
| Mask | Filled | Char |
|---|---|---|
| 0000 | — | (space) |
| 0001 | LR | ▗ |
| 0010 | LL | ▖ |
| 0011 | LL+LR | ▄ |
| 0100 | UR | ▝ |
| 0101 | UR+LR | ▟ (approx — no exact Unicode char) |
| 0110 | UR+LL | ▞ |
| 0111 | UR+LL+LR | ▟ |
| 1000 | UL | ▘ |
| 1001 | UL+LR | ▚ |
| 1010 | UL+LL | ▙ (approx — no exact Unicode char) |
| 1011 | UL+LL+LR | ▙ |
| 1100 | UL+UR | ▀ |
| 1101 | UL+UR+LR | ▜ |
| 1110 | UL+UR+LL | ▛ |
| 1111 | all | █ |
Masks 0101 (UR+LR, “right column”) and 1010 (UL+LL, “left column”) have no exact Unicode codepoint. They are approximated with the nearest Hamming-1 character. This is a known limitation of the Unicode block-element range.
Half-block vs. quad: layout and aspect ratio
Half-block layout
Half-block characters split each terminal cell once — into a top and bottom half. A 10×10 px image renders as 10 cols × 5 rows:
0123456789 ← pixel columns (= terminal columns)
0 ▀▄▀▄▀▄▀▄▀▄ ← terminal row 0 covers pixel rows 0+1
2 ▀▄▀▄▀▄▀▄▀▄ ← terminal row 1 covers pixel rows 2+3
4 ▀▄▀▄▀▄▀▄▀▄
6 ▀▄▀▄▀▄▀▄▀▄
8 ▀▄▀▄▀▄▀▄▀▄
- 1 terminal col = 1 image pixel wide
- 1 terminal row = 2 image pixels tall
Terminal cells are 1:2 (W:H) in screen aspect.
A 10-col × 5-row cell grid is 10·W : 5·2W = 10W : 10W → 1:1 ✓
The image appears with correct proportions.
Quad-block layout (naïve)
Quad characters split each terminal cell twice — into a 2×2 grid. The same 10×10 px image renders as only 5 cols × 5 rows:
02468 ← pixel columns (every other, since 2 px per col)
0 ▞▞▞▞▞ ← terminal row 0 covers pixel rows 0+1
2 ▞▞▞▞▞ ← terminal row 1 covers pixel rows 2+3
4 ▞▞▞▞▞
6 ▞▞▞▞▞
8 ▞▞▞▞▞
- 1 terminal col = 2 image pixels wide
- 1 terminal row = 2 image pixels tall
Screen aspect of the 5-col × 5-row cell grid: 5·W : 5·2W = 5W : 10W = 1:2
The image is horizontally squeezed (or equivalently, vertically stretched).
The 2× horizontal stretch correction
Each quad pixel occupies cell_width/2 × cell_height/2 on screen.
Since cell_height ≈ 2·cell_width, each quad pixel is cell_width/2 × cell_width
— a 1:2 rectangle (narrow and tall).
To make source pixels appear square in the rendered output, the pixel image
fed to Render must be 2× wider than the source image:
| Source image | Naïve quad pixels | After 2× stretch |
|---|---|---|
| 10×10 px | 5 cols × 5 rows (1:2 screen) | 10 cols × 5 rows (1:1 screen ✓) |
| W×H px | W/2 cols × H/2 rows | W cols × H/2 rows ✓ |
This matches the half-block output: both render a square image into N cols × N/2 rows, giving a 1:1 screen aspect.
How ScaleToFit implements the correction
// Treat the source as 2× wider when computing the scale factor.
stretchedW := srcW * 2
targetW, targetH := stretchedW, srcH
if maxW > 0 && targetW > maxW {
targetH = srcH * maxW / stretchedW
targetW = maxW
}
if maxH > 0 && targetH > maxH {
targetW = stretchedW * maxH / srcH
targetH = maxH
}
// ScaleNN upscales: a 10×10 source → 20×10 target (fits cols=10, rows=5).
return halfblock.ScaleNN(img, targetW, targetH)
Upscaling is intentional — without it a small source image would render with the 1:2 pixel distortion regardless of the col/row limits.
Two-colour constraint and neighbour-aware quantisation
Each terminal cell has exactly one fg and one bg colour. When a 2×2 pixel block contains more than two distinct colours, the renderer must quantise to two.
Scoring algorithm (pickBestPair)
For every candidate colour pair (ca, cb):
score = coverage × 4 + continuity
- coverage: number of the 4 pixels that exactly match
caorcb(0–4) - continuity: count of how many of those colours already appear as fg/bg in the left or above neighbour cell (0–4)
Coverage is weighted 4× so exact matches dominate, but continuity breaks ties, keeping colour transitions smooth across cell boundaries.
The higher-count colour of the winning pair becomes fg; the other becomes bg.
Quality rendering variants
The Options struct controls quality trade-offs available to the caller.
Pre-processing steps (colour reduction) are applied to the scaled image before
calling RenderOpts.
Rendering options (Options)
| Field | Type | Effect |
|---|---|---|
HalfblockThreshold | int | Fall back to ▀/▄ when exact coverage < N (only on 3+-colour cells) |
Blend | BlendMode | Neighbourhood pixel blending (see below) |
SplitHalf | bool | Derive fg/bg from halfblock row-averages; apply quad mask for sub-cell precision |
SplitHalfNeighbors | bool | Extends SplitHalf: also tries left/above cell colours as bg candidate, picks lowest quantisation error |
LumSplit | bool | Split sub-pixels at mean BT.601 luminance; colour each group’s average |
Blend modes
| Constant | Behaviour |
|---|---|
BlendNone | Sample each sub-pixel at its exact center (default) |
BlendAlways | 3×3 weighted blend (4:2:1) for every sub-pixel |
BlendAmbiguous | Same 3×3 blend, but only on cells with 3+ distinct colours |
BlendAmbiguousWide | 5×5 blend (radius 2) on ambiguous cells |
Practical note (2026-06-24): BlendAmbiguous / BlendAmbiguousWide produce
visible blurring on photographic content. SplitHalf and SplitHalfNeighbors
give the cleanest results. Halfblock mode is perceptually most pleasant because
its 1:1 “square pixels” are easier on the eye than quad’s 1:2 sub-pixels.
Colour space reduction (ReduceColors)
// Apply before ScaleToFit / RenderOpts:
img = quadblock.ReduceColors(img, quadblock.ColorANSI256)
| Constant | Palette |
|---|---|
ColorFull | 24-bit true colour (no reduction) |
ColorANSI256 | ANSI xterm 256: 16 basic + 6×6×6 cube + 24 grays |
ColorANSI16 | 16 basic ANSI terminal colours |
ColorGray8 | 8-level grayscale (BT.601 luma) |
ColorGray16 | 16-level grayscale |
ColorGray64 | 64-level grayscale |
Nearest-colour matching uses squared Euclidean distance in linear RGB. Transparent pixels are preserved.
The renderer also has worker-aware copies of RenderOpts and RenderToImage.
The serial code remains the baseline implementation; the parallel copies are
called only when the CLI job count is greater than 1, so the current algorithm
behaviour stays pinned while the worker path is exercised separately.
LumSplit algorithm
For each 2×2 cell:
- Compute BT.601 luma
L = 0.299·R + 0.587·G + 0.114·Bfor each sub-pixel. - Compute mean luma as the split threshold.
- Sub-pixels at or above threshold → bright group (fg); below → dark group (bg).
- fg colour = average of original colours in bright group.
bg colour = average of original colours in dark group. - Build the quad mask as usual.
This is the “grayscale-as-base + colour overlay” approach: luminance drives the structure; colour is derived from the real pixel values.
Package structure
internal/quadblock/
render.go — quadChar table, Options, compileCell, ScaleToFit, RenderOpts
colorspace.go — ColorReduction type, ReduceColors, palette definitions
render_test.go — unit tests: char table, mask, quantisation, neighbour lookup
show_test.go — visual test: go test -v -run TestShowImages
ScaleToFit, Render, RenderOpts, and ReduceColors are the public surface;
all internals are unexported.
The package imports internal/halfblock for ScaleNN and LoadImage (tests).
Known limitations
- Masks 0101 / 1010 (vertical column patterns) have no exact Unicode
codepoint. The approximations (
▟/▙) add one extra quadrant. - Terminal font support: quadrant chars (U+2596–U+259F) require a geometric
font renderer.
foot,kitty,alacritty,wezterm,ghosttyare safe.gnome-terminalandxtermmay render them incorrectly. - Cell aspect ratio assumption: the 2× stretch assumes a 1:2 (W:H) terminal cell. Most modern terminals match this; bitmap fonts or unusual DPI may differ.
Sparkline Pixel Art
title: Sparkline Pixel Art weight: 60
Sparkline Pixel Art — Rendering Algorithms & Verification
This document captures the architecture, design decisions, optimal-split algorithm, scanning traversal, and testing harness for the Cati sparkline rendering mode.
1. Overview & Modes
Sparkline mode displays scalar gradients and pixel grids in the terminal by
mapping each terminal cell’s 4×8 source block to the Unicode glyph and two
colours that minimise reconstruction error.
Cati provides two sparkline-family modes:
| Mode | Visual Representation | Growth Direction | Character Set |
|---|---|---|---|
Vertical (spark/vert) | ▂▃▄▅▆▇█ | Bottom-to-Top (Upward) | U+2581 – U+2588 |
Quad (spark/quad) | vertical spark blocks plus ▘▝▖▗▀▄▌▐▚▞▛▜▙▟█ | Best 2D mask | fractional block + quad block candidates |
spark/quad is the only spark mode currently exposed in the main interactive
render-mode cycle. spark/vert remains in the library and test suite as a
useful scalar baseline.
Removed Modes
Earlier versions included spark/upper, spark/right, and spark/left.
spark/upper and spark/right were redundant foreground/background inversions.
spark/left was removed because it produced weak visual results under the
current 4×8 sparkline geometry. Horizontal 1/8 blocks need an 8×8 base grid
to be represented as cleanly as vertical eighths.
2. The Optimal Split Algorithm
To render a terminal cell, Cati analyzes its corresponding source pixel block of size $W_c \times H_c$. In spark/vert, it selects the character index bestK (0..7) and colors (barColor and emptyColor) that minimize the Total Squared Error (SSE) between the reconstructed cell and the source pixels.
For each possible split level ci (0 to 7):
- Division: The cell’s pixels are split into two regions: the “bar” (covering $\frac{ci+1}{8}$ of the cell) and the “empty” space (covering the remaining $\frac{7-ci}{8}$).
- Color Averaging: The average RGB color of the bar region becomes the candidate
fgAvg, and the average RGB color of the empty region becomes the candidatebgAvg. - Error Calculation: The sum of squared Euclidean distances in RGB space is computed between each pixel in the block and its assigned region’s average color: $$\text{SSE} = \sum_{p \in \text{bar}} (p - fgAvg)^2 + \sum_{p \in \text{empty}} (p - bgAvg)^2$$
- Minimization: The level
ciyielding the lowest SSE is chosen asbestK.
spark/quad Candidate Masks
spark/quad generalizes the same SSE idea from one-dimensional split levels to
two-dimensional candidate masks. For each terminal cell it:
- Evaluates the vertical sparkline masks.
- Evaluates quad, half, full, and space masks on the same
4×8block. - Averages source pixels inside the mask to get foreground colour.
- Averages source pixels outside the mask to get background colour.
- Reconstructs the block and selects the rune with the lowest SSE.
Quad candidates are upsampled to 4×8: each quadrant covers a 2×4 rectangle.
This keeps spark/quad in the sparkline geometry family and avoids changing the
pure quadblock renderer.
Tiebreaker: prefer non-splitting characters
When two candidates have equal primary SSE, a secondary tiebreaker is applied to avoid artifacts on solid-colour regions.
Split penalty is 0 if at most one of (FG colour, BG colour) would emit an
ANSI colour sequence (i.e. at least one region is transparent / empty), and 1
if both regions need a colour sequence. For a fully opaque solid-colour block:
█(full block): the background region is empty (bgN = 0) →bgAvg.A = 0→ only FG sequence needed →splitPenalty = 0▁–▇(partial vertical): both regions are opaque →splitPenalty = 1
So █ wins all ties on uniform blocks, producing clean single-colour output
with one sequence per cell instead of two. For mixed blocks the penalty is
irrelevant because SSE differs.
Transparent-pixel cost is a separate primary-tier mechanism: any transparent
source pixel that falls inside a coloured region accumulates
transparentPixelCost = 3 × 255² per pixel added to the SSE. This forces
candidates that extend colour into transparent rows to lose to candidates that
leave those rows empty, overriding what a pure RGB SSE would prefer.
Half-cell fit: where the transparent rows come from
A terminal char is two stacked half-cells (CellH/2 px each). When the
aspect-preserving scaled height ends mid-cell, imgutil.FitDims snaps the
partial last row to the nearest half-cell boundary so it maps onto a
representable glyph:
- nearer a top half-cell → keep
CellH/2rows of content and append exactlyCellH/2transparent rows (extH = CellH/2). The last char renders as a clean upper-half block▀— FG = content colour, BG region fully transparent so no BG sequence is emitted. - nearer a full cell → round up to a full content cell (
extH = 0); the last char is an ordinary content row.
Snapping to the nearest half-cell (rather than keeping the raw remainder and
padding CellH − rem transparent rows) is essential: a mid-cell remainder such
as 6 content + 2 transparent rows matches no block glyph, so the selector would
fall back to quadrant/diagonal chars (▌ ▚ ▘) whose colour bleeds into the
transparent area — a garbled bottom row in RenderOpts. The snap guarantees
extH ∈ {0, CellH/2}, upholding the half-char transparency invariant.
Resolution-independent — the snap is shared by all render modes. Every mode
is built so that CellW / (AspectX · CellH) = 1/2 (halfblock 1/(1·2), quad
2/(2·2), spark 4/(1·8)), so the continuous display height in char rows,
srcH · cols / (2 · srcW), is identical regardless of mode. FitDims makes the
half-cell decision from that continuous ratio (carried as exact integer
hNum/hDen), not from a height already floored to integer pixels. Flooring
first would discard up to ~½ a char at 2 px/char (halfblock, quad) but almost
nothing at 8 px/char (spark), so the modes would disagree on the bottom-row
geometry for the same source and width (halfblock/quad rendering “too short”).
Deciding in the shared continuous unit makes all modes land on the same rows and
the same bottom-row fill. See TestFitDimsUnifiedGeometry.
3. Pixel Scanning Traversal & Pitfalls
The legacy 1D split logic requires that the pixel array passed to the error minimization function is segmented along the split line. This introduces a critical traversal requirement:
- Vertical Spark Mode (
Vertical): Must scan pixels in row-major order (row 0, row 1, …, row H-1). A horizontal split boundary in 1D then maps to a horizontal boundary dividing the top and bottom rows of the cell block. - Quad Combo Mode (
Quad): Uses explicit 2D masks instead of scan-order-dependent splits. - Cropped image bounds: Interactive panning passes cropped
SubImagevalues into the renderer. These images may have non-zeroBounds().Min. Sparkline sampling must addb.Min.X/b.Min.Ywhen derivingx0,x1,y0, andy1; sampling from relative(0,0)coordinates reads out-of-bounds black pixels and makes the background appear to pan while the image stays pinned. - Rendering reconstruction:
sparkline.RenderToImagemust share the same cell selection and mask semantics asRenderOpts. The app uses it for SSIM and other quality metrics, so changing glyph masks requires updating both ANSI rendering and image reconstruction together. - Display-size contract: The
4×8sparkline footprint is a renderer-local glyph grid, not permission to shrink the visible terminal cell rectangle. Interactive viewport construction expands spark crops to the footprint required by the sharedsrc px/cellzoom model, then validates the emitted cell size. A small32×32source at fit/1:1 must render as32×16cells, not silently become8×4cells just because one spark cell analyzes a4×8block.
Warning
Reintroducing horizontal 1/8 block modes under
4×8geometry will be approximate. Use an8×8spark-family geometry first if exact horizontal eighths become important.
4. Verification & The Test Helper Suite
The testhelper package (internal/sparkline/testhelper/) provides automated
validation and visualization of all Cati renderers. It exposes three generator
functions that create source images on the fly so no static binaries need to be
committed to the repo for these test cases.
The renderer now has worker-aware copies of RenderOpts and RenderToImage.
The serial functions remain the baseline implementation; the worker copies are
used by the CLI when -j/--jobs > 1 so the existing output path stays stable
while the parallel path is isolated for comparison and consolidation later.
The spark glyph candidate tables are also prebuilt once, and the mask lookup
for reconstructed cells is kept map-free, so the hot path does not rebuild
per-cell helper state.
When the source or destination is already *image.RGBA, the renderer uses
direct pixel access instead of generic image.Color sampling/writes, which
keeps the benchmarked path allocation-free in cell selection and nearly flat in
image reconstruction.
Current sparkline-family modes are spark/vert, spark/quad, spark/sextant,
and spark/best. The shipped sextant renderer is separate and kept
intentionally narrow as sextant/2x3 (xs). The candidate-scoring sparkline
mode is:
spark/bestexhaustively scores the combined quad + sextant candidate set.
(spark/geom, a cheap heuristic that picked between quad and sextant
candidates, was removed — spark/best covers the same candidate space at higher
quality.)
Generator functions
| Function | What it produces | Located under |
|---|---|---|
GenerateGradients | Horizontal + vertical blue→yellow gradients at 20×20, 4×4, 2×2, 1×1 | testdata/demo_horiz_NxN/, testdata/demo_verti_NxN/ |
GenerateFixtures | Solid-red 4×4 regression fixture | testdata/solid_red_4x4/ |
GenerateGeometrics | Four 20×20 geometric images (see below) | testdata/demo_*_20x20/ |
Geometric images (GenerateGeometrics):
| Subfolder | Description | Colours |
|---|---|---|
demo_diag_20x20 | 45° diagonal split (top-left vs bottom-right) | red / blue |
demo_circle_20x20 | Filled disc, radius 8, centred at (9.5, 9.5) | yellow / blue |
demo_checker_20x20 | Checkerboard with 4×4 px cells | red / blue |
demo_cross_20x20 | 4-pixel-wide cross centred on image | yellow / blue |
Pure saturated colours give each algorithm unambiguous ground truth at every cell boundary: a correct renderer must produce the source colour with no bleed across a hard edge.
Golden comparison
TestGoldenRenders (in cmd/golden_render_test.go) runs every combination of
(source image, char width, algorithm) and compares against a stored PNG.
Each golden is stored at a shared per-character block size derived from the LCM of all registered render modes’ cell geometries. For the current four modes the block is 12×24 px/char (aspect ratio 1:2, matching a real terminal cell):
| mode | cell W×H | kX, kY | block |
|---|---|---|---|
| halfblock | 1×2 | 12×12 | 12×24 ✓ |
| quad | 2×2 | 6×12 | 12×24 ✓ |
| spark | 4×8 | 3× 3 | 12×24 ✓ |
| sextant | 2×3 | 6× 8 | 12×24 ✓ |
Every coarser-resolution algorithm reaches the canvas by integer pixel
replication only — no NN resample. TestUnrepeatLossless asserts this
invariant: unrepeat(upscale(native)) == native for every mode.
TestGoldenBlockIntegerFactors asserts that blockW % CellW == 0 and
blockH % CellH == 0 for all modes.
Adding a new render mode with a different cell geometry will automatically
enlarge the block (computed by goldenCharBlock() from the live registry) so
that all integer-replication invariants are preserved.
TestCLIRender (in cmd/cli_render_test.go) does the same for ANSI terminal
output, storing .ansi golden files.
Run with -update to regenerate all goldens:
go test ./cmd/... -update
Interactive demo table
make demo-widths
make demo-darth
make demo-solder DEMO_WIDTH=80 DEMO_STEPS=3
Runs scripts/demo_widths.go (build-tag ignore, excluded from normal builds)
and prints demo renders in terminal tables. Multi-image runs group by render
mode with one image per column. Single-image runs group by image with
halfblock, quad/splithalf, and spark/quad side by side. -w selects the
maximum render width and -n selects how many 80% downscale steps to show
(default 2). Useful for a quick visual sanity check of all render modes after
algorithm changes.
Render Experiment Lessons
title: Render Experiment Lessons weight: 70
Render Experiment Lessons
June 2026 cleanup: xs / sextant/2x3 is the only new shipped render
algorithm. The experimental sextant search aliases (xg, xb) and geomshape
aliases (sh, shg, shb) were removed from code, CLI parsing, cycling,
metrics, and tests.
A later pass also removed sg / spark/geom (the chooseGeomCandidates
heuristic that switched between quad and sextant candidate sets per block). It
added no quality over spark/best, which already scores the union of both
candidate sets, so it was dead weight in the cycle. Its render_spark_geom_* /
cli_spark_geom_* goldens were dropped with it.
Key rules retained from the failed experiments:
- Every renderer must declare its terminal-cell source footprint and aspect correction in the shared pipeline.
- Static and playback paths must fail with
render aspect mismatchbefore ANSI output when the rendered viewport no longer matches the source aspect. - Opaque source regions must not emit terminal-default background holes.
- Experimental aliases should not be public CLI modes until they pass the same aspect, gap, and golden-image invariants as shipped renderers.
Rendering Bug & Golden Playbook
title: Rendering Bug & Golden Playbook weight: 80
Rendering Bug & Golden-Change Playbook
How we diagnose visual/geometry rendering bugs and change golden images safely.
One rule above all: prove the bug numerically before you touch code, and change a golden image only once you can name the wrong pixel and say why.
Golden PNGs are the project’s ground truth for what every render mode produces.
A careless -update silently rewrites that truth and hides regressions forever.
This playbook is the discipline that prevents it. It is written from a real fix
(spark/quad garbled bottom row → cross-mode half-cell unification, issue
#015); the steps below are the
exact sequence that worked.
The loop
1. Reproduce + isolate → smallest input that shows it; find a control that does NOT
2. Prove the root cause → numbers, not screenshots; explain every "good" and "bad" case
3. Predict golden impact → list exactly which goldens change, before editing code
4. Fix the root → no band-aids; remove earlier band-aids the root fix obsoletes
5. Confirm → live output + unit invariants + ONLY the predicted goldens differ
6. Update goldens → only when 100% sure; regenerate-all, revert-all-but-intended
7. Close the loop → docs + tests + issue in the same commit
Each step has a gate: do not advance until the current step’s gate is green.
1. Reproduce and isolate
- Find the smallest input that reproduces. Shrink width/size until the bug is
trivially inspectable (e.g.
-w 6instead of-w 24). - Find a control that does not reproduce. In #015,
-w 5was clean but-w 6was broken — that contrast is the single most valuable clue, because the fix must explain both. - Capture the raw bytes, not just the picture. For ANSI output:
cati … | cat -v | tail -1shows the exact escape sequences and glyphs of the offending row. Decode the block characters (▀ ▌ ▚ …) — the choice of glyph is the symptom.
Gate: you have a one-line repro command and a one-line near-miss that differ by a single parameter.
2. Prove the root cause — numbers first
Screenshots show that it’s wrong; only numbers show why. Build a tiny probe that runs the real production function (not a reimplementation) over the repro and the control, and prints the geometry/decision for each.
- Put the probe at
scripts/<name>_probe.gowith//go:build ignore, run it withgo run, and delete it before committing (it is a diagnostic, not a test — the test comes in step 7). - The proof must explain every observed case: each “good” width and each
“bad” width must fall out of the same formula. In #015 the bug was exactly
rem ∈ {5,6,7}and the good cases wererem ∈ {0,4}andrem<4— the probe showed that mapping precisely. - Look for an invariant that should hold but doesn’t. #015’s was
CellW/(AspectX·CellH) = 1/2for every mode ⇒ identical continuous height ⇒ the modes must agree; the probe proved they didn’t and pinpointed the floor-before-snap as the only divergence.
Gate: a formula predicts the bug for every repro and every near-miss, and you can state the wrong value in one sentence.
3. Predict golden impact — before editing code
With the root cause as a formula, you can compute which inputs hit it. Enumerate
the golden corpus and mark which goldens fall in the affected range on paper,
before changing a line. In #015 we computed rem for every golden source and
predicted exactly 2 (then 4) goldens would change — and nothing else.
Gate: a concrete list of “goldens that will change” and a one-line reason for each. If your prediction later turns out wrong (more or fewer change), stop — your root-cause model is incomplete; return to step 2.
4. Fix the root, not the symptom
- Fix the cause the proof identified, at the layer where it originates (in #015,
the geometry decision in
FitDims, not the renderer). - Remove band-aids the root fix makes obsolete. #015 had an earlier
symptom-suppression patch in
render.go; the root fix made it dead weight, so it was reverted. Leaving both is how a codebase rots. - Prefer the change that makes a broken case representable over one that hides it (snap geometry to a valid glyph boundary > suppress a colour after the fact).
Gate: the diff touches the layer the proof named, and no compensating hack remains downstream.
5. Confirm — three independent checks
- Live output — re-run the original repro (and the near-miss) against the rebuilt binary; the bytes are now clean and the control is unchanged.
- Codified invariant — add/extend a unit test that asserts the property,
not a pixel (e.g.
TestFitDimsUnifiedGeometry: all modes agree;TestFitDimsHalfCellInvariant:extH ∈ {0, CellH/2}). This is what stops the bug from returning. - Golden diff matches the prediction — run the golden test (without
-update) and confirm the set of failing goldens is exactly the list from step 3 — no more, no less.
Gate: all three green, and the failing-golden set equals the prediction.
6. Update goldens — only when 100% sure
You may regenerate a golden only when you can point at the specific wrong
pixel in the old one and say why it was wrong. In #015 we showed the old
halfblock/quad goldens had 4 transparent bottom rows (a ▀ half-row) where the
true geometry is a full row — and proved it by reading the alpha channel.
Mechanics (the -update flag rewrites all goldens, including byte-only
re-encodes):
# regenerate everything, then keep ONLY the intended files
go test ./cmd/ -run TestGoldenRenders -update
git checkout -- $(git diff --name-only testdata | grep -vE '<intended-file-regex>')
git status --porcelain testdata # must list exactly the intended goldens
Then verify the new goldens encode the intended geometry (re-read the pixels), not just “the test passes now.”
If you are not certain a golden was wrong, do not touch it. A red
TestGoldenRenderson a file you can’t justify is a signal to keep investigating, never a reason to-update.
Gate: every changed golden has a recorded “which pixel, why” justification, and a human approved the change for visual goldens.
7. Close the loop — same commit
- Update the relevant evergreen doc (for render-algorithm changes, SparklinePixelArt.md / QuadPixelArt.md) in the same logical step, not at the end.
- Record the bug, root cause, and golden impact in
issues/with aRefs #NNNin the commit. - Commit message states the root cause and names the regenerated goldens and why.
Gate: go vet ./..., make preflight, and go test ./... are green, and the
commit carries code + tests + docs + golden justification together.
Anti-patterns this playbook exists to prevent
-updateto make the suite green. That doesn’t fix a bug; it ratifies it.- Screenshot-driven fixing. “Looks better now” is not a root cause and won’t survive the next aspect ratio.
- Band-aid stacking. Suppressing a symptom downstream of an unfixed cause leaves two things to misunderstand later.
- Reimplementing the function in the probe. Prove against the real code path, or you prove nothing about production.
- Fixing without a near-miss. If you can’t point at a similar input that works, you don’t yet understand the boundary.
Go Conventions
title: Go Conventions weight: 90
Go Conventions
Language & Deps
- Modern Go — use current language features (
any, generics where they reduce noise) - Minimise external deps; stdlib first
- Allowed:
github.com/spf13/cobrafor CLI,gopkg.in/yaml.v3for config - No ORM, no logging framework, no DI container
Project Layout
- Small: root
main.gowith features split by concern (apply.go,config.go,status.go) - Med: root
main.gowithinternal/sub-packages; nopkg/ - Large/Multi: tools in
cmd/<name>/main.gowith features in split files orinternal/ - Embed static assets with
//go:embed - Never commit binaries —
go builddrops binaries in the repo root. Add it to.gitignoreat project setup time:
Use the# ignore Go binaries /mybinaryBINARYvariable from the Makefile as the canonical name so the.gitignoreentry and the build output always match.
Spec-driven Apps (Optional)
- Use this concept only on demand or when you see a need for it. Example: We are changing strings and layout a lot, and the user wants to have finegrained control.
- Add
spec/<feature>.jsonto drive compile-time features:spec/strings.jsondefines labels, titles, messagesspec/layout.jsondefines app layout, ordering, and morespec/screen-help.jsonhome screen contentspec/screen-home.jsonhelp screen content- add more as needed and create structs for parsing
CLI
- Use Cobra; one
*cobra.Commandper verb, flags defined on that command RunEnotRun— return errors, don’tos.Exitinside commandsSilenceUsage: trueon commands where error is not a usage mistake
Error Handling
- Wrap with context:
fmt.Errorf("settings: %w", err) - No
panicexcept truly unrecoverable init failures - Return errors up; print only at the top level
State Management
- No package-level mutable variables.
Pass state explicitly via function parameters or a named struct.
Package-level vars create hidden coupling and break concurrent use.
// bad var globalClient *http.Client // good type App struct { client *http.Client } init()only for truly static, side-effect-free registration (e.g.flag.Var). Never useinit()to connect to services or load files.
Types & Style
- Unexported types for internal results; exported only when crossing package boundary
- Pointer fields (
*bool,*int) for optional struct values; addboolPtr/intPtrhelpers - Section banners:
// ── Section name ──────────────────────────────────────────── - Doc comments on all exported symbols
Output Discipline
- Functions return results; callers own printing
- Print changed items with two-space indent:
fmt.Printf(" wrote %s\n", path) - Sub-details indented four spaces
Tests
- Table-driven tests with
t.Run - Test files in same package (
package main) - Helpers:
t.Helper(),t.Fatalffor setup failures,t.Errorffor assertion failures - No test frameworks — stdlib
testingonly
Make Conventions
title: Make Conventions weight: 100
Make conventions
Default language assumed: Go. Apply to other languages accordingly.
Structure
- First target is the default goal — always
help - All targets declared
.PHONYusing the⚙️sentinel trick (see below) - One blank line between targets
Variables
Example:
BINARY := claudeconfig # output binary name
CONFIG := config.yaml # default config file
TARGET := $(HOME)/.claude # installation target dir
PROJECT := . # project root (passed to tool as -p)
PREFIX ?= /usr/local # overridable install prefix
- Use
:=for immediate assignment (most vars) - Use
?=for env-overridable vars (PREFIX) - Align
=signs for readability
Phony declaration — ⚙️ sentinel
.PHONY: ⚙️ # make all commands phony
Adding ⚙️ as a prerequisite on every target (e.g. help: ⚙️ ## …) causes Make
to treat all targets as phony without listing each name twice. The Unicode
character is never a real file, so the rule fires unconditionally.
Self-documenting help target
help: ⚙️ ## show this help
@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*## "}; {printf " %-10s %s\n", $$1, $$2}'
Every target that should appear in help gets a ## description comment on the
same line as the rule header. help scrapes them automatically.
Build dependency pattern
Action targets depend on build so the binary is always fresh:
build: ⚙️ ## build the binary
go build -o $(BINARY) .
apply: ⚙️ build ## apply config.yaml to the Claude Code config directory
./$(BINARY) apply -c $(CONFIG) -t $(TARGET) -p $(PROJECT)
buildrebuilds only when sources change (Make’s normal rules apply)- Action targets invoke
./$(BINARY)— the locally-built binary, not the one on$PATH
Install target (Go)
install: ⚙️ build ## install the binary to PREFIX/bin (default: /usr/local/bin)
go install .
@sudo install -m 0755 $(BINARY) $(PREFIX)/bin/$(BINARY) && \
echo "✅ Installed for all users" || echo "⚠️ System install failed"
go installputs the binary in$(GOPATH)/bin(user-local)sudo install -m 0755copies to$(PREFIX)/binfor system-wide availability|| echo …degrades gracefully whensudois unavailable
Test target
test: ⚙️ ## run linter and tests
go vet ./...
go test ./...
Always run go vet before go test; vet catches issues tests may not exercise.