Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions pkg/gif/measure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package gif

import (
"context"
"fmt"
"image/gif"
"os"
"path/filepath"
)

// probe cast column counts. The per-column width is isolated by the slope
// (wideCols - narrowCols), which cancels agg's fixed horizontal padding.
const (
probeNarrowCols = 40
probeWideCols = 120
probeRows = 8
)

// MeasureColumnWidthPx renders two short probe casts that differ only in column
// count and returns the pixel width agg actually advances per column for the
// given font configuration. agg lays columns out at a fixed advance plus fixed
// horizontal padding, so (wideWidth - narrowWidth) / (wideCols - narrowCols)
// yields the per-column width independent of that padding — and independent of
// which font agg resolves on the host, which a static formula cannot know.
func MeasureColumnWidthPx(ctx context.Context, config Config) (float64, error) {
config = probeConfig(NormalizeConfig(config))

dir, err := os.MkdirTemp("", "tuirec-probe-*")
if err != nil {
return 0, fmt.Errorf("probe tempdir: %w", err)
}
defer os.RemoveAll(dir)

narrow, err := renderProbeWidth(ctx, config, dir, probeNarrowCols)
if err != nil {
return 0, err
}

wide, err := renderProbeWidth(ctx, config, dir, probeWideCols)
if err != nil {
return 0, err
}

span := wide - narrow
if span <= 0 {
return 0, fmt.Errorf("probe produced non-increasing widths (%d, %d)", narrow, wide)
}

return float64(span) / float64(probeWideCols-probeNarrowCols), nil
}

// probeConfig strips timeline-only options that would distort or empty the
// synthetic probe render. The cell geometry agg renders at is independent of
// the timeline, but a Select range (e.g. "1..") can trim away the probe cast's
// only events at 0.1s/0.2s, leaving agg an empty selection and breaking
// calibration. Font and spacing options that DO affect column width
// (FontSize, LineHeight, Font, LetterSpacing) are preserved.
func probeConfig(config Config) Config {
config.Select = ""

return config
}

// renderProbeWidth writes a minimal cast of the given column count, renders it
// with agg, and returns the resulting GIF's pixel width.
func renderProbeWidth(ctx context.Context, config Config, dir string, cols int) (int, error) {
castPath := filepath.Join(dir, fmt.Sprintf("probe-%d.cast", cols))
gifPath := filepath.Join(dir, fmt.Sprintf("probe-%d.gif", cols))

cast := fmt.Sprintf("{\"version\":2,\"width\":%d,\"height\":%d}\n[0.1,\"o\",\"x\"]\n[0.2,\"o\",\"y\"]\n", cols, probeRows)
if err := os.WriteFile(castPath, []byte(cast), 0o600); err != nil {
return 0, fmt.Errorf("write probe cast: %w", err)
}

if err := Render(ctx, castPath, gifPath, config); err != nil {
Comment thread
tig marked this conversation as resolved.
return 0, fmt.Errorf("render probe: %w", err)
}

file, err := os.Open(gifPath)
if err != nil {
return 0, fmt.Errorf("open probe gif: %w", err)
}
defer file.Close()

cfg, err := gif.DecodeConfig(file)
if err != nil {
return 0, fmt.Errorf("decode probe gif: %w", err)
}

return cfg.Width, nil
}
35 changes: 35 additions & 0 deletions pkg/gif/measure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package gif

import "testing"

// A Select range that trims past the probe cast's only events (0.1s/0.2s) would
// leave agg an empty selection and silently break calibration. probeConfig must
// clear it while preserving every option that affects column width.
func TestProbeConfigClearsSelectKeepsGeometry(t *testing.T) {
in := Config{
AggPath: "agg",
Theme: "nord",
Speed: 2,
Font: "JetBrains Mono",
FontSize: 15,
LineHeight: 1.3,
LetterSpacing: 0.5,
Select: "1..",
}

got := probeConfig(in)

if got.Select != "" {
t.Fatalf("Select not cleared: %q", got.Select)
}

// Geometry- and render-affecting fields must survive untouched.
if got.FontSize != in.FontSize ||
got.LineHeight != in.LineHeight ||
got.Font != in.Font ||
got.LetterSpacing != in.LetterSpacing ||
got.Theme != in.Theme ||
got.AggPath != in.AggPath {
t.Fatalf("probeConfig altered a non-timeline field: got %+v", got)
}
}
101 changes: 101 additions & 0 deletions pkg/record/calibrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package record

import (
"context"
"math"

"github.com/gui-cs/tuirec/pkg/gif"
)

// alignTolerancePx is the largest per-column rounding error tolerated before the
// font-size is nudged to align the sixel cell grid. agg lays columns out at a
// fractional advance (≈0.6 × font-size), but terminal cell-size reports (CSI
// 16t) are integers, so an app that sizes a sixel raster as cells × reportedCell
// underfills by (advance − round(advance)) per column. At ~0.15px that residual
// is well under a third of a cell across a full-width image; beyond it the gap
// becomes visible (see gui-cs/tuirec#84).
const alignTolerancePx = 0.15

// cellRoundingErr is how far agg's per-column advance at fontSize is from the
// nearest integer — i.e. the per-column error if that integer is reported.
func cellRoundingErr(ratio float64, fontSize int) float64 {
w := ratio * float64(fontSize)

return math.Abs(w - math.Round(w))
}

// alignFontSize picks the font-size whose agg cell width rounds closest to an
// integer, so the reported (integer) cell matches what agg renders. The
// requested size is kept when its rounding error is already within tolerance;
// otherwise nearby sizes (±1 then ±2) are searched and the best is chosen,
// preferring the smallest move on ties. ratio is agg's measured advance per
// font-size pixel. Returns the chosen size and the integer cell width to report.
func alignFontSize(ratio float64, requested int) (fontSize, cellW int) {
if requested < 1 {
requested = 1
}

best := requested
bestErr := cellRoundingErr(ratio, requested)

if bestErr > alignTolerancePx {
for _, delta := range []int{-1, 1, -2, 2} {
cand := requested + delta
if cand < 1 {
continue
}

if e := cellRoundingErr(ratio, cand); e < bestErr-1e-9 {
best = cand
bestErr = e
}
}
}

cellW = int(math.Round(ratio * float64(best)))
if cellW < 1 {
cellW = 1
}

return best, cellW
}

// alignLineHeight returns the integer cell height to report and the line-height
// to pass to agg so that agg renders exactly that height. agg's row height is
// fontSize × lineHeight, so reporting round(fontSize × requested) and rendering
// with lineHeight = cellH / fontSize makes the report exact.
func alignLineHeight(fontSize int, requestedLineHeight float64) (cellH int, aggLineHeight float64) {
cellH = int(math.Round(float64(fontSize) * requestedLineHeight))
if cellH < 1 {
cellH = 1
}

return cellH, float64(cellH) / float64(fontSize)
}

// calibrateGeometry measures agg's actual per-column advance for the configured
// font and returns font settings whose rendered cell is an integer, plus that
// integer cell size to report to sixel queries. This replaces a static
// advance-ratio guess (which is wrong for whatever font agg resolves on the
// host) with a measurement, and aligns the grid so apps that size rasters as
// cells × reportedCell fill exactly (gui-cs/tuirec#84). The returned Config has
// FontSize/LineHeight adjusted for rendering; changed reports whether the
// font-size moved from the request.
func calibrateGeometry(ctx context.Context, gifCfg gif.Config) (adjusted gif.Config, cellW, cellH int, changed bool, err error) {
cfg := gif.NormalizeConfig(gifCfg)

colPx, err := gif.MeasureColumnWidthPx(ctx, cfg)
if err != nil {
return gifCfg, 0, 0, false, err
}

ratio := colPx / float64(cfg.FontSize)
fontSize, cellW := alignFontSize(ratio, cfg.FontSize)
cellH, aggLineHeight := alignLineHeight(fontSize, cfg.LineHeight)

changed = fontSize != cfg.FontSize
cfg.FontSize = fontSize
cfg.LineHeight = aggLineHeight

return cfg, cellW, cellH, changed, nil
}
88 changes: 88 additions & 0 deletions pkg/record/calibrate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package record

import (
"math"
"testing"

"github.com/gui-cs/tuirec/pkg/gif"
)

func TestAlignFontSize(t *testing.T) {
// DejaVu Sans Mono advance ratio measured from agg (8.425px / 14px).
const ratio = 0.601786

tests := []struct {
name string
ratio float64
requested int
wantSize int
wantCellW int
}{
// 14 → cell 8.425, rounding error 0.425 > tolerance, so nudge to 15
// (cell 9.027, error 0.027).
{"poorly aligned nudges up", ratio, 14, 15, 9},
// 20 → cell 12.036, error 0.036 < tolerance, keep as requested.
{"well aligned kept", ratio, 20, 20, 12},
// 15 → cell 9.027, error 0.027 < tolerance, keep.
{"already aligned kept", ratio, 15, 15, 9},
// Exact integer ratio: every size is aligned, keep requested.
{"exact ratio kept", 0.5, 14, 14, 7},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotSize, gotCellW := alignFontSize(tt.ratio, tt.requested)
if gotSize != tt.wantSize || gotCellW != tt.wantCellW {
t.Fatalf("alignFontSize(%.5f, %d) = (size %d, cellW %d); want (size %d, cellW %d)",
tt.ratio, tt.requested, gotSize, gotCellW, tt.wantSize, tt.wantCellW)
}
})
}
}

func TestAlignFontSizeNeverBelowOne(t *testing.T) {
size, cellW := alignFontSize(0.6, 1)
if size < 1 || cellW < 1 {
t.Fatalf("alignFontSize(0.6, 1) = (%d, %d); want both >= 1", size, cellW)
}
}

func TestAlignLineHeightRendersExactInteger(t *testing.T) {
// Reporting cellH and rendering with the returned line-height must make
// agg's row height (fontSize × lineHeight) equal cellH exactly.
cellH, lh := alignLineHeight(15, 1.3) // round(19.5) = 20
if cellH != 20 {
t.Fatalf("cellH = %d; want 20", cellH)
}

rendered := 15 * lh
if math.Abs(rendered-float64(cellH)) > 1e-9 {
t.Fatalf("agg row height %.6f != reported cellH %d", rendered, cellH)
}
}

func TestWillRenderGIF(t *testing.T) {
// A GIF is rendered whenever Output is set; calibration must run there even
// when AggPath is the zero value (normalized to "agg" downstream).
if !willRenderGIF(Config{Output: "out.gif"}) {
t.Fatal("Output set (empty AggPath) should render and calibrate")
}

if !willRenderGIF(Config{Output: "out.gif", GIF: gif.Config{AggPath: "agg"}}) {
t.Fatal("Output set with explicit AggPath should render and calibrate")
}

if willRenderGIF(Config{}) {
t.Fatal("no Output should not render")
}
}

func TestCellRoundingErr(t *testing.T) {
if e := cellRoundingErr(0.6, 10); e > 1e-9 { // 6.0 exactly
t.Fatalf("cellRoundingErr(0.6, 10) = %.6f; want 0", e)
}

if e := cellRoundingErr(0.601786, 14); math.Abs(e-0.425) > 0.01 { // 8.425
t.Fatalf("cellRoundingErr(0.601786, 14) = %.6f; want ~0.425", e)
}
}
Loading
Loading