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.
Runs Scroll in this page as WebAssembly.
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.

Handing over the same keys again, in the same or a new order (a sort change), or with a few versions bumped costs one hash lookup per row: about 4 ms for a reorder of 100k rows and under 1 ms for unchanged rows on g815's efficiency cores (crates/nd-virtual/tests/set_items_cost.rs).

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 key the click or key landed on and the whole selected set, in list order. .on_selection_change reports every change, including a click on empty space that clears the selection, which .on_select skips because there is no key to report. .on_activate fires on a double click or Return. To set the selection from code, call select(key) or set_selected(keys). The list drops keys that are no longer in it, and rows .selectable turns down.

table("files", &self.files, columns, render_cell)
    .selection(Selection::multiple())
    .on_selection_change(cx.listener(|this, e: &SelectionChanged<FileId>, _, cx| this.show(&e.keys, cx)))

Both presenters select the way AppKit does. A click selects one row. The secondary key (Command on macOS, Control elsewhere) toggles a row. Shift selects the range from the anchor, and Shift with the secondary key adds that range. A plain press on a row of a multiple selection keeps the group until the release, so the group can be dragged. A right click outside the selection selects that row alone before the context menu opens. With the list focused, the arrow keys move the cursor (by line and by cell in a grid), Home, End, Page Up and Page Down jump, Shift extends from the anchor, the secondary key with A selects every row, and the row moved to scrolls into view. In a tree, Left and Right collapse and expand, then move to the parent or the first child. In a table or grid, a drag from empty space draws a marquee that replaces the selection, adds to it with Shift, or toggles what it sweeps with the secondary key, and scrolls when held near an edge. On macOS this is NSTableView and NSCollectionView behaviour; the GPUI presenter implements the same rules in nd_virtual::selection. A middle click goes to the row's own .on_middle_click.

A list with a selection is a focus target, as NSTableView is first responder. A press on it or window.focus("files") gives it focus, Tab reaches it, and bindings of the key contexts around it match while it holds it, so an app binds its own keys (Delete, Space for a preview) on the container and leaves the arrows to the list. On GPUI backends the row the keys move carries the theme's focus ring while the keyboard moved focus last; a press hides it. A list without a selection takes no focus.

.drag_rows(kind, |key| Some(id)) lets rows drag out; a row whose id is None stays put. A drag from a selected row carries the whole selection in list order, and one from an unselected row drags it alone and leaves the selection as it was. A press on a row that drags selects it on release, so on_select never fires for the start of a drag (an app that plays a song on select does not play the one being dragged). The GPUI presenter draws the row under the pointer with a count badge when there are several; on AppKit the rows ride AppKit's own dragging session. .accepts_drops([kind]) makes the list a target, with the row under the pointer outlined, and .drop_on(|key| ..) limits which rows take a drop on themselves, as a file manager's folders do. .on_drop reports RowDropped { key, item, modifiers, local }, where key is the row it landed on or None for empty space. Pass DragItem::FILES to take files from outside the app. .drag_out_files(|key| Some(path)) lets the same drag carry files out of the app: once the pointer leaves the window, the platform's drag takes the rows' files to whatever app the user drops them on. That works on the GPUI backend under Wayland, where nd::platform::has(Feature::DragFilesOut); elsewhere the rows still drag inside the app.

table("files", &self.files, columns, render_cell)
    .drag_rows("file", |id: &FileId| Some(id.to_string().into()))
    .drag_out_files(|id| Some(id.path()))
    .accepts_drops(["file", DragItem::FILES])
    .drop_on(|id| id.is_folder())
    .on_drop(cx.listener(|this, e: &RowDropped<FileId>, _, cx| this.drop_into(e.key, &e.item.ids, cx)))

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, an insertion line for drag reorder, or column resize yet, and on macOS rows don't drag or take drops 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.