Native list (§10)
list drives the platform’s recycling list (NSTableView / UITableView /
RecyclerView / GtkListView / QListView), so large collections get native
virtualization, scroll physics, and platform behaviors. It is the one place Day’s
“build once, bind forever” model meets cell reuse, and the resolution is the same model:
a row subtree is built once per physical cell and rebound (a single slot-write into its
ItemSlot) every time that cell is recycled for a new item.
Contrast with each: each builds every row eagerly under one
anchor (great for a dozen items, hopeless for ten thousand). list builds only the rows the
native widget currently shows.
API: shared ItemSlot with each (DP-16)
list(move || messages.get(), |m| m.id, move |row: ItemSlot<Message, u64>| {
column((
label(move || row.field(|m| m.sender.clone())),
label(move || row.field(|m| m.preview.clone())),
))
})
.row_height(RowHeight::Uniform(56.0)) // or ::Automatic (self-sizing, slower)
.on_select(move |key| open(key))
.id("inbox")
The row builder receives the same ItemSlot<T, K> as each (Copy handle, tracked get(),
memoised field() projections). Because cells are recycled, the builder must read through the
slot rather than move the item in, so a surviving cell can be fed a new &T with one write.
Builder options: .row_height(RowHeight), .on_select(Fn(K)), .multi_select(bool),
.on_selection(Fn(Vec<K>)), .selected_rows(Fn() -> Vec<usize>), and (reserved) .row_kind(Fn(&T) -> RowKind)
mapping to native reuse pools.
Imperative scroll-to-end (chat timelines)
A chat timeline wants to stick to the newest message. Two additive builder options drive the native list’s own scroller (not a Day-side scroll view):
let follow = day_reactive::Trigger::new();
list(move || messages.get(), |m| m.id, row_builder)
.scroll_to_end(follow) // each `follow.notify()` scrolls so the last row is fully visible
.stick_to_bottom(true) // convenience: auto-scroll to end after every data reload
// … after appending a message:
follow.notify();
.scroll_to_end(Trigger): awatchon the trigger applies a newListPatch::ScrollToEnd, which each backend maps to its native “make the last row visible” call (NSTableView::scrollRowToVisible·UITableView::scrollToRowAtIndexPath(.bottom)·GtkScrolledWindowvadjustment→max ·QScrollAreascrollbar→max ·ListView::smoothScrollToPosition· XAMLScrollViewer::ChangeView). day-core guards the empty-list case (no patch is sent), and building the list never auto-scrolls..stick_to_bottom(bool): best-effort convenience that scrolls to the end after each data reload. It does not check whether the user is already near the bottom (no cross-backend scroll-position read exists yet); for that finer behavior drivescroll_to_endfrom your own logic instead.
The seam: ListSource (native → Day, synchronous)
Recycling lists pull: the native data-source asks, synchronously, “how many rows?” and “fill
this cell for row N”. Day’s normal native→Day path is enqueue-only (EventSink), so list adds
a second, synchronous seam, injected into the backend the same way the event sink is:
// day-spec
pub struct ListSource {
pub len: Rc<dyn Fn() -> usize>,
pub token_at: Rc<dyn Fn(usize) -> u64>, // stable per-row identity for the native widget
pub bind_row: Rc<dyn Fn(usize, RawHandle)>, // build-or-rebind row `i` into this native cell
pub recycle: Rc<dyn Fn(RawHandle)>, // cell leaving the viewport (optional bookkeeping)
}
trait Toolkit {
// default no-op; a recycling backend stores the source and calls it from its data-source.
fn attach_list(&mut self, _host: &Self::Handle, _source: ListSource) {}
}
day-core builds the ListSource when it realises a LIST node; each closure re-enters the tree
via with_tree(...). The backend calls them on the UI thread from outside any with_tree
borrow (a fresh native scroll callback), so the re-entry is safe.
bind_row is the sanctioned exception to turn-batching (§3.3): it runs the row’s reactive flush
and layout before returning, because the host measures the cell synchronously right after.
The driver (day-core)
Per LIST node the tree holds:
- a row factory supplied by the
list()piece (type-erased overT): given a row index and a cell-anchorRNode, it builds the row subtree and returns itsScope+ root + slot-writer; - a snapshot of the current items + their tokens, refreshed by an effect on the items closure;
- a cell map:
RawHandle → BoundRow { anchor, scope, root, slot_writer, token }.
list_bind_row(host, index, cell):
- adopt
cellinto a cell-anchorRNode(a boundary node whose handle is the native cell, the same trick the window root uses); - if the cell is new, run the row factory (build once); otherwise rebind: one slot-write of
items[index]into the existing row’s signal, and update its token; flush_nowthe row scope + lay the row out within the cell bounds, synchronously.
When the items signal changes, the effect refreshes the snapshot and applies a ListPatch::Reload
so the native widget re-queries the source. (Fine-grained insert/remove/move batching over the
keyed diff, like each’s, is a reserved refinement; Reload is the v1 behavior.)
Per-backend mapping
| Backend | Widget | Recycling | Notes |
|---|---|---|---|
| mock | simulated viewport | yes (test-driven) | MockProbe::scroll_list(range) drives binds; proves the driver |
| AppKit | NSTableView (view-based) | native | makeView/viewFor → bind_row; numberOfRows → len |
| UIKit | UITableView + reuse id | native | cellForRowAt → bind_row |
| Android | RecyclerView + Adapter | native | onBindViewHolder → bind_row |
| GTK 4 | GtkListView + GtkListItemFactory | native | factory bind/unbind → bind_row/recycle |
| Qt | QListView + abstract model, or delegate | emulated (Cap reports Emulated, DP-19) | model rowCount/data |
Building it (mock-first, like M0–M1)
- spec:
kinds::LIST,ListProps { row_height, selectable, multi_select },RowHeight,ListPatch,ListSource,Toolkit::attach_list. (additive; no backend breaks) - pieces:
list()+ builder, reusingItemSlot; produces the type-erased row factory. - core: the driver + cell-anchor adoption +
list_bind_row/list_len/reload. - mock: a simulated viewport +
MockProbehooks; e2e tests: only-visible-rows built, recycle = slot-write (no rebuild), data change → reload rebinds,on_select. - backends: AppKit first (reference), then UIKit/Android/GTK/Qt; showcase
listplayground + walkthrough leg on all five.
Selection
Rows report selection through two events: Event::SelectionChanged(row) (single) and, in
multi-select mode, Event::SelectionSet(rows), the FULL set of selected indices on every
change. .on_selection(Fn(Vec<K>)) receives the selected keys either way (a single-selection
report arrives as a one-element set), so an app tracking the whole selection works on every
toolkit. .selected_rows(Fn() -> Vec<usize>) reactively syncs app state back into the native
selection (ListPatch::Selected; empty clears) without a selection-event echo; drive it from
the same signal on_selection writes to get a two-way binding and a “clear selection” action.
Support matrix: AppKit (native NSTableView multi-selection), Qt and XAML (the
emulated lists: a per-cell press hook, a highlight treatment on the cell’s background, ctrl/cmd
toggles, shift extends) honor multi_select and ListPatch::Selected. The remaining toolkits
report single selection (SelectionChanged) and ignore the multi flag and the programmatic
sync; the one-element on_selection contract still holds there.
Programmatic scrolling
.scroll_to_end(Trigger) follows the last row (above); .scroll_to_row(Signal<Option<usize>>)
jumps to any row: set the signal to Some(row) and the native list scrolls it into view,
realizing it if it was virtualized away (ListPatch::ScrollToRow, clamped to the count). The
row rail’s counterpart to scroll(...).scroll_target(...). Backends without a native
scroll-to-index (GTK ≤ 4.10, Qt, XAML, web) position by uniform row pitch; prefer
RowHeight::Uniform when jumping programmatically there.
A Reload whose rows are the SAME set in a new order (a shuffle, a programmatic sort) animates
as native row moves on AppKit (moveRowAtIndex batch, the same animation a drag commit gets);
other backends apply it instantly. Inserts, removals, and content changes always reload flat.
Drag-to-reorder
list(items, key, row)
.reorderable(true)
.on_reorder(|from, to| { /* rotate the backing Vec + persist */ })
.reorder_guard(|from, to| Reorder::Allow) // optional: Deny / Retarget(i)
reorderable turns on the platform’s own drag mechanism; probe Cap::ListReorder for support.
on_reorder is the commit: row from landed at row to; apply the identical rotation to the
backing data (let it = v.remove(from); v.insert(to, it);) and persist it if the order should
survive a relaunch. It runs at the next event drain, never inside the native drop callback.
reorder_guard vets every proposed drop synchronously, while the drag is live: the native
affordance (the macOS gap, the no-drop cursor) reflects the answer before the user releases.
Deny refuses the drop (the row springs back); Retarget(i) accepts it at a different index,
the “pinned rows” pattern (the Showcase pins its first row this way). Keep the guard pure: it
runs inside the platform’s drag callback, so read state and return: no UI mutation.
The seam is the reorder half of ListSource (ListSource::reorder, present only when
.reorderable()): can_move(from, proposed) -> accepted-index-or--1 for the live verdict, and
move_row(from, to) for the commit, which rotates Day’s row snapshot before returning, so
len/token_at/bind_row answer in the new order while the native move animates, and defers
the app’s on_reorder through the event queue. When the app’s own data change echoes back with
exactly the committed token order, the piece skips the redundant Reload (no post-drop flicker).
The dayscript step reorder: { id, from, to } drives the same guard → commit path without a
native gesture (a guard denial fails the step, non-retryably); that is how CI asserts reordering
on every target.
Per-backend affordances:
| Backend | Mechanism | Affordance | Guard |
|---|---|---|---|
| AppKit | NSTableView drag pipeline (pasteboard row, validateDrop/acceptDrop) | the .gap placeholder opens where the drop would land | live (validate retargets/denies) |
| UIKit | drag delegate + moveRow/targetIndexPathForMove | long-press lift + gap, no editing mode | live (target-for-move) |
| Android | ItemTouchHelper on the RecyclerView | long-press lift, elevation, incremental swaps | live per swap; Retarget reads as deny (the helper can’t relocate the gap) |
| GTK 4 | DragSource/DropTarget (native DnD framework) | row snapshot as drag icon; forbidden cursor on deny (no insertion line yet) | live (motion) |
| Qt | QDrag over the emulated list | grabbed-cell pixmap, 2px insertion line, no-drop cursor | live (drag-move) |
| XAML | WinRT CanDrag/DragOver/Drop over the emulated list | system drag visuals + live no-drop cursor | live (DragOver) |
| ArkUI | SetNodeDraggable + NODE_ON_DROP | system drag preview; denied drops spring back | at drop (SetDragResult) |
| web-dom | pointer-tracked (emulated — no native list reorder in the browser) | lifted cell + animated CSS gap, long-press on touch | live (every hovered slot) |
| mock | MockProbe::list_can_move / list_move | op log | live |
Rows drag within their own list only; nothing is draggable out of the app. RowHeight::Automatic
lists compute the drop slot from a uniform-pitch approximation on GTK/Qt/XAML/ArkUI; prefer
Uniform heights for reorderable lists there.