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)}
{