Skip to main content

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}