mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 02:32:47 +00:00
* perf(app): keep large diffs responsive Render file headers on the canvas and bound expensive text shaping to the active viewport while preserving sticky, hover, and horizontal-scroll behavior. Let native scroll cancellation arbitrate header presses so finger scrolling cannot expand files. * test(app): assert canvas diff header accessibility * test(app): assert canvas diff header semantics
577 lines
17 KiB
TypeScript
577 lines
17 KiB
TypeScript
import {
|
|
ClipOp,
|
|
PaintStyle,
|
|
Skia,
|
|
StrokeCap,
|
|
StrokeJoin,
|
|
type SkCanvas,
|
|
type SkPaint,
|
|
type SkPicture,
|
|
} from "@shopify/react-native-skia";
|
|
import {
|
|
DIFF_FILE_HEADER_CONTENT_HEIGHT,
|
|
DIFF_FILE_HEADER_HEIGHT,
|
|
DIFF_FILE_HEADER_ICON_SIZE,
|
|
DIFF_FILE_HEADER_LEFT,
|
|
DIFF_FILE_HEADER_RIGHT,
|
|
DIFF_FILE_HEADER_TEXT_GAP,
|
|
allocateDiffHeaderTextWidths,
|
|
diffFileChangeKind,
|
|
directorySuffix,
|
|
fileNameForPath,
|
|
formatDiffCount,
|
|
} from "@/git/file-header-presentation";
|
|
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, 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 {
|
|
shapeNativeHeaderText,
|
|
type NativeHeaderTextLayout,
|
|
type NativeShapedHeaderText,
|
|
type NativeTextLayout,
|
|
} from "./text.native";
|
|
import type {
|
|
DiffCell,
|
|
DiffDocumentModel,
|
|
DiffFileSection,
|
|
DiffLineRow,
|
|
DiffPalette,
|
|
} from "./types";
|
|
|
|
const CODE_LEFT_PADDING = 8;
|
|
|
|
export interface NativePaints {
|
|
surface: SkPaint;
|
|
border: SkPaint;
|
|
foreground: SkPaint;
|
|
foregroundMuted: SkPaint;
|
|
addition: SkPaint;
|
|
deletion: SkPaint;
|
|
additionBackground: SkPaint;
|
|
deletionBackground: SkPaint;
|
|
headerSurface: SkPaint;
|
|
headerBorder: SkPaint;
|
|
statusSuccess: SkPaint;
|
|
statusDanger: SkPaint;
|
|
statusWarning: SkPaint;
|
|
emptyBackground: 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),
|
|
additionBackground: paint(palette.additionBackground),
|
|
deletionBackground: paint(palette.deletionBackground),
|
|
headerSurface: paint(palette.headerSurface),
|
|
headerBorder: paint(palette.headerBorder),
|
|
statusSuccess: paint(palette.statusSuccess),
|
|
statusDanger: paint(palette.statusDanger),
|
|
statusWarning: paint(palette.statusWarning),
|
|
emptyBackground: paint(palette.emptyBackground),
|
|
text: Object.fromEntries(
|
|
[
|
|
...new Set([palette.foreground, palette.foregroundMuted, ...Object.values(palette.syntax)]),
|
|
].map((color) => [color, paint(color)]),
|
|
),
|
|
};
|
|
}
|
|
|
|
export function recordNativeHeaderPicture(input: {
|
|
file: DiffFileSection;
|
|
viewportWidth: number;
|
|
textLayout: NativeHeaderTextLayout;
|
|
paints: NativePaints;
|
|
}): SkPicture {
|
|
const recorder = Skia.PictureRecorder();
|
|
const canvas = recorder.beginRecording(
|
|
Skia.XYWHRect(0, 0, input.viewportWidth, DIFF_FILE_HEADER_HEIGHT),
|
|
);
|
|
canvas.drawRect(
|
|
Skia.XYWHRect(0, DIFF_FILE_HEADER_HEIGHT - 1, input.viewportWidth, 1),
|
|
input.paints.headerBorder,
|
|
);
|
|
const iconX = input.viewportWidth - DIFF_FILE_HEADER_RIGHT - DIFF_FILE_HEADER_ICON_SIZE;
|
|
const additions = `+${formatDiffCount(input.file.file.additions)}`;
|
|
const deletions = `-${formatDiffCount(input.file.file.deletions)}`;
|
|
const additionsText = shapeNativeHeaderText({
|
|
layout: input.textLayout,
|
|
text: additions,
|
|
size: "stat",
|
|
tone: "statusSuccess",
|
|
});
|
|
const deletionsText = shapeNativeHeaderText({
|
|
layout: input.textLayout,
|
|
text: deletions,
|
|
size: "stat",
|
|
tone: "statusDanger",
|
|
});
|
|
const statX = iconX - 8 - additionsText.width - 4 - deletionsText.width;
|
|
paintNativeHeaderText(canvas, additionsText, statX);
|
|
paintNativeHeaderText(canvas, deletionsText, statX + additionsText.width + 4);
|
|
paintNativeChangeIcon(
|
|
canvas,
|
|
input.file,
|
|
iconX,
|
|
(DIFF_FILE_HEADER_CONTENT_HEIGHT - DIFF_FILE_HEADER_ICON_SIZE) / 2,
|
|
input.paints,
|
|
);
|
|
|
|
const available = Math.max(0, statX - DIFF_FILE_HEADER_LEFT);
|
|
const name = fileNameForPath(input.file.path);
|
|
const directory = directorySuffix(input.file.path).trimStart();
|
|
const fitted = fitNativeHeaderText(input.textLayout, name, directory, available);
|
|
canvas.save();
|
|
canvas.clipRect(
|
|
Skia.XYWHRect(DIFF_FILE_HEADER_LEFT, 0, available, DIFF_FILE_HEADER_CONTENT_HEIGHT),
|
|
ClipOp.Intersect,
|
|
false,
|
|
);
|
|
paintNativeHeaderText(canvas, fitted.name, DIFF_FILE_HEADER_LEFT);
|
|
if (fitted.directory.width > 0) {
|
|
paintNativeHeaderText(
|
|
canvas,
|
|
fitted.directory,
|
|
DIFF_FILE_HEADER_LEFT + fitted.name.width + DIFF_FILE_HEADER_TEXT_GAP,
|
|
);
|
|
}
|
|
canvas.restore();
|
|
const picture = recorder.finishRecordingAsPicture();
|
|
additionsText.paragraph.dispose();
|
|
deletionsText.paragraph.dispose();
|
|
fitted.name.paragraph.dispose();
|
|
fitted.directory.paragraph.dispose();
|
|
return picture;
|
|
}
|
|
|
|
function paintNativeHeaderText(canvas: SkCanvas, text: NativeShapedHeaderText, x: number): void {
|
|
text.paragraph.paint(canvas, x, (DIFF_FILE_HEADER_CONTENT_HEIGHT - text.height) / 2);
|
|
}
|
|
|
|
function fitNativeHeaderText(
|
|
layout: NativeHeaderTextLayout,
|
|
name: string,
|
|
directory: string,
|
|
available: number,
|
|
): { name: NativeShapedHeaderText; directory: NativeShapedHeaderText } {
|
|
let nameText = shapeNativeHeaderText({ layout, text: name, size: "body", tone: "foreground" });
|
|
let directoryText = shapeNativeHeaderText({
|
|
layout,
|
|
text: directory,
|
|
size: "body",
|
|
tone: "foregroundMuted",
|
|
});
|
|
if (nameText.width + (directory ? 4 + directoryText.width : 0) <= available) {
|
|
return { name: nameText, directory: directoryText };
|
|
}
|
|
|
|
const widths = allocateDiffHeaderTextWidths({
|
|
available,
|
|
nameWidth: nameText.width,
|
|
directoryWidth: directoryText.width,
|
|
});
|
|
if (widths.name < nameText.width) {
|
|
nameText.paragraph.dispose();
|
|
nameText = shapeNativeHeaderText({
|
|
layout,
|
|
text: name,
|
|
size: "body",
|
|
tone: "foreground",
|
|
maximumWidth: widths.name,
|
|
});
|
|
}
|
|
directoryText.paragraph.dispose();
|
|
directoryText = shapeNativeHeaderText({
|
|
layout,
|
|
text: widths.directory > 0 ? directory : "",
|
|
size: "body",
|
|
tone: "foregroundMuted",
|
|
maximumWidth: widths.directory,
|
|
});
|
|
return { name: nameText, directory: directoryText };
|
|
}
|
|
|
|
function paintNativeChangeIcon(
|
|
canvas: SkCanvas,
|
|
file: DiffFileSection,
|
|
x: number,
|
|
y: number,
|
|
paints: NativePaints,
|
|
): void {
|
|
const change = diffFileChangeKind(file.file);
|
|
let source = paints.statusWarning;
|
|
if (change === "added") source = paints.statusSuccess;
|
|
else if (change === "deleted") source = paints.statusDanger;
|
|
const stroke = source.copy();
|
|
stroke.setStyle(PaintStyle.Stroke);
|
|
stroke.setStrokeWidth(DIFF_FILE_HEADER_ICON_SIZE / 12);
|
|
stroke.setStrokeCap(StrokeCap.Round);
|
|
stroke.setStrokeJoin(StrokeJoin.Round);
|
|
const scale = DIFF_FILE_HEADER_ICON_SIZE / 24;
|
|
canvas.drawRRect(
|
|
Skia.RRectXY(
|
|
Skia.XYWHRect(x + 3 * scale, y + 3 * scale, 18 * scale, 18 * scale),
|
|
2 * scale,
|
|
2 * scale,
|
|
),
|
|
stroke,
|
|
);
|
|
if (change === "added") {
|
|
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
|
|
canvas.drawLine(x + 12 * scale, y + 8 * scale, x + 12 * scale, y + 16 * scale, stroke);
|
|
} else if (change === "deleted") {
|
|
canvas.drawLine(x + 8 * scale, y + 12 * scale, x + 16 * scale, y + 12 * scale, stroke);
|
|
} else {
|
|
canvas.drawCircle(x + 12 * scale, y + 12 * scale, scale, stroke);
|
|
}
|
|
stroke.dispose();
|
|
}
|
|
|
|
function paint(color: string): SkPaint {
|
|
const result = Skia.Paint();
|
|
result.setColor(Skia.Color(color));
|
|
return result;
|
|
}
|
|
|
|
export function paintNativeViewport(input: {
|
|
canvas: SkCanvas;
|
|
model: DiffDocumentModel;
|
|
viewportWidth: number;
|
|
viewportHeight: number;
|
|
scrollTop: number;
|
|
horizontalOffsets: Readonly<DiffHorizontalOffsets>;
|
|
textLayout: NativeTextLayout;
|
|
paints: NativePaints;
|
|
}): void {
|
|
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",
|
|
fileIndex: input.fileIndex,
|
|
});
|
|
|
|
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";
|
|
fileIndex?: number;
|
|
}
|
|
|
|
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") {
|
|
if (paintsFixedContent) {
|
|
input.canvas.drawText(
|
|
row.label,
|
|
12,
|
|
y + input.model.lineHeight,
|
|
input.paints.foregroundMuted,
|
|
input.textLayout.font,
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
let borderFiles = input.model.files;
|
|
if (input.fileIndex !== undefined) {
|
|
const file = input.model.files[input.fileIndex];
|
|
borderFiles = file ? [file] : [];
|
|
}
|
|
for (const file of paintsFixedContent ? borderFiles : []) {
|
|
const borderTop = expandedBodyBorderTop(file);
|
|
const isVisible =
|
|
borderTop !== null &&
|
|
borderTop < input.scrollTop + input.viewportHeight &&
|
|
borderTop + DIFF_BODY_BORDER_HEIGHT > input.scrollTop;
|
|
if (!isVisible) continue;
|
|
input.canvas.drawRect(
|
|
Skia.XYWHRect(0, borderTop - input.scrollTop, input.viewportWidth, DIFF_BODY_BORDER_HEIGHT),
|
|
input.paints.border,
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
backgroundPaints: Record<DiffCell["type"], SkPaint>;
|
|
}): void {
|
|
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,
|
|
input.columnWidth - input.file.gutterWidth - CODE_LEFT_PADDING,
|
|
input.row.height,
|
|
),
|
|
ClipOp.Intersect,
|
|
false,
|
|
);
|
|
}
|
|
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;
|
|
}
|
|
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();
|
|
}
|