From 600b905e016bc26f54b946261bdda7855af7f81d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 3 Jun 2026 16:00:29 +0300 Subject: [PATCH] feat(Font): add windowSizeForLine crossfade-window policy --- src/entities/Font/domain/index.ts | 1 + .../windowSizeForLine.test.ts | 38 +++++++++++++++++++ .../windowSizeForLine/windowSizeForLine.ts | 34 +++++++++++++++++ src/entities/Font/index.ts | 1 + 4 files changed, 74 insertions(+) create mode 100644 src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts create mode 100644 src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts diff --git a/src/entities/Font/domain/index.ts b/src/entities/Font/domain/index.ts index bffc09f..0f8a2ea 100644 --- a/src/entities/Font/domain/index.ts +++ b/src/entities/Font/domain/index.ts @@ -8,3 +8,4 @@ export { findSplitIndex, type LineRenderModel, } from './computeLineRenderModel/computeLineRenderModel'; +export { windowSizeForLine } from './windowSizeForLine/windowSizeForLine'; diff --git a/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts new file mode 100644 index 0000000..392871b --- /dev/null +++ b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.test.ts @@ -0,0 +1,38 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import { windowSizeForLine } from './windowSizeForLine'; + +describe('windowSizeForLine', () => { + it('returns 0 for an empty or non-positive line', () => { + expect(windowSizeForLine(0)).toBe(0); + expect(windowSizeForLine(-3)).toBe(0); + }); + + it('floors non-empty short lines at the minimum window of 1', () => { + expect(windowSizeForLine(1)).toBe(1); + expect(windowSizeForLine(2)).toBe(1); + expect(windowSizeForLine(3)).toBe(1); + }); + + it('scales with round(n / 3) in the mid range', () => { + expect(windowSizeForLine(6)).toBe(2); + expect(windowSizeForLine(12)).toBe(4); + }); + + it('caps at the maximum window of 5', () => { + expect(windowSizeForLine(15)).toBe(5); + expect(windowSizeForLine(16)).toBe(5); + expect(windowSizeForLine(100)).toBe(5); + }); + + it('rounds to nearest at fractional boundaries', () => { + // round(4/3)=1, round(5/3)=2, round(13/3)=4, round(14/3)=5 + expect(windowSizeForLine(4)).toBe(1); + expect(windowSizeForLine(5)).toBe(2); + expect(windowSizeForLine(13)).toBe(4); + expect(windowSizeForLine(14)).toBe(5); + }); +}); diff --git a/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts new file mode 100644 index 0000000..daa9f6d --- /dev/null +++ b/src/entities/Font/domain/windowSizeForLine/windowSizeForLine.ts @@ -0,0 +1,34 @@ +/** + * Crossfade-window sizing policy for the dual-font slider. + * + * The slider renders a band of per-char `Character` cells that opacity-crossfade + * between the two fonts; everything outside the band is committed native bulk + * text. A fixed band looked wrong on short lines — a 6-grapheme line left almost + * no bulk, so nearly the whole line shimmered as per-char DOM. The band size + * therefore scales with the line's grapheme count and caps so long lines don't + * pay for an oversized per-char DOM band. + */ + +/** + * Fraction of a line's graphemes that sit in the crossfade band. + */ +const WINDOW_RATIO = 1 / 3; +/** + * Smallest band for a non-empty line — guarantees at least one crossfading char. + */ +const WINDOW_MIN = 1; +/** + * Largest band regardless of line length — bounds per-char DOM cost. + */ +const WINDOW_MAX = 5; + +/** + * Crossfade window size, in graphemes, for a line of `n` graphemes. + * `clamp(round(n / 3), 1, 5)`; an empty/non-positive line gets no window. + */ +export function windowSizeForLine(n: number): number { + if (n <= 0) { + return 0; + } + return Math.min(WINDOW_MAX, Math.max(WINDOW_MIN, Math.round(n * WINDOW_RATIO))); +} diff --git a/src/entities/Font/index.ts b/src/entities/Font/index.ts index b93f0fc..a59a859 100644 --- a/src/entities/Font/index.ts +++ b/src/entities/Font/index.ts @@ -2,6 +2,7 @@ export { computeLineRenderModel, DualFontLayout, findSplitIndex, + windowSizeForLine, } from './domain'; export type { ComparisonLine,