mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 15:12:48 +00:00
fix(app): reveal a deep source match on native
Selecting a match 1,201 lines into an ordinary TypeScript file showed line 1,103 on an Android device. The match was never on screen, so the preview and Open flow could not deliver the occurrence they promised. The cause, measured rather than assumed. The list was told every row is one line of code tall. Probed on the device, a plain row is 17.52dp and a wrapped row is 140.19dp, because a row is as tall as the text needs and ordinary source lines wrap on a phone. Every wrapped line above the match therefore shrank the computed offset, and the list scrolled to a fraction of the real distance. Row geometry is now derived instead of assumed: one visual line is the reader's code size rounded the way the device rounds it, and a line occupies as many visual lines as it wraps onto, counted the way the platform wraps — greedily at spaces, mid-word only for a word too long for a line. The column count comes from measuring one monospace character at the current code size, so it follows the Appearance setting rather than a constant. Two smaller things fell out. The list renders only once that measurement exists, because scrolling a list still sized for a provisional geometry gets clamped to the shorter content and lands short. And the match is revealed near the top with two lines of context rather than centred, since centring is measured against the list's own box, which inside a sheet is taller than the part the reader can see. Verified on an Android device at the default code size and at 20px: preview and Open both show the highlighted text, across files and between two occurrences in one file.
This commit is contained in:
@@ -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<string, unknown>, options: { retries: number }): Promise<void> { /* 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SourceLine> | 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 (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.root} onLayout={measureList}>
|
||||
<Text aria-hidden style={styles.columnProbe} onLayout={measureColumn}>
|
||||
{COLUMN_PROBE}
|
||||
</Text>
|
||||
{changed ? (
|
||||
<Text accessibilityRole="alert" style={styles.notice}>
|
||||
{t("shell.commandCenter.contentChanged")}
|
||||
</Text>
|
||||
) : null}
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
extraData={location}
|
||||
getItemLayout={itemLayout}
|
||||
renderItem={renderLine}
|
||||
/>
|
||||
{columns > 0 ? (
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={lines}
|
||||
keyExtractor={sourceLineKey}
|
||||
initialNumToRender={24}
|
||||
windowSize={9}
|
||||
extraData={location}
|
||||
getItemLayout={itemLayout}
|
||||
renderItem={renderLine}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user