Skip to main content

ashlar_ui/
virtual_list.rs

1//! Virtualized list and table: only visible rows are laid out, painted and registered, so a
2//! frame costs O(visible rows) whatever the row count.
3//!
4//! Rows have one uniform height, or variable heights given by a function. Variable heights are
5//! read once into a Fenwick tree (O(rows)) and kept until the row count or the caller's
6//! `revision` changes; rows named in [`VirtualList::changed_rows`] are read again in O(log rows)
7//! each. Each frame then finds the first visible row in O(log rows).
8//!
9//! The list draws hover, selection and stripes, handles the keyboard (arrows, Home/End, Page
10//! Up/Down, Enter) when it has the focus, and calls the app's painter for each visible row. The app
11//! owns the data and the selection; the response says what the user asked for.
12//!
13//! ```
14//! # use ashlar_ui::virtual_list::{RowHeights, VirtualList};
15//! # fn ui(ui: &mut egui::Ui, names: &[String], selected: &mut Option<usize>) {
16//! let out = VirtualList::new("files", names.len(), RowHeights::Uniform(22.0))
17//!     .selected(*selected)
18//!     .show(ui, |ui, row| {
19//!         ui.label(&names[row.index]);
20//!     });
21//! if let Some(i) = out.select {
22//!     *selected = Some(i);
23//! }
24//! # }
25//! ```
26
27use std::ops::Range;
28
29use egui::accesskit::{Role as NodeRole, SortDirection};
30use egui::{Event, Id, Key, Modifiers, Rect, Sense, Stroke, StrokeKind, Ui, UiBuilder, pos2, vec2};
31
32use ashlar_motion::gesture::{VelocityTracker, decay_k, deceleration, project, rubber_band};
33use ashlar_motion::{Class, Curve, tokens as mtokens};
34
35use crate::automation::{self, Details, Role};
36use crate::motion;
37use crate::selection::Selection;
38use crate::tokens::{self, Tokens};
39
40/// Rows beyond this are not shown (an offset table for 10M rows is 80 MB).
41pub const MAX_ROWS: usize = 10_000_000;
42
43/// How tall rows are.
44#[derive(Clone, Copy)]
45pub enum RowHeights<'a> {
46    Uniform(f32),
47    /// `height(i)` for every row; called for all rows only when `revision` or the row count
48    /// changes, and for the rows in [`VirtualList::changed_rows`]. Non-finite or negative heights
49    /// count as 0, heights are capped at 10 000 points.
50    Variable {
51        revision: u64,
52        height: &'a dyn Fn(usize) -> f32,
53    },
54}
55
56fn sane_height(h: f32) -> f64 {
57    if h.is_finite() { h.clamp(0.0, 10_000.0) as f64 } else { 0.0 }
58}
59
60/// Variable row heights and their running sums, in a Fenwick tree: a row's top, the row at a
61/// given y and a change to one row's height each cost O(log rows).
62#[derive(Clone, Default)]
63pub(crate) struct Offsets {
64    heights: Vec<f32>,
65    /// `tree[k]` (1-based) sums the heights of rows `k - lowbit(k) .. k`.
66    tree: Vec<f64>,
67    total: f64,
68    revision: u64,
69}
70
71fn lowbit(k: usize) -> usize {
72    k & k.wrapping_neg()
73}
74
75impl Offsets {
76    /// Read every row's height when the row count or `revision` changed, else only `changed`
77    /// rows. True when anything was read.
78    fn update(&mut self, rows: usize, revision: u64, height: &dyn Fn(usize) -> f32, changed: &[usize]) -> bool {
79        if self.heights.len() == rows && self.revision == revision && self.tree.len() == rows + 1 {
80            let mut any = false;
81            for &row in changed {
82                if let Some(old) = self.heights.get(row).copied() {
83                    let new = sane_height(height(row)) as f32;
84                    if new != old {
85                        self.set(row, old, new);
86                        any = true;
87                    }
88                }
89            }
90            return any;
91        }
92        // two linear passes: every node takes its prefix sum, then (from the top down, so the
93        // lower prefixes are still there) the prefix before its range is taken off
94        self.heights.clear();
95        self.heights.reserve(rows);
96        self.tree.clear();
97        self.tree.reserve(rows + 1);
98        self.tree.push(0.0);
99        let mut sum = 0.0f64;
100        for i in 0..rows {
101            let h = sane_height(height(i));
102            self.heights.push(h as f32);
103            sum += h;
104            self.tree.push(sum);
105        }
106        for k in (1..=rows).rev() {
107            let before = self.tree.get(k - lowbit(k)).copied().unwrap_or(0.0);
108            if let Some(node) = self.tree.get_mut(k) {
109                *node -= before;
110            }
111        }
112        self.total = sum;
113        self.revision = revision;
114        true
115    }
116
117    fn set(&mut self, row: usize, old: f32, new: f32) {
118        if let Some(h) = self.heights.get_mut(row) {
119            *h = new;
120        }
121        let delta = new as f64 - old as f64;
122        let mut k = row + 1;
123        while let Some(node) = self.tree.get_mut(k) {
124            *node += delta;
125            k += lowbit(k);
126        }
127        // sums drift by rounding with each change; the total is read from the tree
128        self.total = self.prefix(self.heights.len());
129    }
130
131    fn rows(&self) -> usize {
132        self.heights.len()
133    }
134
135    /// Sum of the heights of rows `0..i` (row `i`'s top).
136    fn prefix(&self, i: usize) -> f64 {
137        let mut k = i.min(self.heights.len());
138        let mut sum = 0.0;
139        while k > 0 {
140            sum += self.tree.get(k).copied().unwrap_or(0.0);
141            k -= lowbit(k);
142        }
143        sum.max(0.0)
144    }
145
146    fn height(&self, i: usize) -> f64 {
147        self.heights.get(i).copied().unwrap_or(0.0) as f64
148    }
149
150    /// The last row whose top is at or above `y` (rows of zero height are passed over).
151    fn row_at(&self, y: f64) -> usize {
152        let n = self.heights.len();
153        let mut pos = 0usize;
154        let mut rest = y;
155        let mut step = if n == 0 { 0 } else { 1usize << (usize::BITS - 1 - n.leading_zeros()) };
156        while step > 0 {
157            if let Some(&sum) = self.tree.get(pos + step)
158                && sum <= rest
159            {
160                pos += step;
161                rest -= sum;
162            }
163            step >>= 1;
164        }
165        pos
166    }
167}
168
169/// Geometry of the rows (no egui involved): total height, row spans and visible ranges.
170pub(crate) enum Layout<'o> {
171    Uniform { rows: usize, height: f64 },
172    Variable(&'o Offsets),
173}
174
175impl Layout<'_> {
176    fn rows(&self) -> usize {
177        match self {
178            Layout::Uniform { rows, .. } => *rows,
179            Layout::Variable(o) => o.rows(),
180        }
181    }
182    fn total(&self) -> f64 {
183        match self {
184            Layout::Uniform { rows, height } => *rows as f64 * height,
185            Layout::Variable(o) => o.total,
186        }
187    }
188    /// (top, height) of row `i`.
189    fn span(&self, i: usize) -> (f64, f64) {
190        match self {
191            Layout::Uniform { height, .. } => (i as f64 * height, *height),
192            Layout::Variable(o) => (o.prefix(i), o.height(i)),
193        }
194    }
195    /// The row containing `y` (clamped to the rows).
196    fn row_at(&self, y: f64) -> usize {
197        let rows = self.rows();
198        if rows == 0 {
199            return 0;
200        }
201        let i = match self {
202            Layout::Uniform { height, .. } => {
203                if *height > 0.0 {
204                    (y.max(0.0) / height).floor() as usize
205                } else {
206                    0
207                }
208            }
209            Layout::Variable(o) => o.row_at(y),
210        };
211        i.min(rows - 1)
212    }
213    /// Rows intersecting `top..bottom`, plus `overscan` on each side.
214    fn visible(&self, top: f64, bottom: f64, overscan: usize) -> Range<usize> {
215        let rows = self.rows();
216        if rows == 0 || bottom <= top {
217            return 0..0;
218        }
219        let first = self.row_at(top).saturating_sub(overscan);
220        let last = (self.row_at(bottom) + 1 + overscan).min(rows);
221        first..last
222    }
223}
224
225/// One visible row, as given to the row painter.
226#[derive(Clone, Copy, Debug)]
227pub struct Row {
228    pub index: usize,
229    /// The row's full rect (the painter's `ui` is clipped to it, with horizontal padding).
230    pub rect: Rect,
231    pub selected: bool,
232    pub hovered: bool,
233}
234
235/// What the list reports this frame.
236pub struct ListResponse {
237    /// The list's viewport.
238    pub response: egui::Response,
239    /// Row clicked with the pointer.
240    pub clicked: Option<usize>,
241    pub double_clicked: Option<usize>,
242    pub secondary_clicked: Option<usize>,
243    /// Row the user moved the selection to (click or keyboard).
244    pub select: Option<usize>,
245    /// Enter (or a double click) on the selected row.
246    pub activated: Option<usize>,
247    /// Rows laid out this frame.
248    pub visible: Range<usize>,
249}
250
251/// A virtualized list. See the [module docs](self).
252pub struct VirtualList<'a> {
253    id: &'a str,
254    rows: usize,
255    heights: RowHeights<'a>,
256    selected: Option<usize>,
257    is_selected: Option<&'a dyn Fn(usize) -> bool>,
258    overscan: usize,
259    stripes: bool,
260    scroll_to: Option<usize>,
261    max_height: Option<f32>,
262    row_label: Option<&'a dyn Fn(usize) -> &'a str>,
263    type_ahead: Option<&'a dyn Fn(usize) -> &'a str>,
264    selection: Option<&'a mut Selection>,
265    changed_rows: &'a [usize],
266    fling: bool,
267    label: Option<&'a str>,
268}
269
270/// Typed letters within this many seconds of each other build one type-ahead prefix.
271pub const TYPE_AHEAD_SECS: f64 = 1.0;
272
273impl<'a> VirtualList<'a> {
274    pub fn new(id: &'a str, rows: usize, heights: RowHeights<'a>) -> Self {
275        VirtualList {
276            id,
277            rows: rows.min(MAX_ROWS),
278            heights,
279            selected: None,
280            is_selected: None,
281            overscan: 2,
282            stripes: false,
283            scroll_to: None,
284            max_height: None,
285            row_label: None,
286            type_ahead: None,
287            selection: None,
288            changed_rows: &[],
289            fling: false,
290            label: None,
291        }
292    }
293    /// Drag the list to scroll it (off by default): let go while moving and it keeps going, then
294    /// eases to rest; pulled past either end it stretches with rubber-band resistance and springs
295    /// back. Reduced motion keeps the drag but drops the fling.
296    pub fn fling(mut self, on: bool) -> Self {
297        self.fling = on;
298        self
299    }
300    /// The name a screen reader announces for the list (a human name, not the automation id).
301    pub fn label(mut self, text: &'a str) -> Self {
302        self.label = Some(text).filter(|t| !t.is_empty());
303        self
304    }
305    /// Rows whose height changed since the last frame, with [`RowHeights::Variable`]: only these
306    /// are measured again (O(log rows) each) while the revision and row count stay the same.
307    pub fn changed_rows(mut self, rows: &'a [usize]) -> Self {
308        self.changed_rows = rows;
309        self
310    }
311    /// The selected (cursor) row: highlighted and moved by the keyboard.
312    pub fn selected(mut self, row: Option<usize>) -> Self {
313        self.selected = row;
314        self
315    }
316    /// Highlight several rows (multi-selection); called for visible rows only.
317    pub fn is_selected(mut self, f: &'a dyn Fn(usize) -> bool) -> Self {
318        self.is_selected = Some(f);
319        self
320    }
321    /// Extra rows laid out above and below the viewport.
322    pub fn overscan(mut self, rows: usize) -> Self {
323        self.overscan = rows.min(64);
324        self
325    }
326    pub fn stripes(mut self, yes: bool) -> Self {
327        self.stripes = yes;
328        self
329    }
330    /// Scroll so this row is visible.
331    pub fn scroll_to(mut self, row: Option<usize>) -> Self {
332        self.scroll_to = row;
333        self
334    }
335    pub fn max_height(mut self, h: f32) -> Self {
336        self.max_height = Some(h);
337        self
338    }
339    /// Accessible/automation label of a row (visible rows only): the screen reader's name for it.
340    pub fn row_label(mut self, f: &'a dyn Fn(usize) -> &'a str) -> Self {
341        self.row_label = Some(f);
342        self
343    }
344    /// Type-ahead: letters typed while the list has the focus, within [`TYPE_AHEAD_SECS`] of
345    /// each other, move the cursor to the next row whose text starts with them (ignoring case).
346    /// `text(row)` is called only on a keystroke, from the cursor on.
347    pub fn type_ahead(mut self, text: &'a dyn Fn(usize) -> &'a str) -> Self {
348        self.type_ahead = Some(text);
349        self
350    }
351    /// Multi-selection owned by the app: click, Shift-click and Shift+arrows, ⌘/Ctrl-click and
352    /// ⌘A update it in place. Its cursor replaces [`selected`](Self::selected); rows from
353    /// [`is_selected`](Self::is_selected) still show as selected.
354    pub fn selection(mut self, selection: &'a mut Selection) -> Self {
355        self.selection = Some(selection);
356        self
357    }
358
359    pub fn show(mut self, ui: &mut Ui, row: impl FnMut(&mut Ui, Row)) -> ListResponse {
360        let base = ui.make_persistent_id(self.id);
361        show_rows(ui, base, &mut self, LIST, None, row)
362    }
363}
364
365pub(crate) struct Header<'h> {
366    pub height: f32,
367    pub paint: &'h mut dyn FnMut(&mut Ui, Rect),
368}
369
370/// Shared by the list, the table and the tree.
371/// How `show_rows` registers itself for automation: its own role, and whether it registers
372/// `<id>.row.<n>` per row (the tree registers its nodes by node id instead).
373#[derive(Clone, Copy)]
374pub(crate) struct Kind {
375    pub role: Role,
376    pub rows: bool,
377    /// AccessKit roles of the container and of each visible row.
378    pub node: NodeRole,
379    pub item: NodeRole,
380}
381
382pub(crate) const LIST: Kind = Kind { role: Role::List, rows: true, node: NodeRole::List, item: NodeRole::ListItem };
383
384/// The widget id of visible row `i` (its AccessKit node id).
385pub(crate) fn row_id(base: Id, i: usize) -> Id {
386    base.with(("row", i))
387}
388
389/// `s` starts with `prefix`, ignoring case.
390fn starts_with_ci(s: &str, prefix: &str) -> bool {
391    let mut s = s.chars().flat_map(char::to_lowercase);
392    prefix.chars().flat_map(char::to_lowercase).all(|p| s.next() == Some(p))
393}
394
395/// Feed this frame's typed text into the type-ahead buffer and find the row it names.
396pub(crate) fn type_ahead<'t>(ui: &Ui, base: Id, rows: usize, cursor: Option<usize>, text: &dyn Fn(usize) -> &'t str) -> Option<usize> {
397    let typed: String = ui.input(|i| {
398        if i.modifiers.command || i.modifiers.ctrl || i.modifiers.alt {
399            return String::new();
400        }
401        i.events.iter().filter_map(|e| if let Event::Text(t) = e { Some(t.as_str()) } else { None }).collect()
402    });
403    if typed.is_empty() || rows == 0 {
404        return None;
405    }
406    let key = base.with("type_ahead");
407    let now = ui.input(|i| i.time);
408    let (mut buf, last): (String, f64) = ui.data_mut(|d| d.get_temp(key)).unwrap_or_default();
409    if !(now - last).is_finite() || now - last > TYPE_AHEAD_SECS || now < last {
410        buf.clear();
411    }
412    let fresh = buf.is_empty();
413    for c in typed.chars().filter(|c| !c.is_control()) {
414        if (c != ' ' || !buf.is_empty()) && buf.len() < 64 {
415            buf.push(c);
416        }
417    }
418    ui.data_mut(|d| d.insert_temp(key, (buf.clone(), now)));
419    if buf.is_empty() {
420        return None;
421    }
422    let find = |prefix: &str, from: usize| (0..rows).map(|k| from.saturating_add(k) % rows).find(|&r| starts_with_ci(text(r), prefix));
423    // a new prefix starts after the cursor; a longer one may stay on it
424    let from = cursor.map_or(0, |c| if fresh { c.saturating_add(1) } else { c });
425    find(&buf, from).or_else(|| {
426        // "f", "f", "f" cycles through the rows starting with "f"
427        let mut chars = buf.chars();
428        let first = chars.next()?;
429        chars.all(|c| c == first).then(|| find(first.encode_utf8(&mut [0; 4]), cursor.map_or(0, |c| c.saturating_add(1)))).flatten()
430    })
431}
432
433pub(crate) fn show_rows(
434    ui: &mut Ui,
435    base: Id,
436    list: &mut VirtualList<'_>,
437    kind: Kind,
438    header: Option<Header<'_>>,
439    mut row_fn: impl FnMut(&mut Ui, Row),
440) -> ListResponse {
441    let t = tokens::current(ui.ctx());
442    let offsets_id = base.with("offsets");
443    let mut offsets = None;
444    let layout = match list.heights {
445        RowHeights::Uniform(h) => Layout::Uniform { rows: list.rows, height: sane_height(h).max(1.0) },
446        RowHeights::Variable { revision, height } => {
447            let mut o: Offsets = ui.data_mut(|d| d.remove_temp::<Offsets>(offsets_id)).unwrap_or_default();
448            o.update(list.rows, revision, height, list.changed_rows);
449            offsets = Some(o);
450            match &offsets {
451                Some(o) => Layout::Variable(o),
452                None => Layout::Uniform { rows: 0, height: 1.0 },
453            }
454        }
455    };
456
457    // the frame: a focusable background that receives keys
458    let avail = ui.available_rect_before_wrap();
459    let height = list.max_height.filter(|h| h.is_finite() && *h > 0.0).map_or(avail.height(), |h| h.min(avail.height().max(h.min(40.0))));
460    let outer = Rect::from_min_size(avail.min, vec2(avail.width(), height.max(t.control_height)));
461    let response = ui.interact(outer, base, Sense::focusable_noninteractive());
462    let mut sel = list.selection.take();
463    // nodes are built only while a screen reader (or a test) has AccessKit enabled
464    let accesskit = ui
465        .ctx()
466        .accesskit_node_builder(base, |node| {
467            node.set_role(kind.node);
468            if let Some(label) = list.label {
469                node.set_label(label);
470            }
471            if sel.is_some() {
472                node.set_multiselectable();
473            }
474        })
475        .is_some();
476    let focused = crate::edit::has_keyboard(&response);
477    if focused {
478        // arrows move the selection, not the focus
479        let filter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: true, escape: false };
480        ui.memory_mut(|m| m.set_focus_lock_filter(base, filter));
481    }
482
483    let mut select = None;
484    let mut activated = None;
485    let rows = layout.rows();
486    let cursor = sel.as_ref().and_then(|s| s.cursor()).or(list.selected);
487    let page = ((outer.height() as f64 / layout.span(cursor.unwrap_or(0)).1.max(1.0)) as usize).max(1);
488    if focused && rows > 0 {
489        let cur = cursor.map(|s| s.min(rows - 1));
490        let (up, down, home, end, pgup, pgdn, enter) = ui.input(|i| {
491            (
492                i.key_pressed(Key::ArrowUp),
493                i.key_pressed(Key::ArrowDown),
494                i.key_pressed(Key::Home),
495                i.key_pressed(Key::End),
496                i.key_pressed(Key::PageUp),
497                i.key_pressed(Key::PageDown),
498                i.key_pressed(Key::Enter),
499            )
500        });
501        let next = if up {
502            Some(cur.map_or(0, |c| c.saturating_sub(1)))
503        } else if down {
504            Some(cur.map_or(0, |c| (c + 1).min(rows - 1)))
505        } else if home {
506            Some(0)
507        } else if end {
508            Some(rows - 1)
509        } else if pgup {
510            Some(cur.map_or(0, |c| c.saturating_sub(page)))
511        } else if pgdn {
512            Some(cur.map_or(0, |c| (c + page).min(rows - 1)))
513        } else {
514            list.type_ahead.and_then(|text| type_ahead(ui, base, rows, cur, text))
515        };
516        if let Some(s) = sel.as_deref_mut() {
517            let (shift, all) = ui.input_mut(|i| (i.modifiers.shift, i.consume_key(Modifiers::COMMAND, Key::A)));
518            if let Some(n) = next {
519                if shift { s.extend_to(n) } else { s.select_only(n) }
520            }
521            if all {
522                s.select_all(rows);
523            }
524        }
525        if next.is_some() && next != cur {
526            select = next;
527        }
528        if enter {
529            activated = cur;
530        }
531    }
532    let scroll_target = select.or(list.scroll_to.filter(|r| *r < rows));
533
534    // header (table)
535    let mut body_rect = outer;
536    if let Some(h) = header {
537        let header_rect = Rect::from_min_size(outer.min, vec2(outer.width(), h.height));
538        let mut hui = ui.new_child(UiBuilder::new().max_rect(header_rect).id_salt("ashlar_list_header").accessibility_parent(base));
539        // the header is the table's first row; its cells are column headers
540        ui.ctx().accesskit_node_builder(hui.unique_id(), |n| n.set_role(NodeRole::Row));
541        hui.set_clip_rect(header_rect.intersect(ui.clip_rect()));
542        (h.paint)(&mut hui, header_rect);
543        body_rect.min.y = header_rect.max.y;
544    }
545
546    let view_h_id = base.with("view_h");
547    let mut scroll = egui::ScrollArea::vertical().id_salt(base.with("scroll")).auto_shrink([false, false]).max_height(body_rect.height());
548    if let Some(target) = scroll_target {
549        let state = egui::scroll_area::State::load(ui.ctx(), scroll_area_id(ui, base));
550        let offset = state.map_or(0.0, |s| s.offset.y as f64);
551        let view_h: f32 = ui.data(|d| d.get_temp(view_h_id)).unwrap_or(body_rect.height());
552        let (top, h) = layout.span(target);
553        if top < offset {
554            scroll = scroll.vertical_scroll_offset(top as f32);
555        } else if top + h > offset + view_h as f64 {
556            scroll = scroll.vertical_scroll_offset((top + h - view_h as f64).max(0.0) as f32);
557        }
558    }
559
560    let over = if list.fling { drive_fling(ui, base, &layout, body_rect, scroll_target.is_some(), &mut scroll) } else { 0.0 };
561
562    let mut clicked = None;
563    let mut double_clicked = None;
564    let mut secondary_clicked = None;
565    let mut visible = 0..0;
566    let mut body = ui.new_child(UiBuilder::new().max_rect(body_rect).id_salt("ashlar_list_body").accessibility_parent(base));
567    let out = scroll.show_viewport(&mut body, |ui, viewport| {
568        let total = layout.total().min(f32::MAX as f64 / 4.0) as f32;
569        ui.set_height(total);
570        ui.set_width(ui.available_width());
571        // past an end, the rows follow the rubber band
572        let origin = ui.max_rect().min - vec2(0.0, over);
573        let width = ui.max_rect().width();
574        visible = layout.visible(viewport.min.y as f64, viewport.max.y as f64, list.overscan);
575        let label_fn = list.row_label;
576        let list_id = list.id;
577        for i in visible.clone() {
578            let (top, h) = layout.span(i);
579            let rect = Rect::from_min_size(pos2(origin.x, origin.y + top as f32), vec2(width, h as f32));
580            if h <= 0.0 {
581                continue;
582            }
583            let rid = row_id(base, i);
584            let resp = ui.interact(rect, rid, Sense::click());
585            let selected = sel.as_ref().is_some_and(|s| s.contains(i)) || list.selected == Some(i) || list.is_selected.is_some_and(|f| f(i));
586            let hovered = resp.hovered();
587            if resp.clicked() || resp.secondary_clicked() {
588                ui.memory_mut(|m| m.request_focus(base));
589            }
590            if resp.clicked() {
591                clicked = Some(i);
592                if let Some(s) = sel.as_deref_mut() {
593                    let m = ui.input(|inp| inp.modifiers);
594                    if m.shift {
595                        s.extend_to(i);
596                    } else if m.command {
597                        s.toggle(i);
598                    } else {
599                        s.select_only(i);
600                    }
601                }
602                if cursor != Some(i) {
603                    select = Some(i);
604                }
605            }
606            if accesskit {
607                ui.ctx().accesskit_node_builder(rid, |node| {
608                    node.set_role(kind.item);
609                    if let Some(f) = label_fn {
610                        node.set_label(f(i));
611                    }
612                    node.set_selected(selected);
613                    node.set_position_in_set(i.saturating_add(1));
614                    node.set_size_of_set(rows);
615                });
616            }
617            if resp.double_clicked() {
618                double_clicked = Some(i);
619                activated = Some(i);
620            }
621            if resp.secondary_clicked() {
622                secondary_clicked = Some(i);
623            }
624            paint_row_background(ui, &t, rect, i, selected, hovered, list.stripes, focused);
625            let mut child =
626                ui.new_child(UiBuilder::new().max_rect(rect.shrink2(vec2(t.space.sm, 0.0))).id_salt(("row", i)).accessibility_parent(rid));
627            child.set_clip_rect(rect.intersect(ui.clip_rect()));
628            if selected {
629                child.visuals_mut().override_text_color = Some(t.selection_text);
630            }
631            row_fn(&mut child, Row { index: i, rect, selected, hovered });
632            if kind.rows {
633                let label = label_fn.map_or("", |f| f(i));
634                automation::register_fmt(
635                    ui,
636                    rect,
637                    format_args!("{list_id}.row.{i}"),
638                    label,
639                    Role::Row,
640                    true,
641                    Details { value: None, selected: Some(selected) },
642                );
643            }
644        }
645    });
646    let view_h = out.inner_rect.height();
647    ui.data_mut(|d| d.insert_temp(view_h_id, view_h));
648    // the scroll area's own id (it lives in the body's child ui), for reading its offset
649    ui.data_mut(|d| d.insert_temp(base.with("scroll_id"), out.id));
650    if let Some(o) = offsets {
651        ui.data_mut(|d| d.insert_temp(offsets_id, o));
652    }
653    ui.advance_cursor_after_rect(outer);
654    if focused {
655        ui.painter().rect_stroke(outer, t.radius.sm, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
656    }
657    automation::register_with(ui, &response, list.id, "", kind.role, Details::default());
658    ListResponse { response, clicked, double_clicked, secondary_clicked, select, activated, visible }
659}
660
661/// The id the list's scroll area keeps its offset under, as it reported last frame. The area
662/// lives in the body's child ui, so the id is not `ui`'s own id with the salt.
663fn scroll_area_id(ui: &Ui, base: Id) -> Id {
664    ui.data(|d| d.get_temp::<Id>(base.with("scroll_id"))).unwrap_or_else(|| ui.make_persistent_id(base.with("scroll")))
665}
666
667/// A list's drag-to-scroll state between frames.
668#[derive(Clone, Copy, Debug, Default)]
669struct ListFling {
670    tracker: VelocityTracker<f32>,
671    /// The offset the hand asks for (may be past either end).
672    raw: f32,
673    /// The last offset given to the tracker.
674    tracked: f32,
675    dragging: bool,
676    /// A fling or spring-back is running toward `target`.
677    running: bool,
678    target: f32,
679    /// The scroll offset set last frame (a different one means something else scrolled).
680    last: f32,
681}
682
683/// Drag-to-scroll with fling and rubber band for the list `base` (see [`VirtualList::fling`]):
684/// sets `scroll`'s offset while dragging or flinging and returns the rubber-band overscroll to
685/// draw the rows with (negative above the top).
686fn drive_fling(ui: &Ui, base: Id, layout: &Layout<'_>, body: Rect, scrolling_to: bool, scroll: &mut egui::ScrollArea) -> f32 {
687    let ctx = ui.ctx().clone();
688    let key = base.with("fling");
689    let anim = key.with("offset");
690    let view_h: f32 = ui.data(|d| d.get_temp(base.with("view_h"))).unwrap_or(body.height()).max(1.0);
691    let max = (layout.total().min(f32::MAX as f64 / 4.0) as f32 - view_h).max(0.0);
692    let scroll_id = scroll_area_id(ui, base);
693    let current = egui::scroll_area::State::load(&ctx, scroll_id).map_or(0.0, |s| s.offset.y);
694    // the list scrolls by hand itself (egui's own drag-to-scroll would fight it)
695    *scroll = scroll.clone().scroll_source(egui::scroll_area::ScrollSource { drag: egui::scroll_area::DragScroll::Never, ..Default::default() });
696    let drag = ui.interact(body, key, Sense::drag());
697    // a pointer-only surface: the list and its scroll bar are what a screen reader uses
698    ui.ctx().accesskit_node_builder(drag.id, |n| n.set_role(NodeRole::GenericContainer));
699    let now = ui.input(|i| i.time);
700    let mut f: ListFling = ui.data(|d| d.get_temp(key)).unwrap_or_default();
701    let band = |raw: f32| {
702        if raw < 0.0 {
703            rubber_band(raw, view_h)
704        } else if raw > max {
705            max + rubber_band(raw - max, view_h)
706        } else {
707            raw
708        }
709    };
710    let mut offset = None;
711    if drag.drag_started() {
712        f = ListFling { raw: current, tracked: current, dragging: true, last: current, ..ListFling::default() };
713        f.tracker.add(now, current);
714    }
715    if f.dragging {
716        f.raw -= drag.drag_delta().y;
717        let shown = band(f.raw);
718        // only movement is a sample: a still hand ages the last one until `velocity_at` says 0
719        if shown != f.tracked {
720            f.tracker.add(now, shown);
721            f.tracked = shown;
722        }
723        motion::set(&ctx, anim, shown);
724        offset = Some(shown);
725        if drag.drag_stopped() || !drag.dragged() {
726            f.dragging = false;
727            let reduced = motion::prefs(&ctx).reduced;
728            let v = if reduced { 0.0 } else { f.tracker.velocity_at(now) };
729            let target = project(shown, v, deceleration::NORMAL).clamp(0.0, max);
730            if target != shown {
731                // never faster than the distance left can absorb: a fling stopped by an end eases
732                // into it, and a stretched list springs back without swinging past
733                let reach = (target - shown) * decay_k(deceleration::NORMAL);
734                let v = if v.signum() == reach.signum() { v.abs().min(reach.abs()).copysign(v) } else { 0.0 };
735                let curve = if reduced && v == 0.0 && (0.0..=max).contains(&shown) { Curve::Instant } else { Curve::Spring(mtokens::SETTLE.enter) };
736                motion::release(&ctx, anim, v, target, curve);
737                f.running = true;
738                f.target = target;
739            }
740        }
741    } else if f.running {
742        if scrolling_to || (current - f.last).abs() > 0.5 {
743            // the wheel, the keyboard or the app scrolled: they win
744            f.running = false;
745        } else {
746            let o = motion::value(&ctx, anim, f.target, Class::Gesture, mtokens::SETTLE.enter);
747            if o == f.target {
748                f.running = false;
749            }
750            offset = Some(o);
751        }
752    }
753    let mut over = 0.0;
754    if let Some(o) = offset.filter(|o| o.is_finite()) {
755        let inside = o.clamp(0.0, max);
756        *scroll = scroll.clone().vertical_scroll_offset(inside);
757        over = o - inside;
758        f.last = inside;
759    }
760    ui.data_mut(|d| d.insert_temp(key, f));
761    over
762}
763
764fn paint_row_background(ui: &Ui, t: &Tokens, rect: Rect, i: usize, selected: bool, hovered: bool, stripes: bool, focused: bool) {
765    let fill = if selected {
766        if focused { t.selection } else { t.selection.gamma_multiply(0.7) }
767    } else if hovered {
768        t.surface.hover
769    } else if stripes && i % 2 == 1 {
770        t.surface.row_alt
771    } else {
772        return;
773    };
774    ui.painter().rect_filled(rect, 0, fill);
775}
776
777// ---------------------------------------------------------------------------------------------
778// Table
779// ---------------------------------------------------------------------------------------------
780
781/// One table column.
782#[derive(Clone, Copy, Debug)]
783pub struct Column<'a> {
784    pub title: &'a str,
785    /// Initial width in points (the user's resized width is remembered per table).
786    pub width: f32,
787    pub min_width: f32,
788    pub resizable: bool,
789}
790
791impl<'a> Column<'a> {
792    pub fn new(title: &'a str, width: f32) -> Self {
793        Column { title, width, min_width: 24.0, resizable: true }
794    }
795}
796
797/// The sort indicator shown in a header.
798#[derive(Clone, Copy, Debug, PartialEq, Eq)]
799pub struct Sort {
800    pub column: usize,
801    pub ascending: bool,
802}
803
804/// What the table reports: the list's response plus header clicks.
805pub struct TableResponse {
806    pub list: ListResponse,
807    /// Header clicked (the app re-sorts and passes the new [`Sort`]).
808    pub header_clicked: Option<usize>,
809    /// Current column widths.
810    pub widths: Vec<f32>,
811}
812
813/// A virtualized table: a [`VirtualList`] whose rows are split into resizable columns.
814pub struct VirtualTable<'a> {
815    list: VirtualList<'a>,
816    columns: &'a [Column<'a>],
817    sort: Option<Sort>,
818}
819
820/// Most columns a table shows.
821pub const MAX_COLUMNS: usize = 256;
822
823impl<'a> VirtualTable<'a> {
824    pub fn new(id: &'a str, columns: &'a [Column<'a>], rows: usize, heights: RowHeights<'a>) -> Self {
825        VirtualTable {
826            list: VirtualList::new(id, rows, heights).stripes(true),
827            columns: columns.get(..columns.len().min(MAX_COLUMNS)).unwrap_or(&[]),
828            sort: None,
829        }
830    }
831    pub fn selected(mut self, row: Option<usize>) -> Self {
832        self.list = self.list.selected(row);
833        self
834    }
835    pub fn is_selected(mut self, f: &'a dyn Fn(usize) -> bool) -> Self {
836        self.list = self.list.is_selected(f);
837        self
838    }
839    pub fn sort(mut self, sort: Option<Sort>) -> Self {
840        self.sort = sort;
841        self
842    }
843    pub fn scroll_to(mut self, row: Option<usize>) -> Self {
844        self.list = self.list.scroll_to(row);
845        self
846    }
847    pub fn max_height(mut self, h: f32) -> Self {
848        self.list = self.list.max_height(h);
849        self
850    }
851    /// The name a screen reader announces for the table. See [`VirtualList::label`].
852    pub fn label(mut self, text: &'a str) -> Self {
853        self.list = self.list.label(text);
854        self
855    }
856    pub fn row_label(mut self, f: &'a dyn Fn(usize) -> &'a str) -> Self {
857        self.list = self.list.row_label(f);
858        self
859    }
860    /// See [`VirtualList::changed_rows`].
861    pub fn changed_rows(mut self, rows: &'a [usize]) -> Self {
862        self.list = self.list.changed_rows(rows);
863        self
864    }
865
866    /// See [`VirtualList::type_ahead`].
867    pub fn type_ahead(mut self, text: &'a dyn Fn(usize) -> &'a str) -> Self {
868        self.list = self.list.type_ahead(text);
869        self
870    }
871    /// See [`VirtualList::selection`].
872    pub fn selection(mut self, selection: &'a mut Selection) -> Self {
873        self.list = self.list.selection(selection);
874        self
875    }
876
877    /// `cell(ui, row, column)` paints one visible cell.
878    pub fn show(mut self, ui: &mut Ui, mut cell: impl FnMut(&mut Ui, usize, usize)) -> TableResponse {
879        let t = tokens::current(ui.ctx());
880        let base = ui.make_persistent_id(self.list.id);
881        let widths_id = base.with("widths");
882        let mut stored: Vec<f32> = ui.data_mut(|d| d.remove_temp::<Vec<f32>>(widths_id)).unwrap_or_default();
883        if stored.len() != self.columns.len() {
884            stored = self.columns.iter().map(|c| sane_width(c.width, c.min_width)).collect();
885        }
886        // read by the header and the rows, written by the header's resize handles
887        let widths: Vec<std::cell::Cell<f32>> = stored.iter().map(|w| std::cell::Cell::new(*w)).collect();
888        let mut header_clicked = None;
889        let columns = self.columns;
890        let sort = self.sort;
891        let table_id = self.list.id;
892        let header_h = t.control_height;
893        let mut paint_header = |ui: &mut Ui, rect: Rect| {
894            ui.painter().rect_filled(rect, 0, t.surface.panel);
895            ui.painter().hline(rect.x_range(), rect.bottom() - 0.5, Stroke::new(1.0, t.separator));
896            let mut x = rect.left();
897            for (c, col) in columns.iter().enumerate() {
898                let w = widths.get(c).map_or(col.width, std::cell::Cell::get);
899                let cell = Rect::from_min_size(pos2(x, rect.top()), vec2(w, rect.height()));
900                let resp = ui.interact(cell, base.with(("header", c)), Sense::click());
901                if resp.hovered() {
902                    ui.painter().rect_filled(cell, 0, t.surface.hover);
903                }
904                if resp.clicked() {
905                    header_clicked = Some(c);
906                }
907                let font = Tokens::font(t.type_scale.small);
908                let galley =
909                    crate::cache::galley(ui, (base, "header", c), crate::cache::hash_of(&(col.title, font.size.to_bits(), t.text.secondary)), || {
910                        egui::text::LayoutJob::simple_singleline(col.title.to_owned(), font, t.text.secondary)
911                    });
912                let text_pos = pos2(cell.left() + t.space.sm, cell.center().y - galley.size().y / 2.0);
913                let painter = ui.painter().with_clip_rect(cell.intersect(ui.clip_rect()));
914                painter.galley(text_pos, galley.clone(), t.text.secondary);
915                if let Some(s) = sort.filter(|s| s.column == c) {
916                    let ax = (text_pos.x + galley.size().x + t.space.sm).min(cell.right() - 8.0);
917                    let ay = cell.center().y;
918                    let (a, b) = if s.ascending { (-3.0, 3.0) } else { (3.0, -3.0) };
919                    painter.line_segment([pos2(ax - 3.5, ay + b * 0.5), pos2(ax, ay + a * 0.5 - 0.5)], Stroke::new(1.5, t.text.secondary));
920                    painter.line_segment([pos2(ax, ay + a * 0.5 - 0.5), pos2(ax + 3.5, ay + b * 0.5)], Stroke::new(1.5, t.text.secondary));
921                }
922                resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, col.title));
923                ui.ctx().accesskit_node_builder(resp.id, |n| {
924                    n.set_role(NodeRole::ColumnHeader);
925                    n.set_column_index(c);
926                    if let Some(s) = sort.filter(|s| s.column == c) {
927                        n.set_sort_direction(if s.ascending { SortDirection::Ascending } else { SortDirection::Descending });
928                    }
929                });
930                let sorted = sort.map(|s| s.column == c);
931                automation::register_fmt(
932                    ui,
933                    cell,
934                    format_args!("{table_id}.header.{c}"),
935                    col.title,
936                    Role::Header,
937                    true,
938                    Details { value: None, selected: sorted },
939                );
940                // resize handle on the right edge
941                if col.resizable {
942                    let handle = Rect::from_center_size(pos2(cell.right(), cell.center().y), vec2(6.0, cell.height()));
943                    let hr = ui.interact(handle, base.with(("resize", c)), Sense::drag());
944                    // a pointer-only handle: nothing for a screen reader to announce
945                    ui.ctx().accesskit_node_builder(hr.id, |n| n.set_role(NodeRole::GenericContainer));
946                    if hr.hovered() || hr.dragged() {
947                        ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeColumn);
948                    }
949                    if hr.dragged()
950                        && let Some(w) = widths.get(c)
951                    {
952                        w.set(sane_width(w.get() + hr.drag_delta().x, col.min_width));
953                    }
954                }
955                ui.painter().vline(cell.right() - 0.5, rect.y_range().shrink(4.0), Stroke::new(1.0, t.separator));
956                x += w;
957            }
958        };
959        let list = show_rows(
960            ui,
961            base,
962            &mut self.list,
963            Kind { role: Role::Table, rows: true, node: NodeRole::Table, item: NodeRole::Row },
964            Some(Header { height: header_h, paint: &mut paint_header }),
965            |ui, row| {
966                let mut x = row.rect.left();
967                for (c, col) in columns.iter().enumerate() {
968                    let w = widths.get(c).map_or(col.width, std::cell::Cell::get);
969                    let rect = Rect::from_min_size(pos2(x, row.rect.top()), vec2(w, row.rect.height()));
970                    let inner = rect.shrink2(vec2(t.space.sm, 0.0));
971                    if inner.width() > 0.0 {
972                        let mut cui =
973                            ui.new_child(UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)).id_salt(c));
974                        cui.set_clip_rect(inner.intersect(ui.clip_rect()));
975                        cui.ctx().accesskit_node_builder(cui.unique_id(), |n| {
976                            n.set_role(NodeRole::Cell);
977                            n.set_column_index(c);
978                            n.set_row_index(row.index);
979                        });
980                        cell(&mut cui, row.index, c);
981                    }
982                    x += w;
983                }
984            },
985        );
986        for (dst, w) in stored.iter_mut().zip(&widths) {
987            *dst = w.get();
988        }
989        let out = stored.clone();
990        ui.data_mut(|d| d.insert_temp(widths_id, stored));
991        TableResponse { list, header_clicked, widths: out }
992    }
993}
994
995fn sane_width(w: f32, min: f32) -> f32 {
996    let min = if min.is_finite() { min.clamp(4.0, 2000.0) } else { 24.0 };
997    if w.is_finite() { w.clamp(min, 4000.0) } else { min }
998}
999
1000#[cfg(test)]
1001mod tests;