1use 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
44const INDENT: f32 = 16.0;
46const GUTTER: f32 = 16.0;
49const ROW_H: f32 = 22.0;
51const PATH_H: f32 = 13.0;
53const FOCUS: &str = "\u{bb}";
56const TRI: f32 = 4.0;
58const LABEL_PAD: f32 = 4.0;
60const DOT_COLUMN: f32 = 10.0;
64const DOT: f32 = 3.0;
65
66#[derive(Clone, Copy, PartialEq, Eq)]
68enum Twisty {
69 Open,
70 Closed,
71}
72
73#[derive(Clone, Copy)]
78pub struct NavScene<'a> {
79 pub tree: &'a NavTree,
80 pub theme: &'a Theme,
81}
82
83pub fn body(ui: &mut egui::Ui, scene: NavScene<'_>) -> Option<Action> {
87 let ctx = ui.ctx().clone();
88 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
103fn tree_ids() -> (egui::Id, egui::Id) {
105 (
106 egui::Id::new("nav_tree_expanded"),
107 egui::Id::new("nav_tree_cursor"),
108 )
109}
110
111fn focus_id() -> egui::Id {
115 egui::Id::new("nav_tree_focus")
116}
117
118fn 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 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 let index = Index::of(tree, active_filter);
141
142 let mut scroll_to_cursor = false;
143 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 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 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 if let Some(Refocus(to)) = focus.and_then(|at| breadcrumb(ui, tree, &index, at)) {
180 focus = to;
181 }
182
183 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 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 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 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 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 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 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 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#[derive(Clone, Copy)]
457struct Refocus(Option<BlockId>);
458
459fn 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
488const ROOT_CRUMB: &str = "Diagram";
491
492struct 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 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
582fn 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
601pub(crate) fn nav_filter_id() -> egui::Id {
605 egui::Id::new("nav_filter")
606}
607
608pub 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 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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}