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 grow past 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 that 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-22.

Scroll on Linux

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Scroll on Windows

Captured on Windows 11 26H2, 2026-10-22.

A list

ListState::new ties the list to the view that owns it. A key can be any type that's Clone + Eq + Hash + Debug. The estimate is the height that the list assumes for rows it hasn't 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 can't reach self. Read from entities or captured handles instead. The notes list does this 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's 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 when your view renders again, the row doesn't. A row renders again when its version changes or when 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 a row reads something outside its key, such as a setting, call invalidate_all when that value changes.

Where the view sits

The scroll position is an anchor: a row, and how far into that row the viewport starts. It's never a pixel offset, so these rules 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. When the user scrolls away, following stops. When they scroll back to the end, it starts again. is_following_tail() tells you which state the list is in. If the last row grows after the list reaches the end, as an image row does once its image loads, the row stays fully in view. For chat, Alignment::Bottom also rests short content against the bottom edge.

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 and doesn't animate. Align is Start, Center, End, or Nearest. Nearest scrolls only when the row isn't fully visible. A negative offset leaves room above the target row. The jump settles after the list measures the rows on the way, so a row with a wrong estimate still lands where you asked.

The notes list uses Nearest, so selecting a visible row doesn't 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.

For everything else, use scroll_to_top, scroll_to_end, and scroll_by.

Selection, activation, and row actions

To turn on selection, pass .selection(Selection::single()) or Selection::multiple(). .on_select reports the clicked key and the whole selected set. .on_activate fires on a double click. To set the selection from code, call select(key) or set_selected(keys). The list drops keys that are no longer in it.

Row actions are a method of the list, which asks for them per row. The closure runs for the row that the user swipes:

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. The framework draws the swipe on every platform, with a spring that carries the release velocity and a haptic at the full-swipe threshold. On macOS the table takes horizontal trackpad gestures on rows that have actions and leaves the rest to its scroll view; NSTableViewRowAction is not used because it narrows the button as the row closes. Elsewhere the swipe is arbitrated against the list's own scrolling. For details, see gestures.

Tables, grids, and trees

The other three elements 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) to set what shows when there are no rows. This is usually a status_page.

What each platform does

Rows are the same on every backend. Only 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 actionsDrawn in the row view, from the table's horizontal scroll gesturesDrawn, 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 off, heightOfRow answers from our tree, and the framework measures rows near the viewport outside AppKit callbacks. The data source answers from rows that are already rendered. The framework doesn't use GPUI's own ListState. That type requires that rows outside the viewport never change height without a splice, and that rule is what makes the last rows vanish.

The GPUI presenter doesn't have sticky headers, drag reorder, column resize, or keyboard selection yet. Neither presenter ships type-ahead. On macOS, trees and grids apply changes with a full reload, so they don't animate inserts. For the open list, see TODO.md.

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 cases 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 without changes.

Measuring

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

When you start the example yourself, ND_SCROLL_STEP and ND_ROWS give the same behavior.