mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user