From d5f0814efc1b18e7fc64a73630ea3031dc5febdc Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sun, 31 May 2026 13:24:14 +0300 Subject: [PATCH] fix(comparison): stabilize line rendering, cut per-tick re-renders Extract findSplitIndex; computeLineRenderModel now takes the split index as a primitive. Line derives its model from `split`, so the $derived short-circuits on value equality and skips recomputation on spring ticks that don't move the split (previously every tick rebuilt the model and re-rendered the line). Lay the three regions out as inline boxes on a shared baseline. fontA and fontB now align on the typographic baseline despite differing metrics, and an always-present overflow:hidden strut pins the line-box baseline so the line no longer jumps when a bulk run mounts/unmounts or the last window char morphs to a font of different ascent. --- .../computeLineRenderModel.test.ts | 67 +++++++++--- .../computeLineRenderModel.ts | 101 +++++++++--------- src/entities/Font/lib/dualFontLayout/index.ts | 1 + .../ui/Character/Character.svelte | 20 +++- .../ComparisonView/ui/Line/Line.svelte | 89 ++++++++++++--- .../ui/SliderArea/SliderArea.svelte | 6 +- 6 files changed, 198 insertions(+), 86 deletions(-) diff --git a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts index db8206e..e378393 100644 --- a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts +++ b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.test.ts @@ -4,7 +4,11 @@ import { it, } from 'vitest'; import type { ComparisonLine } from '../DualFontLayout/DualFontLayout'; -import { computeLineRenderModel } from './computeLineRenderModel'; +import { + type LineRenderModel, + computeLineRenderModel, + findSplitIndex, +} from './computeLineRenderModel'; /** * Build a ComparisonLine fixture with given per-char widths. xA/xB are @@ -34,10 +38,24 @@ function makeLine( return out; } +/** + * Test helper: compute split + render model in one step, matching the + * SliderArea call site shape. + */ +function compute( + line: ComparisonLine, + sliderPos: number, + containerWidth: number, + windowSize: number, +): LineRenderModel { + const split = findSplitIndex(line, sliderPos, containerWidth); + return computeLineRenderModel(line, split, windowSize); +} + describe('computeLineRenderModel', () => { it('returns empty model for an empty line', () => { const line = makeLine([]); - const model = computeLineRenderModel(line, 50, 500, 5); + const model = compute(line, 50, 500, 5); expect(model.leftText).toBe(''); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe(''); @@ -49,7 +67,7 @@ describe('computeLineRenderModel', () => { { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); - const model = computeLineRenderModel(line, 0, 500, 0); + const model = compute(line, 0, 500, 0); expect(model.leftText).toBe(''); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe('ABC'); @@ -61,7 +79,7 @@ describe('computeLineRenderModel', () => { { char: 'B', widthA: 10, widthB: 10 }, { char: 'C', widthA: 10, widthB: 10 }, ]); - const model = computeLineRenderModel(line, 100, 500, 0); + const model = compute(line, 100, 500, 0); expect(model.leftText).toBe('ABC'); expect(model.windowChars).toEqual([]); expect(model.rightText).toBe(''); @@ -80,7 +98,7 @@ describe('computeLineRenderModel', () => { { char: 'C', widthA: 10, widthB: 10 }, ]); // Slider just past B's threshold (50%) but not C's (53.33%). - const model = computeLineRenderModel(line, 51, 300, 0); + const model = compute(line, 51, 300, 0); expect(model.leftText).toBe('AB'); expect(model.rightText).toBe('C'); }); @@ -95,7 +113,7 @@ describe('computeLineRenderModel', () => { ]); // Slider past A and B (~thresholds 43.33%, 46.67%); not past C (50%). // split = 2 → halfWindow = 1 → windowStart = 1, windowEnd = 4 - const model = computeLineRenderModel(line, 48, 300, 3); + const model = compute(line, 48, 300, 3); expect(model.leftText).toBe('A'); expect(model.windowChars.map(w => w.char)).toEqual(['B', 'C', 'D']); expect(model.rightText).toBe('E'); @@ -109,7 +127,7 @@ describe('computeLineRenderModel', () => { { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); - const model = computeLineRenderModel(line, 0, 300, 3); + const model = compute(line, 0, 300, 3); expect(model.leftText).toBe(''); expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B', 'C']); expect(model.rightText).toBe('DE'); @@ -123,7 +141,7 @@ describe('computeLineRenderModel', () => { { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); - const model = computeLineRenderModel(line, 100, 300, 3); + const model = compute(line, 100, 300, 3); expect(model.leftText).toBe('AB'); expect(model.windowChars.map(w => w.char)).toEqual(['C', 'D', 'E']); expect(model.rightText).toBe(''); @@ -134,7 +152,7 @@ describe('computeLineRenderModel', () => { { char: 'A', widthA: 10, widthB: 10 }, { char: 'B', widthA: 10, widthB: 10 }, ]); - const model = computeLineRenderModel(line, 50, 300, 5); + const model = compute(line, 50, 300, 5); expect(model.leftText).toBe(''); expect(model.windowChars.map(w => w.char)).toEqual(['A', 'B']); expect(model.rightText).toBe(''); @@ -148,8 +166,8 @@ describe('computeLineRenderModel', () => { { char: 'D', widthA: 10, widthB: 10 }, { char: 'E', widthA: 10, widthB: 10 }, ]); - const a = computeLineRenderModel(line, 40, 300, 3); - const b = computeLineRenderModel(line, 60, 300, 3); + const a = compute(line, 40, 300, 3); + const b = compute(line, 60, 300, 3); // Chars that appear in both windows must carry identical keys. for (const charA of a.windowChars) { const charB = b.windowChars.find(w => w.char === charA.char); @@ -168,10 +186,35 @@ describe('computeLineRenderModel', () => { { char: 'E', widthA: 10, widthB: 10 }, ]); // split = 2 → A,B past; C,D,E not - const model = computeLineRenderModel(line, 48, 300, 5); + const model = compute(line, 48, 300, 5); const expected = new Map([['A', true], ['B', true], ['C', false], ['D', false], ['E', false]]); for (const wc of model.windowChars) { expect(wc.isPast).toBe(expected.get(wc.char)); } }); }); + +describe('findSplitIndex', () => { + it('returns 0 for empty line', () => { + const line = makeLine([]); + expect(findSplitIndex(line, 50, 500)).toBe(0); + }); + + it('returns 0 when slider is before all char thresholds', () => { + const line = makeLine([ + { char: 'A', widthA: 10, widthB: 10 }, + { char: 'B', widthA: 10, widthB: 10 }, + { char: 'C', widthA: 10, widthB: 10 }, + ]); + expect(findSplitIndex(line, 0, 300)).toBe(0); + }); + + it('returns chars.length when slider is past all char thresholds', () => { + const line = makeLine([ + { char: 'A', widthA: 10, widthB: 10 }, + { char: 'B', widthA: 10, widthB: 10 }, + { char: 'C', widthA: 10, widthB: 10 }, + ]); + expect(findSplitIndex(line, 100, 300)).toBe(3); + }); +}); diff --git a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts index 4f506ca..c7e0a63 100644 --- a/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts +++ b/src/entities/Font/lib/dualFontLayout/computeLineRenderModel/computeLineRenderModel.ts @@ -1,7 +1,4 @@ -import type { - ComparisonChar, - ComparisonLine, -} from '../DualFontLayout/DualFontLayout'; +import type { ComparisonLine } from '../DualFontLayout/DualFontLayout'; /** * Per-line render slice consumed by Line.svelte. The window is centered on the @@ -35,64 +32,30 @@ export interface LineRenderModel { rightText: string; } -/** - * Slices a laid-out line into three regions around the slider's split index: - * a fontA bulk run, an N-char crossfade window, and a fontB bulk run. - * - * Pure and allocation-bounded: two strings plus a `windowSize`-length array per call. - * Safe to invoke per slider frame; `line.chars` is treated as read-only. - * - * @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model. - * @param sliderPos Slider position in percent of `containerWidth`, range 0..100. - * @param containerWidth Container width in pixels, used to translate `sliderPos` to a threshold x. - * @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`. - * At line edges the window is shifted (not shrunk) to keep its size. - */ -export function computeLineRenderModel( - line: ComparisonLine, - sliderPos: number, - containerWidth: number, - windowSize: number, -): LineRenderModel { - const chars = line.chars; - const n = chars.length; - if (n === 0) { - return { leftText: '', windowChars: [], rightText: '' }; - } - - const split = findSplitIndex(chars, sliderPos, containerWidth); - - const halfWindow = Math.floor(Math.max(0, windowSize) / 2); - let windowStart = clamp(split - halfWindow, 0, n); - let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n); - windowStart = Math.max(0, windowEnd - Math.max(0, windowSize)); - - const leftText = chars.slice(0, windowStart).map(c => c.char).join(''); - const rightText = chars.slice(windowEnd).map(c => c.char).join(''); - const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({ - key: `${windowStart + idx}-${c.char}`, - char: c.char, - isPast: (windowStart + idx) < split, - })); - - return { leftText, windowChars, rightText }; -} - /** * Returns the count of chars whose flip threshold the slider has crossed. * + * Exposed as a separate step so consumers can pass the resulting primitive + * `split` across component boundaries: when split is unchanged tick-to-tick, + * downstream `$derived` reads of `computeLineRenderModel(line, split, ...)` + * short-circuit on value equality and skip re-rendering. + * * For each candidate split `i`, the line's hypothetical width at that moment is * `prefA[i] + widthA[i] + sufB[i+1]` (past chars in fontA, char `i` flipping, future * chars in fontB). The threshold is the x of char `i`'s center in the centered line. * Thresholds are monotonically non-decreasing in `i`, so the scan short-circuits on * the first miss. */ -function findSplitIndex( - chars: ComparisonChar[], +export function findSplitIndex( + line: ComparisonLine, sliderPos: number, containerWidth: number, ): number { + const chars = line.chars; const n = chars.length; + if (n === 0) { + return 0; + } const sliderX = (sliderPos / 100) * containerWidth; const prefA = new Float64Array(n + 1); @@ -116,6 +79,46 @@ function findSplitIndex( return split; } +/** + * Slices a laid-out line into three regions around a precomputed split index: + * a fontA bulk run, an N-char crossfade window, and a fontB bulk run. + * + * Pure and allocation-bounded: two strings plus a `windowSize`-length array per call. + * Takes `split` as a primitive so callers can feed it into a `$derived` and + * skip re-evaluation on ticks where the split index is unchanged. + * + * @param line Line from `DualFontLayout.layout()`. Empty `chars` yields an empty model. + * @param split Count of chars the slider has passed, in `[0, line.chars.length]`. + * @param windowSize Number of chars in the crossfade window. Clamped to `[0, line.chars.length]`. + * At line edges the window is shifted (not shrunk) to keep its size. + */ +export function computeLineRenderModel( + line: ComparisonLine, + split: number, + windowSize: number, +): LineRenderModel { + const chars = line.chars; + const n = chars.length; + if (n === 0) { + return { leftText: '', windowChars: [], rightText: '' }; + } + + const halfWindow = Math.floor(Math.max(0, windowSize) / 2); + let windowStart = clamp(split - halfWindow, 0, n); + let windowEnd = clamp(windowStart + Math.max(0, windowSize), 0, n); + windowStart = Math.max(0, windowEnd - Math.max(0, windowSize)); + + const leftText = chars.slice(0, windowStart).map(c => c.char).join(''); + const rightText = chars.slice(windowEnd).map(c => c.char).join(''); + const windowChars = chars.slice(windowStart, windowEnd).map((c, idx) => ({ + key: `${windowStart + idx}-${c.char}`, + char: c.char, + isPast: (windowStart + idx) < split, + })); + + return { leftText, windowChars, rightText }; +} + /** * Clamps `value` into the inclusive range `[lo, hi]`. Assumes `lo <= hi`. */ diff --git a/src/entities/Font/lib/dualFontLayout/index.ts b/src/entities/Font/lib/dualFontLayout/index.ts index 66f0434..bffc09f 100644 --- a/src/entities/Font/lib/dualFontLayout/index.ts +++ b/src/entities/Font/lib/dualFontLayout/index.ts @@ -5,5 +5,6 @@ export { } from './DualFontLayout/DualFontLayout'; export { computeLineRenderModel, + findSplitIndex, type LineRenderModel, } from './computeLineRenderModel/computeLineRenderModel'; diff --git a/src/widgets/ComparisonView/ui/Character/Character.svelte b/src/widgets/ComparisonView/ui/Character/Character.svelte index e55c63f..175a5f2 100644 --- a/src/widgets/ComparisonView/ui/Character/Character.svelte +++ b/src/widgets/ComparisonView/ui/Character/Character.svelte @@ -1,6 +1,10 @@ {#if fontA && fontB} - + {#each [0, 1] as s (s)} {