NativeDesktop

Virtualization

Lists, tables, grids and trees over keyed rows: ListState, versions, tail following, jump targets, selection, row actions and how the macOS and GPUI presenters differ.

Any list that can pass a few hundred rows goes through nd-virtual. You keep a ListState<K> in your view, give it keys, and render one row at a time for the keys the presenter asks for. The same state drives list, table, grid and tree.

Example: Scroll. A virtualized list of 100 000 rows with heights from 28 to 124 points, the list `nd diag scroll` measures. Source in examples/scroll. It also runs live in the docs app.
Scroll on macOS

Captured on macOS 27.0, 2026-10-14.

A list

ListState::new ties the list to the view that owns it. Keys are anything Clone + Eq + Hash + Debug. The estimate is the height assumed for rows nobody has measured yet.

let mut list = ListState::new(cx).estimated_row_height(px(60.));
list.set_items_versioned((0..rows).map(|r| (r, 0)), cx);

From examples/scroll/src/lib.rs, line 25.

list(id, &state, render_row) is the element. The closure gets a key and returns one row. It runs only for rows in or near the viewport, outside your view's render and outside any native callback, so it cannot reach self. Read from entities or captured handles instead; the notes list does it with cx.processor.

list("rows", &self.list, |row: u32, _, cx| {
    h_flex()
        .px(px(12.))
        .py(px(4.))
        .gap_2()
        .child(div().w(px(6.)).h(px(height(row) - 8.)).bg(cx.theme().accent))
        .child(text(format!("Row {row}")).tabular_nums())
})
.size_full()

From examples/scroll/src/lib.rs, line 72.

Keys and versions

set_items and set_items_versioned replace the rows by key. There is no splice and no reset. Rows that stay keep their measured height, and the scroll position stays on its row. Inserts and removals animate on macOS.

A row is its own view, so your view re-rendering does not re-render it. A row renders again when its version changes or you call invalidate(&key). Pass versions, such as a modification counter or a hash of what the row shows, and unchanged rows cost nothing. With plain set_items every rendered row renders again.

fn refresh(&mut self, cx: &mut Context<Self>) {
    let rows = self.library.read(cx).rows(self.folder, &self.query);
    // Keyed diff: inserts and removals animate and the anchor row stays put. Versioned: only
    // rows whose note changed re-render.
    self.list.set_items_versioned(rows, cx);
}

From examples/notes/src/note_list.rs, line 54.

If the row reads something outside its key, such as a setting, call invalidate_all when it changes.

Where the view sits

The scroll position is an anchor: a row and how far into it the viewport starts. It is never a pixel offset, so these hold on both presenters:

Estimates matter only for the scrollbar and for how far a jump travels before it settles.

Following the tail

A log or a chat stays at the bottom while rows arrive:

/// A log that stays pinned to its newest line until the user scrolls away from the end.
pub fn log_state<T: 'static>(lines: u32, cx: &mut Context<T>) -> ListState<u32> {
    let mut log = ListState::new(cx).follow(Follow::Tail);
    log.set_items((0..lines).collect::<Vec<_>>(), cx);
    log.scroll_to_end(cx);
    log
}

/// Brings one row to the middle of the list, 96 points above where it would rest.
pub fn reveal(list: &mut ListState<u32>, row: u32, cx: &mut App) {
    list.scroll_to(row, Align::Center, px(-96.), cx);
}

From examples/scroll/src/lib.rs, line 33.

Follow::Tail pins the list to its end while the user is at the end. Scrolling away stops it, scrolling back to the end starts it again, and is_following_tail() says which. A last row that grows after the list reached the end, as an image row does once it loads, stays fully in view. Alignment::Bottom also rests short content against the bottom edge, for chat.

on_near_start and on_near_end fire when the viewport reaches the first or last rows. Load the next page there and call set_items_versioned with the longer list.

Jumping to a row

scroll_to(key, align, offset, cx) brings a row into place and animates short distances on the frame clock. jump_to takes the same arguments without the animation. Align is Start, Center, End or Nearest; Nearest scrolls only when the row is not fully visible. A negative offset leaves room above the result. The jump settles after the rows on the way are measured, so a row with a wrong estimate still lands where you asked.

The notes list uses Nearest so selecting a visible row does not move the list:

pub fn select(&mut self, id: NoteId, cx: &mut Context<Self>) {
    self.list.select(id, cx);
    self.list.scroll_to(id, Align::Nearest, px(0.), cx);
}

From examples/notes/src/note_list.rs, line 47.

scroll_to_top, scroll_to_end and scroll_by cover the rest.

Selection, activation and row actions

.selection(Selection::single()) or Selection::multiple() turns on selection, and .on_select reports it with the clicked key and the whole set. .on_activate fires on double click. Set the selection from code with select(key) or set_selected(keys); keys no longer in the list are dropped.

Row actions are a method of the list, because on macOS they are NSTableViewRowActions that come from the table's delegate. The closure runs for the row being swiped:

list("notes", &self.list, cx.processor(Self::row))
    .estimated_row_height(if compact { px(36.) } else { px(64.) })
    .selection(Selection::single())
    // Row actions belong to the list: on macOS they are NSTableView row actions.
    .row_actions(move |id: NoteId, cx: &App| {
        let pinned = library.read(cx).note(&id).is_some_and(|n| n.pinned);
        RowActions::new()
            .trailing(row_action("Delete").icon(Icon::Trash.lucide()).destructive().dispatch(DeleteNote(id)))
            .leading(
                row_action(if pinned { "Unpin" } else { "Pin" })
                    .icon(Icon::Pin.lucide())
                    .tint(accent)
                    .dispatch(PinNote(id)),
            )
            .full_swipe(RowEdge::Trailing)
    })
    .on_select(move |e: &SelectEvent<NoteId>, window, cx| on_open(e.key, window, cx))
    .empty(status_page("No Notes").icon(Icon::Notebook).description(match shortcut(&NewNote) {
        Some(keys) => format!("Press {keys} to write one."),
        None => "Use the New Note button to write one.".to_owned(),
    }))

From examples/notes/src/note_list.rs, line 149.

full_swipe makes a long swipe on that edge run its first action. Off macOS the swipe is drawn by the framework and arbitrated against the list's own scrolling, see gestures.

Tables, grids and trees

The other three take the same state.

/// The same keys as a table: one cell per column, sorting reported but left to the app.
pub fn row_table(state: &ListState<u32>) -> impl IntoElement {
    let columns = vec![column("row", "Row").width(120.).fixed(), column("height", "Height").sortable()];
    table("rows-table", state, columns, |row, col, _, _| match col {
        0 => text(format!("Row {row}")).tabular_nums(),
        _ => text(format!("{:.0} pt", height(row))).tabular_nums(),
    })
    .on_sort(|sort, _, _| println!("sort {:?}", sort.direction))
}

From examples/scroll/src/lib.rs, line 48.

Pass .empty(element) for what shows when there are no rows, usually a status_page.

What each platform does

Rows are the same on every backend. The container differs:

macOSLinux, Windows, web
List, tableview-based NSTableViewthe GPUI presenter, an element of our own
TreeNSOutlineViewGPUI presenter with drawn disclosure
GridNSCollectionViewGPUI presenter
ScrollingNSScrollView: native momentum and elastic overscrollframework momentum and rubber band
Row actionsNSTableViewRowActiondrawn, with a direction lock against the list
Keyed updateinsertRows, removeRowsapplied directly

nd-virtual owns the height tree on both. On macOS AppKit's own row-height estimation is turned off, heightOfRow answers from our tree, and rows near the viewport are measured outside AppKit callbacks. The data source answers from rows already rendered. GPUI's own ListState is not used: its rule that rows outside the viewport must not change height without a splice is what makes the last rows vanish.

Not on the GPUI presenter yet: sticky headers, drag reorder, column resize and keyboard selection. Type-ahead is not shipped on either. Trees and grids apply changes with a full reload on macOS, so they do not animate inserts. See TODO.md for the open list.

The last-row bug class

Lists that scroll by pixel offset and trust their estimates lose rows at the end when real heights differ, when a row grows after layout, or when rows load above. The conformance suite in conformance.rs pins these down with rows whose heights are far from the estimate:

One suite runs against the model, the GPUI presenter in a headless app and a real NSTableView. A new presenter has to pass it as is.

Measuring

nd diag scroll --rows 100000 launches the scroll example, scrolls it a fixed step every display frame and reads the host's frame interval line back. It passes when no frame was missed after the first second's warm-up and p95 stays within a quarter period of the display's rate. --seconds and --step change the run; see diagnostics for budgets and what a failure prints.

ND_SCROLL_STEP and ND_ROWS give the same behaviour when you start the example by hand.