mirror of
https://github.com/remorses/gpuix.git
synced 2026-10-28 18:59:22 +08:00
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:
1 parent
12fb3449c7
commit
8aef4384aa
7 files changed
+189
-27
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user