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 §ions {
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 §ions {
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 §ions {
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) = §ions 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) = §ions 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}