# Virtualization

Any list that can grow past a few hundred rows goes through [`nd-virtual`](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-virtual/src/lib.rs).
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.** Source in [examples/scroll](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/scroll). Its page on the docs site shows it captured on macOS, Linux and Windows.


## 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.

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

<sub>From [examples/scroll/src/lib.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/scroll/src/lib.rs), line 25.</sub>


`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`.

```rust
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()
```

<sub>From [examples/scroll/src/lib.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/scroll/src/lib.rs), line 72.</sub>


## 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.

```rust
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);
}
```

<sub>From [examples/notes/src/note_list.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes/src/note_list.rs), line 54.</sub>


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:

- A height change above the anchor, an insert above it, or a prepend doesn't move anything on
  screen.
- Rows can be taller than the estimate. Clamping uses measured rows only, so a low estimate can't
  put the end of the list out of reach.
- A resize keeps the anchor row in place.

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:

```rust
/// 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);
}
```

<sub>From [examples/scroll/src/lib.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/scroll/src/lib.rs), line 33.</sub>


`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:

```rust
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);
}
```

<sub>From [examples/notes/src/note_list.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes/src/note_list.rs), line 47.</sub>


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.

```rust
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.

```rust
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:

```rust
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(),
    }))
```

<sub>From [examples/notes/src/note_list.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/notes/src/note_list.rs), line 149.</sub>


`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](https://native.formalsnake.dev/guides/gestures.md).

## Tables, grids, and trees

The other three elements take the same state.

```rust
/// 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))
}
```

<sub>From [examples/scroll/src/lib.rs](https://github.com/FormalStudio/NativeDesktop/blob/main/examples/scroll/src/lib.rs), line 48.</sub>


- `table(id, &state, columns, render_cell)` renders one cell per column. `column(id, title)`
  takes `width`, `min_width`, `fixed`, `sortable`, and `sorted`. Selecting a sortable header
  fires `on_sort` with the column id and direction. The list never reorders itself, so sort your
  data and call `set_items` again. `on_column_resize` reports a resized column.
- `grid(id, &state, (width, height), render_item)` lays out fixed-size cells in as many lanes as
  fit.
- `tree(id, &state, render_row)` takes `set_tree(nodes)` with `TreeNode { key, parent, version }`
  in display order, parents before children. The tree keeps expansion by key, so open branches
  survive an update. Change expansion with `set_expanded`, and listen for it with `on_expand`.

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:

| | macOS | Linux, Windows, web |
| --- | --- | --- |
| List, table | View-based `NSTableView` | The GPUI presenter, an element of our own |
| Tree | `NSOutlineView` | GPUI presenter with drawn disclosure |
| Grid | `NSCollectionView` | GPUI presenter |
| Scrolling | `NSScrollView`: native momentum and elastic overscroll | Framework momentum and rubber band |
| Row actions | Drawn in the row view, from the table's horizontal scroll gestures | Drawn, with a direction lock against the list |
| Keyed update | `insertRows`, `removeRows` | Applied 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](https://github.com/FormalStudio/NativeDesktop/blob/main/docs/plan/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](https://github.com/FormalStudio/NativeDesktop/blob/main/crates/nd-virtual/src/conformance.rs) pins these cases down with rows
whose heights are far from the estimate:

- The last row is reachable by the wheel alone and by a jump.
- It stays reachable when it grows or shrinks after the list has reached the end.
- A height change above the anchor, or a prepend, leaves the visible rows where they were.
- Tail follow stops when the user scrolls and resumes at the end.
- 100,000 rows stay inside the frame budget.

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](https://native.formalsnake.dev/tooling/diagnostics.md).

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