Skip to main content

day_core/
list.rs

1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Recycling-list driver (docs/list.md, §10). The native list host owns scrolling + cell reuse;
5//! Day owns row *content*. day-core injects a [`day_spec::ListSource`] into the backend; when the
6//! native data-source pulls a cell, `bind_row` builds it once (per physical cell) and thereafter
7//! *rebinds* it (one slot-write) as the cell recycles.
8//!
9//! Re-entrancy (the crux): building a row uses `BuildCx`, and reactive bindings patch native
10//! widgets; both acquire `with_tree` per operation. So `bind_row` phases the tree borrow:
11//! `with_tree` (adopt cell) → build/rebind + `flush_sync` **outside** any borrow → `with_tree`
12//! (lay the row out in its cell). Holding the borrow across the build would deadlock the RefCell.
13
14use crate::tree::{RNode, try_with_tree, with_tree};
15use day_reactive::Scope;
16use day_spec::{ListSource, RawHandle, props::RowHeight};
17use std::cell::{Cell, RefCell};
18use std::collections::HashMap;
19use std::rc::Rc;
20
21/// Supplied by the `list()` piece, type-erased over the item type. day-core invokes these to
22/// answer the native data-source and to build/rebind rows.
23pub struct ListDriver {
24    pub row_height: RowHeight,
25    pub track_viewport: bool,
26    /// Current row count (reads the piece's snapshot; no tree access).
27    pub len: Box<dyn Fn() -> usize>,
28    /// Stable identity token for row `index` (for native diffing).
29    pub token_at: Box<dyn Fn(usize) -> u64>,
30    /// Build row `index` into `anchor`. Uses `BuildCx` internally, so it must be called with no
31    /// `with_tree` borrow held. Returns the row's scope + a rebind writer.
32    pub build: Box<dyn Fn(usize, RNode) -> BuiltRow>,
33    /// Drag-to-reorder half, present when the piece is `.reorderable()` (docs/list.md).
34    pub reorder: Option<ListReorderDriver>,
35    /// Swipe-to-delete half, present when the piece is `.deletable()` (docs/list.md).
36    pub delete: Option<ListDeleteDriver>,
37    /// Swipe-ACTIONS half, present when the piece declared edge actions (docs/list.md).
38    pub swipe: Option<ListSwipeDriver>,
39    /// Section headers, present when the piece declared `.sections` (docs/list.md
40    /// "Sections"). Every closure above still speaks item indices; day-core inserts the
41    /// header rows and translates item indices at the toolkit boundary.
42    pub sections: Option<ListSectionsDriver>,
43}
44
45/// The section half the `list()` piece supplies (docs/list.md "Sections"): a key per item and
46/// a header builder. A header row precedes every item whose key differs from the item before.
47pub struct ListSectionsDriver {
48    /// The section key of item `index` in the current snapshot (the piece snapshots keys with
49    /// its rows, and rotates them with a committed move, so this needs no app access).
50    pub key_at: Box<dyn Fn(usize) -> u64>,
51    /// Build the header of the section that starts at item `first` into `anchor`: the same
52    /// contract as [`ListDriver::build`], and its rebind writer takes the section's first item.
53    pub build: Box<dyn Fn(usize, RNode) -> BuiltRow>,
54}
55
56/// The reorder closures the `list()` piece supplies (docs/list.md). Exposed to the backend as
57/// [`day_spec::ListReorder`] by the list source adapter; also driven directly by
58/// [`list_try_reorder`] (the dayscript `reorder` step and the mock probe).
59pub struct ListReorderDriver {
60    /// Consult the app's guard: returns the accepted target index, or -1 to deny.
61    pub can_move: Box<dyn Fn(usize, usize) -> i64>,
62    /// Commit an accepted move: rotates the piece's snapshot + tokens synchronously and defers
63    /// the app's `on_reorder` callback to the next event drain.
64    pub moved: Box<dyn Fn(usize, usize)>,
65}
66
67/// The delete closures the `list()` piece supplies (docs/list.md). Exposed to the backend as
68/// [`day_spec::ListDelete`] by the list source adapter; also driven directly by [`list_try_delete`]
69/// (the dayscript `delete_row` step and the mock probe).
70pub struct ListDeleteDriver {
71    /// Consult the app's guard: may this row be deleted?
72    pub can_delete: Box<dyn Fn(usize) -> bool>,
73    /// Commit an accepted delete: drops the row from the piece's snapshot + tokens synchronously
74    /// and defers the app's `on_delete` callback to the next event drain.
75    pub deleted: Box<dyn Fn(usize)>,
76}
77
78/// The swipe-action closures the `list()` piece supplies (docs/list.md). Exposed to the
79/// backend as [`day_spec::ListSwipe`] by the list source adapter; also driven directly by
80/// [`list_try_swipe`] (the dayscript `swipe_row` step and the mock probe).
81pub struct ListSwipeDriver {
82    /// The offer: the actions for row `index` on `edge`. Pure; runs inside native swipe
83    /// callbacks.
84    pub actions_at: Box<dyn Fn(usize, day_spec::SwipeEdge) -> Vec<day_spec::ListSwipeAction>>,
85    /// Commit an activated action (an index into the offer): defers the app's handler to the
86    /// next event drain.
87    pub performed: Box<dyn Fn(usize, day_spec::SwipeEdge, usize)>,
88}
89
90/// A freshly built row: its `Scope` (owns the row's reactive graph) and a rebind writer that
91/// slot-writes item `index` into the row's `ItemSlot` when the cell is recycled.
92pub struct BuiltRow {
93    pub scope: Scope,
94    pub rebind: Rc<dyn Fn(usize)>,
95}
96
97pub(crate) struct BoundCell {
98    pub anchor: RNode,
99    /// Between `bind_row` and `recycle`: only displayed cells participate in layout.
100    pub active: bool,
101    /// The row subtree's reactive scope, disposed by `remove_subtree` when the list node
102    /// goes away (the cells live in the list machinery, not the node tree, so nothing else
103    /// would dispose their bindings).
104    pub scope: Scope,
105    pub rebind: Rc<dyn Fn(usize)>,
106    /// The width the backend last laid this cell at through `ListSource::layout_cell`: the
107    /// width the native row granted, which on GTK is the list's less the row's own
108    /// padding. Every later layout of the row (a rebind, the dirty-cell sweep, a data change)
109    /// uses it; `None` until the backend has reported one, when the list's width stands in.
110    pub native_width: Option<f64>,
111    /// The width this cell's row was last laid at, by either path. Only a width that actually
112    /// changed invalidates the row's measurement cache: the dirty-cell sweep re-lays every bound
113    /// cell on every pass, and re-measuring each row's text each time made a 500-row list's
114    /// binds take whole seconds.
115    pub laid_width: Option<f64>,
116    /// The height the row was last laid at (`ListSource::cell_height`).
117    pub laid_height: Option<f64>,
118    /// The cell holds a section header (built by [`ListSectionsDriver::build`]). A host that
119    /// pools headers and rows together hands a header's cell to an item row now and then;
120    /// the kinds differ, so `bind_row` rebuilds the cell instead of rebinding it.
121    pub header: bool,
122    /// The host's pinned copy of a header (`ListSource::bind_pinned`): its ids stay parked, so
123    /// lookups find the header row in the list instead.
124    pub pinned: bool,
125}
126
127pub(crate) struct ListState {
128    pub driver: Rc<ListDriver>,
129    /// Physical cell (native handle as usize) → its built row.
130    pub cells: HashMap<usize, BoundCell>,
131    /// The display rows of a sectioned list; `None` without sections, where display row and
132    /// item index coincide.
133    pub sections: Option<Rc<SectionMap>>,
134}
135
136/// One row as the native host sees it in a sectioned list.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub(crate) enum DisplayRow {
139    /// The header of the section whose first item is this index.
140    Header(usize),
141    Item(usize),
142}
143
144/// A sectioned list's display rows (docs/list.md "Sections"): the items with a header row in
145/// front of each section. The native host counts, binds, selects and scrolls display rows; the
146/// app, the piece and dayscript speak item indices. Everything that crosses between them
147/// translates here: the `ListSource` closures, the patches the tree sends, and the selection
148/// events the toolkit raises (through the event sink, which may run inside a tree borrow, so
149/// the map lives outside the tree, in `SECTION_MAPS`).
150pub(crate) struct SectionMap {
151    driver: Rc<ListDriver>,
152    rows: RefCell<Vec<DisplayRow>>,
153    /// Item index → its display row.
154    display: RefCell<Vec<usize>>,
155    /// The display row of each header, in order, with the header's identity token.
156    heads: RefCell<Vec<(usize, u64)>>,
157    /// The snapshot changed since the rows were computed (a reload, a splice, a commit).
158    stale: Cell<bool>,
159}
160
161impl SectionMap {
162    fn new(driver: Rc<ListDriver>) -> Self {
163        SectionMap {
164            driver,
165            rows: RefCell::new(Vec::new()),
166            display: RefCell::new(Vec::new()),
167            heads: RefCell::new(Vec::new()),
168            stale: Cell::new(true),
169        }
170    }
171
172    /// The snapshot changed: recompute on the next read.
173    pub(crate) fn invalidate(&self) {
174        self.stale.set(true);
175    }
176
177    fn ensure(&self) {
178        let driver = &self.driver;
179        let n = (driver.len)();
180        // A changed item count is a change nobody announced yet (the piece refreshes its
181        // snapshot a moment before it reloads the host): never serve rows for the old one.
182        if !self.stale.get() && self.display.borrow().len() == n {
183            return;
184        }
185        let Some(sections) = driver.sections.as_ref() else {
186            return;
187        };
188        let mut rows = Vec::with_capacity(n + n / 4 + 1);
189        let mut display = Vec::with_capacity(n);
190        let mut heads = Vec::new();
191        // How often each key has opened a section so far: a key that recurs further down
192        // (a feed listed twice) opens a header of its own identity.
193        let mut seen: HashMap<u64, u64> = HashMap::new();
194        let mut previous = None;
195        for i in 0..n {
196            let key = (sections.key_at)(i);
197            if previous != Some(key) {
198                let nth = seen.entry(key).or_default();
199                heads.push((rows.len(), Self::header_token(key, *nth)));
200                *nth += 1;
201                rows.push(DisplayRow::Header(i));
202                previous = Some(key);
203            }
204            display.push(rows.len());
205            rows.push(DisplayRow::Item(i));
206        }
207        *self.rows.borrow_mut() = rows;
208        *self.display.borrow_mut() = display;
209        *self.heads.borrow_mut() = heads;
210        self.stale.set(false);
211    }
212
213    pub(crate) fn len(&self) -> usize {
214        self.ensure();
215        self.rows.borrow().len()
216    }
217
218    pub(crate) fn row(&self, display: usize) -> Option<DisplayRow> {
219        self.ensure();
220        self.rows.borrow().get(display).copied()
221    }
222
223    /// The item at display row `display`; `None` for a header or past the end.
224    pub(crate) fn item_of(&self, display: usize) -> Option<usize> {
225        match self.row(display)? {
226            DisplayRow::Item(i) => Some(i),
227            DisplayRow::Header(_) => None,
228        }
229    }
230
231    /// The item a display row stands for: a header stands for its section's first item.
232    pub(crate) fn nearest_item(&self, display: usize) -> Option<usize> {
233        match self.row(display)? {
234            DisplayRow::Item(i) | DisplayRow::Header(i) => Some(i),
235        }
236    }
237
238    pub(crate) fn display_of(&self, item: usize) -> Option<usize> {
239        self.ensure();
240        self.display.borrow().get(item).copied()
241    }
242
243    /// The section around display row `display`, when that row is an item: the display
244    /// rows of its first and last items, and the number of headers up to and including its
245    /// own, which is what separates a display row from its item index inside the section.
246    pub(crate) fn span(&self, display: usize) -> Option<SectionSpan> {
247        self.item_of(display)?;
248        let heads = self.heads.borrow();
249        let section = heads
250            .partition_point(|(h, _)| *h <= display)
251            .checked_sub(1)?;
252        let last = match heads.get(section + 1) {
253            Some((next, _)) => next - 1,
254            None => self.rows.borrow().len() - 1,
255        };
256        Some(SectionSpan {
257            first: heads[section].0 + 1,
258            last,
259            headers: section + 1,
260        })
261    }
262
263    /// The identity token of the header at display row `display`.
264    pub(crate) fn header_token_at(&self, display: usize) -> Option<u64> {
265        self.ensure();
266        let heads = self.heads.borrow();
267        let at = heads.binary_search_by_key(&display, |(h, _)| *h).ok()?;
268        Some(heads[at].1)
269    }
270
271    /// A header's identity token: its section key and how many sections with that key came
272    /// before it, so a section keeps its header while rows move inside it, and two sections
273    /// with the same key (a feed that recurs further down) never share one.
274    fn header_token(key: u64, nth: u64) -> u64 {
275        (key ^ nth.wrapping_mul(0x9E37_79B9_7F4A_7C15)).rotate_left(29) ^ 0xD1B5_4A32_D192_ED03
276    }
277
278    /// A drag of display row `from` toward display row `to`, in item indices: a header never
279    /// moves, and a row stays in its section, so a target past either end lands at that end
280    /// (docs/list.md "Sections"). Answers the span with the clamped item move.
281    fn move_within(&self, from: usize, to: usize) -> Option<(SectionSpan, usize, usize)> {
282        let span = self.span(from)?;
283        let to = to.clamp(span.first, span.last);
284        Some((span, from - span.headers, to - span.headers))
285    }
286}
287
288/// The rows of one section of a sectioned list, as [`SectionMap::span`] answers them.
289#[derive(Clone, Copy, Debug, PartialEq, Eq)]
290pub(crate) struct SectionSpan {
291    /// The display row of the section's first item.
292    pub first: usize,
293    /// The display row of the section's last item.
294    pub last: usize,
295    /// The headers at or above this section: an item's display row less this is its index.
296    pub headers: usize,
297}
298
299impl SectionSpan {
300    /// The display row of item `item`, when it falls inside this section.
301    fn display_of(&self, item: usize) -> Option<usize> {
302        let display = item + self.headers;
303        (self.first..=self.last)
304            .contains(&display)
305            .then_some(display)
306    }
307}
308
309day_reactive::tls_slots! {
310    list;
311    /// Each sectioned list's map by node, for the event sink (which cannot borrow the tree).
312    /// Weak: the list's state owns the map, and an entry outlives its list only until the next
313    /// registration prunes it.
314    static SECTION_MAPS: RefCell<HashMap<day_spec::NodeId, std::rc::Weak<SectionMap>>> =
315        RefCell::new(HashMap::new());
316    /// [`set_scripted_reorder`]'s routes by node.
317    static SCRIPTED_REORDERS: RefCell<HashMap<day_spec::NodeId, ScriptedReorder>> =
318        RefCell::new(HashMap::new());
319}
320
321/// The map of the sectioned list at `node`; `None` for a list without sections.
322fn section_map(node: RNode) -> Option<Rc<SectionMap>> {
323    let id = crate::tree::rnode_to_id(node);
324    SECTION_MAPS.with(|m| m.borrow().get(&id).and_then(std::rc::Weak::upgrade))
325}
326
327/// The map a sectioned list at `node` shares with its source closures and the event sink.
328pub(crate) fn register_sections(node: RNode, driver: Rc<ListDriver>) -> Rc<SectionMap> {
329    let map = Rc::new(SectionMap::new(driver));
330    SECTION_MAPS.with(|m| {
331        let mut m = m.borrow_mut();
332        m.retain(|_, w| w.strong_count() > 0);
333        m.insert(crate::tree::rnode_to_id(node), Rc::downgrade(&map));
334    });
335    map
336}
337
338/// A toolkit's row event on a sectioned list, in display rows, as the item-index event the
339/// piece expects. A header is not an item, so a selection or activation of one (a host that
340/// failed to refuse it) is dropped; every other event passes unchanged.
341pub(crate) fn translate_native_event(
342    id: day_spec::NodeId,
343    ev: day_spec::Event,
344) -> Option<day_spec::Event> {
345    use day_spec::Event;
346    if !matches!(
347        ev,
348        Event::SelectionChanged(_) | Event::SelectionSet(_) | Event::ListActivated(_)
349    ) {
350        return Some(ev);
351    }
352    let Some(map) = SECTION_MAPS.with(|m| m.borrow().get(&id).and_then(std::rc::Weak::upgrade))
353    else {
354        return Some(ev);
355    };
356    match ev {
357        Event::SelectionChanged(i) if i >= 0 => map
358            .item_of(i as usize)
359            .map(|i| Event::SelectionChanged(i as i64)),
360        Event::SelectionSet(rows) => Some(Event::SelectionSet(
361            rows.iter()
362                .filter(|r| **r >= 0)
363                .filter_map(|r| map.item_of(*r as usize))
364                .map(|i| i as i64)
365                .collect(),
366        )),
367        Event::ListActivated(i) => map.item_of(i).map(Event::ListActivated),
368        other => Some(other),
369    }
370}
371
372/// Whether a `bind_row` must build a new row (fresh anchor) or rebind a recycled cell.
373pub enum CellStep {
374    Build {
375        anchor: RNode,
376    },
377    Rebind {
378        rebind: Rc<dyn Fn(usize)>,
379        anchor: RNode,
380    },
381}
382
383/// Register a list's driver and wire its native host's data-source. Call after the list node and
384/// its native handle exist (from within the piece build; `with_tree` is acquired per op).
385pub fn install_list(node: RNode, driver: ListDriver) {
386    with_tree(|t| t.install_list(node, driver));
387}
388
389/// Tell the native list its data changed (re-query the source). Call with no borrow held.
390pub fn list_reload(node: RNode) {
391    with_tree(|t| t.list_reload(node));
392}
393
394/// Imperatively scroll the native list so its last row is fully visible (chat "stick to bottom").
395/// A no-op while the list is empty. Call with no borrow held.
396/// Row-level deltas to the native host: the animatable alternative to [`list_reload`], for
397/// sources that know exactly how their set changed (a live query's maintainer does).
398pub fn list_splice(node: RNode, deltas: Vec<day_spec::props::RowDelta>) {
399    with_tree(|t| t.list_splice(node, deltas));
400}
401
402pub fn list_scroll_to_end(node: RNode) {
403    with_tree(|t| t.list_scroll_to_end(node));
404}
405
406/// Imperatively scroll the native list so row `row` is visible (docs/list.md), realizing it if
407/// needed. Clamped to the row count; a no-op while the list is empty. Call with no borrow held.
408pub fn list_scroll_to_row(node: RNode, row: usize) {
409    with_tree(|t| t.list_scroll_to_row(node, row));
410}
411
412/// Programmatically sync the list's selected rows (empty = clear selection). Applied by the
413/// toolkit without re-emitting a selection event. Call with no borrow held.
414pub fn list_set_selected(node: RNode, rows: Vec<usize>) {
415    with_tree(|t| t.list_set_selected(node, rows));
416}
417
418/// Enter or leave the list's native editing mode (`ListPatch::Editing`, docs/list.md).
419pub fn list_set_editing(node: RNode, on: bool) {
420    with_tree(|t| t.list_set_editing(node, on));
421}
422
423/// Programmatically reorder a list row through the same guard → commit path a native drag takes
424/// (docs/list.md): consult the piece's guard (which may retarget), rotate the snapshot, defer the
425/// app's `on_reorder`, and tell the native host to re-query. This is how the dayscript `reorder`
426/// step and the mock probe drive reordering without a native gesture. Returns the index the row
427/// actually landed at. Call with no borrow held.
428pub fn list_try_reorder(node: RNode, from: usize, to: usize) -> Result<usize, &'static str> {
429    let hook = SCRIPTED_REORDERS.with(|m| m.borrow().get(&crate::tree::rnode_to_id(node)).cloned());
430    if let Some(hook) = hook {
431        return hook(from, to);
432    }
433    let driver = with_tree(|t| t.list_driver(node)).ok_or("no list at this node")?;
434    let Some(re) = driver.reorder.as_ref() else {
435        return Err("list is not reorderable");
436    };
437    let len = (driver.len)();
438    if from >= len || to >= len {
439        return Err("row index out of bounds");
440    }
441    // A sectioned list keeps the row in its section, as the native drag does.
442    let span = section_map(node).and_then(|map| {
443        let span = map.span(map.display_of(from)?)?;
444        Some((map, span))
445    });
446    let to = match &span {
447        Some((map, span)) => {
448            let display = map.display_of(to).unwrap_or(span.last);
449            display.clamp(span.first, span.last) - span.headers
450        }
451        None => to,
452    };
453    let accepted = (re.can_move)(from, to);
454    if accepted < 0 {
455        return Err("reorder denied by the guard");
456    }
457    let accepted = (accepted as usize).min(len - 1);
458    if let Some((_, span)) = &span
459        && span.display_of(accepted).is_none()
460    {
461        return Err("reorder would leave the row's section");
462    }
463    if accepted != from {
464        (re.moved)(from, accepted);
465    }
466    // No native animation on this path; a reload re-binds the visible rows in the new order.
467    list_reload(node);
468    Ok(accepted)
469}
470
471/// A scripted reorder for a node that reorders without a list driver: a toolkit's own sidebar
472/// (`Cap::NavReorder` Native), whose rows the nav moves itself.
473type ScriptedReorder = Rc<dyn Fn(usize, usize) -> Result<usize, &'static str>>;
474
475/// Route [`list_try_reorder`] at `node` (the dayscript `reorder` step) to `reorder`, for a piece
476/// that reorders rows with no list driver of its own. `reorder` gets the step's `from` and `to`
477/// and answers where the row landed, or why it did not move. Replaces any earlier route.
478pub fn set_scripted_reorder(
479    node: RNode,
480    reorder: impl Fn(usize, usize) -> Result<usize, &'static str> + 'static,
481) {
482    let id = crate::tree::rnode_to_id(node);
483    SCRIPTED_REORDERS.with(|m| m.borrow_mut().insert(id, Rc::new(reorder)));
484}
485
486/// Programmatically delete a list row through the same guard → commit path a native swipe takes
487/// (docs/list.md): consult the piece's guard, drop the row from the snapshot, defer the app's
488/// `on_delete`, and tell the native host to re-query. This is how the dayscript `delete_row` step
489/// and the mock probe drive deletion without a native gesture. Call with no borrow held.
490pub fn list_try_delete(node: RNode, index: usize) -> Result<(), &'static str> {
491    let driver = with_tree(|t| t.list_driver(node)).ok_or("no list at this node")?;
492    let Some(del) = driver.delete.as_ref() else {
493        return Err("list is not deletable");
494    };
495    if index >= (driver.len)() {
496        return Err("row index out of bounds");
497    }
498    if !(del.can_delete)(index) {
499        return Err("delete denied by the guard");
500    }
501    (del.deleted)(index);
502    // No native animation on this path; a reload re-binds the visible rows without the deleted
503    // one.
504    list_reload(node);
505    Ok(())
506}
507
508/// Programmatically run a swipe action through the same offer → commit path a native gesture
509/// takes (docs/list.md): pull the row's offer for the edge, refuse when the action is not in
510/// it, and defer the commit. This is how the dayscript `swipe_row` step and the mock probe
511/// drive the swipe path without a native gesture. `expect` pins the button by its label: offers
512/// are state-dependent, and a pinned step must refuse to press a different button rather
513/// than press it and complain (pressing anyway flips state the script did not mean to flip,
514/// which is how one aborted run poisons every later one). Answers the activated label.
515/// Call with no borrow held.
516pub fn list_try_swipe(
517    node: RNode,
518    index: usize,
519    edge: day_spec::SwipeEdge,
520    action: usize,
521    expect: Option<&str>,
522) -> Result<String, String> {
523    let driver = with_tree(|t| t.list_driver(node)).ok_or("no list at this node")?;
524    let Some(sw) = driver.swipe.as_ref() else {
525        return Err("list has no swipe actions".into());
526    };
527    if index >= (driver.len)() {
528        return Err("row index out of bounds".into());
529    }
530    let offer = (sw.actions_at)(index, edge);
531    let Some(a) = offer.get(action) else {
532        return Err("no such action in this row's offer".into());
533    };
534    if let Some(want) = expect
535        && a.label != want
536    {
537        return Err(format!(
538            "the offer holds {:?}, expected {want:?} — not pressed",
539            a.label
540        ));
541    }
542    let label = a.label.clone();
543    (sw.performed)(index, edge, action);
544    Ok(label)
545}
546
547/// The opt-in first-visible report: enqueue-only, deduplicated, in item indices (a sectioned
548/// list's host reports a display row; a header stands for its section's first item).
549fn driver_viewport(
550    node: RNode,
551    driver: &ListDriver,
552    sections: Option<Rc<SectionMap>>,
553) -> Option<Rc<dyn Fn(usize)>> {
554    if !driver.track_viewport {
555        return None;
556    }
557    let previous = std::cell::Cell::new(None);
558    Some(Rc::new(move |row| {
559        let row = match &sections {
560            Some(map) => match map.nearest_item(row) {
561                Some(item) => item,
562                None => return,
563            },
564            None => row,
565        };
566        if previous.replace(Some(row)) != Some(row) {
567            crate::tree::enqueue_event(
568                crate::tree::rnode_to_id(node),
569                day_spec::Event::Custom {
570                    tag: "day-list-first-visible",
571                    num: row as f64,
572                    text: String::new(),
573                },
574            );
575        }
576    }))
577}
578
579/// Build the `ListSource` the backend calls from its data-source. `len`/`token_at` read the driver
580/// directly (no tree). `bind_row` phases the tree borrow around the build + flush (see module doc).
581/// With `sections`, every closure speaks display rows and translates to the driver's items.
582pub(crate) fn make_source(
583    node: RNode,
584    driver: Rc<ListDriver>,
585    sections: Option<Rc<SectionMap>>,
586) -> ListSource {
587    // The row the host means, in the driver's terms: a plain list's rows are its items.
588    let row_of = {
589        let sections = sections.clone();
590        move |display: usize| match &sections {
591            Some(map) => map.row(display),
592            None => Some(DisplayRow::Item(display)),
593        }
594    };
595    let item_of = {
596        let row_of = row_of.clone();
597        move |display: usize| match row_of(display) {
598            Some(DisplayRow::Item(i)) => Some(i),
599            _ => None,
600        }
601    };
602    let first_visible = driver_viewport(node, &driver, sections.clone());
603    let sectioned = sections.is_some();
604    ListSource {
605        first_visible,
606        len: {
607            let (d, sections) = (driver.clone(), sections.clone());
608            Rc::new(move || match &sections {
609                Some(map) => map.len(),
610                None => (d.len)(),
611            })
612        },
613        token_at: {
614            let (d, row_of, sections) = (driver.clone(), row_of.clone(), sections.clone());
615            Rc::new(move |i| match row_of(i) {
616                Some(DisplayRow::Item(i)) => (d.token_at)(i),
617                Some(DisplayRow::Header(first)) => sections
618                    .as_ref()
619                    .and_then(|map| map.header_token_at(i))
620                    .unwrap_or_else(|| (d.token_at)(first)),
621                // Past the end (a stale pull mid-animation): any token, the row is empty.
622                None => (d.token_at)(i),
623            })
624        },
625        is_header: sections.as_ref().map(|map| {
626            let map = map.clone();
627            Rc::new(move |i| matches!(map.row(i), Some(DisplayRow::Header(_))))
628                as Rc<dyn Fn(usize) -> bool>
629        }),
630        bind_row: bind_with(node, driver.clone(), row_of.clone(), false),
631        bind_pinned: bind_with(node, driver.clone(), row_of.clone(), true),
632        // The cell stays pooled; only its dayscript ids clear, so a hidden row past a shrunk
633        // source stops answering lookups (`try_with_tree`: a backend may recycle while a
634        // snapshot draw holds the borrow; the ids clear on the next pass then).
635        layout_cell: Rc::new(move |cell, width| {
636            // From the native cell's own layout pass: skip inside a snapshot borrow, the next
637            // real pass corrects it (same rule as bind_row).
638            let _ = try_with_tree(|t| t.list_layout_cell_width(node, cell as usize, width));
639        }),
640        cell_height: Rc::new(move |cell| {
641            try_with_tree(|t| t.list_cell_height(node, cell as usize)).unwrap_or(0.0)
642        }),
643        recycle: Rc::new(move |cell| {
644            let _ = try_with_tree(|t| t.list_recycle_cell(node, cell as usize));
645        }),
646        // The host's goodbye to a cell: dispose the row built in it. Never dropped under a
647        // borrow, unlike the park above: a host tears cells down from inside a reload or a
648        // removal Day itself started, and a release lost there is the leak this seam closes.
649        release: Rc::new(move |cell| {
650            let key = cell as usize;
651            crate::tree::run_posted_or_defer(Box::new(move || {
652                let _ = try_with_tree(|t| t.list_release_cell(node, key));
653            }));
654        }),
655        // A sectioned list offers no native delete: the gesture animates one row out, and
656        // deleting a section's last row takes its header with it, which the host's row count
657        // would not expect (docs/list.md "Sections"). The scripted path still drives it.
658        delete: driver
659            .delete
660            .as_ref()
661            .filter(|_| !sectioned)
662            .map(|_| day_spec::ListDelete {
663                can_delete: {
664                    let d = driver.clone();
665                    Rc::new(move |i| (d.delete.as_ref().expect("mapped from Some").can_delete)(i))
666                },
667                delete_row: {
668                    let d = driver.clone();
669                    Rc::new(move |i| (d.delete.as_ref().expect("mapped from Some").deleted)(i))
670                },
671            }),
672        // A sectioned list reorders within a section, the platforms' own rule for grouped
673        // tables: a header never lifts, a drop past either end of the row's section lands at
674        // that end, and the guard sees item indices (docs/list.md "Sections").
675        reorder: driver.reorder.as_ref().map(|_| day_spec::ListReorder {
676            can_move: {
677                let (d, sections) = (driver.clone(), sections.clone());
678                Rc::new(move |from, to| {
679                    let re = d.reorder.as_ref().expect("mapped from Some");
680                    let Some(map) = &sections else {
681                        return (re.can_move)(from, to);
682                    };
683                    let Some((span, from, to)) = map.move_within(from, to) else {
684                        return -1;
685                    };
686                    let accepted = (re.can_move)(from, to);
687                    usize::try_from(accepted)
688                        .ok()
689                        .and_then(|i| span.display_of(i))
690                        .map_or(-1, |display| display as i64)
691                })
692            },
693            move_row: {
694                let (d, sections) = (driver.clone(), sections.clone());
695                Rc::new(move |from, to| {
696                    let re = d.reorder.as_ref().expect("mapped from Some");
697                    let Some(map) = &sections else {
698                        return (re.moved)(from, to);
699                    };
700                    // Hosts commit only a target `can_move` accepted, which is in the section.
701                    if let Some((_, from, to)) = map.move_within(from, to) {
702                        (re.moved)(from, to);
703                        map.invalidate();
704                    }
705                })
706            },
707        }),
708        // Swipe actions belong to items: a header offers none.
709        swipe: driver.swipe.as_ref().map(|_| day_spec::ListSwipe {
710            actions_at: {
711                let (d, item_of) = (driver.clone(), item_of.clone());
712                Rc::new(move |i, e| match item_of(i) {
713                    Some(i) => (d.swipe.as_ref().expect("mapped from Some").actions_at)(i, e),
714                    None => Vec::new(),
715                })
716            },
717            perform: {
718                let (d, item_of) = (driver.clone(), item_of.clone());
719                Rc::new(move |i, e, a| {
720                    if let Some(i) = item_of(i) {
721                        (d.swipe.as_ref().expect("mapped from Some").performed)(i, e, a)
722                    }
723                })
724            },
725        }),
726    }
727}
728
729/// `bind_row`, and with `pinned` its `bind_pinned` twin: the same build or rebind, after which a
730/// pinned copy's ids are parked again (`ListSource::bind_pinned`).
731fn bind_with(
732    node: RNode,
733    d: Rc<ListDriver>,
734    row_of: impl Fn(usize) -> Option<DisplayRow> + 'static,
735    pinned: bool,
736) -> Rc<dyn Fn(usize, RawHandle)> {
737    Rc::new(move |index, cell| {
738        let key = cell as usize;
739        // A stale index past the end binds as an item: the driver answers it with an empty row,
740        // as it always has.
741        let (header, at) = match row_of(index) {
742            Some(DisplayRow::Header(first)) => (true, first),
743            Some(DisplayRow::Item(i)) => (false, i),
744            None => (false, index),
745        };
746        // A backend snapshot draws the window while holding the tree borrow; if that draw
747        // re-enters here (a lazy list realizing a row mid-`cacheDisplayInRect`), skip rather than
748        // double-borrow; the row binds on the next real layout pass (tree.rs::try_with_tree).
749        let Some(step) = try_with_tree(|t| t.list_prepare_cell(node, key, cell, header)) else {
750            return;
751        };
752        match step {
753            CellStep::Build { anchor } => {
754                // Build outside the borrow; BuildCx re-acquires with_tree per op.
755                let built = match (header, d.sections.as_ref()) {
756                    (true, Some(s)) => (s.build)(at, anchor),
757                    _ => (d.build)(at, anchor),
758                };
759                with_tree(|t| t.list_store_cell(node, key, anchor, built, header));
760            }
761            CellStep::Rebind { rebind, .. } => rebind(at),
762        }
763        // Apply the slot-write (or first bindings); reactive effects patch natives via their own
764        // with_tree, so this too runs with no borrow held. Then lay the row out.
765        day_reactive::flush_sync();
766        with_tree(|t| {
767            t.list_layout_cell(node, key);
768            if pinned {
769                t.list_pin_cell(node, key);
770            }
771        });
772    })
773}