Skip to main content

eui_core/
list.rs

1//! The WINDOWED `List`: the core lays out only the visible range plus
2//! overscan, reports the content extent, and memoizes item extents per key
3//! so a scroll step is constant work.
4//!
5//! Round 2 (docs/round-2-contract.md §4–5, `src/list.ts`): the LIST numbers
6//! every renderer shares — template item keys and instance suffixes
7//! (duplicates and empty keys → `#<index>`, suffixes ACCUMULATE through
8//! nested templates), Table row keys, the one-axis virtual window
9//! (vertical OR horizontal), `scrollToIndex` offsets, sections and the
10//! sticky header. `fixtures/template-items.json` and
11//! `fixtures/virtual-list.json` lock them.
12
13use std::collections::HashSet;
14use std::sync::Arc;
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use crate::data::{absolute_path, get_pointer};
20
21/// The extent an unmeasured item takes when no theme says `$control.row`.
22pub const DEFAULT_ESTIMATED_ITEM_HEIGHT: f32 = 40.0;
23/// Items rendered beyond each viewport edge (`layout.json windowOverscan`).
24pub const DEFAULT_OVERSCAN: usize = crate::layout::WINDOW_OVERSCAN as usize;
25/// Lists and Tables with more items than this window (`layout.json
26/// windowThreshold`).
27pub const WINDOW_THRESHOLD: usize = crate::layout::WINDOW_THRESHOLD as usize;
28
29// ---------------------------------------------------------------------------
30// Keys and instances (round 2, §4)
31// ---------------------------------------------------------------------------
32
33/// `JSON.stringify` of a value (numbers the JS way).
34fn js_json(v: &Value) -> String {
35    serde_json::to_string(&crate::json::canonical(v)).unwrap_or_default()
36}
37
38/// A key value as a string: scalars like JS `String(v)`, objects and arrays
39/// as their JSON; `None` for missing, null and ``.
40fn key_string(v: Option<&Value>) -> Option<String> {
41    match v {
42        None | Some(Value::Null) => None,
43        Some(Value::String(s)) if s.is_empty() => None,
44        Some(v @ (Value::Object(_) | Value::Array(_))) => Some(js_json(v)),
45        Some(v) => Some(crate::json::to_js_string(v)),
46    }
47}
48
49/// A key as one instance-suffix segment: `~` → `~0`, `.` → `~1` (as JSON
50/// pointer escapes `/`), so dotted keys never collide across nesting levels
51/// (`a.b` + `c` vs `a` + `b.c`). The reference's `instanceSegment`.
52pub fn instance_segment(key: &str) -> String {
53    if !key.contains(['~', '.']) {
54        return key.to_string();
55    }
56    key.replace('~', "~0").replace('.', "~1")
57}
58
59/// The instance key of every item: the value at `key_pointer` (relative to
60/// the item; objects as their JSON) — `#<index>` when that value is
61/// missing, null, `` or a DUPLICATE of an earlier item's key (more `#`s
62/// until unique); without a pointer, the index. `templateItemKeys`.
63pub fn template_item_keys(items: &[Value], key_pointer: Option<&str>) -> Vec<String> {
64    // VAPP-132: every item's own key first; when they are all present and
65    // distinct (the usual case) that IS the answer, without the sequential
66    // walk's set of owned keys.
67    if let Some(p) = key_pointer {
68        let pointer = if p.starts_with('/') { std::borrow::Cow::Borrowed(p) } else { std::borrow::Cow::Owned(format!("/{p}")) };
69        let own: Option<Vec<String>> = items.iter().map(|item| key_string(get_pointer(item, &pointer))).collect();
70        if let Some(own) = own {
71            let mut distinct: rustc_hash::FxHashSet<&str> = rustc_hash::FxHashSet::with_capacity_and_hasher(own.len(), Default::default());
72            if own.iter().all(|k| distinct.insert(k.as_str())) {
73                drop(distinct);
74                return own;
75            }
76        }
77    }
78    let mut seen: HashSet<String> = HashSet::with_capacity(items.len());
79    let pointer = key_pointer.map(|k| if k.starts_with('/') { k.to_string() } else { format!("/{k}") });
80    items
81        .iter()
82        .enumerate()
83        .map(|(index, item)| {
84            let mut key = index.to_string();
85            if let Some(p) = &pointer {
86                key = match key_string(get_pointer(item, p)) {
87                    Some(own) if !seen.contains(&own) => own,
88                    _ => format!("#{index}"),
89                };
90                while seen.contains(&key) {
91                    key = format!("#{key}");
92                }
93            }
94            seen.insert(key.clone());
95            key
96        })
97        .collect()
98}
99
100/// Table row keys: the `row_key` FIELD (default `id`) under the same rules.
101pub fn table_row_keys(rows: &[Value], row_key: &str) -> Vec<String> {
102    let token = row_key.replace('~', "~0").replace('/', "~1");
103    template_item_keys(rows, Some(&format!("/{token}")))
104}
105
106/// One item a template renders.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108pub struct TemplateInstance {
109    /// The item's key ([`template_item_keys`]).
110    pub key: String,
111    /// The item's data pointer (its scope).
112    pub path: String,
113    pub index: usize,
114    /// The suffix every node of the item wears: the enclosing instance's
115    /// suffix + `.<key>` ([`instance_segment`]: `.a~1b` for key `a.b`). A
116    /// node's painted id = its id + the suffix.
117    pub instance: String,
118}
119
120/// The items a `template` renders in a data scope: one per array item at
121/// `template.path` (relative to `scope`), keyed by `template.key`;
122/// `instance` = the suffix of the item the template sits in (`` at the top).
123/// The key is escaped by [`instance_segment`].
124pub fn template_instances(data: &Value, template: &crate::types::Template, scope: &str, instance: &str) -> Vec<TemplateInstance> {
125    let path = absolute_path(&template.path, scope);
126    let Some(list) = get_pointer(data, &path).and_then(Value::as_array) else { return Vec::new() };
127    let keys = template_item_keys(list, template.key.as_deref());
128    keys.into_iter().enumerate().map(|(index, key)| TemplateInstance { path: format!("{path}/{index}"), index, instance: format!("{instance}.{}", instance_segment(&key)), key }).collect()
129}
130
131// ---------------------------------------------------------------------------
132// The virtual window (round 2, §5; one axis)
133// ---------------------------------------------------------------------------
134
135/// Every item's extent on the list's axis: a measured item (`Some`) keeps
136/// its extent; an unmeasured one takes the MEAN measured extent once any
137/// item is measured (the content length stays stable while rows measure in),
138/// else `row` (the theme's `$control.row`). [`ListWindow`] applies the same
139/// rule. Mirrors `itemExtents` (round 2 §5, `virtual-list.json`).
140pub fn item_extents(measured: &[Option<f64>], row: f64) -> Vec<f64> {
141    let (sum, n) = measured.iter().flatten().fold((0.0, 0usize), |(s, n), m| (s + m, n + 1));
142    let estimate = if n > 0 { sum / n as f64 } else { row };
143    measured.iter().map(|m| m.unwrap_or(estimate)).collect()
144}
145
146/// Start offsets of items laid end to end with `gap` between them (a
147/// `divided` list adds `$control.hairline` to the gap): `offsets[i]` =
148/// where item i starts, `offsets[count]` = the content length.
149pub fn item_offsets(extents: &[f64], gap: f64) -> Vec<f64> {
150    let mut out = Vec::with_capacity(extents.len() + 1);
151    let mut at = 0.0;
152    for (i, e) in extents.iter().enumerate() {
153        out.push(at);
154        at += e + if i + 1 < extents.len() { gap } else { 0.0 };
155    }
156    out.push(at);
157    out
158}
159
160/// The window over a list: items `start..end` render, `before`/`after` =
161/// the spacers, `total` = the content length.
162#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
163pub struct WindowRange {
164    pub start: usize,
165    pub end: usize,
166    pub before: f64,
167    pub after: f64,
168    pub total: f64,
169}
170
171/// The items to render for a viewport `[scroll, scroll + viewport)` on the
172/// list's axis (relative to its content start): every item intersecting it
173/// (an item ending exactly at `scroll` counts) plus `overscan` on each
174/// side. `virtualWindow`.
175pub fn virtual_window(extents: &[f64], gap: f64, scroll: f64, viewport: f64, overscan: usize) -> WindowRange {
176    let count = extents.len();
177    let off = item_offsets(extents, gap);
178    let total = off[count];
179    // Offsets are monotonic: binary searches keep a 100,000-row window O(log n).
180    let (mut lo, mut hi) = (0, count);
181    while lo < hi {
182        let mid = (lo + hi) / 2;
183        if off[mid] + extents[mid] < scroll {
184            lo = mid + 1;
185        } else {
186            hi = mid;
187        }
188    }
189    let start = lo;
190    let end = start + off[start..count].partition_point(|&o| o < scroll + viewport);
191    let start = start.saturating_sub(overscan);
192    let end = (end + overscan).min(count);
193    if end <= start {
194        return WindowRange { start, end: start, before: 0.0, after: total, total };
195    }
196    WindowRange { start, end, before: off[start], after: total - (off[end - 1] + extents[end - 1]), total }
197}
198
199/// How `scrollToIndex` aligns the item.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum ScrollAlign {
203    Start,
204    Center,
205    End,
206    #[default]
207    Nearest,
208}
209
210impl ScrollAlign {
211    pub fn parse(s: &str) -> Option<ScrollAlign> {
212        Some(match s {
213            "start" => ScrollAlign::Start,
214            "center" => ScrollAlign::Center,
215            "end" => ScrollAlign::End,
216            "nearest" => ScrollAlign::Nearest,
217            _ => return None,
218        })
219    }
220}
221
222/// The scroll offset that brings item `index` into a viewport at `scroll`
223/// (`scrollToIndex`): `start` = its start at the viewport start after
224/// `inset` (the pinned header), `end`, `center`, `nearest` = the smallest
225/// move (none when visible). Clamped to `[0, max(0, total - viewport)]`.
226#[allow(clippy::too_many_arguments)]
227pub fn scroll_offset_for_index(extents: &[f64], gap: f64, index: usize, viewport: f64, scroll: f64, align: ScrollAlign, inset: f64) -> f64 {
228    if index >= extents.len() {
229        return scroll;
230    }
231    let off = item_offsets(extents, gap);
232    scroll_offset_at(off[index], extents[index], off[extents.len()], viewport, scroll, align, inset)
233}
234
235/// [`scroll_offset_for_index`] for an item at `top` with extent `size` in
236/// content of length `total` (O(1) over cached offsets).
237pub fn scroll_offset_at(top: f64, size: f64, total: f64, viewport: f64, scroll: f64, align: ScrollAlign, inset: f64) -> f64 {
238    let next = match align {
239        ScrollAlign::Start => top - inset,
240        ScrollAlign::End => top + size - viewport,
241        ScrollAlign::Center => top + size / 2.0 - (viewport + inset) / 2.0,
242        ScrollAlign::Nearest if top - inset < scroll => top - inset,
243        ScrollAlign::Nearest if top + size > scroll + viewport => top + size - viewport,
244        ScrollAlign::Nearest => scroll,
245    };
246    next.min((total - viewport).max(0.0)).max(0.0)
247}
248
249// ---------------------------------------------------------------------------
250// Sections and sticky headers
251// ---------------------------------------------------------------------------
252
253/// Consecutive items sharing a `sectionBy` value.
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct ListSection {
256    /// The shared value as a string (`` for missing).
257    pub value: String,
258    /// Index of the section's first item in the data.
259    pub start: usize,
260    pub count: usize,
261}
262
263/// Consecutive items with the same `section_by` value (a pointer relative
264/// to each item) form a section; the data order is kept.
265pub fn list_sections(items: &[Value], section_by: &str) -> Vec<ListSection> {
266    let pointer = if section_by.starts_with('/') { section_by.to_string() } else { format!("/{section_by}") };
267    let mut out: Vec<ListSection> = Vec::new();
268    for (index, item) in items.iter().enumerate() {
269        let value = match get_pointer(item, &pointer) {
270            None | Some(Value::Null) => String::new(),
271            Some(v @ (Value::Object(_) | Value::Array(_))) => js_json(v),
272            Some(v) => crate::json::to_js_string(v),
273        };
274        match out.last_mut() {
275            Some(last) if last.value == value => last.count += 1,
276            _ => out.push(ListSection { value, start: index, count: 1 }),
277        }
278    }
279    out
280}
281
282/// A flat row of a sectioned list: a section header or a data item.
283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "lowercase")]
285pub enum SectionRow {
286    Header(usize),
287    Item(usize),
288}
289
290/// The flat ROW list a sectioned list windows: a header before each
291/// section's items.
292pub fn section_rows(sections: &[ListSection]) -> Vec<SectionRow> {
293    let mut rows = Vec::with_capacity(sections.iter().map(|s| s.count + 1).sum());
294    for (i, s) in sections.iter().enumerate() {
295        rows.push(SectionRow::Header(i));
296        rows.extend((0..s.count).map(|k| SectionRow::Item(s.start + k)));
297    }
298    rows
299}
300
301/// `scrollToIndex` on a SECTIONED list: the DATA index → its flat row
302/// ([`section_rows`]; absent → `scroll`, no move), then
303/// [`scroll_offset_for_index`] over the rows' extents with `inset` = the
304/// extent of the item's own section header when headers are sticky (it
305/// pins over the item), else 0. The reference's `scrollOffsetForItem`.
306#[allow(clippy::too_many_arguments)]
307pub fn scroll_offset_for_item(rows: &[SectionRow], row_extents: &[f64], gap: f64, data_index: usize, viewport: f64, scroll: f64, align: ScrollAlign, sticky_headers: bool) -> f64 {
308    let Some(row) = rows.iter().position(|r| *r == SectionRow::Item(data_index)) else { return scroll };
309    let header = rows[..row].iter().rposition(|r| matches!(r, SectionRow::Header(_)));
310    let inset = match header {
311        Some(h) if sticky_headers => row_extents.get(h).copied().unwrap_or(0.0),
312        _ => 0.0,
313    };
314    scroll_offset_for_index(row_extents, gap, row, viewport, scroll, align, inset)
315}
316
317/// The pinned header at a scroll offset: `row` (a flat row index) drawn at
318/// `offset` on the list's axis.
319#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
320pub struct StickyHeader {
321    pub row: usize,
322    pub offset: f64,
323}
324
325/// The LAST header row starting at or before `scroll` pins at the viewport
326/// start, pushed back by the next header (`min(scroll, nextStart −
327/// extent)`). `row_offsets` = [`item_offsets`] of the flat rows,
328/// `header_rows` ascending. `None` before the first header.
329pub fn sticky_header(row_offsets: &[f64], row_extents: &[f64], header_rows: &[usize], scroll: f64) -> Option<StickyHeader> {
330    sticky_header_by(|r| row_offsets[r], |r| row_extents[r], header_rows, scroll)
331}
332
333/// [`sticky_header`] over accessors: only the header rows are read
334/// (binary search; O(log headers)).
335pub fn sticky_header_by(offset: impl Fn(usize) -> f64, extent: impl Fn(usize) -> f64, header_rows: &[usize], scroll: f64) -> Option<StickyHeader> {
336    let pinned = header_rows.partition_point(|&h| offset(h) <= scroll);
337    let pinned = pinned.checked_sub(1)?;
338    let row = header_rows[pinned];
339    let at = match header_rows.get(pinned + 1) {
340        Some(&next) => scroll.min(offset(next) - extent(row)),
341        None => scroll,
342    };
343    Some(StickyHeader { row, offset: at })
344}
345
346#[derive(Debug, Clone)]
347pub struct ListWindow {
348    /// Measured (laid out) heights per item key.
349    heights: rustc_hash::FxHashMap<String, f32>,
350    /// The sum of the measured heights: unmeasured rows are estimated at the
351    /// AVERAGE measured height (the estimate only until the first pass).
352    measured_sum: f64,
353    pub estimated_item_height: f32,
354    pub overscan: usize,
355    pub gap: f32,
356    pub scroll_offset: f32,
357    /// Bumped whenever an offset may move (a height recorded, the gap).
358    version: u64,
359    /// The offsets last computed.
360    cache: Option<OffsetsCache>,
361}
362
363/// Offsets memoized for one keys list (by identity) at one version + gap.
364#[derive(Debug, Clone)]
365struct OffsetsCache {
366    keys: usize,
367    len: usize,
368    version: u64,
369    gap: f32,
370    estimate: f32,
371    offsets: Arc<Vec<f32>>,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
375pub struct VisibleRange {
376    /// First rendered index (overscan included).
377    pub start: usize,
378    /// One past the last rendered index.
379    pub end: usize,
380}
381
382impl ListWindow {
383    pub fn new(estimated_item_height: f32, overscan: usize, gap: f32) -> ListWindow {
384        ListWindow { heights: Default::default(), measured_sum: 0.0, estimated_item_height, overscan, gap, scroll_offset: 0.0, version: 0, cache: None }
385    }
386
387    /// Remember one row's laid-out height. Returns true when it changed.
388    pub fn record(&mut self, key: &str, height: f32) -> bool {
389        match self.heights.get(key) {
390            Some(h) if (h - height).abs() < 0.001 => false,
391            old => {
392                self.measured_sum += (height - old.copied().unwrap_or(0.0)) as f64;
393                self.heights.insert(key.to_string(), height);
394                self.version += 1;
395                true
396            }
397        }
398    }
399
400    /// The extent of an unmeasured item ([`item_extents`]): the mean
401    /// measured extent once any item was measured (keeps the scroll extent
402    /// stable, so a fling lands on the real end in one pass), else
403    /// `estimated_item_height` (the theme's `$control.row`, round 2 §5).
404    pub fn estimate(&self) -> f32 {
405        self.measured_mean().unwrap_or(self.estimated_item_height)
406    }
407
408    /// The mean measured extent (`None` before the first pass).
409    pub fn measured_mean(&self) -> Option<f32> {
410        (!self.heights.is_empty()).then(|| (self.measured_sum / self.heights.len() as f64) as f32)
411    }
412
413    /// Every item's extent in key order (measured, else the estimate).
414    pub fn extents<'a>(&self, keys: impl Iterator<Item = &'a str>) -> Vec<f64> {
415        let estimate = self.estimate();
416        keys.map(|k| self.heights.get(k).copied().unwrap_or(estimate) as f64).collect()
417    }
418
419    pub fn height_of(&self, key: &str) -> f32 {
420        self.heights.get(key).copied().unwrap_or_else(|| self.estimate())
421    }
422
423    pub fn forget(&mut self, key: &str) {
424        if let Some(h) = self.heights.remove(key) {
425            self.measured_sum -= h as f64;
426            self.version += 1;
427        }
428    }
429
430    pub fn clear(&mut self) {
431        self.heights.clear();
432        self.measured_sum = 0.0;
433        self.version += 1;
434    }
435
436    /// [`Self::offsets`] memoized per keys list (by identity) until a height
437    /// or the gap changes: a scroll step reuses them (O(log n) per step).
438    pub fn offsets_cached(&mut self, keys: &Arc<Vec<String>>) -> Arc<Vec<f32>> {
439        let id = Arc::as_ptr(keys) as usize;
440        if let Some(c) = &self.cache {
441            if c.keys == id && c.len == keys.len() && c.version == self.version && c.gap == self.gap && c.estimate == self.estimate() {
442                return c.offsets.clone();
443            }
444        }
445        let offsets = Arc::new(self.offsets(keys.iter().map(String::as_str)));
446        self.cache = Some(OffsetsCache { keys: id, len: keys.len(), version: self.version, gap: self.gap, estimate: self.estimate(), offsets: offsets.clone() });
447        offsets
448    }
449
450    /// The pinned header ([`sticky_header`]) on the memoized offsets
451    /// ([`Self::offsets_cached`]): a row's extent = the next offset − the
452    /// gap, so nothing per row is recomputed. Header rows past the keys are
453    /// ignored; `header_rows` ascending. Returns the pin and the row's own
454    /// offset.
455    pub fn sticky_header_cached(&mut self, keys: &Arc<Vec<String>>, header_rows: &[usize], scroll: f64) -> Option<(StickyHeader, f64)> {
456        let offsets = self.offsets_cached(keys);
457        let count = keys.len();
458        let gap = self.gap as f64;
459        let headers = &header_rows[..header_rows.partition_point(|&h| h < count)];
460        let offset = |r: usize| offsets[r] as f64;
461        let extent = |r: usize| offsets[r + 1] as f64 - offsets[r] as f64 - if r + 1 < count { gap } else { 0.0 };
462        sticky_header_by(offset, extent, headers, scroll).map(|pin| (pin, offset(pin.row)))
463    }
464
465    /// Offsets of every row's top plus the total height at the end.
466    /// Linear in the row count (one pass, no allocation per row beyond the
467    /// output); the surface calls it once per layout pass.
468    pub fn offsets<'a>(&self, keys: impl Iterator<Item = &'a str>) -> Vec<f32> {
469        let mut out = Vec::new();
470        let mut y = 0.0f32;
471        let mut first = true;
472        let estimate = self.estimate();
473        for key in keys {
474            if !first {
475                y += self.gap;
476            }
477            first = false;
478            out.push(y);
479            y += self.heights.get(key).copied().unwrap_or(estimate);
480        }
481        out.push(y);
482        out
483    }
484
485    /// Total content height for `count` rows (estimated where unmeasured).
486    pub fn content_height<'a>(&self, keys: impl Iterator<Item = &'a str>) -> f32 {
487        self.offsets(keys).last().copied().unwrap_or(0.0)
488    }
489
490    /// The items to lay out for a viewport of `viewport` (on the list's
491    /// axis) at the current scroll offset, overscan on both ends: every item
492    /// intersecting `[scroll, scroll + viewport)` (an item ending exactly at
493    /// `scroll` counts), [`virtual_window`] on memoized offsets, O(log n).
494    /// `offsets` = [`Self::offsets`].
495    pub fn visible_range(&self, offsets: &[f32], viewport: f32) -> VisibleRange {
496        let count = offsets.len().saturating_sub(1);
497        if count == 0 {
498            return VisibleRange::default();
499        }
500        let top = self.scroll_offset.max(0.0);
501        let bottom = top + viewport.max(0.0);
502        // Item i ends at offsets[i + 1] - gap (the last at offsets[count]).
503        let end_of = |i: usize| if i + 1 < count { offsets[i + 1] - self.gap } else { offsets[count] };
504        let (mut lo, mut hi) = (0, count);
505        while lo < hi {
506            let mid = (lo + hi) / 2;
507            if end_of(mid) < top {
508                lo = mid + 1;
509            } else {
510                hi = mid;
511            }
512        }
513        let first = lo;
514        let last = first + offsets[first..count].partition_point(|&o| o < bottom);
515        let start = first.saturating_sub(self.overscan);
516        let end = (last + self.overscan).min(count);
517        VisibleRange { start: start.min(end), end }
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    #[test]
526    fn unmeasured_items_take_row_then_the_measured_mean() {
527        assert_eq!(item_extents(&[None, None], 36.0), vec![36.0, 36.0]);
528        assert_eq!(item_extents(&[Some(40.0), None, Some(60.0)], 36.0), vec![40.0, 50.0, 60.0]);
529        // The window applies the same rule.
530        let keys = ["a", "b", "c"];
531        let mut w = ListWindow::new(36.0, 5, 0.0);
532        assert_eq!(w.extents(keys.iter().copied()), vec![36.0; 3]);
533        w.record("a", 40.0);
534        w.record("c", 60.0);
535        assert_eq!(w.extents(keys.iter().copied()), item_extents(&[Some(40.0), None, Some(60.0)], 36.0));
536    }
537
538    #[test]
539    fn window_is_constant_per_scroll_step() {
540        let keys: Vec<String> = (0..10_000).map(|i| format!("k{i}")).collect();
541        let mut w = ListWindow::new(40.0, 5, 0.0);
542        w.record("k3", 80.0);
543        let offsets = w.offsets(keys.iter().map(String::as_str));
544        assert_eq!(offsets.len(), 10_001);
545        assert_eq!(offsets[10_000], 80.0 * 10_000.0, "unmeasured rows take the measured mean");
546        w.scroll_offset = 0.0;
547        let r = w.visible_range(&offsets, 800.0);
548        assert_eq!(r.start, 0);
549        assert!(r.end <= 20 && r.end >= 10, "{r:?}");
550        w.scroll_offset = 200_000.0;
551        let r = w.visible_range(&offsets, 800.0);
552        assert!(r.end - r.start <= 2 * 5 + 22);
553        assert!(offsets[r.start] <= 200_000.0);
554        assert!(offsets[r.end.min(9_999)] >= 200_000.0 + 800.0 - 40.0 * 6.0 || r.end == 10_000);
555        // The same window as the shared `virtual_window` (round 2).
556        let extents = w.extents(keys.iter().map(String::as_str));
557        let shared = virtual_window(&extents, 0.0, 200_000.0, 800.0, 5);
558        assert_eq!((r.start, r.end), (shared.start, shared.end));
559    }
560}