day_core/tree_driver.rs
1// Copyright © The Daybrite Project
2// SPDX-License-Identifier: MPL-2.0
3
4//! Recycling-tree driver (docs/tree.md). [`crate::list`]'s shape addressed by token instead of
5//! row index: the native tree host owns scrolling, disclosure and cell reuse; Day owns row
6//! *content*. day-core injects a [`day_spec::TreeSource`] into the backend; when the native
7//! data-source pulls a cell, `bind_row` builds it once (per physical cell) and thereafter
8//! *rebinds* it (one slot-write) as the cell recycles.
9//!
10//! Re-entrancy follows `list.rs` exactly: `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::{MoveVerdict, TreeSource, props::RowHeight};
17use std::collections::HashMap;
18use std::rc::Rc;
19
20/// Supplied by the `tree()` piece, type-erased over the item type. day-core invokes these to
21/// answer the native data-source and to build/rebind rows. All hierarchy queries are
22/// token-addressed (`None` = the root level); see docs/tree.md for why indices cannot
23/// address tree rows.
24pub struct TreeDriver {
25 pub row_height: RowHeight,
26 /// How many children `parent` has (reads the piece's snapshot; no tree access).
27 pub children_len: Box<dyn Fn(Option<u64>) -> usize>,
28 /// The i-th child of `parent`: the stable token every backend keys its rows by.
29 pub child_token: Box<dyn Fn(Option<u64>, usize) -> u64>,
30 /// Whether this token can hold children at all (draws or omits the disclosure).
31 pub expandable: Box<dyn Fn(u64) -> bool>,
32 pub section_header: Box<dyn Fn(u64) -> bool>,
33 /// The piece's current desired expansion state for this token (untracked read of the
34 /// app's expansion signal), which is what the flattener descends into.
35 pub expanded: Box<dyn Fn(u64) -> bool>,
36 /// Build the row for `token` into `anchor`. Uses `BuildCx` internally, so it must be
37 /// called with no `with_tree` borrow held. Returns the row's scope + a rebind writer.
38 pub build: Box<dyn Fn(u64, RNode) -> TreeBuiltRow>,
39 /// The row's type-ahead string (docs/tree.md); native type-select answers from it.
40 pub type_select_text: Box<dyn Fn(u64) -> String>,
41 /// Resolve a dayscript row id (the piece's `.row_id` string) to its token. This is how the
42 /// `expand:`/`tree_move:` steps and the mock address rows without native gestures.
43 pub resolve_row: RowResolver,
44 /// The row's summon-time context menu (docs/menus.md), `None` = no row menus.
45 pub row_menu: Option<RowMenuFn>,
46 /// Drag-to-move half, present when the piece is `.movable(true)` (docs/tree.md).
47 pub moves: Option<TreeMovesDriver>,
48}
49
50/// A `.row_id` string → token resolver (aliased for the field above; clippy's
51/// type-complexity bound).
52pub type RowResolver = Box<dyn Fn(&str) -> Option<u64>>;
53
54/// A row's summon-time context-menu builder (aliased for the field above).
55pub type RowMenuFn = Box<dyn Fn(u64) -> Vec<day_spec::MenuItem>>;
56
57/// The move closures the `tree()` piece supplies (docs/tree.md). Exposed to the backend as
58/// [`day_spec::TreeMoves`] by the tree source adapter; also driven directly by [`tree_try_move`]
59/// (the dayscript `tree_move:` step and the mock probe).
60pub struct TreeMovesDriver {
61 /// Consult the guards (structural + the app's): may `token` land under `parent` at
62 /// `index`? `index: None` = dropped onto the parent (append).
63 #[allow(clippy::type_complexity)]
64 pub can_move: Box<dyn Fn(u64, Option<u64>, Option<usize>) -> MoveVerdict>,
65 /// Commit an accepted move: defers the app's `on_move` through the event queue
66 /// ([`day_spec::Event::TreeMove`]); the app's own data write drives the reload.
67 #[allow(clippy::type_complexity)]
68 pub moved: Box<dyn Fn(u64, Option<u64>, Option<usize>)>,
69}
70
71/// A freshly built tree row: its `Scope` (owns the row's reactive graph) and a rebind writer
72/// that slot-writes `token`'s row into the cell's slot when the cell is recycled.
73pub struct TreeBuiltRow {
74 pub scope: Scope,
75 pub rebind: Rc<dyn Fn(u64)>,
76}
77
78pub(crate) struct TreeBoundCell {
79 pub anchor: RNode,
80 /// The row subtree's reactive scope, disposed by `remove_subtree` when the tree node
81 /// goes away (cells live in the tree machinery, not the node tree).
82 pub scope: Scope,
83 pub rebind: Rc<dyn Fn(u64)>,
84 /// The width the backend last laid this cell at through `TreeSource::layout_cell`: the
85 /// indented width the native row granted. Every later layout of the row uses it;
86 /// `None` until the backend has reported one, when the tree's width stands in.
87 pub native_width: Option<f64>,
88 pub native_height: Option<f64>,
89 /// The width this cell's row was last laid at, by either path; only a width that actually
90 /// changed invalidates the row's measurement cache (see `BoundCell::laid_width`).
91 pub laid_width: Option<f64>,
92 /// The height the row was last laid at (`TreeSource::cell_height`).
93 pub laid_height: Option<f64>,
94}
95
96pub(crate) struct TreeState {
97 pub driver: Rc<TreeDriver>,
98 /// Physical cell (native handle as usize) → its built row.
99 pub cells: HashMap<usize, TreeBoundCell>,
100}
101
102/// Whether a `bind_row` must build a new row (fresh anchor) or rebind a recycled cell.
103pub enum TreeCellStep {
104 Build {
105 anchor: RNode,
106 },
107 Rebind {
108 rebind: Rc<dyn Fn(u64)>,
109 anchor: RNode,
110 },
111}
112
113/// Register a tree's driver and wire its native host's data-source. Call after the tree node
114/// and its native handle exist (from within the piece build; `with_tree` is acquired per op).
115pub fn install_tree(node: RNode, driver: TreeDriver) {
116 with_tree(|t| t.install_tree(node, driver));
117}
118
119/// Tell the native tree its data changed (re-query the source). Expansion and selection
120/// survive by token (docs/tree.md). Call with no borrow held.
121pub fn tree_reload(node: RNode) {
122 with_tree(|t| t.tree_reload(node));
123}
124
125/// Programmatically disclose or collapse one row. Applied by the toolkit without re-emitting
126/// `Event::TreeExpanded`; redundant applications are no-ops. Call with no borrow held.
127pub fn tree_set_expanded(node: RNode, token: u64, expanded: bool) {
128 with_tree(|t| t.tree_set_expanded(node, token, expanded));
129}
130
131/// Programmatically sync the tree's selected rows (empty = clear). Applied by the toolkit
132/// without re-emitting a selection event. Call with no borrow held.
133pub fn tree_set_selected(node: RNode, tokens: Vec<u64>) {
134 with_tree(|t| t.tree_set_selected(node, tokens));
135}
136
137/// Scroll this row into view, realizing it if needed. The piece expands the row's ancestors
138/// (through the app's expansion signal) before issuing this. Call with no borrow held.
139pub fn tree_reveal(node: RNode, token: u64) {
140 with_tree(|t| t.tree_reveal(node, token));
141}
142
143/// The tree's driver, for the guard → commit paths run outside the borrow (`None` when
144/// `node` hosts no tree).
145pub fn tree_driver(node: RNode) -> Option<Rc<TreeDriver>> {
146 with_tree(|t| t.tree_driver(node))
147}
148
149/// Programmatically move a tree row through the same guard → commit path a native drag takes
150/// (docs/tree.md): consult the piece's guards, defer the app's `on_move`, and tell the native
151/// host to re-query. This is how the dayscript `tree_move:` step and the mock probe drive
152/// moves without a native gesture. Call with no borrow held.
153pub fn tree_try_move(
154 node: RNode,
155 token: u64,
156 parent: Option<u64>,
157 index: Option<usize>,
158) -> Result<(), &'static str> {
159 let driver = tree_driver(node).ok_or("no tree at this node")?;
160 let Some(mv) = driver.moves.as_ref() else {
161 return Err("tree is not movable");
162 };
163 match (mv.can_move)(token, parent, index) {
164 MoveVerdict::Deny => Err("move denied by the guard"),
165 MoveVerdict::Allow => {
166 (mv.moved)(token, parent, index);
167 Ok(())
168 }
169 }
170}
171
172/// The flattened visible rows: `(token, depth)` in display order, descending only into
173/// expanded rows (docs/tree.md). The shared substrate the emulated backends, the keyboard
174/// handler and the mock probe all read; one implementation, kind-agnostic over any token tree.
175pub fn tree_visible_rows(driver: &TreeDriver) -> Vec<(u64, u16)> {
176 let mut out = Vec::new();
177 // Manual stack, children pushed in reverse so pop order is display order.
178 let mut stack: Vec<(Option<u64>, u16)> = vec![(None, 0)];
179 while let Some((parent, depth)) = stack.pop() {
180 if let Some(tok) = parent {
181 out.push((tok, depth - 1));
182 if !(driver.expandable)(tok) || !(driver.expanded)(tok) {
183 continue;
184 }
185 }
186 let n = (driver.children_len)(parent);
187 for i in (0..n).rev() {
188 stack.push((Some((driver.child_token)(parent, i)), depth + 1));
189 }
190 }
191 out
192}
193
194/// Build the `TreeSource` the backend calls from its data-source. The hierarchy queries read
195/// the driver directly (no tree); `bind_row` phases the tree borrow around the build + flush
196/// exactly as the list's does (see module doc).
197pub(crate) fn make_tree_source(node: RNode, driver: Rc<TreeDriver>) -> TreeSource {
198 let (d_len, d_tok, d_exp, d_type, d_menu, d_moves, d_bind) = (
199 driver.clone(),
200 driver.clone(),
201 driver.clone(),
202 driver.clone(),
203 driver.clone(),
204 driver.clone(),
205 driver,
206 );
207 let d_header = d_exp.clone();
208 let d_open = d_exp.clone();
209 TreeSource {
210 section_header: Rc::new(move |t| (d_header.section_header)(t)),
211 expanded: Rc::new(move |t| (d_open.expanded)(t)),
212 children_len: Rc::new(move |p| (d_len.children_len)(p)),
213 child_token: Rc::new(move |p, i| (d_tok.child_token)(p, i)),
214 expandable: Rc::new(move |t| (d_exp.expandable)(t)),
215 type_select_text: Rc::new(move |t| (d_type.type_select_text)(t)),
216 row_menu: d_menu.row_menu.as_ref().map(|_| {
217 let d = d_menu.clone();
218 Rc::new(move |t: u64| (d.row_menu.as_ref().expect("mapped from Some"))(t))
219 as Rc<dyn Fn(u64) -> Vec<day_spec::MenuItem>>
220 }),
221 bind_row: Rc::new(move |token, cell| {
222 let key = cell as usize;
223 // Same skip rule as the list: a backend snapshot drawing inside a with_tree
224 // borrow may re-enter here; bind on the next real layout pass instead.
225 let Some(step) = try_with_tree(|t| t.tree_prepare_cell(node, key, cell)) else {
226 return;
227 };
228 match step {
229 TreeCellStep::Build { anchor } => {
230 // Build outside the borrow; BuildCx re-acquires with_tree per op.
231 let built = (d_bind.build)(token, anchor);
232 with_tree(|t| t.tree_store_cell(node, key, anchor, built));
233 }
234 TreeCellStep::Rebind { rebind, .. } => rebind(token),
235 }
236 day_reactive::flush_sync();
237 with_tree(|t| t.tree_layout_cell(node, key));
238 }),
239 recycle: Rc::new(move |cell| {
240 // The pooled cell keeps its built row (rebind is the fast path), but its element
241 // ids go now; see `TreeOps::tree_recycle_cell`. Skip quietly inside a borrow;
242 // the backend defers this call to a safe turn.
243 let _ = try_with_tree(|t| t.tree_recycle_cell(node, cell as usize));
244 }),
245 layout_cell: Rc::new(move |cell, width| {
246 // From the native cell's own layout pass: skip inside a snapshot borrow, the
247 // next real pass corrects it (same rule as bind_row).
248 let _ = try_with_tree(|t| t.tree_layout_cell_width(node, cell as usize, width));
249 }),
250 layout_cell_size: Rc::new(move |cell, size| {
251 let _ = try_with_tree(|t| t.tree_layout_cell_size(node, cell as usize, size));
252 }),
253 cell_height: Rc::new(move |cell| {
254 try_with_tree(|t| t.tree_cell_height(node, cell as usize)).unwrap_or(0.0)
255 }),
256 moves: d_moves.moves.as_ref().map(|_| day_spec::TreeMoves {
257 can_move: {
258 let d = d_moves.clone();
259 Rc::new(move |tok, parent, idx| {
260 (d.moves.as_ref().expect("mapped from Some").can_move)(tok, parent, idx)
261 })
262 },
263 move_node: {
264 let d = d_moves.clone();
265 Rc::new(move |tok, parent, idx| {
266 (d.moves.as_ref().expect("mapped from Some").moved)(tok, parent, idx)
267 })
268 },
269 }),
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 /// A fixed tree: 1{ 2, 3{ 4 } }, 5. Driver closures over static tables.
278 fn fixture(open: &'static [u64]) -> TreeDriver {
279 fn kids(parent: Option<u64>) -> &'static [u64] {
280 match parent {
281 None => &[1, 5],
282 Some(1) => &[2, 3],
283 Some(3) => &[4],
284 _ => &[],
285 }
286 }
287 TreeDriver {
288 section_header: Box::new(|_| false),
289 row_height: RowHeight::Automatic,
290 children_len: Box::new(|p| kids(p).len()),
291 child_token: Box::new(|p, i| kids(p)[i]),
292 expandable: Box::new(|t| !kids(Some(t)).is_empty()),
293 expanded: Box::new(move |t| open.contains(&t)),
294 build: Box::new(|_, _| unreachable!("flattener never builds")),
295 type_select_text: Box::new(|_| String::new()),
296 resolve_row: Box::new(|_| None),
297 row_menu: None,
298 moves: None,
299 }
300 }
301
302 #[test]
303 fn flattener_descends_only_into_expanded_rows() {
304 assert_eq!(tree_visible_rows(&fixture(&[])), [(1, 0), (5, 0)]);
305 assert_eq!(
306 tree_visible_rows(&fixture(&[1])),
307 [(1, 0), (2, 1), (3, 1), (5, 0)]
308 );
309 assert_eq!(
310 tree_visible_rows(&fixture(&[1, 3])),
311 [(1, 0), (2, 1), (3, 1), (4, 2), (5, 0)]
312 );
313 // An expanded token that is not itself visible (3 without 1) changes nothing.
314 assert_eq!(tree_visible_rows(&fixture(&[3])), [(1, 0), (5, 0)]);
315 }
316}