Files
paseo/packages/app/src/git/diff-document/text.native.ts
T
Mohamed Boudra 33a52d0ae7 Speed up cold diffs and wrapped text measurement (#4676)
* perf(app): optimize wrapped measurements and checkpoint native layout experiment

Compute full grapheme geometry only for final wrap fragments. Preserve shaped-text search behavior while bounding additive wrap probes. Keep the Android background layout experiment isolated from the normal diff renderer; native renderer integration and iOS bindings remain unfinished.

* perf(server): reduce cold diff generation work

Keep active diff reads owned across invalidation and wait for the initial watcher inventory before cold generation. Batch patch and blob reads while preserving per-file size isolation, bounded output, and daemon responsiveness. Avoid constructing fallback tokens when full-file highlighting already covers both sides.

* test(server): exercise oversized highlighted diff rejection

The single-line fixture now bypasses highlighting under the long-line guard, so it no longer reaches the structured payload limit. Use short dense lines to keep testing oversized rejection and continued socket usability.

* fix(server): queue fresh diffs for forced reads

A forced request following a repository mutation must not reuse an older active build. Invalidate that result and coalesce waiting callers onto one subsequent build. Give the cache a named object input and completed-value type.

* test(server): share deferred diff loader setup

* test(server): name completed diff cache setup

* refactor(server): use aliases for new diff imports

* fix(server): keep shipped diff imports resolvable by Node

The TypeScript build preserves path aliases without a runtime resolver. Restore relative imports for shipped code and declarations, and correct the server import guideline that prompted the regression.
2026-09-11 08:07:01 +02:00

354 lines
11 KiB
TypeScript

import {
Skia,
listFontFamilies,
matchFont,
type SkFont,
type SkParagraph,
} from "@shopify/react-native-skia";
import { fragmentTextForRange, visibleRowRange } from "./model";
import { codeTextColor } from "./palette";
import {
createCachedAsciiTextMetrics,
createChunkedAdvanceMeasurer,
createChunkedWidthMeasurer,
requiresShaping,
type CachedAsciiTextMetrics,
} from "./text-measurement";
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
const PARAGRAPH_WIDTH = 100_000;
export interface NativeTextLayout {
font: SkFont;
paragraphs: Array<Array<Array<SkParagraph | null>>>;
}
export interface NativeHeaderTextLayout {
families: string[] | undefined;
fontSize: number;
statFontSize: number;
palette: Pick<DiffPalette, "foreground" | "foregroundMuted" | "statusSuccess" | "statusDanger">;
}
export interface NativeShapedHeaderText {
paragraph: SkParagraph;
width: number;
height: number;
}
export type NativeHeaderTextTone = keyof NativeHeaderTextLayout["palette"];
export function createNativeHeaderTextLayout(input: {
configuredFamily: string;
fontSize: number;
statFontSize: number;
palette: NativeHeaderTextLayout["palette"];
}): NativeHeaderTextLayout {
const configured = input.configuredFamily
.split(",")
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
// These are React Native's platform-default UI sentinels, not custom faces.
// Omitting the family lets Skia resolve the same platform system font.
.filter((family) => family && family !== "normal" && family !== "system-ui");
const families = configured.length > 0 ? [...new Set(configured)] : undefined;
return {
families,
fontSize: input.fontSize,
statFontSize: input.statFontSize,
palette: input.palette,
};
}
export function shapeNativeHeaderText(input: {
layout: NativeHeaderTextLayout;
text: string;
size: "body" | "stat";
tone: NativeHeaderTextTone;
maximumWidth?: number;
}): NativeShapedHeaderText {
const paragraph = Skia.ParagraphBuilder.Make({ maxLines: 1, ellipsis: "…" })
.pushStyle({
...(input.layout.families ? { fontFamilies: input.layout.families } : {}),
fontSize: input.size === "body" ? input.layout.fontSize : input.layout.statFontSize,
color: Skia.Color(input.layout.palette[input.tone]),
})
.addText(input.text)
.pop()
.build();
paragraph.layout(input.maximumWidth ?? PARAGRAPH_WIDTH);
return {
paragraph,
width: paragraph.getLongestLine(),
height: paragraph.getHeight(),
};
}
export interface NativeTextLayoutStore {
font: SkFont;
asciiMetrics: CachedAsciiTextMetrics;
paragraphsByCell: Map<DiffCell, Array<SkParagraph | null>>;
textStyles: Map<string, ReturnType<typeof textStyle>>;
ownedParagraphs: Set<SkParagraph>;
families: string[];
fontSize: number;
lineHeight: number;
palette: DiffPalette;
}
export function disposeNativeTextLayout(layout: NativeTextLayoutStore): void {
for (const paragraph of layout.ownedParagraphs) paragraph.dispose();
layout.ownedParagraphs.clear();
layout.paragraphsByCell.clear();
layout.textStyles.clear();
layout.font.dispose();
}
export function createNativeTextLayoutStore(input: {
configuredFamily: string;
fontSize: number;
lineHeight: number;
palette: DiffPalette;
}): NativeTextLayoutStore {
const families = nativeFontFamilies(input.configuredFamily);
const font = primaryFont(families, input.fontSize);
return {
font,
asciiMetrics: createCachedAsciiTextMetrics({
glyphIds: (text) => font.getGlyphIDs(text),
measure: (text) => font.getTextWidth(text),
}),
paragraphsByCell: new Map(),
textStyles: new Map(),
ownedParagraphs: new Set(),
families,
fontSize: input.fontSize,
lineHeight: input.lineHeight,
palette: input.palette,
};
}
export function prepareNativeTextLayout(
store: NativeTextLayoutStore,
model: DiffDocumentModel,
window?: { top: number; height: number },
): NativeTextLayout {
const range = window
? visibleRowRange(model.rows, window.top, window.height)
: { start: 0, end: model.rows.length };
const retainedCells = new Set<DiffCell>();
const retainedParagraphs = new Set<SkParagraph>();
const paragraphs: NativeTextLayout["paragraphs"] = [];
paragraphs.length = model.rows.length;
for (let index = range.start; index < range.end; index++) {
const row = model.rows[index]!;
if (row.kind !== "line") continue;
paragraphs[index] = row.cells.map((cell) => {
if (!cell) return [];
retainedCells.add(cell);
const cached = store.paragraphsByCell.get(cell);
const start = window ? Math.max(0, Math.floor((window.top - row.top) / model.lineHeight)) : 0;
const end = window
? Math.min(
cell.fragments.length,
Math.ceil((window.top + window.height - row.top) / model.lineHeight),
)
: cell.fragments.length;
const cellParagraphs: Array<SkParagraph | null> = [];
cellParagraphs.length = cell.fragments.length;
for (let fragmentIndex = start; fragmentIndex < end; fragmentIndex++) {
const fragment = cell.fragments[fragmentIndex]!;
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) {
cellParagraphs[fragmentIndex] = null;
continue;
}
const paragraph =
cached?.[fragmentIndex] ??
createFragmentParagraph({
cell,
fragment,
families: store.families,
fontSize: store.fontSize,
lineHeight: store.lineHeight,
palette: store.palette,
textStyles: store.textStyles,
});
store.ownedParagraphs.add(paragraph);
retainedParagraphs.add(paragraph);
cellParagraphs[fragmentIndex] = paragraph;
}
store.paragraphsByCell.set(cell, cellParagraphs);
return cellParagraphs;
});
}
for (const paragraph of store.ownedParagraphs) {
if (retainedParagraphs.has(paragraph)) continue;
paragraph.dispose();
store.ownedParagraphs.delete(paragraph);
}
for (const cell of store.paragraphsByCell.keys()) {
if (!retainedCells.has(cell)) store.paragraphsByCell.delete(cell);
}
return { font: store.font, paragraphs };
}
export function createNativeTextMeasurer(input: {
configuredFamily: string;
fontSize: number;
}): TextMeasurer {
const families = nativeFontFamilies(input.configuredFamily);
const font = primaryFont(families, input.fontSize);
const primary = {
glyphIds: (text: string) => font.getGlyphIDs(text),
measure: (text: string) => font.getTextWidth(text),
};
const asciiMetrics = createCachedAsciiTextMetrics(primary);
const black = Skia.Color("black");
const shapeParagraph = (text: string) =>
createParagraph({
text,
families,
fontSize: input.fontSize,
lineHeight: Math.round(input.fontSize * 1.5),
color: black,
});
return {
measure(text) {
if (!requiresRetainedParagraph(text, asciiMetrics)) return asciiMetrics.measure(text);
const paragraph = shapeParagraph(text);
const width = paragraph.getLongestLine();
paragraph.dispose();
return width;
},
measureWidth: createChunkedWidthMeasurer((graphemes) => {
const text = graphemes.join("");
if (!requiresRetainedParagraph(text, asciiMetrics))
return asciiMetrics.measureWidth(graphemes);
const paragraph = shapeParagraph(text);
const width = paragraphAdvance(paragraph, text.length);
paragraph.dispose();
return width;
}),
measureAdvances: createChunkedAdvanceMeasurer({
requiresShaping: (text) => requiresRetainedParagraph(text, asciiMetrics),
measureAdditive: (graphemes) => asciiMetrics.measureAdvances(graphemes),
measureShaped(graphemes) {
const paragraph = shapeParagraph(graphemes.join(""));
let end = 0;
const advances = graphemes.map((grapheme) => {
end += grapheme.length;
return paragraphAdvance(paragraph, end);
});
paragraph.dispose();
return advances;
},
}),
};
}
function paragraphAdvance(paragraph: SkParagraph, end: number): number {
return paragraph
.getRectsForRange(0, end)
.reduce((right, rectangle) => Math.max(right, rectangle.x + rectangle.width), 0);
}
function requiresRetainedParagraph(text: string, asciiMetrics: CachedAsciiTextMetrics): boolean {
return requiresShaping(text) || !asciiMetrics.hasEveryGlyph(text);
}
function createFragmentParagraph(input: {
cell: DiffCell;
fragment: DiffFragment;
families: string[];
fontSize: number;
lineHeight: number;
palette: DiffPalette;
textStyles: NativeTextLayoutStore["textStyles"];
}): SkParagraph {
const builder = Skia.ParagraphBuilder.Make(
paragraphStyle(input.families, input.fontSize, input.lineHeight),
);
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
const color = codeTextColor(input.cell, input.palette);
builder.pushStyle(retainedTextStyle(input, color)).addText(input.fragment.text).pop();
} else {
for (const run of input.cell.tokens) {
const start = Math.max(input.fragment.start, run.start);
const end = Math.min(input.fragment.end, run.end);
if (end <= start) continue;
builder
.pushStyle(retainedTextStyle(input, run.color))
.addText(fragmentTextForRange(input.fragment, start, end))
.pop();
}
}
const paragraph = builder.build();
paragraph.layout(PARAGRAPH_WIDTH);
return paragraph;
}
function createParagraph(input: {
text: string;
families: string[];
fontSize: number;
lineHeight: number;
color: ReturnType<typeof Skia.Color>;
}): SkParagraph {
const paragraph = Skia.ParagraphBuilder.Make(
paragraphStyle(input.families, input.fontSize, input.lineHeight),
)
.pushStyle({
fontFamilies: input.families,
fontSize: input.fontSize,
color: input.color,
})
.addText(input.text)
.pop()
.build();
paragraph.layout(PARAGRAPH_WIDTH);
return paragraph;
}
function paragraphStyle(families: string[], fontSize: number, lineHeight: number) {
return {
maxLines: 1,
strutStyle: {
strutEnabled: true,
forceStrutHeight: true,
fontFamilies: families,
fontSize,
heightMultiplier: lineHeight / fontSize,
halfLeading: true,
},
};
}
function retainedTextStyle(
input: { families: string[]; fontSize: number; textStyles: NativeTextLayoutStore["textStyles"] },
color: string,
) {
let style = input.textStyles.get(color);
if (!style) {
style = textStyle(input.families, input.fontSize, color);
input.textStyles.set(color, style);
}
return style;
}
function textStyle(families: string[], fontSize: number, color: string) {
return { fontFamilies: families, fontSize, color: Skia.Color(color) };
}
function primaryFont(families: string[], size: number): SkFont {
const available = new Set(listFontFamilies());
const family = families.find((candidate) => available.has(candidate)) ?? "System";
return matchFont({ fontFamily: family, fontSize: size });
}
export function nativeFontFamilies(configuredFamily: string): string[] {
const configured = configuredFamily
.split(",")
.map((family) => family.trim().replace(/^['"]|['"]$/g, ""))
.filter(Boolean);
return [...new Set([...configured, "Menlo", "SF Mono", "monospace", "System"])];
}