Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 Systemspec/input.yaml decision tree, internal/input package, SGR 1006 mouse protocol, UTF-8 tokenization, move vs drag, --input-test TUI. Read before touching input handling or spec/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.

CharacterVisual RepresentationTarget PixelsColor Source
Top half filledTop pixel color, Bottom transparentForeground color = top; Background color = none
Bottom half filledTop transparent, Bottom pixel colorForeground color = bottom; Background color = none
Fully filledTop & Bottom identical colorsForeground color = top/bottom
Empty/TransparentBoth pixels transparentNone

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 Systemspec/input.yaml tokenizer decision tree, internal/input package, SGR 1006 mouse, UTF-8 handling, --input-test TUI.
  • 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, or Ctrl+C while 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 a SecurityError in modern browsers if the website is opened directly from the local disk using the file:// 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 on image, image/color, and math should not sit in cmd/. 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/imgutilFitPixelDims (aspect-ratio fit, no upscale), CropImage (zero-copy SubImage for RGBA). Zero project deps.
    • internal/pixelartScale2x/Scale3x, Sharpen/Sharpen05/Sharpen10. Already extracted but had no tests (now has 9 tests).
  • RenderQuality stays in cmd/ — 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 from cmd/ssim.go but had zero callers. Extracting is a natural moment to prune.
  • Functions used only within the package stay unexported. metrics.Luma was exported initially, but no caller outside internal/metrics referenced it. Unexporting avoids committing to a public API that may change.
  • Avoid package-name redundancy in exported names. metrics.QualityGridK reads as “metrics quality grid K” — the Quality prefix is noise. metrics.GridK is 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 types core.Cell and core.Grid shared by all renderers.
  • v1/term — Implements cross-platform terminal size detection utilities (term.TermWidth() and term.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) error
    • RenderToGrid(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. Dims is 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 by buildRef for SSIM reference generation and by the hint-bar for meta.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 via viewSpec().

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, not k. k is an internal ladder parameter; the hint bar should report the actual source pixels represented by one terminal cell.
  • The common geometry is n : 2n source pixels per cell footprint. n is 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 enum halves/quarters/adaptive for generating k from 1.0 up to srcW

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

  1. Collect k-values into a map[float64]bool (dedup)
  2. Iterate map keys into a slice, then sort.Float64Slice(ks).Sort() (ascending k)
  3. 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:

  • viewerCore struct 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 from orig, for video it builds from lastRawFrame. The callback updates vc.lastVP and vc.curQ only — 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 / handleMouse return (quit, changed bool, unhandledAction string). An unhandledAction is a spec action that the core did not claim — the owning viewer handles it (toggle_pan for image viewer, toggle_play_pause for video viewer).
  • switchMode(oldRC) calls preserveZoomForMode + recenterForMode in one step; replaces the inline duplicate in the image viewer and the switchVideoMode closure 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 via fitMenuItems(items, maxCols). maxCols is writerTermCols(w) with termCols as fallback for non-tty writers (tests, pipes).
  • Hint bar (drawHintBar): enforced via truncateANSI(text, cols-2). Same fallback logic.
  • Pixel renders (rc.render): pre-validated by validateRenderSize before ANSI emission; actual output width is verified in tests via lineCapWriter.

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.toml annotations 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


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/quad combo: In spark/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:

Functionffprobe queryReturns
ProbeVideoFPSstream=r_frame_rateNative FPS as float64 (parses num/den)
ProbeVideoDurationformat=durationDuration in seconds
ProbeVideoDimensionsstream=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:

  1. Full drain at loop top — a labeled for/select empties the entire buffer before entering the blocking select. A single-token drain allowed frames to starve inputs on a burst.
  2. 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 3 to 2 or 1 columns if the terminal width is too narrow (< 60 or < 40 characters).
  • Rows: Automatically shifts from 2 rows to 1 row if the terminal height is limited (< 14 lines).
  • Cell Width & Height:
    cellW = (termCols - (gridCols-1)*gapX) / gridCols
    cellH = (gridRowsLimit - (gridRows-1)*gapY) / gridRows
    
    Where gridRowsLimit = 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

  1. Initialize a blank image.RGBA with dimensions termCols wide and gridRowsLimit * 2 high.
  2. Retrieve or build the thumbnail for each page item, scaled to fit cellW columns and (cellH - 1) * 2 pixel rows.
  3. Draw each thumbnail onto the composite canvas at its computed pixel offset (left, top * 2).
  4. Move the cursor to (1, marginTop + 1) and run halfblock.Render on the composite canvas.
  5. 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 column col is within btn.col and btn.col + btn.width.
  • Cells: Checks if clicked cursor coordinates (c, r) reside within a cell’s bounding box:
    c >= left && c < left+cellW && r >= top && r < top+cellH
    
    If true, the index itemIdx is 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/Space opens the selected item.
  • [ / ] / Page Up / Page Down trigger page transitions.
  • a toggles 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 keyDefaultDescription
preview_videostrueWhether to extract video preview frames
max_jobsCPU/2Parallel thumbnail worker count (0 = auto; overridden by -j/--jobs)
video_frames10Number 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:

ActionEffect
Space (first press)Enter pan mode — status bar shows hint
Move mouseImage follows cursor (grab-and-pull)
Space (second press)Exit pan mode
Left-button dragAlways 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.goresolveKeyName(), tokenizeInput(), ad-hoc escape parsing
  • cmd/interactive.goparseSGRMouse(), 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 via utf8.DecodeRuneInString
  • any — 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)MaskMeaning
0–10x03Button (0=left, 1=middle, 2=right, 3=no-button)
20x04Shift held
30x08Meta/Alt held
40x10Ctrl held
50x20Motion event
60x40Scroll 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 with spec/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 in DefaultSpec().
  • \x03 Ctrl-C is always hardcoded in every keyboard handler as a last-resort quit safeguard, independent of the spec. Do not remove it even if the quit button’s <c-c> key is loaded from spec.
  • Tokenizer rule order matters: utf8_multibyte must precede any_char or multi-byte chars are split. sgr_mouse must precede csi_sequence or the \x1b[< prefix gets misidentified.
  • scan_class: alpha_tilde for CSI sequences: scans until an alphabetic character or ~. This correctly terminates \x1b[5~ (PgUp) at ~ and \x1b[A at A.

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 (loadButtons returning 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

FileSchemaRole
spec/style.yamlschemas/style.schema.jsonAll visual tokens: colors, borders, caps, scrollbar
spec/labels.yamlschemas/labels.schema.jsonNon-button strings: icons, hints, URLs, titles
spec/buttons.yamlschemas/buttons.schema.jsonButton text, action bindings, keyboard shortcuts
spec/views.yamlschemas/views.schema.jsonPer-view layout rows; hidden_keys: for invisible bindings
spec/theme.yamlschemas/theme.schema.jsonSemantic style tokens (primary, secondary, danger, …)
spec/controls.yamlschemas/controls.schema.jsonSettings form fields with type/min/max/values
spec/config.yamlschemas/config.schema.jsonApp config defaults loaded before user config
spec/about.yamlAbout 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):

AliasTerminal 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 drives drawBottomMenu; hint rows drive drawHintBar
  • hidden_keys: — contributes to key maps via loadViewKeyRows() 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:

  1. No stale actions — every action: value in buttons.yaml appears in the schema enum AND has a handler in Go
  2. No unused buttons — every button defined in buttons.yaml appears in at least one view row or hidden_keys: row in views.yaml
  3. 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
  4. No Go fallbacks — Go loading functions (loadButtons, loadButtonKeyDefs, etc.) do not contain hardcoded copies of spec content; the spec file is the only source
  5. Keys are specced — every key that triggers an action must have a keys: entry in buttons.yaml on the button that owns the action; undocumented hardcoded keys are a bug
  6. 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:

TestWhat it checks
TestSpecButtonsLoadloadButtonKeyDefs() returns non-empty map; all buttons have non-empty action
TestSpecButtonsAllUsedevery button name in buttons.yaml appears in some view’s row or hidden_keys:
TestSpecActionsAllHandledevery action: in buttons.yaml has a case in viewKeyAction dispatch (or is structural-documented)
TestSpecViewsLoadloadViewButtonRows() and loadViewKeyRows() return entries for all expected views
TestSpecKeyResolveresolveKeyName maps all documented aliases correctly
TestSpecNoGoFallbackloadButtons("") 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.yaml with text, style, action, keys
  • Placed button in a row: or hidden_keys: in views.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.md section 3 if the data flow changed
  • No stale entries remain (removed button removed from all view rows)
  • go vet ./... clean, go test ./... green, make install succeeds

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

  • height in ~/.config/cati/config is renamed to max_preview_height.
  • + / = increments cfgHeight by 1 row (clamped to termRows); - 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

FileSchemaPurpose
spec/style.yamlschemas/style.schema.jsonColors, borders, grid style, header bar, scrollbar
spec/labels.yamlschemas/labels.schema.jsonNon-button strings: icons, hints, header template
spec/buttons.yamlschemas/buttons.schema.jsonButton text + action bindings (single source of truth)
spec/views.yamlschemas/views.schema.jsonDeclarative button-row layouts per view
spec/theme.yamlschemas/theme.schema.jsonSemantic style tokens (primary, secondary, active, …)
spec/controls.yamlschemas/controls.schema.jsonTunable runtime controls with get/set action names
spec/config.yamlschemas/config.schema.jsonApp config defaults — read by loadSpecConfigDefaults() as the base layer before user config
spec/about.yamlAbout 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:

VariableAvailable inDescription
active_filehint_browserFilename of the currently selected item
active_settinghint_settingsName of the focused settings field
preview_statehint_browserThumbnail status: img, vid, Nf (N frames), (loading), ""
queue_sizehint_browserPending thumb-load jobs: ↻N or "" when idle
last_keyall hintsHuman-readable name of last input event ("j", "Up", "Scroll Up", …)
ssimhint_viewerSSIM quality score as "0.823"
render_modehint_viewerCurrent rendering mode name ("halfblock", "spark/quad", "quad/splithalf", "quad/edge-snap", …)
zoom_levelhint_viewerNearest ladder source pixels per rendered terminal cell, e.g. "src px/cell=1.25"; raw crop ratio is shown by the Info action
meta.namebrowser + viewerBase filename
meta.name_shorthint_viewerBase filename shortened with ... to fit the hint bar
meta.extbrowser + viewerLowercase extension without dot
meta.sizebrowser + viewerHuman-readable file size ("3.2 MB")
meta.modifiedbrowser + viewerFile modification date ("2024-01-15")
meta.src_wbrowser + viewerSource pixel width ("1920")
meta.src_hbrowser + viewerSource pixel height ("1080")
meta.src_resbrowser + viewer"1920×1080" or "" if unknown
meta.disp_wbrowser + viewerDisplay area width in chars
meta.disp_hbrowser + viewerDisplay area height in chars
meta.disp_modebrowser + viewer"half", "quad", or "spark"
meta.disp_resbrowser + viewer"80×24 half" or ""
meta.durationbrowser + viewer"1:23", "45s", or ""
meta.fpsbrowser + viewerFrame rate ("29.97") or ""
meta.vcodecbrowser + viewerVideo codec ("h264") or ""
meta.acodecbrowser + viewerAudio codec ("aac") or ""
meta.bitratebrowser + viewer"5.2 Mbps" or ""
meta.containerbrowser + viewerContainer format ("mp4") or ""
meta.titlebrowser + viewerTitle tag from file metadata
meta.authorbrowser + viewerArtist/author tag
meta.datebrowser + viewerCapture date from tags
meta.locationbrowser + viewerGPS string from tags
meta.camerabrowser + viewerDevice/camera model from tags
meta.commentbrowser + viewerComment 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 with control_bar styling

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
  • / call applySettingsDelta(c, ±1, &tempCfg) which uses c.Min/c.Max for int fields and c.Values for 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 the conditions map
  • Renders literal content between { } blocks with ctrlAnsi styling
  • Returns []menuButton with {label, action, col, width} for click detection
  • termCols is a fallback column budget used when writerTermCols(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)
  • vars provides runtime values like active_file and active_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_bar section in spec/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/config as view_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 --height sets viewRows, not total terminal rows. The current chrome uses a button bar at termRows-1 and a hint bar at termRows. Button actions: zoom_in, zoom_out, back, quit.
  • Video viewer: same layout. Adds paused bool; Space bar toggles. conditions["playing"] = !paused passed to drawBottomMenu so { 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:

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

MaskFilledChar
0000 (space)
0001LR
0010LL
0011LL+LR
0100UR
0101UR+LR (approx — no exact Unicode char)
0110UR+LL
0111UR+LL+LR
1000UL
1001UL+LR
1010UL+LL (approx — no exact Unicode char)
1011UL+LL+LR
1100UL+UR
1101UL+UR+LR
1110UL+UR+LL
1111all

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 : 10W1: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 imageNaïve quad pixelsAfter 2× stretch
10×10 px5 cols × 5 rows (1:2 screen)10 cols × 5 rows (1:1 screen ✓)
W×H pxW/2 cols × H/2 rowsW 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 ca or cb (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)

FieldTypeEffect
HalfblockThresholdintFall back to / when exact coverage < N (only on 3+-colour cells)
BlendBlendModeNeighbourhood pixel blending (see below)
SplitHalfboolDerive fg/bg from halfblock row-averages; apply quad mask for sub-cell precision
SplitHalfNeighborsboolExtends SplitHalf: also tries left/above cell colours as bg candidate, picks lowest quantisation error
LumSplitboolSplit sub-pixels at mean BT.601 luminance; colour each group’s average

Blend modes

ConstantBehaviour
BlendNoneSample each sub-pixel at its exact center (default)
BlendAlways3×3 weighted blend (4:2:1) for every sub-pixel
BlendAmbiguousSame 3×3 blend, but only on cells with 3+ distinct colours
BlendAmbiguousWide5×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)
ConstantPalette
ColorFull24-bit true colour (no reduction)
ColorANSI256ANSI xterm 256: 16 basic + 6×6×6 cube + 24 grays
ColorANSI1616 basic ANSI terminal colours
ColorGray88-level grayscale (BT.601 luma)
ColorGray1616-level grayscale
ColorGray6464-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:

  1. Compute BT.601 luma L = 0.299·R + 0.587·G + 0.114·B for each sub-pixel.
  2. Compute mean luma as the split threshold.
  3. Sub-pixels at or above threshold → bright group (fg); below → dark group (bg).
  4. fg colour = average of original colours in bright group.
    bg colour = average of original colours in dark group.
  5. 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, ghostty are safe. gnome-terminal and xterm may 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:

ModeVisual RepresentationGrowth DirectionCharacter Set
Vertical (spark/vert) ▂▃▄▅▆▇█Bottom-to-Top (Upward)U+2581U+2588
Quad (spark/quad)vertical spark blocks plus ▘▝▖▗▀▄▌▐▚▞▛▜▙▟█Best 2D maskfractional 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):

  1. 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}$).
  2. 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 candidate bgAvg.
  3. 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$$
  4. Minimization: The level ci yielding the lowest SSE is chosen as bestK.

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:

  1. Evaluates the vertical sparkline masks.
  2. Evaluates quad, half, full, and space masks on the same 4×8 block.
  3. Averages source pixels inside the mask to get foreground colour.
  4. Averages source pixels outside the mask to get background colour.
  5. 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/2 rows of content and append exactly CellH/2 transparent 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 SubImage values into the renderer. These images may have non-zero Bounds().Min. Sparkline sampling must add b.Min.X / b.Min.Y when deriving x0, x1, y0, and y1; 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.RenderToImage must share the same cell selection and mask semantics as RenderOpts. 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×8 sparkline 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 shared src px/cell zoom model, then validates the emitted cell size. A small 32×32 source at fit/1:1 must render as 32×16 cells, not silently become 8×4 cells just because one spark cell analyzes a 4×8 block.

Warning

Reintroducing horizontal 1/8 block modes under 4×8 geometry will be approximate. Use an 8×8 spark-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/best exhaustively 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

FunctionWhat it producesLocated under
GenerateGradientsHorizontal + vertical blue→yellow gradients at 20×20, 4×4, 2×2, 1×1testdata/demo_horiz_NxN/, testdata/demo_verti_NxN/
GenerateFixturesSolid-red 4×4 regression fixturetestdata/solid_red_4x4/
GenerateGeometricsFour 20×20 geometric images (see below)testdata/demo_*_20x20/

Geometric images (GenerateGeometrics):

SubfolderDescriptionColours
demo_diag_20x2045° diagonal split (top-left vs bottom-right)red / blue
demo_circle_20x20Filled disc, radius 8, centred at (9.5, 9.5)yellow / blue
demo_checker_20x20Checkerboard with 4×4 px cellsred / blue
demo_cross_20x204-pixel-wide cross centred on imageyellow / 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):

modecell W×HkX, kYblock
halfblock1×212×1212×24 ✓
quad2×26×1212×24 ✓
spark4×83× 312×24 ✓
sextant2×36× 812×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 mismatch before 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 6 instead of -w 24).
  • Find a control that does not reproduce. In #015, -w 5 was clean but -w 6 was 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 -1 shows 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.go with //go:build ignore, run it with go 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 were rem ∈ {0,4} and rem<4 — the probe showed that mapping precisely.
  • Look for an invariant that should hold but doesn’t. #015’s was CellW/(AspectX·CellH) = 1/2 for 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

  1. 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.
  2. 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.
  3. 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 TestGoldenRenders on 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 a Refs #NNN in 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

  • -update to 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/cobra for CLI, gopkg.in/yaml.v3 for config
  • No ORM, no logging framework, no DI container

Project Layout

  • Small: root main.go with features split by concern (apply.go, config.go, status.go)
  • Med: root main.go with internal/ sub-packages; no pkg/
  • Large/Multi: tools in cmd/<name>/main.go with features in split files or internal/
  • Embed static assets with //go:embed
  • Never commit binariesgo build drops binaries in the repo root. Add it to .gitignore at project setup time:
    # ignore Go binaries
    /mybinary
    
    Use the BINARY variable from the Makefile as the canonical name so the .gitignore entry 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>.json to drive compile-time features:
    • spec/strings.json defines labels, titles, messages
    • spec/layout.json defines app layout, ordering, and more
    • spec/screen-help.json home screen content
    • spec/screen-home.json help screen content
    • add more as needed and create structs for parsing

CLI

  • Use Cobra; one *cobra.Command per verb, flags defined on that command
  • RunE not Run — return errors, don’t os.Exit inside commands
  • SilenceUsage: true on commands where error is not a usage mistake

Error Handling

  • Wrap with context: fmt.Errorf("settings: %w", err)
  • No panic except 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 use init() 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; add boolPtr/intPtr helpers
  • 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.Fatalf for setup failures, t.Errorf for assertion failures
  • No test frameworks — stdlib testing only

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 .PHONY using 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)
  • build rebuilds 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 install puts the binary in $(GOPATH)/bin (user-local)
  • sudo install -m 0755 copies to $(PREFIX)/bin for system-wide availability
  • || echo … degrades gracefully when sudo is 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.