From 18db4e016ace557bda770908201d29389f7fb57a Mon Sep 17 00:00:00 2001 From: Tig Date: Tue, 9 Jun 2026 21:46:02 -0600 Subject: [PATCH 1/3] Calibrate sixel cell size to agg's actual rendering (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tuirec advertised a sixel cell resolution from a static formula (~0.6*fontSize, rounded to int). That was wrong two ways: the 0.6 ratio does not match whatever monospace font agg resolves on the host, and agg's per-column advance is fractional (e.g. 8.425px at font-size 14) while terminal cell-size reports (CSI 16t) must be integers. An app that sizes a sixel raster as cells*reportedCell therefore rendered a few percent undersized — at font-size 14, ~5% (visibly short of borders). Fix: when a GIF will be rendered, measure agg's real per-column advance by rendering two probe casts that differ only in column count (the slope cancels agg's fixed padding and is independent of the resolved font), then align the grid so the integer report matches what agg renders: - Width: pick the font-size nearest the requested one whose cell rounds closest to an integer (kept as-is when already within ~0.15px; font-size is integer-only in agg, so this is the only width lever). - Height: report round(fontSize*lineHeight) and pass agg a line-height of cellH/fontSize so agg's row height equals the report exactly. The chosen font-size/line-height are used for the render too, so the cast reports and the GIF agree. Falls back to the previous formula when no GIF is produced (agg unavailable) or calibration fails. Verified end-to-end: UICatalog's About-box fire now fills the dialog interior edge-to-edge (font-size 14 auto-aligns to 15; cell 9x20; raster 540px vs ~542px interior = 99.6% vs the previous ~96%). Co-Authored-By: Claude Fable 5 --- pkg/gif/measure.go | 79 +++++++++++++++++++++++++++ pkg/record/calibrate.go | 101 +++++++++++++++++++++++++++++++++++ pkg/record/calibrate_test.go | 70 ++++++++++++++++++++++++ pkg/record/record.go | 26 ++++++++- 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 pkg/gif/measure.go create mode 100644 pkg/record/calibrate.go create mode 100644 pkg/record/calibrate_test.go diff --git a/pkg/gif/measure.go b/pkg/gif/measure.go new file mode 100644 index 0000000..e0bdf9f --- /dev/null +++ b/pkg/gif/measure.go @@ -0,0 +1,79 @@ +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 = 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 +} + +// 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 { + 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 +} diff --git a/pkg/record/calibrate.go b/pkg/record/calibrate.go new file mode 100644 index 0000000..d9e4e48 --- /dev/null +++ b/pkg/record/calibrate.go @@ -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 +} diff --git a/pkg/record/calibrate_test.go b/pkg/record/calibrate_test.go new file mode 100644 index 0000000..a1e5e29 --- /dev/null +++ b/pkg/record/calibrate_test.go @@ -0,0 +1,70 @@ +package record + +import ( + "math" + "testing" +) + +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 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) + } +} diff --git a/pkg/record/record.go b/pkg/record/record.go index d91d31a..f195d68 100644 --- a/pkg/record/record.go +++ b/pkg/record/record.go @@ -83,8 +83,13 @@ type Result struct { // queries. The size and font config are normalized with the same defaults // pty.Start and gif.Render apply, so a zero-valued Config still reports the // dimensions the PTY and agg actually use instead of zeros (which would break -// app layout or yield a 0x0 raster). The cell size mirrors agg's rendering: +// app layout or yield a 0x0 raster). The cell size approximates agg's rendering: // row = fontSize*lineHeight, column ~= 0.6*fontSize for a monospace font. +// +// This is a fallback estimate. When a GIF is rendered, Run replaces it with a +// measured, grid-aligned cell via calibrateGeometry, because the 0.6 column +// ratio is wrong for whatever font agg actually resolves on the host and a +// fractional advance cannot be reported as an integer cell (gui-cs/tuirec#84). func sixelGeometry(size pty.Size, gifConfig gif.Config) (cols, rows, cellW, cellH int) { size = pty.NormalizeSize(size) gifConfig = gif.NormalizeConfig(gifConfig) @@ -177,6 +182,25 @@ func Run(parent context.Context, config Config) (Result, error) { // capability and report the screen/cell size. This lets recorded apps // detect sixel support, lay out their UI, and emit DCS payloads. cols, rows, cellW, cellH := sixelGeometry(config.Size, config.GIF) + + // When a GIF will be rendered, calibrate the reported cell size against the + // cell agg actually renders and align the font so the integer cell report + // matches it exactly (gui-cs/tuirec#84). Falls back to the formula geometry + // above if calibration fails or no GIF is produced (no agg available). + if config.Output != "" && config.GIF.AggPath != "" { + if adjusted, cw, ch, changed, calErr := calibrateGeometry(ctx, config.GIF); calErr != nil { + logf(config, "sixel cell calibration failed (%v); using formula %dx%d px\n", calErr, cellW, cellH) + } else { + config.GIF = adjusted + cellW, cellH = cw, ch + logf(config, "sixel cell calibrated to %dx%d px (agg font-size %d, line-height %.4f)\n", cellW, cellH, adjusted.FontSize, adjusted.LineHeight) + + if changed && config.LogWriter != nil { + fmt.Fprintf(config.LogWriter, "tuirec: adjusted agg font-size to %d to align the sixel cell grid (#84)\n", adjusted.FontSize) + } + } + } + ptyReader = newSixelInterceptor(ptyReader, session, cols, rows, cellW, cellH) // Wrap the recorder with a synchronized writer when pointer injection is From 18ff0ae87bc22dd457958492ac6d74251c86606b Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 10 Jun 2026 13:33:39 -0600 Subject: [PATCH 2/3] Calibrate on zero-value agg path; ignore --select in probe (#84 review) Two P2s from Codex review on #86: - record.Run gated calibration on `config.Output != "" && GIF.AggPath != ""`, but a GIF renders whenever Output is set (AggPath is normalized to "agg" downstream). Zero-value-config API callers therefore rendered a GIF but skipped calibration, falling back to the 0.6*fontSize formula. Gate on a new willRenderGIF(config) that mirrors the render condition only. - MeasureColumnWidthPx reused the caller's full config, including Select. The probe cast only has events at 0.1s/0.2s, so a Select like "1.." trims it to empty and calibration silently fails back to the formula. Strip Select via probeConfig() (geometry is timeline-independent); font/spacing options that do affect column width are preserved. Added unit tests for both seams (TestWillRenderGIF, TestProbeConfig...). Co-Authored-By: Claude Fable 5 --- pkg/gif/measure.go | 14 +++++++++++++- pkg/gif/measure_test.go | 35 +++++++++++++++++++++++++++++++++++ pkg/record/calibrate_test.go | 18 ++++++++++++++++++ pkg/record/record.go | 12 +++++++++++- 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 pkg/gif/measure_test.go diff --git a/pkg/gif/measure.go b/pkg/gif/measure.go index e0bdf9f..4e7f501 100644 --- a/pkg/gif/measure.go +++ b/pkg/gif/measure.go @@ -23,7 +23,7 @@ const ( // 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 = NormalizeConfig(config) + config = probeConfig(NormalizeConfig(config)) dir, err := os.MkdirTemp("", "tuirec-probe-*") if err != nil { @@ -49,6 +49,18 @@ func MeasureColumnWidthPx(ctx context.Context, config Config) (float64, error) { 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) { diff --git a/pkg/gif/measure_test.go b/pkg/gif/measure_test.go new file mode 100644 index 0000000..0f9ee0c --- /dev/null +++ b/pkg/gif/measure_test.go @@ -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) + } +} diff --git a/pkg/record/calibrate_test.go b/pkg/record/calibrate_test.go index a1e5e29..5f7fa24 100644 --- a/pkg/record/calibrate_test.go +++ b/pkg/record/calibrate_test.go @@ -3,6 +3,8 @@ package record import ( "math" "testing" + + "github.com/gui-cs/tuirec/pkg/gif" ) func TestAlignFontSize(t *testing.T) { @@ -59,6 +61,22 @@ func TestAlignLineHeightRendersExactInteger(t *testing.T) { } } +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) diff --git a/pkg/record/record.go b/pkg/record/record.go index f195d68..8568869 100644 --- a/pkg/record/record.go +++ b/pkg/record/record.go @@ -99,6 +99,16 @@ func sixelGeometry(size pty.Size, gifConfig gif.Config) (cols, rows, cellW, cell return size.Cols, size.Rows, cellW, cellH } +// willRenderGIF reports whether Run will render a GIF, and therefore whether the +// sixel cell size should be calibrated against agg. It mirrors the render gate +// (`config.Output != ""`). AggPath is deliberately not checked: an empty path is +// the common zero-value API case and is normalized to "agg" by gif.Render and +// calibrateGeometry alike, so gating on it would skip calibration for renders +// that still happen. +func willRenderGIF(config Config) bool { + return config.Output != "" +} + type gifRenderer struct{} type contextWriter struct { @@ -187,7 +197,7 @@ func Run(parent context.Context, config Config) (Result, error) { // cell agg actually renders and align the font so the integer cell report // matches it exactly (gui-cs/tuirec#84). Falls back to the formula geometry // above if calibration fails or no GIF is produced (no agg available). - if config.Output != "" && config.GIF.AggPath != "" { + if willRenderGIF(config) { if adjusted, cw, ch, changed, calErr := calibrateGeometry(ctx, config.GIF); calErr != nil { logf(config, "sixel cell calibration failed (%v); using formula %dx%d px\n", calErr, cellW, cellH) } else { From b1810518e6ef3a5dd43c10806f31570ea3907f6a Mon Sep 17 00:00:00 2001 From: Tig Date: Wed, 10 Jun 2026 13:39:13 -0600 Subject: [PATCH 3/3] Run calibration before the recording clock starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Windows integration failure (TestRunRecordsKeystrokesAndRenders: "recording hit max duration") introduced by enabling calibration in the zero-value path. The recording's MaxDuration context is created at the top of Run, and calibration ran after it — so the agg probe-render wall-clock time was charged against the recording budget. With MaxDuration=1s and two agg renders on Windows, the recording timed out immediately. Move geometry computation + calibration to before the MaxDuration context is created, and run the probes under their own timeout off `parent`. The recording now gets its full budget; probe time is pre-recording setup. Co-Authored-By: Claude Fable 5 --- pkg/record/record.go | 57 +++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/pkg/record/record.go b/pkg/record/record.go index 8568869..af12f00 100644 --- a/pkg/record/record.go +++ b/pkg/record/record.go @@ -130,6 +130,11 @@ const ( sourceContext ) +// calibrationTimeout bounds the agg probe renders used to calibrate the sixel +// cell size. It is independent of the recording's MaxDuration because +// calibration is pre-recording setup, not part of the captured timeline. +const calibrationTimeout = 60 * time.Second + // Run executes one recording pipeline. func Run(parent context.Context, config Config) (Result, error) { config = normalizeConfig(config) @@ -144,6 +149,33 @@ func Run(parent context.Context, config Config) (Result, error) { return Result{}, err } + // Compute the sixel cell geometry to report and, when a GIF will be rendered, + // calibrate it against the cell agg actually renders so the integer cell + // report matches it exactly (gui-cs/tuirec#84). This runs BEFORE the + // MaxDuration context is created so the agg probe-render time is not charged + // against the recording budget — otherwise a short MaxDuration would + // spuriously trip "recording hit max duration". Falls back to the formula + // geometry if calibration fails or no GIF is produced (no agg available). + cols, rows, cellW, cellH := sixelGeometry(config.Size, config.GIF) + + if willRenderGIF(config) { + calCtx, calCancel := context.WithTimeout(parent, calibrationTimeout) + adjusted, cw, ch, changed, calErr := calibrateGeometry(calCtx, config.GIF) + calCancel() + + if calErr != nil { + logf(config, "sixel cell calibration failed (%v); using formula %dx%d px\n", calErr, cellW, cellH) + } else { + config.GIF = adjusted + cellW, cellH = cw, ch + logf(config, "sixel cell calibrated to %dx%d px (agg font-size %d, line-height %.4f)\n", cellW, cellH, adjusted.FontSize, adjusted.LineHeight) + + if changed && config.LogWriter != nil { + fmt.Fprintf(config.LogWriter, "tuirec: adjusted agg font-size to %d to align the sixel cell grid (#84)\n", adjusted.FontSize) + } + } + } + ctx, cancel := context.WithTimeout(parent, config.MaxDuration) defer cancel() @@ -189,28 +221,9 @@ func Run(parent context.Context, config Config) (Result, error) { } // Always intercept DA1/DA2 and geometry queries to advertise sixel - // capability and report the screen/cell size. This lets recorded apps - // detect sixel support, lay out their UI, and emit DCS payloads. - cols, rows, cellW, cellH := sixelGeometry(config.Size, config.GIF) - - // When a GIF will be rendered, calibrate the reported cell size against the - // cell agg actually renders and align the font so the integer cell report - // matches it exactly (gui-cs/tuirec#84). Falls back to the formula geometry - // above if calibration fails or no GIF is produced (no agg available). - if willRenderGIF(config) { - if adjusted, cw, ch, changed, calErr := calibrateGeometry(ctx, config.GIF); calErr != nil { - logf(config, "sixel cell calibration failed (%v); using formula %dx%d px\n", calErr, cellW, cellH) - } else { - config.GIF = adjusted - cellW, cellH = cw, ch - logf(config, "sixel cell calibrated to %dx%d px (agg font-size %d, line-height %.4f)\n", cellW, cellH, adjusted.FontSize, adjusted.LineHeight) - - if changed && config.LogWriter != nil { - fmt.Fprintf(config.LogWriter, "tuirec: adjusted agg font-size to %d to align the sixel cell grid (#84)\n", adjusted.FontSize) - } - } - } - + // capability and report the screen/cell size (computed/calibrated above). + // This lets recorded apps detect sixel support, lay out their UI, and emit + // DCS payloads. ptyReader = newSixelInterceptor(ptyReader, session, cols, rows, cellW, cellH) // Wrap the recorder with a synchronized writer when pointer injection is