Skip to main content

blockworx/panels/
nav_tree.rs

1//! The Hierarchy segment of the [navigator](crate::shell::navigator). A
2//! filter box over a tree view of the whole document: expanding a row
3//! reveals its child blocks; selecting a row selects that block on the
4//! canvas (navigating the canvas to the block's own level first).
5//!
6//! The segment keeps the name of what the tree holds. A parts list would be
7//! an assembly tree of parts and subassemblies with instance counts; every
8//! row here is a block with its own id — blockworx has no instancing, so
9//! there is nothing to count and no parts list to browse, and what the tree
10//! shows is containment.
11//!
12//! Three mechanisms make a deep tree workable in a narrow panel:
13//!
14//! 1. **Focus re-roots.** A branch row carries `»`, and answers a
15//!    double-click, by redrawing the tree from that node with a breadcrumb
16//!    above it. Indentation resets, so a name's width does not depend on how
17//!    deep it really is.
18//! 2. **The filter flattens.** While filtering, matches are a flat list with
19//!    their ancestor path beneath each, truncated from the *left* so the
20//!    immediate parent survives. Ancestors are never rows of their own —
21//!    three matches would become fifteen rows of context. The filter reads
22//!    the whole document, crossing the focus boundary.
23//! 3. **Reveal from canvas.** Selecting geometry opens every ancestor of the
24//!    block, moves the cursor onto it and scrolls it into view — dropping a
25//!    focus that would otherwise hide it.
26//!
27//! Leaf counts on closed branches are here; instance counts (`×4`) have
28//! nothing to count.
29//!
30//! Not built: sticky ancestor headers while scrolling deep.
31
32use crate::canvas::convert::IntoEgui as _;
33use std::collections::HashSet;
34
35use crate::{
36    kernel::NavTree,
37    path::Scope,
38    theme::{Role, Theme, accent_role},
39    tools::tool::Action,
40};
41use blockworx_doc::id::BlockId;
42use blockworx_kernel::nav::{self, Filtered, Index, Row};
43
44/// Horizontal step per tree depth, in pixels.
45const INDENT: f32 = 16.0;
46/// Width of the chevron/twisty column. Always reserved — even for childless
47/// blocks — so leaf labels line up with their expandable siblings.
48const GUTTER: f32 = 16.0;
49/// Height of one tree row, in pixels.
50const ROW_H: f32 = 22.0;
51/// Extra height a filtered row takes for the ancestor path under its label.
52const PATH_H: f32 = 13.0;
53/// The glyph a branch row carries to re-root the tree on itself.
54/// Latin-1, so no font this app ships can fail to draw it.
55const FOCUS: &str = "\u{bb}";
56/// Half-extent of the little expand/collapse triangle.
57const TRI: f32 = 4.0;
58/// Padding between the gutter and the label.
59const LABEL_PAD: f32 = 4.0;
60/// The column the row's own dot sits in, between the twisty and the label,
61/// and the dot's radius. A directory tree's rows each wear one, and it is
62/// where the guide from the parent arrives.
63const DOT_COLUMN: f32 = 10.0;
64const DOT: f32 = 3.0;
65
66/// A row's disclosure triangle: pointing down (children shown) or right.
67#[derive(Clone, Copy, PartialEq, Eq)]
68enum Twisty {
69    Open,
70    Closed,
71}
72
73/// What the navigator renders against: the session's tree — every block,
74/// the canvas's current path, and the blocks selected on it — and the theme
75/// a row's dot gets its colour from, resolved the same way the canvas
76/// resolves it, so the tree and the drawing name one thing one colour.
77#[derive(Clone, Copy)]
78pub struct NavScene<'a> {
79    pub tree: &'a NavTree,
80    pub theme: &'a Theme,
81}
82
83/// The panel's body, drawn into whatever `ui` the workspace gives it: the
84/// filter box over the tree. Returns the navigation [`Action`] a click or a
85/// keystroke produced.
86pub fn body(ui: &mut egui::Ui, scene: NavScene<'_>) -> Option<Action> {
87    let ctx = ui.ctx().clone();
88    // Substring-matches block labels anywhere in the hierarchy. Stateless
89    // here, so it persists in egui's temp data store frame to frame.
90    let filter_id = nav_filter_id();
91    let mut filter: String = ctx.data_mut(|d| d.get_temp::<String>(filter_id).unwrap_or_default());
92    ui.add(
93        egui::TextEdit::singleline(&mut filter)
94            .id(filter_id)
95            .hint_text("Search all blocks\u{2026}")
96            .desired_width(f32::INFINITY),
97    );
98    ctx.data_mut(|d| d.insert_temp(filter_id, filter.clone()));
99    ui.separator();
100    tree_view(ui, scene, &filter)
101}
102
103/// The persistent expand/collapse set and keyboard cursor ids for the tree.
104fn tree_ids() -> (egui::Id, egui::Id) {
105    (
106        egui::Id::new("nav_tree_expanded"),
107        egui::Id::new("nav_tree_cursor"),
108    )
109}
110
111/// Which block the tree is currently re-rooted on. Panel state,
112/// like the filter and the expand set, so it survives a frame without the
113/// editor holding a field for it.
114fn focus_id() -> egui::Id {
115    egui::Id::new("nav_tree_focus")
116}
117
118/// Render the document tree and return the navigation [`Action`] it produced.
119/// Rows are laid out depth-first from whatever the tree is focused on;
120/// branches carry a twisty and a `»`, and every block — leaf or branch — is
121/// selectable. A non-empty `filter` flattens the tree to its matches
122/// wherever in the document they live.
123fn tree_view(ui: &mut egui::Ui, scene: NavScene<'_>, filter: &str) -> Option<Action> {
124    let NavScene { tree, theme } = scene;
125    let (path, selected) = (&tree.path, tree.selected.as_slice());
126    let ctx = ui.ctx().clone();
127    let (expanded_id, cursor_id) = tree_ids();
128    let mut expanded: HashSet<BlockId> =
129        ctx.data_mut(|d| d.get_temp(expanded_id).unwrap_or_default());
130    let mut cursor: Option<BlockId> = ctx.data_mut(|d| d.get_temp::<BlockId>(cursor_id));
131    let mut focus: Option<BlockId> = ctx.data_mut(|d| d.get_temp::<BlockId>(focus_id()));
132    // The tree is keyboard-driven while the pointer is over it. egui focus on a
133    // non-interactive sentinel doesn't survive the tool switch a row-click
134    // triggers, so hover is the reliable signal for a side panel.
135    let tree_rect = ui.max_rect();
136    let keyboard_active = ui.rect_contains_pointer(tree_rect);
137    let (filtered, active_filter) = Filtered::of(filter);
138
139    // Parent links, and the blocks whose own labels match the search.
140    let index = Index::of(tree, active_filter);
141
142    let mut scroll_to_cursor = false;
143    // Follow the canvas: when the selection or the active path
144    // changes, open the active block's ancestors, move the cursor onto it and
145    // scroll it into view, so the tree always reflects where the canvas is.
146    // Edge-triggered off a stored signature so it never fights the user's own
147    // expand/collapse frame to frame.
148    let sig_id = egui::Id::new("nav_tree_reveal_sig");
149    let signature = (selected.to_vec(), path.segments().to_vec());
150    let changed = ctx
151        .data_mut(|d| d.get_temp::<(Vec<BlockId>, Vec<BlockId>)>(sig_id))
152        .as_ref()
153        != Some(&signature);
154    if changed {
155        // Open the whole active path so the current diagram's contents show.
156        for &seg in path.segments() {
157            expanded.insert(seg);
158        }
159        if let Some(target) = selected
160            .first()
161            .copied()
162            .or_else(|| path.segments().last().copied())
163        {
164            // A focus the target does not live under would hide the row the
165            // canvas just asked for, so revealing gives the focus up.
166            if focus.is_some_and(|at| !index.under(at, target)) {
167                focus = None;
168            }
169            index.expand_ancestors(Scope::Root, target, &mut expanded);
170            cursor = Some(target);
171            scroll_to_cursor = true;
172        }
173        ctx.data_mut(|d| d.insert_temp(sig_id, signature));
174    }
175
176    // The breadcrumb, above the tree and belonging to it: the way back
177    // out of a focus, one ancestor at a time. Drawn only while focused —
178    // an unfocused tree hangs from the document and has nothing to say.
179    if let Some(Refocus(to)) = focus.and_then(|at| breadcrumb(ui, tree, &index, at)) {
180        focus = to;
181    }
182
183    // The filter reads the whole document: it starts from the tree's real
184    // root whatever the panel is focused on.
185    let root = match filtered {
186        Filtered::Yes => Scope::Root,
187        Filtered::No => nav::rooted_at(tree, focus),
188    };
189    let walk = |expanded: &HashSet<BlockId>| index.rows(tree, expanded, root, filtered);
190    let mut rows = walk(&expanded);
191    if cursor.is_none_or(|c| !rows.iter().any(|r| r.id == c)) {
192        cursor = rows.first().map(|r| r.id);
193    }
194
195    let mut navigation = None;
196    if keyboard_active {
197        navigation = KeyNav {
198            rows: &rows,
199            index: &index,
200            top_id: root,
201            expanded: &mut expanded,
202            cursor: &mut cursor,
203            scroll: &mut scroll_to_cursor,
204        }
205        .handle_keys(&ctx);
206        // Expansion may have changed; re-walk so the render matches.
207        rows = walk(&expanded);
208    }
209
210    let refocus = egui::ScrollArea::both()
211        .auto_shrink([false, false])
212        .show(ui, |ui| {
213            if rows.is_empty() {
214                ui.weak(if filtered == Filtered::Yes {
215                    "No matches"
216                } else {
217                    "No blocks"
218                });
219                return None;
220            }
221
222            let font = egui::TextStyle::Body.resolve(ui.style());
223            let small = egui::TextStyle::Small.resolve(ui.style());
224            // Lay out with a placeholder color so each row can be painted in its
225            // own (selected/normal) color via the galley fallback.
226            let ink = egui::Color32::PLACEHOLDER;
227            let lay = |ui: &egui::Ui, text: String, font: &egui::FontId| {
228                ui.painter().layout_no_wrap(text, font.clone(), ink)
229            };
230            let galleys: Vec<_> = rows
231                .iter()
232                .map(|r| lay(ui, r.label.clone(), &font))
233                .collect();
234            let extras: Vec<_> = rows
235                .iter()
236                .map(|r| r.leaves.map(|n| lay(ui, n.to_string(), &small)))
237                .collect();
238            let focus_mark = lay(ui, FOCUS.to_owned(), &font);
239            let content_w = |row: &Row, gw: f32| {
240                row.depth as f32 * INDENT
241                    + GUTTER
242                    + DOT_COLUMN
243                    + LABEL_PAD
244                    + gw
245                    + LABEL_PAD
246                    + focus_mark.size().x
247            };
248            let row_w = rows
249                .iter()
250                .zip(&galleys)
251                .map(|(r, g)| content_w(r, g.size().x))
252                .fold(ui.available_width(), f32::max);
253
254            let visuals = ui.visuals().clone();
255            let guide = visuals.widgets.noninteractive.bg_stroke.color;
256            let quiet = visuals.weak_text_color();
257            let accent = visuals.selection.stroke.color;
258            let mut refocus = None;
259
260            for ((row, galley), extra) in rows.iter().zip(galleys).zip(extras) {
261                let height = ROW_H + if row.ancestors.is_some() { PATH_H } else { 0.0 };
262                let (rect, resp) =
263                    ui.allocate_exact_size(egui::vec2(row_w, height), egui::Sense::click());
264                let is_selected = selected.contains(&row.id);
265                let is_cursor = cursor == Some(row.id);
266                let line = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), ROW_H));
267
268                if is_selected {
269                    ui.painter()
270                        .rect_filled(rect, 4.0, visuals.selection.bg_fill);
271                } else if resp.hovered() {
272                    ui.painter()
273                        .rect_filled(rect, 4.0, visuals.widgets.hovered.bg_fill);
274                }
275
276                let p = ui.painter();
277                let gutter_x = rect.left() + row.depth as f32 * INDENT;
278                let dot_at = egui::pos2(gutter_x + GUTTER + DOT_COLUMN * 0.5, line.center().y);
279                let stroke = egui::Stroke::new(1.0, guide);
280                // The tree guides the user asked for: *"a small colored dot
281                // next to each object, with a connected line going up to the
282                // top."* Each ancestor level draws its own trunk down the
283                // row, the row's own parent draws an elbow across to the
284                // dot, and the trunk of a parent whose last child this is
285                // stops at that elbow rather than running on past the
286                // family — which is what makes a directory tree readable as
287                // one.
288                for level in 0..row.depth {
289                    let x = rect.left() + level as f32 * INDENT + INDENT * 0.5;
290                    let ends_here = level + 1 == row.depth && row.last_child;
291                    let bottom = if ends_here {
292                        dot_at.y
293                    } else {
294                        rect.bottom() + LABEL_PAD
295                    };
296                    p.line_segment(
297                        [egui::pos2(x, rect.top() - LABEL_PAD), egui::pos2(x, bottom)],
298                        stroke,
299                    );
300                    if level + 1 == row.depth {
301                        p.line_segment(
302                            [
303                                egui::pos2(x, dot_at.y),
304                                egui::pos2(dot_at.x - DOT, dot_at.y),
305                            ],
306                            stroke,
307                        );
308                    }
309                }
310                p.circle_filled(
311                    dot_at,
312                    DOT,
313                    theme
314                        .resolve(accent_role(row.accent).unwrap_or(Role::AccentDefault))
315                        .egui(),
316                );
317
318                if row.has_children {
319                    paint_twisty(
320                        p,
321                        egui::pos2(gutter_x + GUTTER * 0.5, line.center().y),
322                        if row.open {
323                            Twisty::Open
324                        } else {
325                            Twisty::Closed
326                        },
327                        quiet,
328                    );
329                }
330
331                let label_color = if is_selected {
332                    accent
333                } else {
334                    visuals.text_color()
335                };
336                let label_x = gutter_x + GUTTER + DOT_COLUMN + LABEL_PAD;
337                let label_w = galley.size().x;
338                p.galley(
339                    egui::pos2(label_x, line.center().y - galley.size().y * 0.5),
340                    galley,
341                    label_color,
342                );
343                let mut after = label_x + label_w + LABEL_PAD;
344                if let Some(count) = extra {
345                    let w = count.size().x;
346                    p.galley(
347                        egui::pos2(after, line.center().y - count.size().y * 0.5),
348                        count,
349                        quiet,
350                    );
351                    after += w + LABEL_PAD;
352                }
353                // Where this match lives, shortened from the left so
354                // the immediate parent — the part that tells them apart —
355                // survives the panel's width.
356                if let Some(ancestors) = &row.ancestors {
357                    let room = rect.right() - label_x;
358                    let text = nav::shortened(ancestors, room, |text| {
359                        ui.painter()
360                            .layout_no_wrap(text.to_owned(), small.clone(), ink)
361                            .size()
362                            .x
363                    });
364                    if !text.is_empty() {
365                        ui.painter().text(
366                            egui::pos2(label_x, line.bottom()),
367                            egui::Align2::LEFT_TOP,
368                            text,
369                            small.clone(),
370                            quiet,
371                        );
372                    }
373                }
374                // The re-root affordance, on the branch rows that have
375                // somewhere to go.
376                let mark = egui::Rect::from_min_size(
377                    egui::pos2(after, line.top()),
378                    egui::vec2(focus_mark.size().x + LABEL_PAD, ROW_H),
379                );
380                if row.has_children {
381                    ui.painter().text(
382                        mark.left_center(),
383                        egui::Align2::LEFT_CENTER,
384                        FOCUS,
385                        font.clone(),
386                        quiet,
387                    );
388                }
389
390                if is_cursor && keyboard_active {
391                    ui.painter().rect_stroke(
392                        rect,
393                        4.0,
394                        egui::Stroke::new(1.0, accent),
395                        egui::StrokeKind::Inside,
396                    );
397                }
398
399                // A double-click is the other half of the focus
400                // affordance, so it outranks the select the same press made.
401                if resp.double_clicked() && row.has_children {
402                    refocus = Some(Refocus(Some(row.id)));
403                } else if resp.clicked() {
404                    let chevron = egui::Rect::from_min_size(
405                        egui::pos2(gutter_x, line.top()),
406                        egui::vec2(GUTTER, ROW_H),
407                    );
408                    let at = resp.interact_pointer_pos();
409                    if row.has_children && at.is_some_and(|pt| chevron.contains(pt)) {
410                        toggle(&mut expanded, row.id);
411                    } else if row.has_children && at.is_some_and(|pt| mark.contains(pt)) {
412                        refocus = Some(Refocus(Some(row.id)));
413                    } else {
414                        cursor = Some(row.id);
415                        let extend = ctx.input(|i| i.modifiers.shift);
416                        navigation = Some(Action::NavSelect {
417                            block: row.id,
418                            extend,
419                        });
420                    }
421                }
422
423                if scroll_to_cursor && is_cursor {
424                    resp.scroll_to_me(Some(egui::Align::Center));
425                }
426            }
427            refocus
428        })
429        .inner;
430    if let Some(Refocus(to)) = refocus {
431        focus = to;
432        // A tree re-rooted somewhere else has no business keeping the old
433        // root's cursor, which is no longer one of its rows.
434        cursor = to;
435    }
436
437    ctx.data_mut(|d| {
438        d.insert_temp(expanded_id, expanded);
439        if let Some(c) = cursor {
440            d.insert_temp(cursor_id, c);
441        }
442        match focus {
443            Some(at) => {
444                d.insert_temp(focus_id(), at);
445            }
446            None => {
447                d.remove_temp::<BlockId>(focus_id());
448            }
449        }
450    });
451    navigation
452}
453
454/// What re-rooting the tree asks for: a block to hang it from, or the
455/// document's own root.
456#[derive(Clone, Copy)]
457struct Refocus(Option<BlockId>);
458
459/// The panel's own breadcrumb, drawn above a focused tree: the
460/// document, then every ancestor down to the block the tree hangs from.
461/// Clicking a segment re-roots there; the tree's indentation restarts from
462/// whatever it lands on. This is the panel's, not the status strip's — that
463/// one says where the *canvas* is, which is a different question.
464fn breadcrumb(ui: &mut egui::Ui, tree: &NavTree, index: &Index, focus: BlockId) -> Option<Refocus> {
465    let mut trail: Vec<BlockId> = index.ancestor_ids(Scope::Root, focus);
466    trail.push(focus);
467    let mut picked = None;
468    ui.horizontal_wrapped(|ui| {
469        ui.spacing_mut().item_spacing.x = 2.0;
470        if ui.small_button(ROOT_CRUMB).clicked() {
471            picked = Some(Refocus(None));
472        }
473        for (ndx, id) in trail.iter().enumerate() {
474            ui.label(egui::RichText::new("/").small().weak());
475            let last = ndx + 1 == trail.len();
476            let label = egui::RichText::new(nav::label_of(tree, *id)).small();
477            if last {
478                ui.label(label);
479            } else if ui.small_button(label).clicked() {
480                picked = Some(Refocus(Some(*id)));
481            }
482        }
483    });
484    ui.separator();
485    picked
486}
487
488/// What the breadcrumb calls the document's own root — the tree's home,
489/// which is a scope rather than a block and so has no label of its own.
490const ROOT_CRUMB: &str = "Diagram";
491
492/// Keyboard tree navigation. Arrow keys move the highlight and Right/Left/Space
493/// expand-collapse, mutating the cursor and expand set in place *without* touching
494/// the canvas; only Enter commits, returning [`Action::NavSelect`] to select and
495/// frame the highlighted block. Keys are consumed so the canvas below does not
496/// also act on them.
497/// The tree state keyboard navigation reads and writes: the flattened rows and
498/// their parent links, plus the cursor/expansion/scroll it mutates in place.
499struct KeyNav<'a> {
500    rows: &'a [Row],
501    index: &'a Index,
502    top_id: Scope,
503    expanded: &'a mut HashSet<BlockId>,
504    cursor: &'a mut Option<BlockId>,
505    scroll: &'a mut bool,
506}
507
508impl KeyNav<'_> {
509    fn handle_keys(&mut self, ctx: &egui::Context) -> Option<Action> {
510        use egui::{Key, Modifiers};
511
512        let Self {
513            rows,
514            index,
515            top_id,
516            expanded,
517            cursor,
518            scroll,
519        } = self;
520        let (rows, index, top_id) = (*rows, *index, *top_id);
521        let key = |k: Key| ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k));
522
523        let idx = cursor.and_then(|c| rows.iter().position(|r| r.id == c))?;
524        let row = &rows[idx];
525        let mut moved_to: Option<BlockId> = None;
526
527        if key(Key::ArrowDown) {
528            if idx + 1 < rows.len() {
529                moved_to = Some(rows[idx + 1].id);
530            }
531        } else if key(Key::ArrowUp) {
532            if idx > 0 {
533                moved_to = Some(rows[idx - 1].id);
534            }
535        } else if key(Key::Home) {
536            moved_to = rows.first().map(|r| r.id);
537        } else if key(Key::End) {
538            moved_to = rows.last().map(|r| r.id);
539        } else if key(Key::ArrowRight) {
540            if row.has_children && !row.open {
541                expanded.insert(row.id);
542            } else if row.has_children && idx + 1 < rows.len() {
543                moved_to = Some(rows[idx + 1].id);
544            }
545        } else if key(Key::ArrowLeft) {
546            if row.has_children && row.open {
547                expanded.remove(&row.id);
548            } else if let Some(p) = index.parent_of(row.id)
549                && p != top_id
550                && let Scope::Block(next) = p
551            {
552                moved_to = Some(next);
553            }
554        } else if key(Key::Space) {
555            if row.has_children {
556                toggle(expanded, row.id);
557            }
558        } else if key(Key::Enter) {
559            // Commit: select the highlighted block on the canvas and frame it. Arrow
560            // keys only move the highlight, so the canvas isn't re-framed on every
561            // keystroke.
562            return Some(Action::NavSelect {
563                block: row.id,
564                extend: false,
565            });
566        }
567
568        if let Some(next) = moved_to {
569            **cursor = Some(next);
570            **scroll = true;
571        }
572        None
573    }
574}
575
576fn toggle(set: &mut HashSet<BlockId>, id: BlockId) {
577    if !set.remove(&id) {
578        set.insert(id);
579    }
580}
581
582/// Paint the little expand/collapse triangle centered at `c`: pointing down when
583/// `open`, right when collapsed.
584fn paint_twisty(painter: &egui::Painter, c: egui::Pos2, twisty: Twisty, color: egui::Color32) {
585    let pts = if twisty == Twisty::Open {
586        vec![
587            egui::pos2(c.x - TRI, c.y - TRI * 0.6),
588            egui::pos2(c.x + TRI, c.y - TRI * 0.6),
589            egui::pos2(c.x, c.y + TRI),
590        ]
591    } else {
592        vec![
593            egui::pos2(c.x - TRI * 0.6, c.y - TRI),
594            egui::pos2(c.x - TRI * 0.6, c.y + TRI),
595            egui::pos2(c.x + TRI, c.y),
596        ]
597    };
598    painter.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE));
599}
600
601/// Widget id of the nav dialog's filter box. (The canvas needs no special case
602/// for it: any non-canvas widget holding keyboard focus suppresses the canvas
603/// key shortcuts — see `compute_interaction`.)
604pub(crate) fn nav_filter_id() -> egui::Id {
605    egui::Id::new("nav_filter")
606}
607
608/// Forget what was typed in the filter box: it is a transient
609/// search, not a persistent view, so dismissing the navigator clears it.
610pub fn clear_filter(ctx: &egui::Context) {
611    ctx.data_mut(|d| d.remove::<String>(nav_filter_id()));
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617    use crate::kernel::{NavTree, chrome::block_label};
618    use crate::path::BlockPath;
619    use blockworx_doc::document::IndexedDocument;
620
621    /// The navigator's tree over `scene`, the way the session builds it.
622    fn tree_of(scene: &mut Scene) -> NavTree {
623        NavTree::of(&scene.indexed(), BlockPath::empty(), Vec::new())
624    }
625    use crate::widget::test_fixtures::{self as fx, Scene};
626    use blockworx_doc::{fixtures::block_id, id::BlockId};
627    use blockworx_geom::{Rect, pos2, vec2};
628
629    fn square(n: u32, name: &str) -> Vec<blockworx_doc::opcode::OpCodes> {
630        vec![
631            fx::block_in(
632                n,
633                Scope::Root,
634                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
635            ),
636            fx::titled(n, name),
637        ]
638    }
639
640    fn nested(n: u32, parent: u32, name: &str) -> Vec<blockworx_doc::opcode::OpCodes> {
641        vec![
642            fx::block_in(
643                n,
644                Scope::Block(block_id(parent)),
645                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
646            ),
647            fx::titled(n, name),
648        ]
649    }
650
651    #[test]
652    fn block_name_falls_back_to_the_id_when_unnamed() {
653        let id = block_id(1);
654        let mut scene = Scene::new(vec![
655            fx::block_in(
656                1,
657                Scope::Root,
658                Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0)),
659            ),
660            fx::titled(1, ""),
661        ]);
662        let indexed = scene.indexed();
663        assert_eq!(block_label(&indexed, id), id.to_string());
664    }
665
666    /// The panel body must not hammer the repaint clock while idle.
667    #[test]
668    fn an_open_navigator_settles() {
669        let mut scene = Scene::new(
670            ["alu", "mux", "regs"]
671                .iter()
672                .enumerate()
673                .flat_map(|(i, name)| {
674                    let x = i as f32 * 100.0;
675                    vec![
676                        fx::block_in(
677                            i as u32 + 1,
678                            Scope::Root,
679                            Rect::from_min_max(pos2(x, 0.0), pos2(x + 80.0, 80.0)),
680                        ),
681                        fx::titled(i as u32 + 1, name),
682                    ]
683                })
684                .collect(),
685        );
686        let tree = tree_of(&mut scene);
687        let settle = crate::canvas::settle::probe(30, |ui| {
688            let _ = body(
689                ui,
690                NavScene {
691                    theme: &Theme::from_embedded(),
692                    tree: &tree,
693                },
694            );
695        });
696        crate::canvas::settle::assert_settles(&settle, 4);
697    }
698
699    /// The tree answers a click on a row by selecting that block — driven
700    /// through the body's own layout, so a row that moved or stopped taking
701    /// clicks fails here.
702    #[test]
703    fn a_row_click_selects_its_block() {
704        let mut scene = Scene::new(square(1, "alu"));
705        let tree = tree_of(&mut scene);
706        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(320.0, 400.0));
707        let mut chrome = crate::panels::painted::Chrome::new(screen);
708        let mut fired = None;
709        let frame = |ui: &mut egui::Ui| {
710            body(
711                ui,
712                NavScene {
713                    theme: &Theme::from_embedded(),
714                    tree: &tree,
715                },
716            )
717        };
718        chrome.settle(|ui| {
719            let _ = frame(ui);
720        });
721        let row = chrome.rect("alu").expect("the tree drew its one row");
722        chrome.click_at(row.center(), |ui| {
723            if let Some(action) = frame(ui) {
724                fired = Some(action);
725            }
726        });
727        assert!(
728            matches!(fired, Some(Action::NavSelect { block, .. }) if block == block_id(1)),
729            "a click on the row did not select its block",
730        );
731    }
732
733    #[test]
734    fn block_name_joins_name_and_type_skipping_empties() {
735        let id = block_id(1);
736        let mut scene = Scene::new(
737            square(1, "Adder")
738                .into_iter()
739                .chain([fx::typed(1, "ALU")])
740                .collect(),
741        );
742        let indexed = scene.indexed();
743        let label = block_label(&indexed, id);
744        assert_eq!(label, "Adder/ALU");
745    }
746
747    #[test]
748    fn block_name_omits_empty_type_segment() {
749        let mut scene = Scene::new(square(1, "Adder"));
750        let indexed = scene.indexed();
751        assert_eq!(block_label(&indexed, block_id(1)), "Adder");
752    }
753
754    /// The panel driven through real frames, with the canvas's selection
755    /// under the test's control — so the three tree mechanisms are proved
756    /// by what the panel actually painted, not by what `Walk` returned.
757    struct Tree<'a> {
758        chrome: crate::panels::painted::Chrome,
759        canvas: Canvas<'a>,
760    }
761
762    struct Canvas<'a> {
763        document: &'a IndexedDocument<'a>,
764        path: BlockPath,
765        selected: Vec<BlockId>,
766        fired: Option<Action>,
767    }
768
769    impl Canvas<'_> {
770        fn frame(&mut self, ui: &mut egui::Ui) {
771            let tree = NavTree::of(self.document, self.path.clone(), self.selected.clone());
772            let fired = body(
773                ui,
774                NavScene {
775                    theme: &Theme::from_embedded(),
776                    tree: &tree,
777                },
778            );
779            if fired.is_some() {
780                self.fired = fired;
781            }
782        }
783    }
784
785    impl<'a> Tree<'a> {
786        fn over(document: &'a IndexedDocument<'a>) -> Self {
787            let mut tree = Tree {
788                chrome: crate::panels::painted::Chrome::new(Rect::from_min_size(
789                    pos2(0.0, 0.0),
790                    vec2(320.0, 500.0),
791                )),
792                canvas: Canvas {
793                    document,
794                    path: BlockPath::empty(),
795                    selected: Vec::new(),
796                    fired: None,
797                },
798            };
799            tree.settle();
800            tree
801        }
802
803        fn settle(&mut self) {
804            let Self { chrome, canvas } = self;
805            chrome.settle(|ui| canvas.frame(ui));
806        }
807
808        fn click_on(&mut self, text: &str) {
809            let Self { chrome, canvas } = self;
810            chrome.click_on(text, |ui| canvas.frame(ui));
811        }
812
813        fn double_click_on(&mut self, text: &str) {
814            let at = self
815                .chrome
816                .rect(text)
817                .unwrap_or_else(|| panic!("nothing painted reads {text:?}"))
818                .center();
819            let Self { chrome, canvas } = self;
820            chrome.double_click_at(at, |ui| canvas.frame(ui));
821        }
822
823        fn type_into_the_filter(&mut self, text: &str) {
824            self.click_on(FILTER_HINT);
825            let Self { chrome, canvas } = self;
826            chrome.type_text(text, |ui| canvas.frame(ui));
827        }
828
829        /// What selecting `block` on the canvas puts through the panel.
830        fn select_on_the_canvas(&mut self, block: BlockId) {
831            self.canvas.selected = vec![block];
832            self.settle();
833        }
834
835        fn shows(&self, text: &str) -> bool {
836            self.chrome.shows(text)
837        }
838    }
839
840    const FILTER_HINT: &str = "Search all blocks\u{2026}";
841
842    /// Platform > Services > Auth, with Marketing off to one side: three
843    /// levels and a sibling subtree to prove a re-rooting left behind.
844    fn deep() -> Vec<blockworx_doc::opcode::OpCodes> {
845        square(1, "Platform")
846            .into_iter()
847            .chain(nested(2, 1, "Services"))
848            .chain(nested(3, 2, "Auth"))
849            .chain(square(4, "Marketing"))
850            .collect()
851    }
852
853    /// Re-rooting through a real frame: the `»` on a branch row re-roots the
854    /// tree there, the panel's own breadcrumb appears above it, and the way
855    /// back is a click on that breadcrumb.
856    #[test]
857    fn the_focus_affordance_re_roots_the_tree_and_the_breadcrumb_undoes_it() {
858        let mut scene = Scene::new(deep());
859        let indexed = scene.indexed();
860        let mut tree = Tree::over(&indexed);
861        assert!(
862            tree.shows("Platform") && tree.shows("Marketing"),
863            "precondition: both root blocks are rows",
864        );
865        assert!(
866            !tree.shows(ROOT_CRUMB),
867            "an unfocused tree has no breadcrumb"
868        );
869        assert_eq!(
870            tree.chrome.rects(FOCUS).len(),
871            1,
872            "only the branch row offers to re-root",
873        );
874
875        tree.click_on(FOCUS);
876        assert!(tree.shows(ROOT_CRUMB), "the breadcrumb did not appear");
877        assert!(
878            tree.shows("Services"),
879            "the focused block's child is not a row"
880        );
881        assert!(
882            !tree.shows("Marketing"),
883            "a block outside the focus is still a row: {:?}",
884            tree.chrome.texts(),
885        );
886
887        tree.click_on(ROOT_CRUMB);
888        assert!(
889            tree.shows("Marketing") && !tree.shows(ROOT_CRUMB),
890            "the breadcrumb's root did not put the whole document back",
891        );
892    }
893
894    /// The other half of the focus affordance: a double-click on a branch
895    /// row re-roots on it too.
896    #[test]
897    fn a_double_click_re_roots_the_tree() {
898        let mut scene = Scene::new(deep());
899        let indexed = scene.indexed();
900        let mut tree = Tree::over(&indexed);
901        tree.double_click_on("Platform");
902        assert!(tree.shows(ROOT_CRUMB), "the double-click did not focus");
903        assert!(
904            tree.shows("Services") && !tree.shows("Marketing"),
905            "the tree did not re-root on the block double-clicked: {:?}",
906            tree.chrome.texts(),
907        );
908    }
909
910    /// The filter through a real frame: it flattens the tree to the
911    /// matches, each carrying its ancestor path — and the ancestors
912    /// themselves are not rows.
913    #[test]
914    fn the_filter_flattens_to_matches_carrying_their_path() {
915        let mut scene = Scene::new(deep());
916        let indexed = scene.indexed();
917        let mut tree = Tree::over(&indexed);
918        tree.type_into_the_filter("auth");
919        assert!(
920            tree.shows("Auth"),
921            "the match is not a row: {:?}",
922            tree.chrome.texts()
923        );
924        assert!(
925            tree.shows("Platform/Services"),
926            "the match does not say where it lives: {:?}",
927            tree.chrome.texts(),
928        );
929        for ancestor in ["Platform", "Services"] {
930            assert!(
931                !tree.shows(ancestor),
932                "{ancestor:?} is drawn as a row of its own, not as a path",
933            );
934        }
935    }
936
937    /// The filter's other half: it reads the whole document, so a match
938    /// outside the focused subtree is still found.
939    #[test]
940    fn the_filter_crosses_the_focus_boundary() {
941        let mut scene = Scene::new(deep());
942        let indexed = scene.indexed();
943        let mut tree = Tree::over(&indexed);
944        tree.click_on(FOCUS);
945        assert!(
946            tree.shows(ROOT_CRUMB) && !tree.shows("Marketing"),
947            "precondition: the tree is focused and Marketing is outside it",
948        );
949        tree.type_into_the_filter("market");
950        assert!(
951            tree.shows("Marketing"),
952            "the filter stopped at the focus boundary: {:?}",
953            tree.chrome.texts(),
954        );
955    }
956
957    /// Reveal through a real frame: selecting geometry on the canvas opens
958    /// the block's ancestors and puts its row on screen, giving up a focus
959    /// that would have hidden it.
960    #[test]
961    fn selecting_on_the_canvas_reveals_the_block_in_the_tree() {
962        let mut scene = Scene::new(deep());
963        let indexed = scene.indexed();
964        let mut tree = Tree::over(&indexed);
965        tree.click_on(FOCUS);
966        assert!(
967            tree.shows(ROOT_CRUMB) && !tree.shows("Marketing"),
968            "precondition: the tree is focused away from Marketing",
969        );
970
971        tree.select_on_the_canvas(block_id(4));
972        assert!(
973            tree.shows("Marketing"),
974            "the canvas selection was never revealed: {:?}",
975            tree.chrome.texts(),
976        );
977        assert!(
978            !tree.shows(ROOT_CRUMB),
979            "a focus that could not show the selection survived it",
980        );
981
982        tree.select_on_the_canvas(block_id(3));
983        assert!(
984            tree.shows("Auth"),
985            "a deep block's ancestors were not opened: {:?}",
986            tree.chrome.texts(),
987        );
988    }
989
990    /// One predicate, three consumers: the blocks the canvas draws a second,
991    /// inset border on, the blocks the navigator hangs a disclosure triangle
992    /// on, and the blocks the PDF cuts a page for are one set. They agree by
993    /// construction — all three call [`structure`] — and this fails the day one
994    /// of them grows a spelling of its own.
995    #[test]
996    fn the_canvas_the_navigator_and_the_pdf_agree_on_what_opens_a_scope() {
997        use blockworx_editor::shape::ShapeRef;
998        use std::collections::BTreeSet;
999
1000        let mut scene = Scene::new(fx::nested_scopes());
1001        let live: Vec<BlockId> = {
1002            let indexed = scene.indexed();
1003            indexed.doc.blocks().map(|(id, _)| id).collect()
1004        };
1005        let nested: BTreeSet<BlockId> = live
1006            .iter()
1007            .copied()
1008            .filter(|&id| crate::path::structure(&scene.indexed(), id).opens_a_scope())
1009            .collect();
1010        assert!(
1011            !nested.is_empty() && nested.len() < live.len(),
1012            "precondition: the fixture must hold both kinds of block, got {nested:?} of {live:?}",
1013        );
1014
1015        // The canvas: every scope in turn, since a level only ever draws its
1016        // own children.
1017        let doc = scene.doc.clone();
1018        let mut double_bordered: BTreeSet<BlockId> = BTreeSet::new();
1019        for scope in std::iter::once(BlockPath::empty()).chain(live.iter().map(|&id| {
1020            let mut path = BlockPath::to_parent_of(&doc, id).expect("a live block has a path");
1021            path.push(id);
1022            path
1023        })) {
1024            scene.path = scope;
1025            let drawing = scene.drawing();
1026            for (id, shape) in drawing.blocks_layer() {
1027                let ShapeRef::Block(block) = shape else {
1028                    continue;
1029                };
1030                if block.structure.opens_a_scope() {
1031                    double_bordered.insert(id.block().expect("a block layer holds blocks"));
1032                }
1033            }
1034        }
1035
1036        let indexed = scene.indexed();
1037        let pages: BTreeSet<BlockId> = blockworx_export::pdf::page_scopes(&indexed)
1038            .into_iter()
1039            .filter_map(|scope| scope.block())
1040            .collect();
1041        let branches: BTreeSet<BlockId> =
1042            nav::branch_rows(&NavTree::of(&indexed, BlockPath::empty(), Vec::new()))
1043                .into_iter()
1044                .collect();
1045
1046        assert_eq!(double_bordered, nested, "the canvas draws a different set");
1047        assert_eq!(pages, nested, "the PDF pages a different set");
1048        // The navigator hangs its tree *from* the top block, which is the PDF's
1049        // first page and the canvas's outermost double border but never a row
1050        // of its own — the one, principled difference.
1051        let root = crate::path::tree_root(&indexed)
1052            .block()
1053            .expect("the fixture designates a top");
1054        assert_eq!(
1055            branches,
1056            nested
1057                .iter()
1058                .copied()
1059                .filter(|&id| id != root)
1060                .collect::<BTreeSet<_>>(),
1061            "the navigator branches on a different set",
1062        );
1063    }
1064}