Skip to main content

blockworx_kernel/
nav.rs

1//! The navigator's tree, flattened into the rows a panel draws.
2//!
3//! [`NavTree`] is what the document holds; what a panel puts on screen is a
4//! flat list, because three things have to happen to a nested tree before it
5//! is legible in a narrow panel and all three change which rows exist rather
6//! than how they look:
7//!
8//! 1. **Focus re-roots.** The tree is redrawn from one node, so indentation
9//!    resets and a name's width does not depend on how deep it really is.
10//! 2. **The filter flattens.** Matches become a flat list carrying their
11//!    ancestry as text; ancestors are never rows of their own, since three
12//!    matches would otherwise become fifteen rows of context. The filter
13//!    reads the whole document, crossing the focus boundary.
14//! 3. **Reveal opens ancestors.** Selecting geometry opens every ancestor of
15//!    the block, and gives up a focus that would hide it.
16//!
17//! Which rows those are is one answer, here, so two front ends browsing one
18//! document browse the same tree. What is *not* here is the state the answer
19//! is taken against — the expand set, the focus, the filter text — which is
20//! each shell's own (shell-on-kernel D7).
21
22use std::collections::{HashMap, HashSet};
23
24use blockworx_doc::id::BlockId;
25use blockworx_editor::path::Scope;
26
27use crate::chrome::NavTree;
28
29/// Whether the tree is showing a filtered subset.
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
31pub enum Filtered {
32    Yes,
33    No,
34}
35
36impl Filtered {
37    /// What `filter` asks for, and the trimmed filter itself where it asks
38    /// for anything at all.
39    #[must_use]
40    pub fn of(filter: &str) -> (Self, Option<&str>) {
41        match Some(filter.trim()).filter(|text| !text.is_empty()) {
42            Some(text) => (Filtered::Yes, Some(text)),
43            None => (Filtered::No, None),
44        }
45    }
46}
47
48/// One row of the flattened tree.
49#[derive(Clone, PartialEq, Eq, Debug)]
50pub struct Row {
51    pub id: BlockId,
52    pub depth: usize,
53    pub has_children: bool,
54    /// Whether this row's children are shown beneath it.
55    pub open: bool,
56    pub label: String,
57    /// Where this row lives, root-first — drawn under the label while the
58    /// filter has flattened the tree. `None` in the unfiltered tree, whose
59    /// indentation says it already.
60    pub ancestors: Option<Vec<String>>,
61    /// How many leaves this branch holds, printed while it is closed. `None`
62    /// on a leaf and on an open branch, both of which show their contents
63    /// instead.
64    pub leaves: Option<usize>,
65    /// The block's own accent index, which colours its dot. `None` where the
66    /// block has taken no accent.
67    pub accent: Option<u8>,
68    /// Whether this row is the last of its parent's children on screen —
69    /// which is where the guide down from that parent stops.
70    pub last_child: bool,
71}
72
73/// The child→parent links of the whole reachable tree and, while searching,
74/// the blocks whose own label matches.
75///
76/// Ancestors are *not* in the matched set: a match's ancestry renders as a
77/// path under its label, never as rows of its own. The top block is never
78/// included either — it is the invisible root the tree hangs from.
79pub struct Index {
80    parent: HashMap<BlockId, Scope>,
81    matched: HashSet<BlockId>,
82}
83
84impl Index {
85    /// Index `tree`, marking what `filter` matches.
86    ///
87    /// The walk starts at the document's root whatever the tree is focused
88    /// on, which is what makes the filter cross the focus boundary.
89    #[must_use]
90    pub fn of(tree: &NavTree, filter: Option<&str>) -> Self {
91        let mut parent: HashMap<BlockId, Scope> = HashMap::new();
92        let mut matched: HashSet<BlockId> = HashSet::new();
93        for (node, above) in tree.walk() {
94            parent.insert(
95                node.id,
96                above.last().map_or(Scope::Root, |&id| Scope::Block(id)),
97            );
98            if filter.is_some_and(|text| matches_filter(&node.label, text)) {
99                matched.insert(node.id);
100            }
101        }
102        Self { parent, matched }
103    }
104
105    /// Where `id` hangs — the scope holding it, which is the tree's root for
106    /// a block the root itself holds.
107    #[must_use]
108    pub fn parent_of(&self, id: BlockId) -> Option<Scope> {
109        self.parent.get(&id).copied()
110    }
111
112    /// Every ancestor of `target` between `top` and it, outermost first.
113    #[must_use]
114    pub fn ancestor_ids(&self, top: Scope, target: BlockId) -> Vec<BlockId> {
115        let mut up = Vec::new();
116        let mut cur = target;
117        while let Some(&at) = self.parent.get(&cur) {
118            if at == top {
119                break;
120            }
121            let Scope::Block(next) = at else { break };
122            up.push(next);
123            cur = next;
124        }
125        up.reverse();
126        up
127    }
128
129    /// Whether `target` is inside `root`'s subtree — what tells a reveal
130    /// whether the current focus would hide the row it is about to select.
131    #[must_use]
132    pub fn under(&self, root: BlockId, target: BlockId) -> bool {
133        let mut cur = target;
134        while let Some(&at) = self.parent.get(&cur) {
135            let Scope::Block(next) = at else { return false };
136            if next == root {
137                return true;
138            }
139            cur = next;
140        }
141        false
142    }
143
144    /// Open every ancestor of `target` up to, but not including, `top`, so
145    /// `target`'s row appears in the flattened tree. `target` itself is left
146    /// as it was — revealing a block does not expand its own children.
147    pub fn expand_ancestors(&self, top: Scope, target: BlockId, expanded: &mut HashSet<BlockId>) {
148        for id in self.ancestor_ids(top, target) {
149            expanded.insert(id);
150        }
151    }
152
153    /// The labels between the tree's root and `id`, root first — what is
154    /// printed under a match. Empty for a child of the root, which has no
155    /// ancestry to explain.
156    #[must_use]
157    pub fn ancestry(&self, tree: &NavTree, id: BlockId) -> Vec<String> {
158        self.ancestor_ids(Scope::Root, id)
159            .into_iter()
160            .map(|at| label_of(tree, at))
161            .collect()
162    }
163
164    /// Depth-first flatten of the tree hanging from `root`.
165    ///
166    /// [`Filtered::Yes`] descends everywhere but emits only the matches, flat
167    /// and carrying their ancestry; [`Filtered::No`] emits the tree as it
168    /// stands, opened where the user opened it.
169    #[must_use]
170    pub fn rows(
171        &self,
172        tree: &NavTree,
173        expanded: &HashSet<BlockId>,
174        root: Scope,
175        filtered: Filtered,
176    ) -> Vec<Row> {
177        let mut out = Vec::new();
178        self.walk(
179            Walk {
180                tree,
181                expanded,
182                node: root,
183                depth: 0,
184                filtered,
185            },
186            &mut out,
187        );
188        mark_last_children(&mut out);
189        out
190    }
191
192    fn walk(&self, at: Walk<'_>, out: &mut Vec<Row>) {
193        let Walk {
194            tree,
195            expanded,
196            node,
197            depth,
198            filtered,
199        } = at;
200        for child in tree.children_of(node) {
201            let cid = child.id;
202            let has_children = child.opens_a_scope();
203            match filtered {
204                Filtered::Yes => {
205                    if self.matched.contains(&cid) {
206                        out.push(Row {
207                            id: cid,
208                            depth: 0,
209                            has_children,
210                            open: false,
211                            label: child.label.clone(),
212                            ancestors: Some(self.ancestry(tree, cid)),
213                            leaves: None,
214                            accent: child.accent,
215                            last_child: true,
216                        });
217                    }
218                    self.walk(
219                        Walk {
220                            node: Scope::Block(cid),
221                            ..at
222                        },
223                        out,
224                    );
225                }
226                Filtered::No => {
227                    let open = has_children && expanded.contains(&cid);
228                    out.push(Row {
229                        id: cid,
230                        depth,
231                        has_children,
232                        open,
233                        label: child.label.clone(),
234                        ancestors: None,
235                        leaves: (has_children && !open).then(|| child.leaves()),
236                        accent: child.accent,
237                        last_child: false,
238                    });
239                    if open {
240                        self.walk(
241                            Walk {
242                                node: Scope::Block(cid),
243                                depth: depth + 1,
244                                ..at
245                            },
246                            out,
247                        );
248                    }
249                }
250            }
251        }
252    }
253}
254
255/// One step of the depth-first flatten: where it is, and everything constant
256/// across the whole walk.
257#[derive(Clone, Copy)]
258struct Walk<'a> {
259    tree: &'a NavTree,
260    expanded: &'a HashSet<BlockId>,
261    node: Scope,
262    depth: usize,
263    filtered: Filtered,
264}
265
266/// Which rows end their parent's run of children: the last row at a given
267/// depth before the tree steps back out of it. A directory tree's guide down
268/// from a parent stops at its last child rather than running on past the
269/// family, and the flat row list is enough to say which row that is.
270fn mark_last_children(rows: &mut [Row]) {
271    for i in 0..rows.len() {
272        let depth = rows[i].depth;
273        rows[i].last_child = rows[i + 1..]
274            .iter()
275            .take_while(|later| later.depth >= depth)
276            .all(|later| later.depth > depth);
277    }
278}
279
280/// The scope the tree hangs from: the focused block, or the tree's own root
281/// when nothing is focused or the focused block has since left the document.
282///
283/// [`Scope::Root`] is the tree's root here — the document's top block, whose
284/// children the tree lists — since the top is never a row of its own.
285#[must_use]
286pub fn rooted_at(tree: &NavTree, focus: Option<BlockId>) -> Scope {
287    focus
288        .filter(|id| tree.node(*id).is_some())
289        .map_or(Scope::Root, Scope::Block)
290}
291
292/// Case-insensitive substring match against the block's `name/type` label; an
293/// empty or whitespace filter matches all.
294#[must_use]
295pub fn matches_filter(label: &str, filter: &str) -> bool {
296    let wanted = filter.trim().to_lowercase();
297    wanted.is_empty() || label.to_lowercase().contains(&wanted)
298}
299
300/// A row's label, falling back to the block's id when the block has left the
301/// tree so a breadcrumb never shows a blank segment.
302#[must_use]
303pub fn label_of(tree: &NavTree, id: BlockId) -> String {
304    tree.node(id)
305        .map_or_else(|| id.to_string(), |node| node.label.clone())
306}
307
308/// `segments` joined into a path that fits `room`, dropped from the LEFT: the
309/// immediate parent is what tells two matches apart, so it is the end that
310/// survives. `measure` is the caller's own text measurement, so what is
311/// dropped depends on the font actually painting it.
312#[must_use]
313pub fn shortened(segments: &[String], room: f32, measure: impl Fn(&str) -> f32) -> String {
314    if segments.is_empty() {
315        return String::new();
316    }
317    for start in 0..segments.len() {
318        let text = if start == 0 {
319            segments.join("/")
320        } else {
321            format!("\u{2026}/{}", segments[start..].join("/"))
322        };
323        if measure(&text) <= room || start + 1 == segments.len() {
324            return text;
325        }
326    }
327    String::new()
328}
329
330/// Every block the tree hangs a disclosure triangle on, read off the rows
331/// themselves with every branch expanded — the navigator's own answer, for
332/// the test that holds it to the canvas's double borders and the PDF's pages.
333/// The tree root is never a row of its own, so it is never here.
334#[cfg(any(test, feature = "test-support"))]
335#[must_use]
336pub fn branch_rows(tree: &NavTree) -> Vec<BlockId> {
337    let expanded: HashSet<BlockId> = tree.walk().map(|(node, _)| node.id).collect();
338    Index::of(tree, None)
339        .rows(tree, &expanded, Scope::Root, Filtered::No)
340        .into_iter()
341        .filter(|row| row.has_children)
342        .map(|row| row.id)
343        .collect()
344}
345
346#[cfg(test)]
347mod tests {
348    use blockworx_doc::fixtures::block_id;
349    use blockworx_editor::path::{BlockPath, tree_root};
350    use blockworx_editor::widget::test_fixtures::{self as fx, Scene};
351    use blockworx_geom::{Rect, pos2};
352
353    use super::*;
354
355    /// The navigator's tree over `scene`, the way the session builds it.
356    fn tree_of(scene: &mut Scene) -> NavTree {
357        NavTree::of(&scene.indexed(), BlockPath::empty(), Vec::new())
358    }
359
360    fn square(n: u32, name: &str) -> Vec<blockworx_doc::opcode::OpCodes> {
361        vec![
362            fx::block_in(
363                n,
364                Scope::Root,
365                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
366            ),
367            fx::titled(n, name),
368        ]
369    }
370
371    fn nested(n: u32, parent: u32, name: &str) -> Vec<blockworx_doc::opcode::OpCodes> {
372        vec![
373            fx::block_in(
374                n,
375                Scope::Block(block_id(parent)),
376                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
377            ),
378            fx::titled(n, name),
379        ]
380    }
381
382    /// Platform > Services > Auth, with Marketing off to one side: three
383    /// levels and a sibling subtree to prove a re-rooting left behind.
384    fn deep() -> Vec<blockworx_doc::opcode::OpCodes> {
385        square(1, "Platform")
386            .into_iter()
387            .chain(nested(2, 1, "Services"))
388            .chain(nested(3, 2, "Auth"))
389            .chain(square(4, "Marketing"))
390            .collect()
391    }
392
393    /// A walk over `tree` hanging from `root`, matching `filter` — the shape
394    /// the flatten tests read.
395    fn rows_of(
396        tree: &NavTree,
397        root: Scope,
398        filter: Option<&str>,
399        expanded: &HashSet<BlockId>,
400    ) -> Vec<Row> {
401        let (filtered, _) = Filtered::of(filter.unwrap_or_default());
402        Index::of(tree, filter).rows(tree, expanded, root, filtered)
403    }
404
405    fn labels(rows: &[Row]) -> Vec<(&str, usize, bool)> {
406        rows.iter()
407            .map(|row| (row.label.as_str(), row.depth, row.last_child))
408            .collect()
409    }
410
411    #[test]
412    fn matches_filter_is_case_insensitive_substring_and_empty_matches_all() {
413        assert!(matches_filter("Adder", ""));
414        assert!(matches_filter("Adder", "   "));
415        assert!(matches_filter("Adder", "add"));
416        assert!(matches_filter("Adder", "DER"));
417        assert!(!matches_filter("Adder", "mux"));
418    }
419
420    /// The filter reads the whole label, so a block's type is searchable too.
421    #[test]
422    fn the_filter_reads_the_type_as_well_as_the_name() {
423        let mut scene = Scene::new(
424            square(1, "Adder")
425                .into_iter()
426                .chain([fx::typed(1, "ALU")])
427                .collect(),
428        );
429        let label = crate::chrome::block_label(&scene.indexed(), block_id(1));
430        assert_eq!(label, "Adder/ALU");
431        assert!(matches_filter(&label, "alu"));
432    }
433
434    /// A search flattens the tree to the matches themselves. The ancestors
435    /// are NOT rows — they ride under each match as its path — and an
436    /// unrelated subtree contributes nothing at all.
437    #[test]
438    fn a_search_flattens_to_the_matches_with_their_path_beneath() {
439        let target = block_id(2);
440        let mut scene = Scene::new(
441            square(1, "Platform")
442                .into_iter()
443                .chain(nested(2, 1, "Authentication"))
444                .chain(square(3, "Marketing"))
445                .collect(),
446        );
447        let tree = tree_of(&mut scene);
448
449        let rows = rows_of(&tree, Scope::Root, Some("auth"), &HashSet::new());
450        assert_eq!(
451            rows.iter().map(|row| row.id).collect::<Vec<_>>(),
452            vec![target],
453            "only the matching label matches, and the ancestor is not a row",
454        );
455        assert_eq!(rows[0].depth, 0, "a flattened match is not indented");
456        assert_eq!(
457            rows[0].ancestors.as_deref(),
458            Some(["Platform".to_owned()].as_slice()),
459            "the match does not carry the path it lives at",
460        );
461        assert_eq!(
462            Index::of(&tree, None).ancestry(&tree, target),
463            vec!["Platform"],
464        );
465    }
466
467    /// A path too long for the panel is dropped from the left, so the
468    /// immediate parent — the segment that tells two matches apart —
469    /// survives.
470    #[test]
471    fn an_ancestor_path_is_shortened_from_the_left() {
472        let segments: Vec<String> = ["Platform", "Services", "Auth"]
473            .iter()
474            .map(|at| (*at).to_owned())
475            .collect();
476        // One unit per character, so the assertions are about which segments
477        // survive rather than about a font.
478        let measure = |text: &str| text.chars().count() as f32;
479
480        assert_eq!(
481            shortened(&segments, 100.0, measure),
482            "Platform/Services/Auth"
483        );
484        assert_eq!(
485            shortened(&segments, 20.0, measure),
486            "\u{2026}/Services/Auth"
487        );
488        assert_eq!(
489            shortened(&segments, 6.0, measure),
490            "\u{2026}/Auth",
491            "the immediate parent is the last thing to go",
492        );
493        assert_eq!(shortened(&[], 100.0, measure), "");
494    }
495
496    /// A closed branch says how many leaves it holds, so its size is legible
497    /// without opening it; an open one shows them instead.
498    #[test]
499    fn a_closed_branch_counts_its_leaves() {
500        let mut scene = Scene::new(
501            square(1, "Platform")
502                .into_iter()
503                .chain(nested(2, 1, "Services"))
504                .chain(nested(3, 2, "Auth"))
505                .chain(nested(4, 2, "Billing"))
506                .collect(),
507        );
508        let tree = tree_of(&mut scene);
509        let closed = rows_of(&tree, Scope::Root, None, &HashSet::new());
510        assert_eq!(closed.len(), 1, "precondition: one closed branch");
511        assert_eq!(
512            closed[0].leaves,
513            Some(2),
514            "Platform holds two leaves, however deep they sit",
515        );
516
517        let expanded: HashSet<BlockId> = std::iter::once(block_id(1)).collect();
518        let open = rows_of(&tree, Scope::Root, None, &expanded);
519        assert_eq!(open[0].leaves, None, "an open branch shows its contents");
520        assert_eq!(open[1].leaves, Some(2), "and its closed child counts");
521    }
522
523    /// Focusing a block re-roots the tree on it, so its children become the
524    /// first rows at depth zero and everything outside is gone.
525    #[test]
526    fn a_focus_re_roots_the_tree_and_resets_the_indentation() {
527        let mut scene = Scene::new(deep());
528        let tree = tree_of(&mut scene);
529        assert_eq!(
530            rooted_at(&tree, None),
531            Scope::Root,
532            "no focus hangs the tree from the document",
533        );
534        let root = rooted_at(&tree, Some(block_id(2)));
535        assert_eq!(root, Scope::Block(block_id(2)));
536
537        let rows = rows_of(&tree, root, None, &HashSet::new());
538        assert_eq!(
539            rows.iter().map(|row| row.id).collect::<Vec<_>>(),
540            vec![block_id(3)]
541        );
542        assert_eq!(rows[0].depth, 0, "the focused root's children start over");
543
544        // A block that has since left the document cannot hold the tree.
545        assert_eq!(rooted_at(&tree, Some(block_id(9))), Scope::Root);
546    }
547
548    /// What makes the guides read as a tree rather than as a grid is where
549    /// they *stop* — a parent's trunk ends at its last child, so the flat row
550    /// list has to know which row that is.
551    #[test]
552    fn a_parents_guide_stops_at_its_last_child() {
553        let mut scene = Scene::new(deep());
554        let tree = tree_of(&mut scene);
555        let expanded: HashSet<BlockId> = [block_id(1), block_id(2)].into_iter().collect();
556        let rows = rows_of(&tree, Scope::Root, None, &expanded);
557        let named = |name: &str| {
558            rows.iter()
559                .find(|row| row.label.starts_with(name))
560                .unwrap_or_else(|| panic!("no {name} row: {:?}", labels(&rows)))
561        };
562        assert_eq!(
563            rows.len(),
564            4,
565            "precondition: Platform > Services > Auth, and Marketing beside them: {:?}",
566            labels(&rows),
567        );
568        assert!(
569            !named("Platform").last_child,
570            "Marketing follows Platform at the root, so Platform is not the last child",
571        );
572        assert!(
573            named("Marketing").last_child,
574            "nothing follows Marketing at the root",
575        );
576        assert!(
577            named("Services").last_child && named("Auth").last_child,
578            "an only child is its parent's last: {:?}",
579            labels(&rows),
580        );
581        assert!(
582            named("Auth").depth > named("Services").depth,
583            "precondition: the deep row really is deeper",
584        );
585    }
586
587    /// What tells a reveal whether the current focus would hide the row the
588    /// canvas just asked for.
589    #[test]
590    fn a_block_is_under_its_ancestors_and_nothing_else() {
591        let mut scene = Scene::new(deep());
592        let tree = tree_of(&mut scene);
593        let index = Index::of(&tree, None);
594        assert!(
595            index.under(block_id(1), block_id(3)),
596            "Auth is inside Platform"
597        );
598        assert!(index.under(block_id(2), block_id(3)));
599        assert!(
600            !index.under(block_id(4), block_id(3)),
601            "Auth is not inside Marketing",
602        );
603        assert!(
604            !index.under(block_id(3), block_id(3)),
605            "nothing is inside itself",
606        );
607    }
608
609    /// Revealing a deep block opens its whole ancestor chain (but not the
610    /// block itself), so it becomes a visible row.
611    #[test]
612    fn expand_ancestors_opens_the_chain_to_a_deep_block() {
613        let (a, b, c) = (block_id(1), block_id(2), block_id(3));
614        let mut scene = Scene::new(
615            square(1, "A")
616                .into_iter()
617                .chain(nested(2, 1, "B"))
618                .chain(nested(3, 2, "C"))
619                .collect(),
620        );
621        let tree = tree_of(&mut scene);
622
623        let mut expanded = HashSet::new();
624        Index::of(&tree, None).expand_ancestors(Scope::Root, c, &mut expanded);
625        assert!(expanded.contains(&a));
626        assert!(expanded.contains(&b));
627        assert!(!expanded.contains(&c), "the target is not force-expanded");
628
629        let rows = rows_of(&tree, Scope::Root, None, &expanded);
630        assert!(
631            rows.iter().any(|row| row.id == c),
632            "the deep block is now a row",
633        );
634    }
635
636    /// Collapsed by default: only the root scope's own children are rows
637    /// until a branch is expanded.
638    #[test]
639    fn flatten_is_collapsed_until_expanded() {
640        let (parent, child) = (block_id(1), block_id(2));
641        let mut scene = Scene::new(
642            square(1, "Platform")
643                .into_iter()
644                .chain(nested(2, 1, "Services"))
645                .collect(),
646        );
647        let tree = tree_of(&mut scene);
648
649        let collapsed = rows_of(&tree, Scope::Root, None, &HashSet::new());
650        assert_eq!(
651            collapsed.iter().map(|row| row.id).collect::<Vec<_>>(),
652            vec![parent]
653        );
654        assert!(collapsed[0].has_children);
655        assert!(!collapsed[0].open);
656
657        let expanded: HashSet<BlockId> = std::iter::once(parent).collect();
658        let opened = rows_of(&tree, Scope::Root, None, &expanded);
659        assert_eq!(
660            opened.iter().map(|row| row.id).collect::<Vec<_>>(),
661            vec![parent, child]
662        );
663    }
664
665    /// With no designated top the tree hangs from the document root, so
666    /// root-level blocks are the first rows. Designating one as the top makes
667    /// it the invisible root instead — its children become the first rows and
668    /// it shows no row of its own.
669    #[test]
670    fn the_tree_hangs_from_the_designated_top_or_the_document_root() {
671        let (outer, inner) = (block_id(1), block_id(2));
672        let ops: Vec<_> = square(1, "Platform")
673            .into_iter()
674            .chain(nested(2, 1, "Services"))
675            .collect();
676
677        let mut rooted = Scene::new(ops.clone());
678        let indexed = rooted.indexed();
679        assert_eq!(tree_root(&indexed), Scope::Root, "no top designated yet");
680        let tree = NavTree::of(&indexed, BlockPath::empty(), Vec::new());
681        let rows = rows_of(&tree, Scope::Root, None, &HashSet::new());
682        assert_eq!(
683            rows.iter().map(|row| row.id).collect::<Vec<_>>(),
684            vec![outer]
685        );
686
687        let mut topped = Scene::new(ops.into_iter().chain([fx::top(1)]).collect());
688        let indexed = topped.indexed();
689        assert_eq!(tree_root(&indexed), Scope::Block(outer));
690        let tree = NavTree::of(&indexed, BlockPath::empty(), Vec::new());
691        let rows = rows_of(&tree, Scope::Root, None, &HashSet::new());
692        assert_eq!(
693            rows.iter().map(|row| row.id).collect::<Vec<_>>(),
694            vec![inner],
695            "the top block is the invisible root, not a row",
696        );
697    }
698}