diff --git a/packages/app/src/file-pane/source/row-layout.test.ts b/packages/app/src/file-pane/source/row-layout.test.ts new file mode 100644 index 0000000000..e1b12ddb1f --- /dev/null +++ b/packages/app/src/file-pane/source/row-layout.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { sourceRowOffsets, visualLineCount } from "./row-layout"; + +// Measured on a Pixel-class Android device at the default code size: one visual line is 17.5234dp +// after the device rounds it, and 49 monospace characters fit beside the gutter. +const VISUAL_LINE_HEIGHT = 17.5234375; +const COLUMNS = 49; + +// An ordinary TypeScript line, 119 characters, of the kind that fills a real source file. +const ORDINARY_LINE = + "export function handlerNumber1(input: Record, options: { retries: number }): Promise { /* 1 */ }"; + +describe("visualLineCount", () => { + it("keeps a line that fits on one visual line", () => { + expect(visualLineCount("const a = 1;", COLUMNS)).toBe(1); + expect(visualLineCount("", COLUMNS)).toBe(1); + }); + + it("counts the visual lines an ordinary source line occupies", () => { + expect(visualLineCount(ORDINARY_LINE, COLUMNS)).toBe(3); + }); + + it("breaks early at a space, where dividing by the column count would not", () => { + // A larger code size narrows the columns. Character division says four lines; the platform + // cannot split "handlerNumber1(input:" across a space that is not there, so it needs five. + expect(Math.ceil(ORDINARY_LINE.length / 33)).toBe(4); + expect(visualLineCount(ORDINARY_LINE, 33)).toBe(5); + }); + + it("breaks a single word that is wider than the line", () => { + expect(visualLineCount("x".repeat(COLUMNS * 3), COLUMNS)).toBe(3); + }); + + it("assumes one visual line until the column width is measured", () => { + expect(visualLineCount(ORDINARY_LINE, 0)).toBe(1); + }); +}); + +describe("sourceRowOffsets", () => { + it("stacks rows by the visual lines they really occupy", () => { + const offsets = sourceRowOffsets([{ text: "const a = 1;" }, { text: ORDINARY_LINE }], { + columns: COLUMNS, + visualLineHeight: VISUAL_LINE_HEIGHT, + }); + expect(Array.from(offsets)).toEqual([0, VISUAL_LINE_HEIGHT, VISUAL_LINE_HEIGHT * 4]); + }); + + it("puts a match after a thousand ordinary lines where it is really drawn", () => { + // The regression: 1,200 ordinary lines, match on 1,201. Claiming one visual line per row + // reported 1200 * 17.52 = 21,028, which is about line 1,100 on screen — the match was roughly + // 90 lines below the fold and never revealed. + const lines = [ + ...Array.from({ length: 1200 }, () => ({ text: ORDINARY_LINE })), + { text: 'const target = "needle";' }, + ]; + const offsets = sourceRowOffsets(lines, { + columns: COLUMNS, + visualLineHeight: VISUAL_LINE_HEIGHT, + }); + expect(offsets[1200]).toBeCloseTo(1200 * 3 * VISUAL_LINE_HEIGHT, 5); + expect(offsets[1200]).toBeGreaterThan(1200 * VISUAL_LINE_HEIGHT * 2.9); + }); +}); diff --git a/packages/app/src/file-pane/source/row-layout.ts b/packages/app/src/file-pane/source/row-layout.ts new file mode 100644 index 0000000000..9e3294b5e9 --- /dev/null +++ b/packages/app/src/file-pane/source/row-layout.ts @@ -0,0 +1,55 @@ +/** + * Where each source row sits in the list. + * + * A row is one visual line of code tall, and a line too long for the available columns wraps onto + * more of them. The virtualized list is told these offsets so it can jump straight to a match + * thousands of lines in; if they claim one visual line per row, an ordinary source file — whose + * lines routinely wrap on a phone — puts the match hundreds of lines off screen. + */ +export interface SourceRowGeometry { + /** Monospace characters that fit beside the gutter; 0 before the first measurement. */ + columns: number; + /** Height of one visual line, already rounded the way the device rounds it. */ + visualLineHeight: number; +} + +/** + * Visual lines a line of code occupies, wrapped the way the platform wraps it: greedily at spaces, + * and mid-word only for a word too long to fit a line of its own. Dividing the character count by + * the column count instead under-counts every line that has to break early at a space, and the + * error compounds over a long file. One line until the column width has been measured. + */ +export function visualLineCount(text: string, columns: number): number { + if (columns <= 0) return 1; + let rows = 1; + let used = 0; + for (const word of text.split(" ")) { + const gap = used === 0 ? 0 : 1; + if (used + gap + word.length <= columns) { + used += gap + word.length; + continue; + } + if (used > 0) rows += 1; + if (word.length > columns) { + rows += Math.ceil(word.length / columns) - 1; + used = word.length % columns || columns; + continue; + } + used = word.length; + } + return rows; +} + +/** Cumulative top edge of every row, with one extra entry for the end of the last row. */ +export function sourceRowOffsets( + lines: ReadonlyArray<{ text: string }>, + geometry: SourceRowGeometry, +): Float64Array { + const stops = new Float64Array(lines.length + 1); + for (let index = 0; index < lines.length; index += 1) { + stops[index + 1] = + stops[index] + + visualLineCount(lines[index].text, geometry.columns) * geometry.visualLineHeight; + } + return stops; +} diff --git a/packages/app/src/file-pane/source/view.native.tsx b/packages/app/src/file-pane/source/view.native.tsx index 85c6410f96..571cc195d7 100644 --- a/packages/app/src/file-pane/source/view.native.tsx +++ b/packages/app/src/file-pane/source/view.native.tsx @@ -1,12 +1,13 @@ import { useTranslation } from "react-i18next"; -import { useCallback, useEffect, useMemo, useRef } from "react"; -import { FlatList, Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FlatList, PixelRatio, Text, View, type LayoutChangeEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import type { WorkspaceFileLocation } from "@/workspace/file-open"; import type { EditorVisualTheme } from "../editor/extensions.web"; import { selectSourcePresentation } from "./presentation"; +import { sourceRowOffsets } from "./row-layout"; interface FileSourceViewProps { content: string; @@ -21,6 +22,8 @@ interface FileSourceViewProps { interface SourceLine { number: number; tokens: HighlightToken[]; + /** The line's own text, which decides how many visual lines its row occupies. */ + text: string; } export function FileSourceView({ @@ -71,31 +74,57 @@ function VirtualizedSource({ return highlightCode(source, filename).map((tokens, index) => ({ number: index + 1, tokens, + text: tokens.map((token) => token.text).join(""), })); - return source - .split("\n") - .map((text, index) => ({ number: index + 1, tokens: [{ text, style: null }] })); + return source.split("\n").map((text, index) => ({ + number: index + 1, + tokens: [{ text, style: null }], + text, + })); }, [content, filename, presentation]); - // A row is exactly one line of code at the reader's own code size, so the list can jump straight - // to a match thousands of lines in. Hard-coding this height instead of deriving it left every - // reader who changed their code size looking at the wrong part of the file. - const rowHeight = sourceRowHeight(codeFontSize); + // Jumping to a match thousands of lines in only works if the list is told the truth about row + // heights. A row is one visual line at the reader's own code size, rounded the way the device + // rounds it, times however many visual lines the text wraps onto. Assuming one line per row put + // a match in an ordinary source file hundreds of lines off screen, because ordinary lines wrap. + const [columnWidth, setColumnWidth] = useState(0); + const [listWidth, setListWidth] = useState(0); + const visualLineHeight = PixelRatio.roundToNearestPixel(sourceRowHeight(codeFontSize)); + const columns = + columnWidth > 0 && listWidth > 0 + ? Math.max(1, Math.floor((listWidth - GUTTER_WIDTH) / columnWidth)) + : 0; + const offsets = useMemo( + () => sourceRowOffsets(lines, { columns, visualLineHeight }), + [columns, lines, visualLineHeight], + ); const itemLayout = useCallback( (_data: ArrayLike | null | undefined, index: number) => ({ - length: rowHeight, - offset: index * rowHeight, + length: offsets[index + 1] - offsets[index], + offset: offsets[index], index, }), - [rowHeight], + [offsets], + ); + const measureColumn = useCallback( + (event: LayoutChangeEvent) => + setColumnWidth(event.nativeEvent.layout.width / COLUMN_PROBE.length), + [], + ); + const measureList = useCallback( + (event: LayoutChangeEvent) => setListWidth(event.nativeEvent.layout.width), + [], ); useEffect(() => { if (!location.lineStart) return; listRef.current?.scrollToIndex({ index: Math.min(location.lineStart - 1, lines.length - 1), animated: false, - viewPosition: 0.5, + // Near the top with a little context above, rather than centred: centring is measured + // against the list's own box, which inside a sheet is taller than the part on screen. + viewPosition: 0, + viewOffset: visualLineHeight * CONTEXT_LINES_ABOVE_MATCH, }); - }, [lines.length, location.lineStart, navigationRevision]); + }, [lines.length, location.lineStart, navigationRevision, offsets, visualLineHeight]); const selectedLine = lines[(location.lineStart ?? 1) - 1]?.tokens .map((token) => token.text) .join(""); @@ -114,22 +143,27 @@ function VirtualizedSource({ [changed, location], ); return ( - + + + {COLUMN_PROBE} + {changed ? ( {t("shell.commandCenter.contentChanged")} ) : null} - + {columns > 0 ? ( + + ) : null} ); } @@ -162,12 +196,16 @@ function sourceLineKey(line: SourceLine): string { return String(line.number); } -/** One rendered code line; the stylesheet below builds its row from the same ratio. */ +/** One visual line of code; the stylesheet below builds its row from the same ratio. */ function sourceRowHeight(codeFontSize: number): number { return codeFontSize * SOURCE_LINE_RATIO; } const SOURCE_LINE_RATIO = 1.45; +const GUTTER_WIDTH = 56; +/** Measured once to learn how wide one monospace character is at the reader's code size. */ +const COLUMN_PROBE = "0".repeat(10); +const CONTEXT_LINES_ABOVE_MATCH = 2; const styles = StyleSheet.create((theme) => ({ root: { flex: 1 }, @@ -186,13 +224,19 @@ const styles = StyleSheet.create((theme) => ({ unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, line: { flexDirection: "row", minHeight: sourceRowHeight(theme.fontSize.code) }, gutter: { - width: 56, + width: GUTTER_WIDTH, paddingRight: theme.spacing[3], textAlign: "right", color: theme.colors.foregroundMuted, fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.code, }, + columnProbe: { + position: "absolute", + opacity: 0, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.code, + }, text: { flex: 1, fontFamily: theme.fontFamily.mono,