Number virtualized highlight matches from an app-supplied offset

`activeIndex` promised "the nth match in the document" and could not keep that
promise inside a `<virtual-list>`. The list mounts a window of its rows, ordinals
are handed out during paint, and paint only ever sees that window, so the
sequence restarted at 0 on the first mounted row.

The documented pattern in the README was wrong because of it. It counted matches
across all rows with `findRanges`, then fed that document-wide cursor straight
back as `activeIndex`, which native read as an index over the painted rows:

    hits [{"t":"fox 50","a":false},{"t":"fox 51","a":false},
          {"t":"fox 52","a":false},{"t":"fox 53","a":false}]

`activeIndex: 52` over a window starting at row 50 marked nothing at all. Shift
the window and it marks an arbitrary visible row instead. There was no test for
`highlight` together with `<virtual-list>`, which is why it shipped.

indexOffset
-----------

The app owns the window, so the app says where it starts. `indexOffset` is the
number of matches above the mounted rows, and the per-frame ordinal cursor begins
there instead of at 0:

    document                    mounted window        paint
    match 0..49   above the window, indexOffset = 50
    match 50   ─────────────►   row 50   ─────────►   ordinal 50
    match 51   ─────────────►   row 51   ─────────►   ordinal 51
    match 52   ─────────────►   row 52   ─────────►   ordinal 52  <- active
    match 53   ─────────────►   row 53   ─────────►   ordinal 53
    match 54..  below, never built

This is the same contract `<virtual-list>` already has with `windowStart` and
`itemCount`: native cannot know what it never built, so the app tells it.

A cursor outside the window now marks nothing, which is correct. It used to mark
whatever row happened to be at that position in the viewport.

`index_offset` is excluded from `matcher_hash`, exactly like `active_index`, so
scrolling a find bar never rescans or re-folds any text.

`useTextSearch` takes it through as an option, next to the `total` it already
accepted for the same reason.

Testing
-------

The React test renders a 1000-item list with `windowStart={50}`, scrolls to row
50 so the rows really paint, and asserts `activeIndex: 52` lands on "fox 52".
Without the offset that assertion finds no active wash at all. A second case
asserts a cursor past the window marks nothing.

Three native tests cover the offset shifting the whole sequence, a cursor above
the window, and `matcher_hash` ignoring the offset.

The `highlight` prop has not been released, so this updates its pending changeset
rather than adding a fix entry for an API nobody has.

Session: ses_fc15ed901ffedvtR2Z3cCZPIAR
This commit is contained in:
Tommy D. Rossi committed 2026-08-26 17:36:18 +02:00
1 parent 12fb3449c7
commit 8aef4384aa
7 files changed
+189 -27

No files matched your search

+15 -9
View File
@@ -47,26 +47,32 @@ const search = useTextSearch({ query })
| `ranges` | explicit `[start, end)` UTF-16 pairs |
| `color` / `activeColor` | any CSS colour; defaults come from the theme |
| `activeIndex` | which match gets `activeColor`, for a find cursor |
| `indexOffset` | matches before this subtree; only for virtualized content |
| `radius` | corner radius of the wash, default 2 |
Pass an array to paint several at once. Later entries draw on top.
**Matching rules.** Matches are non-overlapping and leftmost-first. A match never crosses a line, exactly like browser find. It does cross the several host nodes React creates for one interpolated line, which matters more than it sounds: `<text>Hello {name}!</text>` is three separate host text nodes, and `Hello Tommy` still matches. The nearest declaration wins, so a nested `highlight` replaces its ancestor's for that subtree.
**Counting in a virtual list.** `<virtual-list>` never builds off-screen rows, so native cannot count or scroll to a match that was never painted. `onHighlight` counts retained text only. The new `findRanges` export is the same matcher in JS, so you can count your own row data without the two ever disagreeing:
**Searching a virtual list.** `<virtual-list>` never builds off-screen rows, so native only ever sees the mounted window. Two things follow, and both are the app's job because the app owns the row data.
Count the matches yourself with the new `findRanges` export, which is the same matcher in JS, so the two can never disagree. Then say where your window starts with `indexOffset`, the number of matches above it. Without that, native numbers the mounted rows from zero, `activeIndex` means "the nth visible match", and the find cursor lands on the wrong row.
```tsx
import { findRanges, useTextSearch } from '@gpuix/react'
const hits = useMemo(
() =>
rows.flatMap((row, i) =>
findRanges({ text: row.text, query }).length > 0 ? [i] : [],
),
// One entry per row, so a prefix sum gives both numbers.
const perRow = useMemo(
() => rows.map((row) => findRanges({ text: row.text, query }).length),
[rows, query],
)
const search = useTextSearch({ query, total: hits.length })
listRef.current.scrollToItem(hits[search.active])
const search = useTextSearch({
query,
total: perRow.reduce((n, count) => n + count, 0),
indexOffset: perRow.slice(0, windowStart).reduce((n, count) => n + count, 0),
})
listRef.current.scrollToItem(rowOfMatch(search.active))
```
**Testing.** A highlight is a quad, so `getPaintedText()` cannot see it. The new `renderer.getPaintedHighlights()` reports the matched range in UTF-16 units plus the boxes it drew, one per visual row:
@@ -81,4 +87,4 @@ expect(hit.rects).toHaveLength(1)
**Matching contract.** Unicode default lowercasing, not full case folding, so `ff` does not match `ff`. A word boundary is any code point that is not Unicode Alphabetic, a digit, or `_`. Both the native matcher and `findRanges` follow the same rules.
`activeIndex` counts matches in document order, in the order they paint, so it means the same thing whether a match sits in a `<text>` or inside a `<code>` block.
`activeIndex` counts matches in document order, in the order they paint, so it means the same thing whether a match sits in a `<text>` or inside a `<code>` block. Virtualized content is the one case paint cannot see on its own, which is what `indexOffset` is for.
+7
View File
@@ -206,6 +206,13 @@ build-time index so a match split over several interpolated runs takes exactly
one ordinal. The `assigned` memo makes a row gpui paints twice keep its numbers
rather than advance the cursor again.
Paint only sees what is mounted, so a **virtualized subtree must say where it
starts**. `indexOffset` is the number of matches above the window, and the
sequence begins there. Without it `activeIndex` silently means "the nth visible
match" and a find cursor lands on the wrong row. `<virtual-list>` already takes
`windowStart` and `itemCount` from the app for the same reason. It is excluded
from `matcher_hash`, like `activeIndex`, so scrolling never rescans text.
`onHighlight` is queued during build and flushed **after** the root build
returns, keyed on `MatchSet::identity()` rather than the count. Emitting inline
lets a `setState` in the handler re-enter the build; keying on the count alone
+26 -14
View File
@@ -1427,6 +1427,7 @@ for those.
| `ranges` | explicit `[start, end)` UTF-16 pairs |
| `color` / `activeColor` | any CSS colour; defaults come from the theme |
| `activeIndex` | which match gets `activeColor`, for a find cursor |
| `indexOffset` | matches before this subtree; only for virtualized content |
| `radius` | corner radius of the wash, default 2 |
Pass an **array** to paint several at once, for example search matches plus a
@@ -1458,30 +1459,41 @@ text, so GPUIX still highlights it. Only element chrome, a code gutter or a diff
file header, is excluded.
<details>
<summary>Counting matches in a virtual list</summary>
<summary>Searching a virtual list</summary>
`<virtual-list>` never builds off-screen rows, so native cannot count or scroll
to a match that was never painted. The `onHighlight` count covers retained text
only. Your app owns the row data, so count there and pass the result in:
`<virtual-list>` never builds off-screen rows, so native can only see the
mounted window. Two things follow, and both are the app's job because the app
owns the row data.
**Count the matches yourself** with `findRanges`, which follows the same
contract as the native matcher. Pass the result as `total`.
**Say where your window starts** with `indexOffset`, the number of matches above
it. Without it native numbers the mounted rows from zero, `activeIndex` means
"the nth visible match", and the find cursor lands on the wrong row.
```tsx
import { findRanges, useTextSearch } from '@gpuix/react'
const hits = useMemo(
() =>
rows.flatMap((row, i) =>
findRanges({ text: row.text, query }).length > 0 ? [i] : [],
),
// One entry per row, so a prefix sum gives both numbers.
const perRow = useMemo(
() => rows.map((row) => findRanges({ text: row.text, query }).length),
[rows, query],
)
const search = useTextSearch({ query, total: hits.length })
const above = useMemo(
() => perRow.slice(0, windowStart).reduce((n, count) => n + count, 0),
[perRow, windowStart],
)
const search = useTextSearch({
query,
total: perRow.reduce((n, count) => n + count, 0),
indexOffset: above,
})
// search.next() moves the cursor; you do the scrolling
listRef.current.scrollToItem(hits[search.active])
listRef.current.scrollToItem(rowOfMatch(search.active))
```
`findRanges` follows the same contract as the native matcher, so the two agree
about what counts.
</details>
<details>
+65 -3
View File
@@ -34,6 +34,15 @@ pub struct HighlightSpec {
pub color: Hsla,
pub active_color: Hsla,
pub active_index: Option<usize>,
/// Matches that come before this subtree in the caller's document, so the
/// nth match here is numbered `index_offset + n`.
///
/// A `<virtual-list>` mounts a window of its rows, so nothing native can
/// see the matches above it. Without this, `activeIndex` would silently
/// mean "the nth match in the window" and a find cursor would land on the
/// wrong row. `<virtual-list>` already takes `windowStart` and `itemCount`
/// from the app for the same reason.
pub index_offset: usize,
pub radius: f32,
}
@@ -58,8 +67,9 @@ impl HighlightSet {
(!specs.is_empty()).then_some(Self { specs })
}
/// Key for the match cache. Deliberately excludes `active_index`, `color`
/// and `active_color`: moving a find-bar cursor must not rescan any text.
/// Key for the match cache. Deliberately excludes `active_index`,
/// `index_offset`, `color` and `active_color`: moving a find-bar cursor
/// must not rescan any text.
pub fn matcher_hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
@@ -126,6 +136,10 @@ impl HighlightSpec {
.get("activeIndex")
.and_then(serde_json::Value::as_u64)
.map(|n| n as usize),
index_offset: object
.get("indexOffset")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as usize,
radius: object
.get("radius")
.and_then(serde_json::Value::as_f64)
@@ -279,7 +293,10 @@ fn wash(
if let Some(assigned) = state.assigned.get(&slot) {
return *assigned;
}
let next = state.cursor.entry((slot.0, slot.1)).or_insert(0);
let next = state
.cursor
.entry((slot.0, slot.1))
.or_insert(spec.index_offset);
let ordinal = *next;
*next += 1;
state.assigned.insert(slot, ordinal);
@@ -673,6 +690,7 @@ mod tests {
color: gpui::rgba(0xff000080).into(),
active_color: gpui::rgba(0x00ff0080).into(),
active_index: None,
index_offset: 0,
radius: 2.0,
}
}
@@ -1032,6 +1050,50 @@ mod tests {
assert!(first[0].active && second[0].active, "one match, one ordinal");
}
/// A virtualized subtree holds a window of the document, so the app says
/// how many matches came before it and `activeIndex` keeps meaning "the
/// nth match in the document".
#[test]
fn index_offset_shifts_the_whole_sequence() {
let groups = GroupList::collect(&interpolated_tree(), 1);
let mut s = spec("l");
s.index_offset = 50;
s.active_index = Some(51);
let ctx = context(&groups, HighlightSet { specs: vec![s] });
let painted = paint(&ctx, &groups);
let washes = &painted[&Arc::<str>::from("3:0")];
assert_eq!(washes.len(), 2);
assert!(!washes[0].active, "ordinal 50");
assert!(washes[1].active, "ordinal 51");
}
/// A cursor outside the mounted window paints no active wash at all,
/// rather than marking an arbitrary visible match.
#[test]
fn a_cursor_above_the_window_marks_nothing() {
let groups = GroupList::collect(&interpolated_tree(), 1);
let mut s = spec("l");
s.index_offset = 50;
s.active_index = Some(9);
let ctx = context(&groups, HighlightSet { specs: vec![s] });
assert!(paint(&ctx, &groups)
.values()
.flatten()
.all(|wash| !wash.active));
}
#[test]
fn matcher_hash_ignores_the_index_offset() {
let a = spec("foo");
let mut b = spec("foo");
b.index_offset = 400;
assert_eq!(
HighlightSet { specs: vec![a] }.matcher_hash(),
HighlightSet { specs: vec![b] }.matcher_hash()
);
}
#[test]
fn identity_ignores_the_find_cursor_but_not_the_query() {
let groups = GroupList::collect(&interpolated_tree(), 1);
@@ -318,6 +318,61 @@ describe("highlight", () => {
)
expect(matched(renderer)).toEqual(["needle"])
})
// A `<virtual-list>` mounts a window of its rows, so native can only number
// the matches inside that window. `indexOffset` is how the app says how many
// matches came before it, which is what keeps `activeIndex` meaning "the nth
// match in the document".
it("numbers matches from indexOffset in a windowed virtual list", () => {
const { render, renderer } = createTestRoot()
const windowed = (start: number, activeIndex: number) => (
<div
style={{ width: 400, height: 160 }}
highlight={{ query: "fox", activeIndex, indexOffset: start }}
>
<virtual-list
itemCount={1000}
windowStart={start}
overdraw={0}
estimatedItemHeight={40}
style={{ width: 400, height: 160 }}
>
{Array.from({ length: 4 }, (_, offset) => (
<div key={start + offset} style={{ height: 40, flexShrink: 0 }}>
<text>{`fox ${start + offset}`}</text>
</div>
))}
</virtual-list>
</div>
)
// One match per row, so the row index is also the document match index.
render(windowed(50, 52))
renderer.scrollToItem(renderer.findByType("virtual-list")[0].id, 50)
expect(renderer.getPaintedText()).toContain("fox 50")
const active = renderer.getPaintedHighlights().filter((hit) => hit.active)
expect(active).toHaveLength(1)
expect(active[0].text).toBe("fox 52")
// A cursor outside the mounted window paints no active wash at all.
render(windowed(50, 900))
expect(renderer.getPaintedHighlights().filter((hit) => hit.active)).toEqual([])
})
it("leaves activeIndex zero-based when there is no indexOffset", () => {
const { render, renderer } = createTestRoot()
render(
<div highlight={{ query: "fox", activeIndex: 1, indexOffset: 0 }}>
<text>fox fox fox</text>
</div>
)
expect(renderer.getPaintedHighlights().map((hit) => hit.active)).toEqual([
false,
true,
false,
])
})
})
describe("useTextSearch", () => {
+11 -1
View File
@@ -22,6 +22,14 @@ export interface TextSearchOptions {
* replaces the count reported by native, which only counts retained text.
*/
total?: number
/**
* Matches that come before the searched subtree, for virtualized content.
*
* A `<virtual-list>` mounts a window of its rows, so native numbers only what
* that window holds. Pass the number of matches above it and the find cursor
* lands on the right row. Ignored when `total` is unset.
*/
indexOffset?: number
}
export interface TextSearch {
@@ -53,6 +61,7 @@ export function useTextSearch(options: TextSearchOptions): TextSearch {
activeColor,
radius,
total: suppliedTotal,
indexOffset,
} = options
const [reported, setReported] = useState(0)
const [requested, setRequested] = useState(0)
@@ -74,8 +83,9 @@ export function useTextSearch(options: TextSearchOptions): TextSearch {
activeColor,
radius,
activeIndex: active,
indexOffset,
}
}, [query, caseSensitive, wholeWord, color, activeColor, radius, active])
}, [query, caseSensitive, wholeWord, color, activeColor, radius, active, indexOffset])
const onHighlight = useCallback((event: EventPayload) => {
setReported(event.matchCount ?? 0)
+10
View File
@@ -318,6 +318,16 @@ export interface HighlightSpec {
activeColor?: string
/** Index of the match to highlight differently, for a find-bar cursor. */
activeIndex?: number
/**
* Number of matches that come before this subtree in your document.
* `activeIndex` is compared against `indexOffset + n` for the nth match here.
*
* Only needed for virtualized content: a `<virtual-list>` mounts a window of
* its rows, so native can only number what that window contains. Count the
* matches in the rows before `windowStart` with `findRanges` and pass the
* total. Defaults to 0.
*/
indexOffset?: number
/** Corner radius of the wash. Defaults to 2. */
radius?: number
}