Native list (§10)

list drives the platform’s recycling list (NSTableView / UITableView / RecyclerView / GtkListView / QListView / WinUI ItemsRepeater), so large collections get native virtualization, scroll physics, and platform behaviors. It is the one place Day’s build-once 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 (fine for a dozen items; ten thousand would all be built up front). list builds only the rows the native widget currently shows.

The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the example messages and catalog setup.

API: row sources, shared with each

On windows-winui, ItemsRepeater and StackLayout own realization and scroll extents inside a native ScrollViewer. Its element factory reuses ItemContainer rows with Day content; it does not allocate a cell per source index. Selection, keyboard navigation, drag-and-drop, and content-sized rows follow the realized containers. A SwipeControl offers declared leading/trailing actions and guarded deletion, with the same commands in a native row context menu for mouse and keyboard use. Row content can still declare its own context menu. Action callbacks validate the row token and cell generation before use. The deprecated windows-xaml backend retains its emulated implementation; references to “XAML” below refer to that backend, not WinUI.

list-recycles-distant-rows checks that distant jumps keep a bounded number of rows alive and rebind their actions; list-releases-discarded-rows churns a list by jumps, shrinks, regrowth and reorders and checks the same bound; list-shrink-and-regrow checks emptying and repopulating a scrolled list. Run them with the other list cases using day test --project apps/conformance -t windows-winui 'list-*'.

list(source, row) and each(source, row) take a RowSource (where the rows come from) and one row builder. Two sources ship:

Plain data: an items closure and a key function, wrapped in items(…):

list(
    items(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())),
        ))
        .id_of(move || id!(message_row, row.key()))
    },
)
.row_height(RowHeight::Automatic) // the default: each row as tall as its content
.on_select(move |key| open(key))
.id(id!(inbox))

The row builder receives an ItemSlot<T, K> (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.

A day-model store (feature model; docs/model.md): passed directly for collection order, or through store.rows(projection) for a display order/filter expressed as a tracked key projection:

list(store.rows(model::ordered_keys), |slot: ModelSlot<Item>| {
    row((
        label(move || slot.name().read()),   // wakes only for THIS row's name
        toggle(slot.done()),                 // two-way; follows the cell across recycles
    ))
})
.on_select(|it: Elem<Item>| open(it.key()))

ModelSlot is itself a day-model Source, so the derive’s field accessors hang off it and resolve the current row on every operation; a control bound once at build keeps working as the cell recycles. The costs stay local: a field edit patches the one control showing it (the row set is unchanged, so the native reload is skipped and nothing is cloned); a change the projection reads re-runs only the projection; and a cell scrolled across the whole collection leaves no observation claims behind (day-pieces/tests/model_rows.rs measures all three).

Builder options: .row_height(RowHeight), .on_select(Fn(Ref)) (the key for a plain source, the row’s Elem for a store source), .multi_select(), .on_selection(Fn(Vec<Ref>)), .selected_rows(Fn() -> Vec<usize>), .selection(binding) (store sources, below), and (reserved, unshipped) .row_kind mapping to native reuse pools.

Asynchronous row images

Android row binding can reenter during a DayFixed measurement while the Day tree is busy. The adapter retains the cell and posts a deferred bind with a row-token/lifetime check; RecyclerView otherwise keeps the empty holder as already bound. This matters for lists first opened in a cover, before any later search or reload has occurred.

Keep the image view mounted and bind its source to the current item’s loaded image or a bundled placeholder. When a cell is rebound, cancel its previous request and associate the result with the requested URL or item key. Check that identity before displaying a result; an older request must not populate a newer row. Clear or replace the previous pixels before waiting for the network, and dispose the task with the row’s scope.

A two-axis .frame(width, height) keeps rows stable while loading. It is a measurement boundary, not a placement barrier: both image-source updates and conditional children inside it are laid out when they change, including in detached native list and tree cells. List cells parked by recycle retain their contents and pending layout dirt, but skip both width-change layout and dirty-cell sweeps. bind_row reactivates and lays them out before redisplay. Resize work therefore follows the active viewport rather than every physical cell accumulated in the native reuse pool.

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(items(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()       // convenience: auto-scroll to end after every data reload
// … after appending a message:
follow.notify();
  • .scroll_to_end(Trigger): a watch on the trigger applies a new ListPatch::ScrollToEnd, which each backend maps to its native “make the last row visible” call (NSTableView::scrollRowToVisible · UITableView::scrollToRowAtIndexPath(.bottom) · GtkScrolledWindow vadjustment→max · QScrollArea scrollbar→max · ListView::smoothScrollToPosition · XAML ScrollViewer::ChangeView). day-core guards the empty-list case (no patch is sent), and building the list never auto-scrolls.
  • .stick_to_bottom(): 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 drive scroll_to_end from your own logic instead.

Floating group headers

.first_visible_row(Signal<usize>) receives the first actually visible row, excluding overscan. Use that index to select a group title in a top-aligned list overlay. This is opt-in and event-driven: native callbacks enqueue a deduplicated report; the event pump updates the signal when the tree is available (a report during layout waits for the current borrow to end). The list owns no group model and adds no selectable header records. AppKit, UIKit, Android, GTK, Qt, XAML/WinUI, DOM and ArkUI report scroll changes; mock exposes list_first_visible for tests. GTK, XAML and DOM use the uniform row pitch, so floating headers require uniform rows there. The initial index is zero; clamp it when data shrinks. Native drag reordering retains its existing capability contract; apps can offer explicit move controls on all toolkits.

ListSource: how the backend pulls rows

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 channel, ListSource, injected into the backend the same way the event sink is:

// day-spec
pub struct ListSource {
    pub first_visible: Option<Rc<dyn Fn(usize)>>, // opt-in enqueue-only viewport report
    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: its ids park until the next bind
    pub release: Rc<dyn Fn(RawHandle)>,         // host discarded the cell for good: its row is disposed
    pub layout_cell: Rc<dyn Fn(RawHandle, f64)>, // re-lay that cell's row at the width the platform gave it
    pub cell_height: Rc<dyn Fn(RawHandle) -> f64>, // the row's laid height, padding included (a self-measuring cell's height)
}

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 realizes 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 over T): given a row index and a cell-anchor RNode, it builds the row subtree and returns a BuiltRow: the row’s Scope and a rebind writer that slot-writes another index into the row’s ItemSlot;
  • a snapshot of the current items + their tokens, refreshed by an effect on the items closure;
  • a cell map: native cell → BoundCell { anchor, active, scope, rebind, native_width, laid_width, laid_height }: the row each physical cell holds, whether it is on display, and the width and height it was last laid at.

list_bind_row(host, index, cell):

  1. adopt cell into a cell-anchor RNode (a boundary node whose handle is the native cell, the same trick the window root uses);
  2. if the cell is new, run the row factory (build once); otherwise rebind: restore the row’s parked element ids and slot-write items[index] into the existing row’s signal;
  3. flush the row scope and lay the row out within the cell bounds, synchronously; the host reads cell_height right after.

recycle(cell) parks the row’s element ids and marks the cell inactive; its laid height is forgotten, so the next row it shows is sized afresh rather than reported as a change. A later bind_row of the same cell rebinds instead of building.

release(cell) is the host’s goodbye: it discarded the cell for good (GTK’s factory teardown, a UITableViewCell deallocating), so Day disposes the row built there, scope, nodes and ids, exactly as it does for every cell when the list itself goes. Pools discard cells on a far jump (UITableView handed back 465 fresh cells over one run of the recycling case, GtkListView 244), and without this call each discarded cell left one dead row behind. The call is safe inside a day-core borrow (a host often tears cells down from inside a reload or a removal Day started): the work is held for the borrow’s end, never dropped. A handle seen again after its release is a new cell and builds afresh.

When the items signal changes, the effect refreshes the snapshot and applies a ListPatch::Reload so the native widget re-queries the source. A source connection that reports its own row events (a day-persistence query, for one) sends ListPatch::Splice(Vec<RowDelta>) instead: sequential Insert, Remove and Move deltas the host applies as native row changes, with the selection following its rows. A plain items closure has no such events and reloads.

Per-backend mapping

BackendWidgetRecyclingNotes
mocksimulated viewportyes (test-driven)MockProbe::list_bind/list_recycle/list_release drive binds, pooling and discards; exercises the driver
AppKitNSTableView (view-based)nativemakeView/viewFor → bind_row; numberOfRows → len
UIKitUITableView + reuse idnativecellForRowAt → bind_row; didEndDisplaying → recycle; the cell’s dealloc → release (the table discards surplus cells as well as reusing them)
AndroidRecyclerView + AdapternativeonBindViewHolder → bind_row
GTK 4GtkListView + GtkListItemFactory; each cell a DayCell, a custom container that asks for no width and lays the row out at the width the themed row node grants itnativefactory bind/unbind → bind_row/recycle, teardown → release; the cell’s size_allocate → layout_cell. GtkListView keeps up to 200 spare items by design and rebinds them, so a five-row viewport settles at about 205 rows alive; items beyond that are torn down and released. A Reload splices only the row-count difference and re-binds the bound cells in place, top to bottom: replacing every model item would make GtkListView destroy each row widget and build a second set
QtQListView over a count-only QAbstractListModel and a delegate that paints only the item chrome; each cell a plain QWidget placed over its row as a viewport childnative — a bounded pool (the viewport plus overscan) the Rust side retargets on every window change: a row scrolling out parks its cell and recycles it, a row scrolling in takes a spare and bind_row rebinds itthe view owns selection, keys, focus, drag; Day owns the rows. Cells are never index widgets: the view deletes those on removal. Every Qt callback into Day is posted
ArkUIList + NodeAdapter; each cell a ListItem holding the rownativeadapter attach/detach → bind_row/recycle; item ids carry a generation so a reload never rebinds a stale cell
WinUIItemsRepeater + StackLayout; each cell an ItemContainer holding a Canvas for the rownativethe repeater’s realization callbacks post bind_row/recycle (calling the source inside XAML layout would re-enter Day)
weba DOM scroller with a slot per rowemulatedrows in view plus an overscan are built; a row scrolled far away keeps its element

Row metrics

A row reads at the platform’s size without the app padding it. day-core lays each row inside the margins the backend’s own list gives a row and keeps it at least that list’s row height, content centered (Toolkit::list_row_metrics, asked of the list when a cell is first used), so a row piece holds only its content: row((icon, label)), not row(..).padding(..).

BackendMargins (top, sides)Minimum heightSource
UIKita stock cell’s top margin; the table’s layout margins at the sides (16 or 20pt)the height UIKit fits a stock cell with one line of its default content configurationmeasured from UIKit at runtime
Android8dp, 16dp56dpMaterial one-line list item
AppKit4pt, 8pt28ptmacOS table row
GTK8px, 12px44pxAdwaita rich-list row
Qt4px, 8px28pxitem view row
web10px, 16px44pxa touch-sized web row
ArkUI8vp, 12vp48vpHarmonyOS list item
WinUI0, 12px40pxListViewItem padding and MinHeight

A Uniform row height still wins: the row is that tall and its content sits inside the margins, or, where the row is too short for them, centered in the whole row.

List items

list_item(title) is a row’s standard content drawn as the platform draws its own list rows: a leading icon, the title, an optional subtitle and trailing detail, and a check mark. Every part takes a constant, a signal or a closure, and changes in place, so a recycled row follows its item.

list(scene.items.rows(keys), move |slot| {
    list_item(move || slot.name().read())
        .icon(move || slot.kind().read().icon())     // a resource/vectors glyph, as a template
        .icon_tint(move || slot.tint().read().color())
        .subtitle(move || slot.note().read())
        .detail(move || slot.count().read().to_string())
        .checked(move || slot.done().read())         // the platform's check mark, announced as selected
})

Where Cap::ListItem answers Native the toolkit realizes kinds::LIST_ITEM itself, and the row takes the platform’s fonts, icon size, spacing, secondary colors and check mark:

BackendRealized as
UIKitUIListContentView with the cell’s default content configuration; the checkmark accessory’s glyph and a secondary value beside it
Androida Material 3 list item (DayListItem): 24dp leading icon, body-large headline, body-medium supporting text, label-small trailing text, a primary-colored check; 56dp, 72dp with a supporting line
AppKitan image-and-text table cell’s parts: a 16pt template image, the system font, a smaller secondary subtitle, the accent checkmark; at least 28pt
GTKa row box: a 16px icon, the title over a dim caption, a dim value, object-select-symbolic in the accent color; at least 44px
Qt, web, ArkUI, WinUIcomposed from vector, label and image with the same parts

A native list item is the platform’s whole row (Flex::whole_row): the list gives it the side margins from the table above, which line up with the separators, and the item sizes its own height. A composed item, like any custom row, takes the full row metrics.

Row heights

RowHeight::Uniform(h) makes every row h points tall. Each platform has a fixed-pitch fast path for that, and content taller than h is clipped.

RowHeight::Automatic, the default, makes each row as tall as its content at the row’s width, padding included. A row grows when the reader picks a larger text size (Dynamic Type on iOS, font size on Android), when its text wraps onto another line, and when its content changes after it was first shown. Use it for any row with text in it; keep Uniform for rows whose height cannot change, such as a fixed-size thumbnail grid.

day-core measures a row when the backend binds it, and ListSource::cell_height answers that height as soon as bind_row returns. When day-core re-lays a bound row at a different height later, it sends ListPatch::RowSizeInvalidated { cell, height }. That patch arrives inside a layout pass, so a backend applies it on its next main-loop turn and never asks the source anything synchronously.

BackendHow a row takes its height
AppKittableView:heightOfRow: answers the height recorded for the row’s token, or the last measured height for a row not bound yet; a bound row whose drawn height differs gets an unanimated noteHeightOfRowsWithIndexesChanged. A Uniform list does not answer that selector, so it keeps NSTableView’s fixed rowHeight
UIKitself-sizing cells: UITableViewAutomaticDimension, and DayListCell answers systemLayoutSizeFittingSize:… with the row’s height at its content view’s width; a change runs an unanimated beginUpdates/endUpdates
Androidthe holder’s layout height, set after each bind
GTKDayCell measures from cell_height; a change queues a resize of the bound list item
Qtthe model answers each row’s SizeHintRole: the height Day laid it at, or for a row not measured yet the running average of the measured ones; heights are remembered per row token across reloads, and the changes of one turn land as one relayout that keeps the top row where it was
webrows stack by their measured heights; rows not built yet stand at the average
XAMLrows stack by a running total of measured heights; rows not built yet stand at the average
ArkUINODE_HEIGHT on the row’s list item

ArkUI’s automatic row heights are covered by list-rows-size-to-content, verified on the OpenHarmony 7.0 phone emulator. The XAML rows have not been run on a device yet.

A text size change while the app runs

day_core::note_text_size_changed() forgets every measurement Day holds and re-lays every bound row, so labels everywhere, and content-sized rows with them, take the new size. UIKit calls it after UIContentSizeCategoryDidChangeNotification, and again when a page that was off screen then (another tab) is shown with the new size in its traits. Android recreates the activity on a fontScale change, as it does for a theme or language change, and the tree is rebuilt at the new scale. The desktop and web backends do not report a live change yet; the new size applies from the next launch.

The list-rows-size-to-content conformance case checks it on every toolkit: caption, body and large-title rows and a wrapped footnote each take their content’s height natively (every next row starts one row height below, allowing only for the chrome the host adds to each row), the large-title row is taller than the caption row, the wrapped row taller than the body row, and a body row that grows is re-laid at its new height and shrinks back.

Building it (mock-first, like M0–M1)

  1. spec: kinds::LIST, ListProps { row_height, selectable, multi_select }, RowHeight, ListPatch, ListSource, Toolkit::attach_list. (additive; no backend breaks)
  2. pieces: list() + builder, reusing ItemSlot; produces the type-erased row factory.
  3. core: the driver + cell-anchor adoption + list_bind_row/list_len/reload.
  4. mock: a simulated viewport + MockProbe hooks; e2e tests: only-visible-rows built, recycle = slot-write (no rebuild), data change → reload rebinds, on_select.
  5. backends: AppKit first (reference), then UIKit/Android/GTK/Qt; showcase list playground + 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. The binding also applies its initial value after mounting, deferred until row-source updates settle. AppKit preserves the selected tokens across content reloads and reorders; deleting a selected token clears it instead of selecting whichever item inherits its index.

A list of day-model store rows can bind single selection by key instead: .selection(binding) takes any Binding<Option<u64>> holding the selected row’s key. A row the user picks writes its key (clearing writes None) without an echo back to the native list; a key the app writes selects that row through the same ListPatch::Selected sync and scrolls it into view (list_scroll_to_row) once the row is in the snapshot, so an app that inserts a row and selects it in one turn sees it revealed after the reload that brings it. The selection follows its row through inserts, moves and reloads, and the initial key selects without scrolling. The day new starter binds its list this way (.selection(scene.selected)), with the same signal driving ContentList::detail_for (navigation).

Support matrix: AppKit (native NSTableView multi-selection), Qt, XAML and web-dom (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. Android and ArkUI report single selection (a tap replaces, the touch idiom) but do honor ListPatch::Selected: the sync paints the visible cells (the theme accent at 20% alpha as the cell background) and newly bound cells inherit their row’s state, which is what lets the composed tree’s selection follow the canvas (docs/tree.md). GTK uses native GtkSingleSelection for programmatic sync, reapplying the requested row after model reloads without an event echo. Empty sync clears selection, and out-of-range rows remain unselected. The sync does not take keyboard focus. 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.

AppKit separates selection from keyboard focus. .selected_rows uses native selection but never steals focus from search or other panes. Bind .focused(signal) and set the signal to true for explicit reading commands that should make the table the first responder (and therefore give its selected row the active blue highlight). The native table reports focus loss and gain back to the binding; the surrounding scroll view is not the responder.

Keyboard

A selectable list is a tab stop, and the arrow keys walk it: ↑/↓ move one row, Home and End jump to the first and last, and the row that lands is scrolled into view by the edge it left, so a held arrow scrolls a line at a time rather than recentering on every step. On a multi_select list, shift moves the far end of a range while the near end stays put, so shift+↓ ↓ ↑ grows twice and shrinks once instead of restarting from wherever the last press ended. A press reports exactly what a click on the same row reports, so on_select/on_selection need no keyboard case.

On the desktops this is the native widget’s own behavior: nothing in Day sits ahead of the responder chain, so a focused table, outline or QListView gets its arrow keys the way it always would (docs/menus.md). Qt earns it since 2026-09, when its list became a real Qt item view hosting Day’s cells; before that it was a scroll area of Day’s own, and an arrow scrolled it. On web-dom there is no native list to inherit it from, so the backend builds it: the host carries role="listbox" and the tab stop, cells carry role="option" and aria-selected, and the shim routes the keys into day_dom_list_key. Focus sits on the host rather than a row, because rows are recycled as the list scrolls and focus parked on one would evaporate under it.

UIKit table lists recycle hidden cells in didEndDisplaying and rebind them in willDisplay, so removed rows stop answering element-id lookups and redisplayed rows regain their ids. Reloads are deferred outside the tree borrow: UIKit may retire cells synchronously inside reloadData, and recycling must be able to access Day’s tree at that point.

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 signal is the row rail’s counterpart to scroll(...).scroll_target(...). Backends without a native scroll-to-index (GTK, 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. A Splice from a source that reports row events animates its inserts and removals as native row changes on AppKit, UIKit (one delta at a time; a splice of several reloads) and Qt; the other backends and every content change reload flat.

Drag-to-reorder

list(source, row)
    .reorderable(
        Reorder::new(|from, to| { /* rotate the backing Vec + persist */ })
            .guard(|from, to| ReorderVerdict::Allow)   // optional: Deny / Retarget(i)
            .enabled(move || !busy.get()),             // optional: withhold the gesture live
    )

A list is reorderable exactly when it carries a Reorder value; the value holds the commit handler, an optional guard and an optional availability, so the feature cannot be half configured. reorderable turns on the platform’s drag mechanism; probe Cap::ListReorder for support. Reorder::new(moved) 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. ReorderVerdict::Deny refuses the drop (the row springs back); ReorderVerdict::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 without mutating UI. Reorder::enabled takes a constant or a reactive bool and is read when the platform asks whether a drag may begin, so an edit mode or a loading state can withhold the gesture without rebuilding the list.

The reorder half of ListSource (ListSource::reorder, present only when the list carries a Reorder) has two calls: 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 Reorder handler 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. When it answers with any other order, the piece reloads: a store’s own row deltas describe the change from its order before the gesture, not from the rows the gesture already moved, so they cannot be replayed. On AppKit, a deferred native reload and retile reconciles the table after the drop callback, preserving its selection and scroll position. This keeps temporary drag geometry from becoming the final document scroll extent. The reconciliation drains the app callback first and safely skips a disposed list.

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:

BackendMechanismAffordanceGuard
AppKitNSTableView drag pipeline (pasteboard row, validateDrop/acceptDrop)the .gap placeholder opens where the drop would landlive (validate retargets/denies)
UIKitdrag delegate + moveRow/targetIndexPathForMovelong-press lift + gap, no editing modelive (target-for-move)
AndroidItemTouchHelper on the RecyclerViewlong-press lift, elevation, incremental swapslive per swap; Retarget reads as deny (the helper can’t relocate the gap)
GTK 4DragSource/DropTarget (native DnD framework)row snapshot as drag icon; forbidden cursor on deny (no insertion line yet)live (motion)
QtQDrag over the QListViewgrabbed-cell pixmap, 2px insertion line, no-drop cursorlive (drag-move)
XAMLWinRT CanDrag/DragOver/Drop over the emulated listsystem drag visuals + live no-drop cursorlive (DragOver)
WinUIWinRT drag events on recycled ItemContainer rowssystem drag visuals + live no-drop cursorlive (DragOver); target is the actual row, including variable heights
ArkUISetNodeDraggable + NODE_ON_DROPsystem drag preview; denied drops spring backat drop (SetDragResult)
web-dompointer-tracked (emulated — no native list reorder in the browser)lifted cell + animated CSS gap, long-press on touchlive (every hovered slot)
mockMockProbe::list_can_move / list_moveop loglive

Swipe-to-delete

list(source, row)
    .deletable(
        Delete::new(|index| { /* remove from the backing Vec + persist */ })
            .label(res::str::delete())      // optional: the word on the affordance
            .guard(|index| index != 0),     // optional: protect individual rows
    )

A list is deletable exactly when it carries a Delete value: the commit handler, an optional label, an optional per-row guard and an optional .enabled(..) availability. deletable turns on the platform’s delete gesture; probe Cap::ListDelete for support. Delete::new(deleted) is the commit: row index is gone; apply the identical removal to the backing data (v.remove(index)) and persist it if the change should survive a relaunch. It runs at the next event drain, never inside the native swipe callback.

Delete::guard is consulted before the affordance is offered, not after the gesture: a row that answers false shows no delete action at all, rather than one that fails on use. Keep it pure; it runs inside the platform’s swipe callback.

Delete::label carries the app’s own word for the action; it takes the same text sources as a label (res::str::delete() directly, no .format()), resolved in the locale current when the list builds. A toolkit has no access to the app’s Fluent catalog and so cannot translate anything itself; left unset, each backend falls back to its platform’s wordless idiom (a trash glyph), which needs no translation.

The delete half of ListSource (ListSource::delete, present only when the list carries a Delete) has two calls: can_delete(index) -> bool for the offer, and delete_row(index) for the commit, which drops the row from Day’s snapshot before returning, so len/token_at/bind_row answer for the shorter list while the native removal animates, and defers the app’s Delete handler through the event queue; the reorder half follows the same rule.

WinUI supports deletion through SwipeControl and the row context menu. Other desktop toolkits answer Unsupported for the delete affordance: GTK, Qt and XAML have no swipe idiom at all, and macOS’s row actions (the swipe-actions section below rides them) do not carry the delete affordance yet. A list that must be editable everywhere pairs .deletable(..) with an explicit control (a menu item, a per-row button) and lets the mobile toolkits add the gesture on top.

The dayscript step delete_row: { id, row } drives the same guard → commit path without a native gesture (a guard refusal fails the step, non-retryably), which is how CI asserts deletion on every target, including the desktops, where there is no gesture to simulate.

BackendMechanismAffordance
UIKittrailingSwipeActionsConfigurationForRowAtIndexPathrow tracks the finger, destructive action reveals behind it, full swipe commits
AndroidItemTouchHelper swipe (START, so it is the trailing edge in RTL too)row slides to reveal a red field carrying the label; notifyItemRemoved animates the close-up
WinUISwipeControl trailing SwipeItems and MenuFlyoutreveal then press Delete; the guard is checked both when offering and invoking it
AppKit · GTK · Qt · XAML · web—Unsupported: the delete affordance does not ride macOS’s row actions yet, and the rest have no swipe idiom; use a menu item or a row button
ArkUINODE_LIST_ITEM_SWIPE_ACTION: a trailing swipe-action item on each deletable rowthe row slides to reveal the labeled action; pressing it runs the guard and commits

Swipe actions

Swipe actions generalize swipe-to-delete: app-declared buttons reveal behind a row as the user drags it aside, on either edge, with the platform’s full-swipe shortcut for the first one (Mail’s triage gestures).

list(source, row)
    .swipe_trailing(move |i| {
        let read = is_read(i);
        vec![
            swipe_action(if read { str::mark_unread() } else { str::mark_read() })
                .symbol(if read { Symbol::CircleFilled } else { Symbol::Circle })
                .tint(palette().accent)
                .action(move || set_read(i, !read)),
        ]
    })
    .swipe_leading(move |i| vec![
        swipe_action(str::delete_word())
            .destructive()
            .action(move || remove(i)),
    ])

The provider runs at gesture time, with the row index, as the row starts to slide, so the offer reflects the row’s current state (the “Mark as Read” / “Mark as Unread” flip above is why it is a closure rather than a fixed list). Keep it pure and fast: it runs inside the platform’s swipe callback. The action handlers run later, at the event drain, never inside the native gesture. When an activation drains, the provider is invoked again and the action looked up by position, so the handler always closes over the row’s current state.

symbol puts a glyph on the button where the platform draws one: above the label on macOS (Mail’s row-action look), in place of it on iOS (the label stays the accessibility name). The platforms without a glyph slot show the label alone.

Edges are semantic, not geometric: Leading follows the reading direction (left in LTR, right in RTL), exactly as every platform’s swipe API already spells it. On Apple platforms a full swipe activates the edge’s first action; WinUI uses reveal-and-press buttons. destructive takes the platform’s destructive styling (red, on the Apple toolkits); tint colors the button where the platform honors one.

Probe Cap::ListSwipeActions: Native on macOS (NSTableView’s tableView:rowActionsForRow:edge:, the two-finger Mail swipe) and iOS (UISwipeActionsConfiguration, sharing one pipeline with swipe-to-delete: on the trailing edge a deletable list offers its delete action first, then the row’s own trailing offer). It is also Native on WinUI (SwipeControl, with labels, symbols and tints), which adds a mouse/keyboard MenuFlyout for the same actions. Everywhere else the answer is Unsupported and no affordance is drawn, so pair each action with an explicit control (a menu item, a toolbar button) for the rest, as the delete section advises.

ListSource::swipe, present only when an edge is declared, carries the swipe half: actions_at(index, edge) -> Vec<ListSwipeAction> pulls the offer (label and styling; the handlers stay in the pieces layer), and perform(index, edge, action) commits an activation, deferring the app’s handler through the event queue.

The dayscript step swipe_row: { id, row, edge?, action?, label?|key? } drives the same offer → commit path without a native gesture (edge defaults to trailing, action to 0, the full-swipe button). label: (literal) or key: (a Fluent key resolved in the run’s locale) pins which button the step may press; pinning matters because offers are state-dependent. The pin is checked before the press: a mismatched offer refuses the activation and fails the step, leaving the row’s state untouched, so a stale pin (say, from an aborted earlier run’s leftover state) fails once instead of flipping state and poisoning every later run. An empty offer or an out-of-range action also fails, non-retryably. This is how a walkthrough exercises the wiring on every target, including the toolkits that show no affordance.

Editing mode

list(source, row)
    .reorderable(Reorder::new(|from, to| { /* … */ }))
    .deletable(Delete::new(|index| { /* … */ }))
    .on_activate(|row| edit(row))
    .editing(move || editing.get())   // or .editing(true) for a page that is always editing

editing puts the list in the platform’s own editing mode, the one an Edit button enters on iOS: each row the Delete offers shows the red delete control at its leading edge, each row the Reorder moves shows a grip at its trailing edge, and a tap still activates the row, so a list of things to configure can open each one from the same screen. The value is reactive; a change animates the controls in or out. The delete control commits through the same Delete::guard → handler path as the swipe, and the grip through the same Reorder::guard → handler path as a long-press drag.

Probe Cap::ListEditing. Only UIKit has a separate editing mode: on the other toolkits a list’s rows already drag, swipe, and open their menus as they are, so editing is ignored there and the list renders as it would without it. A screen that manages a list everywhere uses .editing(true) for iOS and keeps its explicit controls for the toolkits that answer Unsupported for ListDelete.

BackendMechanism
UIKitUITableView.setEditing(_:animated:), editingStyleForRowAtIndexPath, commitEditingStyle, allowsSelectionDuringEditing
AppKit · GTK · Qt · XAML · web · Android · ArkUI— (Unsupported; ignored)

While editing, UIKit narrows each cell’s content view for the two controls. Rows are DayListCells that report the narrower width through ListSource::layout_cell, so Day lays the row out to fit between them rather than under them.

Separators

list(source, row).separators()      // or .no_separators()

The host draws row separators, at the row boundary; row content does not. A hand-drawn hairline inside a row sits wherever the row’s own layout puts it, which is not where the native selection ends and not where the platform slides its rows: it misaligns with the selection under a uniform pitch, doubles up with iOS’s native line, and stays frozen while a swipe-action reveal slides the row past it. The host’s separator sits at the boundary, so none of those apply.

Left unset, each platform keeps its own default: iOS draws separators, the desktops don’t. .separators() forces them on, .no_separators() off (an iOS list whose rows draw their own separation turns the native line off rather than showing both).

BackendMechanism
AppKitgridStyleMask = solidHorizontalGridLineMask, separatorColor
UIKitthe table’s own separatorStyle (default on; false sets .none)
GTKa border-bottom on the ListView’s row CSS nodes
weba border-bottom inside each cell frame (border-box)
Qtthe delegate paints a hairline along each row’s bottom edge (the palette’s Mid, translucent)
Android · XAML · ArkUInot lowered yet — a force is ignored; rows separate by their pitch

Reorder + row-height caveat

Rows drag within their own list only; nothing is draggable out of the app. On XAML and the web, an Automatic list’s drop slot comes from a uniform-pitch approximation, so a drag across rows of different heights can land a slot off; prefer Uniform heights for reorderable lists there. GTK, Qt and ArkUI find the row under the pointer, and AppKit, UIKit and Android use their platform’s own drag.

Selection and activation

Selection identifies the row the user is browsing. Activation performs its main action, such as opening a document or playing a station. Use separate callbacks when selecting a row should update an inspector without starting that action:

list(stations, station_row)
    .on_select(move |station| selected.set(Some(station)))
    .on_activate(move |station| player.play(station));

Desktop lists activate on double-click or Enter; Qt follows the platform’s activation preference. UIKit, Android, and ArkUI activate on a tap. The browser uses double-click or Enter for a mouse and a single tap for touch. Arrow-key selection and programmatic selection do not activate rows. on_activate receives the row’s key or model reference from the current list snapshot, so reordering does not leave it pointing to an old index. An explicit button within a row can offer the same action separately.

At the toolkit boundary, Event::ListActivated(index) carries the current zero-based row index. It is independent of SelectionChanged and SelectionSet; no selection change is required when the user activates an already selected row.

Dayscript keeps the two operations separate:

- select: { id: stations, index: 0 }
- activate: { id: stations, index: 0 }

activate invokes the row without changing selection. An out-of-range index is ignored. Recordings preserve activation even when the row was already selected.

AppKit plain labels inside selected native rows use the system selected text color at paint time, including inactive-window selection. Their normal colors are preserved for reuse and deselection; explicitly attributed text retains its authored run styling.