fix(app): stabilize canvas diff rendering

Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:38 +02:00
parent 4d23905587
commit c7b103a2cd
50 changed files with 2180 additions and 580 deletions
@@ -1,16 +1,30 @@
import { ClipOp, Skia, type SkCanvas, type SkPaint } from "@shopify/react-native-skia";
import {
ClipOp,
Skia,
type SkCanvas,
type SkPaint,
type SkPicture,
} from "@shopify/react-native-skia";
import { visibleRowRange } from "./model";
import { nativeTextRuns } from "./native-text-runs";
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
import { codeLineNumberTone } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type { NativeTextLayout } from "./text.native";
import type { DiffCell, DiffDocumentModel, DiffLineRow, DiffPalette } from "./types";
import type {
DiffCell,
DiffDocumentModel,
DiffFileSection,
DiffLineRow,
DiffPalette,
} from "./types";
const CODE_LEFT_PADDING = 8;
interface NativePaints {
export interface NativePaints {
surface: SkPaint;
border: SkPaint;
foreground: SkPaint;
foregroundMuted: SkPaint;
addition: SkPaint;
deletion: SkPaint;
@@ -18,13 +32,14 @@ interface NativePaints {
deletionBackground: SkPaint;
headerSurface: SkPaint;
emptyBackground: SkPaint;
syntax: Record<string, SkPaint>;
text: Record<string, SkPaint>;
}
export function createNativePaints(palette: DiffPalette): NativePaints {
return {
surface: paint(palette.surface),
border: paint(palette.border),
foreground: paint(palette.foreground),
foregroundMuted: paint(palette.foregroundMuted),
addition: paint(palette.addition),
deletion: paint(palette.deletion),
@@ -32,7 +47,11 @@ export function createNativePaints(palette: DiffPalette): NativePaints {
deletionBackground: paint(palette.deletionBackground),
headerSurface: paint(palette.headerSurface),
emptyBackground: paint(palette.emptyBackground),
syntax: Object.fromEntries(Object.values(palette.syntax).map((color) => [color, paint(color)])),
text: Object.fromEntries(
[
...new Set([palette.foreground, palette.foregroundMuted, ...Object.values(palette.syntax)]),
].map((color) => [color, paint(color)]),
),
};
}
@@ -52,113 +71,308 @@ export function paintNativeViewport(input: {
textLayout: NativeTextLayout;
paints: NativePaints;
}): void {
"worklet";
input.canvas.drawRect(
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
input.paints.surface,
paintNativeRange({ ...input, layer: "all" });
}
export function recordNativeSlabPictures(input: {
model: DiffDocumentModel;
fileIndex: number;
top: number;
height: number;
viewportWidth: number;
textLayout: NativeTextLayout;
paints: NativePaints;
}): {
fixed: SkPicture;
gutter: SkPicture;
content: { unified: SkPicture; left: SkPicture; right: SkPicture };
} {
const file = input.model.files[input.fileIndex];
const fixedRecorder = Skia.PictureRecorder();
const fixedCanvas = fixedRecorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
);
paintNativeRange({
canvas: fixedCanvas,
model: input.model,
viewportWidth: input.viewportWidth,
viewportHeight: input.height,
scrollTop: input.top,
horizontalOffsets: {},
textLayout: input.textLayout,
paints: input.paints,
layer: "fixed",
});
const recordContent = (contentCell: "unified" | "left" | "right") => {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(
Skia.XYWHRect(0, 0, Math.max(input.viewportWidth, file?.contentWidth ?? 0), input.height),
);
paintNativeRange({
canvas,
model: input.model,
viewportWidth: input.viewportWidth,
viewportHeight: input.height,
scrollTop: input.top,
horizontalOffsets: {},
textLayout: input.textLayout,
paints: input.paints,
layer: "content",
contentCell,
});
return recorder.finishRecordingAsPicture();
};
const gutterRecorder = Skia.PictureRecorder();
const gutterCanvas = gutterRecorder.beginRecording(
Skia.XYWHRect(0, 0, input.viewportWidth, input.height),
);
paintNativeGutter({
canvas: gutterCanvas,
model: input.model,
top: input.top,
height: input.height,
textLayout: input.textLayout,
paints: input.paints,
});
return {
fixed: fixedRecorder.finishRecordingAsPicture(),
gutter: gutterRecorder.finishRecordingAsPicture(),
content: {
unified: recordContent("unified"),
left: recordContent("left"),
right: recordContent("right"),
},
};
}
function paintNativeGutter(input: {
canvas: SkCanvas;
model: DiffDocumentModel;
top: number;
height: number;
textLayout: NativeTextLayout;
paints: NativePaints;
}): void {
const range = visibleRowRange(input.model.rows, input.top, input.height);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row || row.kind !== "line") continue;
const file = input.model.files[row.fileIndex];
if (!file) continue;
const y = row.top - input.top;
const columnWidth = input.model.viewportWidth / row.cells.length;
row.cells.forEach((cell, cellIndex) => {
if (!cell) return;
const columnX = cellIndex * columnWidth;
input.canvas.drawRect(
Skia.XYWHRect(columnX + file.gutterWidth, y, 1, reviewDividerHeight(row.height)),
input.paints.border,
);
const label = String(cell.lineNumber ?? "");
const font = input.textLayout.font;
input.canvas.drawText(
label,
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
y + input.model.lineHeight * 0.78,
input.paints[codeLineNumberTone(cell)],
font,
);
});
}
}
interface PaintNativeRangeInput {
canvas: SkCanvas;
model: DiffDocumentModel;
viewportWidth: number;
viewportHeight: number;
scrollTop: number;
horizontalOffsets: Readonly<DiffHorizontalOffsets>;
textLayout: NativeTextLayout;
paints: NativePaints;
layer: "all" | "fixed" | "content";
contentCell?: "unified" | "left" | "right";
}
function paintNativeRange(input: PaintNativeRangeInput): void {
"worklet";
const paintsFixedContent = input.layer !== "content";
const paintsCodeContent = input.layer !== "fixed";
if (paintsFixedContent) {
input.canvas.drawRect(
Skia.XYWHRect(0, 0, input.viewportWidth, input.viewportHeight),
input.paints.surface,
);
}
const backgroundPaints: Record<DiffCell["type"], SkPaint> = {
add: input.paints.additionBackground,
context: input.paints.surface,
empty: input.paints.emptyBackground,
header: input.paints.headerSurface,
remove: input.paints.deletionBackground,
};
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row) continue;
const y = row.top - input.scrollTop;
if (row.kind === "status") {
input.canvas.drawText(
row.label,
12,
y + input.model.lineHeight,
input.paints.foregroundMuted,
input.textLayout.font,
);
if (paintsFixedContent) {
input.canvas.drawText(
row.label,
12,
y + input.model.lineHeight,
input.paints.foregroundMuted,
input.textLayout.font,
);
}
continue;
}
paintLine({
...input,
row,
y,
horizontalOffset: horizontalOffsetForPath(input.horizontalOffsets, row.path),
const file = input.model.files[row.fileIndex];
if (!file) continue;
const horizontalOffset = horizontalOffsetForPath(input.horizontalOffsets, row.path);
const columnWidth = input.viewportWidth / row.cells.length;
row.cells.forEach((cell, cellIndex) => {
const columnX = cellIndex * columnWidth;
if (paintsFixedContent) {
paintNativeFixedCell({ input, row, file, cell, columnX, columnWidth, y, backgroundPaints });
}
if (
paintsCodeContent &&
cell &&
paintsNativeContentCell(input, row.cells.length, cellIndex)
) {
paintNativeCodeCell({
input,
row,
file,
cell,
cellIndex,
columnX,
columnWidth,
horizontalOffset,
y,
});
}
});
}
}
function paintLine(input: {
canvas: SkCanvas;
model: DiffDocumentModel;
viewportWidth: number;
function paintsNativeContentCell(
input: PaintNativeRangeInput,
columnCount: number,
cellIndex: number,
): boolean {
if (input.layer !== "content") return true;
if (columnCount === 1) return input.contentCell === "unified";
return input.contentCell === (cellIndex === 0 ? "left" : "right");
}
function paintNativeFixedCell(input: {
input: PaintNativeRangeInput;
row: DiffLineRow;
file: DiffFileSection;
cell: DiffCell | null;
columnX: number;
columnWidth: number;
y: number;
horizontalOffset: number;
textLayout: NativeTextLayout;
paints: NativePaints;
backgroundPaints: Record<DiffCell["type"], SkPaint>;
}): void {
"worklet";
const file = input.model.files[input.row.fileIndex];
if (!file) return;
const columnWidth = input.viewportWidth / input.row.cells.length;
input.row.cells.forEach((cell, cellIndex) => {
const columnX = cellIndex * columnWidth;
input.canvas.drawRect(
Skia.XYWHRect(columnX, input.y, columnWidth, input.row.height),
background(cell, input.paints),
);
if (input.row.reviewHeight > 0) {
input.canvas.drawRect(
Skia.XYWHRect(
columnX,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
columnWidth,
input.row.reviewHeight,
),
reviewBackgroundPaint(input.paints.surface),
);
}
if (!cell) return;
input.canvas.drawRect(
Skia.XYWHRect(columnX + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height)),
input.paints.border,
);
if (cell.lineNumber !== null) {
const label = String(cell.lineNumber);
const font = input.textLayout.font;
input.canvas.drawText(
label,
columnX + file.gutterWidth - 7 - font.getTextWidth(label),
input.y + input.model.lineHeight * 0.78,
lineNumberPaint(cell, input.paints),
font,
);
}
const textX = columnX + file.gutterWidth + CODE_LEFT_PADDING;
input.canvas.save();
input.canvas.clipRect(
const backgroundPaint = input.cell
? input.backgroundPaints[input.cell.type]
: input.input.paints.emptyBackground;
input.input.canvas.drawRect(
Skia.XYWHRect(input.columnX, input.y, input.columnWidth, input.row.height),
backgroundPaint,
);
input.input.canvas.drawRect(
Skia.XYWHRect(
input.columnX,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
input.columnWidth,
input.row.reviewHeight,
),
reviewBackgroundPaint(input.input.paints.surface),
);
if (!input.cell) return;
input.input.canvas.drawRect(
Skia.XYWHRect(
input.columnX + input.file.gutterWidth,
input.y,
1,
reviewDividerHeight(input.row.height),
),
input.input.paints.border,
);
const label = String(input.cell.lineNumber ?? "");
const font = input.input.textLayout.font;
input.input.canvas.drawText(
label,
input.columnX + input.file.gutterWidth - 7 - font.getTextWidth(label),
input.y + input.input.model.lineHeight * 0.78,
input.input.paints[codeLineNumberTone(input.cell)],
font,
);
}
function paintNativeCodeCell(input: {
input: PaintNativeRangeInput;
row: DiffLineRow;
file: DiffFileSection;
cell: DiffCell;
cellIndex: number;
columnX: number;
columnWidth: number;
horizontalOffset: number;
y: number;
}): void {
const textX = input.columnX + input.file.gutterWidth + CODE_LEFT_PADDING;
const clipsContent = input.input.layer === "all";
if (clipsContent) {
input.input.canvas.save();
input.input.canvas.clipRect(
Skia.XYWHRect(
textX,
input.y,
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
input.columnWidth - input.file.gutterWidth - CODE_LEFT_PADDING,
input.row.height,
),
ClipOp.Intersect,
false,
);
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
for (const [fragmentIndex, fragment] of cell.fragments.entries()) {
const paragraph = input.textLayout.paragraphs[input.row.index]?.[cellIndex]?.[fragmentIndex];
paragraph?.paint(input.canvas, textX - offset, input.y + fragment.top);
}
const offset = input.input.model.wrapLines ? 0 : input.horizontalOffset;
for (const [fragmentIndex, fragment] of input.cell.fragments.entries()) {
const paragraph =
input.input.textLayout.paragraphs[input.row.index]?.[input.cellIndex]?.[fragmentIndex];
if (paragraph) {
paragraph.paint(input.input.canvas, textX - offset, input.y + fragment.top);
continue;
}
input.canvas.restore();
});
}
function background(cell: DiffCell | null, paints: NativePaints): SkPaint {
"worklet";
if (!cell) return paints.emptyBackground;
if (cell.type === "add") return paints.additionBackground;
if (cell.type === "remove") return paints.deletionBackground;
if (cell.type === "header") return paints.headerSurface;
return paints.surface;
}
function lineNumberPaint(cell: DiffCell, paints: NativePaints): SkPaint {
"worklet";
return paints[codeLineNumberTone(cell)];
const fragmentX = textX - offset;
const baseline = input.y + fragment.baseline;
if (input.cell.tokens.length === 0 || input.cell.type === "header") {
input.input.canvas.drawText(
fragment.text,
fragmentX,
baseline,
input.cell.type === "header"
? input.input.paints.foregroundMuted
: input.input.paints.foreground,
input.input.textLayout.font,
);
continue;
}
for (const run of nativeTextRuns(input.cell, fragment)) {
input.input.canvas.drawText(
run.text,
fragmentX + run.left,
baseline,
input.input.paints.text[run.color] ?? input.input.paints.foreground,
input.input.textLayout.font,
);
}
}
if (clipsContent) input.input.canvas.restore();
}