Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

widgets/virtual_list.rs

//! A ten-thousand-row list that costs a screenful.
//!
//! The core builds every child a view declares, so a naive thousand-row
//! column pays for a thousand rows on every frame. `Core::scroll_geometry`
//! is the way out: it retains what the last layout resolved for a scroll
//! container — its box, its content size and the clamped offset — so the
//! *view* can decide which rows are worth declaring, and hold the space of
//! the rest with two spacers.
//!
//! Two spellings of the same thing:
//!
//!   - **`widgets::uniform_list`** is the uniform-row case done — visible
//!     range, two rows of overscan, the two spacers, and rows opened at
//!     their *data* index so a row keeps its hover, focus and tweens as the
//!     built range slides over it.
//!   - **`widgets::visible_rows` + `ui.scroll_geometry`** is the arithmetic
//!     alone, for a view that builds its own container (a header inside the
//!     scroller, a grid, rows that are not all one node). `--by-hand` runs
//!     that one; it is what the widget does, unrolled.
//!
//! And when the rows are *not* all one height — a log whose lines wrap —
//! `widgets::list` is the same idea over prefix sums instead of a
//! stride: heights come from a `measure` callback it runs only for the rows
//! it is about to build, everything else stands at the mean of those, and
//! the row the window starts in is put back where it was after each frame
//! learns something, so the content never slides. `--variable` runs that.
//!
//! Run: cargo run -p kui-native --example virtual_list
//!      cargo run -p kui-native --example virtual_list -- --by-hand
//!      cargo run -p kui-native --example virtual_list -- --variable
//!      cargo run -p kui-native --example virtual_list -- --headless [--variable]

use kui_devtools::{Drive, Example};
use kui_native::{
    Align, App, Color, Core, Key, NodeSpec, Role, TextStyle, TextWrap, Theme, Ui, UiEvent, Value,
    widgets,
};

const ROWS: usize = 10_000;
const ROW_H: f32 = 28.0;
/// The geometry a view slices by is the previous frame's, so a resize (and
/// the frame a wheel jump lands on) is one frame late; two rows cover it.
const OVERSCAN: usize = 2;

struct VirtualList {
    selected: usize,
    mode: Mode,
    /// What the last frame built, for the header to report.
    built: std::ops::Range<usize>,
    /// `--variable` only: the row heights, which the app owns because the
    /// widget is composed from primitives and keeps nothing of its own.
    heights: widgets::RowHeights,
}

#[derive(Clone, Copy, PartialEq)]
enum Mode {
    Widget,
    ByHand,
    Variable,
}

/// `--variable`'s data: lines of very different lengths, so wrapping gives
/// the rows three or four different heights and no stride describes them.
fn line_of(i: usize) -> String {
    let words = 2 + (i * 7 + i / 3) % 22;
    let mut s = format!("{i:>5}  ");
    for w in 0..words {
        s.push_str(["log", "line", "of", "some", "length", "here"][w % 6]);
        s.push(' ');
    }
    s
}

const BODY: f32 = 13.0;

/// A row's background: the accent where it is selected, and otherwise the
/// two surfaces a striped list alternates between. Opaque rather than a
/// translucent wash, because a `hover_bg` replaces a background instead of
/// compositing over it.
fn row_bg(t: &Theme, i: usize, selected: usize) -> Color {
    if i == selected {
        t.surface.mix(t.accent, 0.28)
    } else if i.is_multiple_of(2) {
        t.surface
    } else {
        t.sunken
    }
}

/// One row. Whatever declares it, it has to come out exactly `ROW_H` tall:
/// that is the stride the arithmetic above and below it assumes.
fn row(ui: &mut Ui<'_>, i: usize, selected: usize) {
    let t = ui.theme();
    let bg = row_bg(&t, i, selected);
    ui.with(
        NodeSpec::row()
            .fill()
            .pad(6.0)
            .gap(8.0)
            .cross_align(Align::Center)
            .bg(bg)
            .hover_bg(bg.mix(t.accent, 0.12))
            .on_click(Value::map([
                ("kind", Value::str("pick")),
                ("row", Value::Int(i as i64)),
            ]))
            .role(Role::ListItem)
            .label(format!("row {i} of {ROWS}")),
        |ui| {
            ui.text(&format!("{i:>5}"), TextStyle::new(13.0).color(t.faint));
            ui.text(&format!("log line {i}"), TextStyle::new(13.0).color(t.fg));
        },
    );
}

/// The style a `--variable` row's text is measured *and* drawn in. The
/// measurement is only worth anything if it is the same style: `measure_text`
/// is what layout would give a text node with this content and this style.
fn body(t: &Theme) -> TextStyle {
    TextStyle::new(BODY).color(t.fg).wrap(TextWrap::Word)
}

fn list_spec(t: &Theme) -> NodeSpec {
    NodeSpec::column()
        .fill()
        .bg(t.bg)
        .role(Role::List)
        .label("log")
}

impl VirtualList {
    /// The whole list, in one call.
    fn widget(&mut self, ui: &mut Ui<'_>) {
        let selected = self.selected;
        let spec = list_spec(&ui.theme());
        let (mut first, mut last) = (usize::MAX, 0usize);
        widgets::uniform_list(ui, "log", spec, ROWS, ROW_H, |ui, i| {
            first = first.min(i);
            last = i + 1;
            row(ui, i, selected);
        });
        self.built = if last == 0 { 0..0 } else { first..last };
    }

    /// The same list with the container in the app's hands: read the
    /// geometry, slice, and open each row at its data index.
    fn by_hand(&mut self, ui: &mut Ui<'_>) {
        // The key the container *will* have — `child_key` is the same hash
        // the open below computes, so the geometry can be read before the
        // node it belongs to is declared.
        let key: Key = ui.child_key("log");
        // `None` until a layout has resolved the container: on the first
        // frame slice by the viewport instead, and ask for the frame that
        // will know better.
        let (offset_y, vh) = match ui.scroll_geometry(key) {
            Some(g) => (g.offset.y, g.rect.h),
            None => {
                ui.request_frame();
                (ui.scroll_offset(key).y, ui.viewport().h)
            }
        };
        let range = widgets::visible_rows(offset_y, vh, 0.0, ROW_H, ROWS, OVERSCAN);
        self.built = range.clone();

        let selected = self.selected;
        // `scroll_y()` implies the clip; `gap(0)` because the stride is
        // `ROW_H` and nothing else.
        let spec = list_spec(&ui.theme()).scroll_y().gap(0.0);
        ui.with_keyed("log", spec, |ui| {
            // Keyed, not auto-keyed: an auto key *is* the sibling index, and
            // the rows already occupy that namespace at their data indices.
            let lead = range.start as f32 * ROW_H;
            if lead > 0.0 {
                ui.leaf_keyed("lead", spacer(lead));
            }
            for i in range.clone() {
                // The key auto-keying would have given row `i` in a list
                // that built them all — so hover, focus and any tween stay
                // with the row as the window slides over it.
                ui.with_indexed(
                    i as u64,
                    NodeSpec::column().grow_width().height(ROW_H),
                    |ui| row(ui, i, selected),
                );
            }
            let tail = (ROWS - range.end) as f32 * ROW_H;
            if tail > 0.0 {
                ui.leaf_keyed("tail", spacer(tail));
            }
        });
    }
}

fn spacer(h: f32) -> NodeSpec {
    NodeSpec::column().grow_width().height(h)
}

impl VirtualList {
    /// Rows of different heights, from `measure_text` — the number layout
    /// itself would give the row's text at that width, so what the row
    /// measures is what the row gets.
    fn variable(&mut self, ui: &mut Ui<'_>) {
        let selected = self.selected;
        let t = ui.theme();
        let (mut first, mut last) = (usize::MAX, 0usize);
        widgets::list(
            ui,
            "log",
            list_spec(&t).pad(6.0),
            &mut self.heights,
            |ui, i, w| {
                // The row pads itself by 6 on each side, and that padding is
                // part of the stride the arithmetic uses.
                ui.measure_text(&line_of(i), &body(&t), Some(w - 12.0))
                    .height
                    + 12.0
            },
            |ui, i| {
                first = first.min(i);
                last = i + 1;
                let bg = row_bg(&t, i, selected);
                ui.text_in(
                    NodeSpec::column()
                        .fill()
                        .pad(6.0)
                        .bg(bg)
                        .hover_bg(bg.mix(t.accent, 0.12))
                        .on_click(Value::map([
                            ("kind", Value::str("pick")),
                            ("row", Value::Int(i as i64)),
                        ]))
                        .role(Role::ListItem)
                        .label(format!("row {i} of {ROWS}")),
                    &line_of(i),
                    body(&t),
                );
            },
        );
        self.built = if last == 0 { 0..0 } else { first..last };
    }
}

impl App for VirtualList {
    fn view(&mut self, ui: &mut Ui<'_>) {
        let t = ui.theme();
        ui.with(NodeSpec::column().fill().bg(t.bg), |ui| {
            let built = self.built.len();
            let how = match self.mode {
                Mode::Widget => "uniform_list",
                Mode::ByHand => "by hand",
                Mode::Variable => "list",
            };
            ui.text_in(
                NodeSpec::row().grow_width().pad(8.0).bg(t.surface),
                &format!(
                    "{ROWS} rows, {built} built ({how}) — row {} selected",
                    self.selected
                ),
                TextStyle::new(14.0).color(t.fg),
            );

            match self.mode {
                Mode::Widget => self.widget(ui),
                Mode::ByHand => self.by_hand(ui),
                Mode::Variable => self.variable(ui),
            }
        });
    }

    fn on_event(&mut self, ev: UiEvent) {
        // A row's `on_click` payload arrives verbatim: the row it named.
        if let Some(i) = ev.payload.get_int("row") {
            self.selected = i as usize;
        }
    }
}

impl Example for VirtualList {
    const FLAGS: &'static [(&'static str, &'static str)] = &[
        (
            "--by-hand",
            "the same list unrolled from scroll_geometry + visible_rows",
        ),
        (
            "--variable",
            "rows of no fixed height, through widgets::list",
        ),
    ];
    const KEYS: &'static [(&'static str, &'static str)] = &[("wheel", "scroll 10,000 rows")];

    fn window(&self) -> kui_devtools::Window {
        kui_devtools::Window::default().size(560.0, 480.0)
    }

    /// The same view against a bare `Core`. Two frames, because the first
    /// has only the viewport to slice by; then a wheel to row 300, which
    /// reaches no event handler — the next frame's view reads the new
    /// offset; then a click on a row the first frame never built.
    fn headless(&mut self, core: &mut Core) -> Result<(), String> {
        let mut d = Drive::new(core, 480.0, 300.0);
        d.frame(self);
        let screenful = (300.0 / ROW_H) as usize;
        d.check(
            !self.built.is_empty() && self.built.len() < 4 * screenful,
            "frame 1 slices by the viewport, having no geometry yet",
        )?;
        d.frame(self);
        d.check(
            self.built.start == 0
                && self.built.len() >= screenful
                && self.built.len() < 4 * screenful,
            "frame 2 builds a screenful and its overscan, not 10,000 rows",
        )?;
        let log = d.key_of("log").ok_or("no log container")?;
        let g = d
            .core
            .scroll_geometry(log)
            .ok_or("the log was not laid out")?;
        d.check(
            g.content.h >= (ROWS as f32) * ROW_H * 0.9,
            "the content is the whole list's height",
        )?;

        d.wheel(self, 240.0, 200.0, 0.0, -300.0 * ROW_H);
        d.frame(self);
        d.frame(self);
        // Uniform rows land within the overscan of row 300; rows of
        // varying height land wherever 8,400 px of them reach, which is
        // the point of measuring them.
        let landed = if self.mode == Mode::Variable {
            self.built.start > 100
        } else {
            self.built.start >= 290 && self.built.start <= 300
        };
        d.check(landed, "a wheel to row 300 re-slices the built range")?;
        if self.mode == Mode::Variable {
            let measured = (0..ROWS)
                .filter(|&i| self.heights.measured(i).is_some())
                .count();
            d.check(
                measured > 0 && measured < ROWS / 10,
                "only the rows built so far were measured",
            )?;
        }

        // A row that was never in the first frame's range is an ordinary
        // node: it hit-tests, and its payload comes back as the app wrote it.
        d.click(self, 240.0, 100.0);
        let clicked = self.selected;
        d.check(
            clicked > 100 && self.built.contains(&clicked),
            "a row the first frame never built is clickable",
        )
    }
}

fn main() {
    let mode = if std::env::args().any(|a| a == "--variable") {
        Mode::Variable
    } else if std::env::args().any(|a| a == "--by-hand") {
        Mode::ByHand
    } else {
        Mode::Widget
    };
    kui_devtools::run(
        env!("CARGO_BIN_NAME"),
        VirtualList {
            selected: 0,
            mode,
            built: 0..0,
            // 28 is a guess, and all it decides is how wrong the scrollbar
            // is before anything has been measured.
            heights: widgets::RowHeights::new(ROWS, ROW_H),
        },
    );
}