Repository navigation
Calibrate sixel cell size to agg's actual rendering (#84) #86
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.