1use crate::canvas::convert::IntoEgui as _;
24use blockworx_doc::rev::Rev;
25use blockworx_store::doc::{At, TimeStep, Viewing};
26
27use crate::{
28 history::{Direction, consequence},
29 kernel::{Consequences, Crumb, Lens, Liveness, Locked, TopBar},
30 panels::overlay::{EXPORT_ICON, export_format_menu, icon_image},
31 preferences::Preferences,
32 shell::glass::{self, Live, Opened, Tint},
33 theme::{Role, Theme},
34 tools::{
35 commands::{Act, CommandId, CommandSet},
36 tool::Action,
37 },
38};
39#[cfg(not(target_arch = "wasm32"))]
42use crate::tools::commands::Effect;
43
44#[cfg(not(target_arch = "wasm32"))]
46pub struct Document<'a> {
47 pub draft: &'a mut String,
51 pub renaming: blockworx_store::doc::Renaming,
52}
53
54pub struct Docked<'a> {
58 pub model: &'a TopBar,
59 pub navigator: Opened,
61 pub theme: &'a Theme,
62 pub prefs: &'a mut Preferences,
63 #[cfg(not(target_arch = "wasm32"))]
65 pub recent: &'a [std::path::PathBuf],
66 #[cfg(not(target_arch = "wasm32"))]
68 pub document: Document<'a>,
69}
70
71pub struct Clicked {
74 pub action: Option<Act>,
75 pub browse: bool,
76}
77
78pub fn top_bar(
80 chrome: &mut super::Chrome,
81 commands: &mut CommandSet,
82 mut bar: Docked<'_>,
83) -> Clicked {
84 let viewing = bar.model.lens.viewing;
85 let width = chrome.safe().viewport().width();
86 let tint = tint(chrome.ctx(), viewing, bar.theme);
87 chrome.shaped(glass::Berth::TopBar, egui::Vec2::ZERO, tint, |ui| {
88 ui.set_width(width - 2.0 * f32::from(glass::Shape::TopBar.margin().left));
89 let mut clicked = Clicked {
90 action: None,
91 browse: false,
92 };
93 let take = |clicked: &mut Clicked, act: Option<Act>| {
94 if act.is_some() {
95 clicked.action = act;
96 }
97 };
98 let picked = left(ui, commands, &mut bar);
99 take(&mut clicked, picked.action);
100 ui.allocate_ui_with_layout(
104 ui.available_size(),
105 egui::Layout::right_to_left(egui::Align::Center),
106 |ui| {
107 let right = right(ui, commands, &bar.model.steps, bar.navigator, viewing);
108 take(&mut clicked, right.action);
109 clicked.browse |= right.browse;
110 take(
111 &mut clicked,
112 centre(ui, &bar.model.lens, bar.theme).map(Act::from),
113 );
114 },
115 );
116 clicked
117 })
118}
119
120fn tint(ctx: &egui::Context, viewing: Viewing, theme: &Theme) -> Tint {
124 let through = glass::Progress::new(ctx.animate_bool_with_time(
125 egui::Id::new("top_bar_tint"),
126 matches!(viewing, Viewing::Past(_)),
127 glass::TINT_MOTION.as_secs_f32(),
128 ));
129 if f32::from(through) <= 0.0 {
130 return Tint::None;
131 }
132 Tint::Over(
133 theme
134 .resolve(Role::ViewingTint)
135 .egui()
136 .gamma_multiply(f32::from(through)),
137 )
138}
139
140fn left(ui: &mut egui::Ui, commands: &mut CommandSet, bar: &mut Docked<'_>) -> Picked {
143 let mut picked = menu(ui, commands, bar);
144 live_dot(ui, bar.model.liveness, bar.theme);
145 if let Some(crumb) = breadcrumb(ui, bar) {
146 picked.action = Some(crumb);
147 }
148 picked
149}
150
151#[derive(Default)]
153struct Picked {
154 action: Option<Act>,
155}
156
157fn live_dot(ui: &mut egui::Ui, liveness: Liveness, theme: &Theme) {
161 let (role, says) = dot(liveness);
162 ui.add_space(DOT_LEAD);
163 let (rect, response) =
164 ui.allocate_exact_size(egui::Vec2::splat(DOT_SIZE), egui::Sense::hover());
165 ui.painter()
166 .circle_filled(rect.center(), DOT_SIZE * 0.5, theme.resolve(role).egui());
167 response.on_hover_text(says);
168 ui.add_space(DOT_LEAD);
169}
170
171fn dot(liveness: Liveness) -> (Role, &'static str) {
175 match liveness {
176 Liveness::Recorded => (Role::LiveDot, "All changes recorded"),
177 Liveness::ReadOnly(Locked::Lens) => (
178 Role::DotReadOnly,
179 "Read-only \u{2014} an earlier rev is on the canvas",
180 ),
181 Liveness::ReadOnly(Locked::Container) => (
182 Role::DotReadOnly,
183 "Read-only \u{2014} this document was opened without a write lock",
184 ),
185 Liveness::Scratch => (
186 Role::DotScratch,
187 "Scratch session \u{2014} not yet saved to disk",
188 ),
189 }
190}
191
192fn breadcrumb(ui: &mut egui::Ui, bar: &mut Docked<'_>) -> Option<Act> {
199 let mut action = None;
200 ui.spacing_mut().item_spacing.x = SEGMENT_GAP;
201 let here = bar.model.scope.here();
202 for shown in bar.model.scope.collapsed() {
203 match shown {
204 Crumb::Root => {
205 if let Some(picked) = root_segment(ui, bar, here) {
206 action = Some(picked);
207 }
208 }
209 Crumb::Level(depth) => {
210 separator(ui);
211 let name = &bar.model.scope.names[depth - 1];
212 if segment(ui, name, Standing::from(depth == here)) {
213 action = Some(bar.model.scope.up_to(depth).into());
214 }
215 }
216 Crumb::Elided(hidden) => {
217 separator(ui);
218 if let Some(depth) = elision(ui, &bar.model.scope.names, hidden) {
219 action = Some(bar.model.scope.up_to(depth).into());
220 }
221 }
222 }
223 }
224 action
225}
226
227fn root_segment(ui: &mut egui::Ui, bar: &mut Docked<'_>, here: usize) -> Option<Act> {
235 #[cfg(not(target_arch = "wasm32"))]
236 {
237 let renaming = bar.document.renaming;
238 let name = bar.model.name.as_str();
239 let draft = &mut *bar.document.draft;
240 if let Some(typed) = name_in_place(ui, name, draft, renaming, Standing::from(here == 0)) {
241 return Some(match typed {
242 Typed::Renamed(to) => Effect::RenameDocument(to).into(),
243 Typed::Rose => bar.model.scope.up_to(0).into(),
244 });
245 }
246 None
247 }
248 #[cfg(target_arch = "wasm32")]
249 {
250 let standing = Standing::from(here == 0);
253 let response = crumb(ui, &bar.model.name, Voice::Full, Listens::from(standing));
254 (standing == Standing::Ancestor && response.clicked())
255 .then(|| bar.model.scope.up_to(0).into())
256 }
257}
258
259#[cfg(not(target_arch = "wasm32"))]
261enum Typed {
262 Renamed(String),
263 Rose,
264}
265
266#[cfg(not(target_arch = "wasm32"))]
273fn name_in_place(
274 ui: &mut egui::Ui,
275 name: &str,
276 draft: &mut String,
277 renaming: blockworx_store::doc::Renaming,
278 standing: Standing,
279) -> Option<Typed> {
280 let editing = ui
281 .ctx()
282 .data(|d| d.get_temp(renaming_id()).unwrap_or(false));
283 let close = |ui: &egui::Ui| {
284 ui.ctx().data_mut(|d| d.insert_temp(renaming_id(), false));
285 };
286 if !editing {
287 let response = crumb(ui, name, Voice::Full, Listens::Yes);
291 let renames =
292 response.double_clicked() && renaming == blockworx_store::doc::Renaming::Offered;
293 if renames {
294 draft.clear();
295 draft.push_str(name);
296 ui.ctx().data_mut(|d| d.insert_temp(renaming_id(), true));
297 ui.ctx().memory_mut(|m| m.request_focus(name_field_id()));
298 }
299 if renames || response.double_clicked() {
304 disarm_rise(ui.ctx());
305 } else if response.clicked() && standing == Standing::Ancestor {
306 arm_rise(ui.ctx());
307 }
308 let hint = match renaming {
309 blockworx_store::doc::Renaming::Offered => RENAME_HINT,
310 blockworx_store::doc::Renaming::Withheld => RENAME_WITHHELD,
311 };
312 response.on_hover_text(hint);
313 return rise_is_due(ui.ctx()).then_some(Typed::Rose);
314 }
315 let field = ui.add(
316 egui::TextEdit::singleline(draft)
317 .id(name_field_id())
318 .desired_width(NAME_WIDTH),
319 );
320 if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
321 close(ui);
322 return None;
323 }
324 if field.lost_focus() {
328 close(ui);
329 return (draft.as_str() != name).then(|| Typed::Renamed(draft.clone()));
330 }
331 None
332}
333
334fn elision(ui: &mut egui::Ui, names: &[String], hidden: Vec<usize>) -> Option<usize> {
341 let mut picked = None;
342 let button = egui::Button::new(egui::RichText::new(ELLIPSIS).weak())
343 .min_size(egui::vec2(ELLIPSIS_WIDTH, CRUMB_HEIGHT))
344 .corner_radius(CRUMB_RADIUS)
345 .frame_when_inactive(false);
346 egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| {
347 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
348 for depth in hidden {
349 if let Some(name) = names.get(depth - 1)
350 && ui.button(name).clicked()
351 {
352 picked = Some(depth);
353 }
354 }
355 });
356 picked
357}
358
359#[derive(Clone, Copy, PartialEq, Eq)]
361enum Standing {
362 Here,
363 Ancestor,
364}
365
366impl From<bool> for Standing {
367 fn from(here: bool) -> Self {
368 if here {
369 Standing::Here
370 } else {
371 Standing::Ancestor
372 }
373 }
374}
375
376fn segment(ui: &mut egui::Ui, text: &str, standing: Standing) -> bool {
381 let response = crumb(ui, text, Voice::from(standing), Listens::from(standing));
382 match standing {
383 Standing::Here => false,
384 Standing::Ancestor => response.on_hover_text(format!("Go to {text}")).clicked(),
385 }
386}
387
388#[derive(Clone, Copy, PartialEq, Eq)]
392enum Voice {
393 Full,
394 Muted,
395}
396
397impl From<Standing> for Voice {
398 fn from(standing: Standing) -> Self {
399 match standing {
400 Standing::Here => Voice::Full,
401 Standing::Ancestor => Voice::Muted,
402 }
403 }
404}
405
406#[derive(Clone, Copy, PartialEq, Eq)]
410enum Listens {
411 Yes,
412 No,
413}
414
415impl From<Standing> for Listens {
416 fn from(standing: Standing) -> Self {
417 match standing {
418 Standing::Here => Listens::No,
419 Standing::Ancestor => Listens::Yes,
420 }
421 }
422}
423
424fn crumb(ui: &mut egui::Ui, text: &str, voice: Voice, listens: Listens) -> egui::Response {
435 let font = egui::TextStyle::Body.resolve(ui.style());
436 let mut job = egui::text::LayoutJob::simple_singleline(
437 text.to_owned(),
438 font,
439 egui::Color32::PLACEHOLDER,
442 );
443 job.wrap = egui::text::TextWrapping::truncate_at_width(CRUMB_WIDTH - 2.0 * CRUMB_PAD);
444 let galley = ui.painter().layout_job(job);
445 let size = egui::vec2(galley.size().x + 2.0 * CRUMB_PAD, CRUMB_HEIGHT);
446 let sense = match listens {
447 Listens::Yes => egui::Sense::click(),
448 Listens::No => egui::Sense::hover(),
449 };
450 let (rect, response) = ui.allocate_exact_size(size, sense);
451 if !ui.is_rect_visible(rect) {
452 return response;
453 }
454 let visuals = ui.visuals();
455 let ink = match (voice, listens == Listens::Yes && response.hovered()) {
456 (Voice::Full, _) | (Voice::Muted, true) => glass::full_ink(visuals),
457 (Voice::Muted, false) => visuals.weak_text_color(),
458 };
459 if listens == Listens::Yes && (response.hovered() || response.is_pointer_button_down_on()) {
460 let fill = ui.style().interact(&response).weak_bg_fill;
461 ui.painter().rect_filled(rect, CRUMB_RADIUS, fill);
462 }
463 let at = egui::pos2(
464 rect.left() + CRUMB_PAD,
465 rect.center().y - galley.size().y * 0.5,
466 );
467 ui.painter().galley(at, galley, ink);
468 response
469}
470
471#[cfg(not(target_arch = "wasm32"))]
474fn arm_rise(ctx: &egui::Context) {
475 let at = ctx.input(|i| i.time);
476 ctx.data_mut(|data| data.insert_temp(rising_id(), Rising { at }));
477 ctx.request_repaint_after(double_click_window(ctx));
478}
479
480#[cfg(not(target_arch = "wasm32"))]
481fn disarm_rise(ctx: &egui::Context) {
482 ctx.data_mut(|data| data.remove::<Rising>(rising_id()));
483}
484
485#[cfg(not(target_arch = "wasm32"))]
489fn rise_is_due(ctx: &egui::Context) -> bool {
490 let Some(armed) = ctx.data(|data| data.get_temp::<Rising>(rising_id())) else {
491 return false;
492 };
493 let waited = core::time::Duration::from_secs_f64((ctx.input(|i| i.time) - armed.at).max(0.0));
494 match double_click_window(ctx).checked_sub(waited) {
495 None | Some(core::time::Duration::ZERO) => {
496 disarm_rise(ctx);
497 true
498 }
499 Some(left) => {
500 ctx.request_repaint_after(left);
501 false
502 }
503 }
504}
505
506#[cfg(not(target_arch = "wasm32"))]
510fn double_click_window(ctx: &egui::Context) -> core::time::Duration {
511 core::time::Duration::from_secs_f64(
512 ctx.options(|options| options.input_options.max_double_click_delay),
513 )
514}
515
516#[cfg(not(target_arch = "wasm32"))]
519#[derive(Clone, Copy)]
520struct Rising {
521 at: f64,
522}
523
524fn separator(ui: &mut egui::Ui) {
525 ui.label(egui::RichText::new(SEPARATOR).weak());
526}
527
528fn centre(ui: &mut egui::Ui, lens: &Lens, theme: &Theme) -> Option<Action> {
537 if !matches!(lens.viewing, Viewing::Past(_)) {
538 return None;
539 }
540 let room = ui.available_rect_before_wrap();
541 let row = |rect: egui::Rect| {
542 egui::UiBuilder::new()
543 .max_rect(rect)
544 .layout(egui::Layout::left_to_right(egui::Align::Center))
545 };
546 let wanted = {
547 let mut probe = ui.new_child(row(room).sizing_pass().invisible());
548 let _ = mode(&mut probe, lens, theme);
549 probe.min_rect().width().min(room.width())
550 };
551 let at = egui::Rect::from_min_size(
552 egui::pos2(room.center().x - wanted * 0.5, room.top()),
553 egui::vec2(wanted, room.height()),
554 );
555 let mut child = ui.new_child(row(at));
556 let action = mode(&mut child, lens, theme);
557 ui.advance_cursor_after_rect(room);
558 action
559}
560
561fn mode(ui: &mut egui::Ui, lens: &Lens, theme: &Theme) -> Option<Action> {
564 let Viewing::Past(at) = lens.viewing else {
565 return None;
566 };
567 let ink = theme.resolve(Role::ViewingInk).egui();
568 let mut action = None;
569 ui.visuals_mut().widgets.inactive.fg_stroke.color = ink;
573 ui.label(
574 egui::RichText::new(headline(at, &lens.age))
575 .color(ink)
576 .strong(),
577 );
578 if let Some(stepped) = stepper(ui, lens) {
579 action = Some(stepped);
580 }
581 if glass::tap_filled(
582 ui,
583 RETURN,
584 theme.resolve(Role::ViewingInk).egui(),
585 theme.resolve(Role::ViewingReturnInk).egui(),
586 RETURN_HINT,
587 )
588 .clicked()
589 {
590 action = Some(Action::ViewHead);
591 }
592 action
593}
594
595fn headline(at: Rev, age: &str) -> String {
601 let rev = format!("Rev {}", at.get());
602 match age.trim() {
603 "" => rev,
604 age => format!("{rev} \u{00b7} {age}"),
605 }
606}
607
608fn stepper(ui: &mut egui::Ui, lens: &Lens) -> Option<Action> {
613 let mut action = None;
614 for (icon, hover, to) in [
615 (
616 STEP_OLDER_ICON,
617 "Older rev",
618 lens.viewing.stepped(lens.head, TimeStep::Back),
619 ),
620 (
621 STEP_NEWER_ICON,
622 "Newer rev",
623 lens.viewing.stepped(lens.head, TimeStep::Forward),
624 ),
625 ] {
626 if glass::tap_button(ui, icon, Live::from(to.is_some()), hover).clicked() {
627 action = Some(match to {
628 Some(At::Rev(rev)) => Action::ViewRev(rev),
629 _ => Action::ViewHead,
630 });
631 }
632 }
633 action
634}
635
636fn right(
640 ui: &mut egui::Ui,
641 commands: &mut CommandSet,
642 steps: &Consequences,
643 navigator: Opened,
644 viewing: Viewing,
645) -> Clicked {
646 let mut clicked = None;
647 let browse = glass::tap_toggle(
648 ui,
649 BROWSE_ICON,
650 Live::Yes,
651 navigator,
652 browse_hover(ui.ctx()),
653 )
654 .clicked();
655 let fit = commands.contains(CommandId::FitView);
656 if glass::tap_button(ui, FIT_ICON, Live::from(fit), fit_hover(ui.ctx())).clicked() {
657 clicked = Some(CommandId::FitView);
658 }
659 let can_rise = Live::from(commands.contains(CommandId::GoUp));
664 if glass::tap_button(ui, UP_ICON, can_rise, rise_hover(can_rise)).clicked() {
665 clicked = Some(CommandId::GoUp);
666 }
667 glass::separator(ui, glass::Run::Row);
670 if let Some(id) = tape(ui, commands, steps, viewing) {
671 clicked = Some(id);
672 }
673 Clicked {
674 action: clicked.and_then(|id| commands.take(id)),
675 browse,
676 }
677}
678
679fn tape(
681 ui: &mut egui::Ui,
682 commands: &mut CommandSet,
683 steps: &Consequences,
684 viewing: Viewing,
685) -> Option<CommandId> {
686 let mut clicked = None;
687 for (id, icon, step, of) in [
688 (CommandId::Redo, REDO_ICON, Direction::Forward, &steps.redo),
689 (CommandId::Undo, UNDO_ICON, Direction::Back, &steps.undo),
690 ] {
691 let live = Live::from(commands.contains(id));
692 let hover = consequence(step, of.as_ref(), (live == Live::Yes).into(), viewing);
693 if glass::tap_button(ui, icon, live, hover).clicked() {
694 clicked = Some(id);
695 }
696 }
697 clicked
698}
699
700fn fit_hover(ctx: &egui::Context) -> String {
703 let chords: Vec<String> = crate::tools::commands::chords(CommandId::FitView)
704 .map(|chord| crate::keys::spelled(ctx, *chord))
705 .collect();
706 if chords.is_empty() {
707 FIT.to_owned()
708 } else {
709 format!("{FIT} ({})", chords.join(", "))
710 }
711}
712
713fn browse_hover(ctx: &egui::Context) -> String {
716 let chord = ctx.format_shortcut(&egui::KeyboardShortcut::new(
717 egui::Modifiers::COMMAND,
718 egui::Key::Backslash,
719 ));
720 format!("{BROWSE} ({chord})")
721}
722
723fn rise_hover(can_rise: Live) -> &'static str {
726 if can_rise == Live::Yes {
727 "Go up a level \u{2014} out to the scope that holds this one"
728 } else {
729 "Go up a level \u{2014} the canvas is at the document root"
730 }
731}
732
733const GITHUB_URL: &str = "https://github.com/samitbasu/blockworx";
735
736fn menu(ui: &mut egui::Ui, commands: &mut CommandSet, bar: &mut Docked<'_>) -> Picked {
739 let mut picked = Picked::default();
740 let button = egui::Button::image(icon_image(ui, MENU_ICON))
741 .min_size(egui::Vec2::splat(glass::TAP))
742 .frame_when_inactive(false);
743 egui::containers::menu::MenuButton::from_button(button)
744 .ui(ui, |ui| {
745 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
746 #[cfg(not(target_arch = "wasm32"))]
747 if let Some(chosen) =
748 crate::panels::file_menu::menu(ui, bar.recent, bar.model.lens.viewing)
749 {
750 picked.action = Some(chosen.into());
751 }
752 #[cfg(not(target_arch = "wasm32"))]
753 ui.separator();
754 if let Some(chosen) = import_export(ui, commands) {
755 picked.action = Some(chosen);
756 }
757 ui.separator();
758 ui.menu_button("Help", |ui| {
759 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
763 if ui.button("GitHub").clicked() {
764 ui.ctx().open_url(egui::OpenUrl::new_tab(GITHUB_URL));
765 }
766 });
767 ui.menu_button("Preferences", |ui| {
768 crate::preferences_menu::menu(ui, bar.prefs);
769 });
770 ui.separator();
771 palette_hint(ui);
772 })
773 .0
774 .on_hover_text("Document menu");
775 picked
776}
777
778fn palette_hint(ui: &mut egui::Ui) {
781 let chord = ui.ctx().format_shortcut(&egui::KeyboardShortcut::new(
782 egui::Modifiers::COMMAND,
783 egui::Key::K,
784 ));
785 ui.add_enabled(
786 false,
787 egui::Button::new(format!("Search tools, blocks and revs\u{2003}{chord}")),
788 );
789}
790
791fn import_export(ui: &mut egui::Ui, commands: &mut CommandSet) -> Option<Act> {
795 let mut action = None;
796 let import = ui.add_enabled(
799 commands.contains(CommandId::Import),
800 egui::Button::image_and_text(icon_image(ui, IMPORT_ICON), "Import\u{2026}"),
801 );
802 if import.clicked() {
803 action = commands.take(CommandId::Import);
804 }
805 ui.menu_image_text_button(icon_image(ui, EXPORT_ICON), "Export", |ui| {
806 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
807 if let Some(format) = export_format_menu(ui, crate::export::ExportScope::View) {
808 action = Some(
809 Action::Export {
810 format,
811 selection: None,
812 }
813 .into(),
814 );
815 }
816 });
817 action
818}
819
820pub fn escape_exits(ctx: &egui::Context) -> Option<Action> {
828 let pressed = !ctx.egui_wants_keyboard_input()
829 && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
830 pressed.then_some(Action::ViewHead)
831}
832
833#[cfg(not(target_arch = "wasm32"))]
835fn rising_id() -> egui::Id {
836 egui::Id::new("top_bar_rising")
837}
838
839#[cfg(not(target_arch = "wasm32"))]
840fn renaming_id() -> egui::Id {
841 egui::Id::new("top_bar_renaming")
842}
843
844#[cfg(not(target_arch = "wasm32"))]
845fn name_field_id() -> egui::Id {
846 egui::Id::new("top_bar_name_field")
847}
848
849#[cfg(not(target_arch = "wasm32"))]
851const RENAME_HINT: &str = "Double-click to rename";
852#[cfg(not(target_arch = "wasm32"))]
854const RENAME_WITHHELD: &str = "This session has no diagram on disk to rename";
855#[cfg(not(target_arch = "wasm32"))]
857const NAME_WIDTH: f32 = 160.0;
858
859const FIT: &str = "Zoom to fit";
860const BROWSE: &str = "Browse";
861const RETURN: &str = "Return";
862const RETURN_HINT: &str = "Return to current (Escape)";
863
864const DOT_SIZE: f32 = 8.0;
866const DOT_LEAD: f32 = 4.0;
867
868const CRUMB_HEIGHT: f32 = 32.0;
871const CRUMB_WIDTH: f32 = 200.0;
872const CRUMB_PAD: f32 = 9.0;
873const CRUMB_RADIUS: u8 = 9;
874const ELLIPSIS_WIDTH: f32 = 30.0;
877const SEGMENT_GAP: f32 = 1.0;
878const SEPARATOR: &str = "/";
879const ELLIPSIS: &str = "\u{00b7}\u{00b7}\u{00b7}";
880
881const MENU_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-menu.svg");
886const IMPORT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-import.svg");
887const UNDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-undo.svg");
888const REDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-redo.svg");
889const FIT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-fit.svg");
890const UP_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-level-up.svg");
891const BROWSE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-panel.svg");
892const STEP_OLDER_ICON: egui::ImageSource<'static> =
898 egui::include_image!("../../icons/icon-step-older.svg");
899const STEP_NEWER_ICON: egui::ImageSource<'static> =
900 egui::include_image!("../../icons/icon-step-newer.svg");
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905 use crate::canvas::convert::IntoGeom as _;
906 use crate::history::{Consequence, Kind};
907 use crate::panels::painted::Chrome;
908 use crate::path::BlockPath;
909 use crate::path::Scope as PathScope;
910 use crate::shell::tests::screen;
911 use crate::tools::commands::{CommandContext, History};
912 use crate::widget::test_fixtures::{self as fx, Scene};
913 use blockworx_doc::fixtures::{block_id, rev};
914 use blockworx_geom::{Pos2, Rect};
915 use blockworx_store::doc::{Attachment, Saving, Viewing, Writability};
916
917 fn of(target: &str, kind: Kind) -> Consequence {
918 Consequence {
919 target: target.to_owned(),
920 kind,
921 }
922 }
923
924 const DOCUMENT: &str = "engine";
925 const PLATE_SLACK: f32 = 4.0;
929 const NEWEST: &str = "Add block Filter";
931 const AT: u64 = 2;
933 const HEAD: u64 = 4;
934
935 struct Session {
937 history: History,
938 steps: Consequences,
939 kind: Kind,
942 writability: Writability,
943 attachment: Attachment,
944 viewing: Viewing,
945 navigator: Opened,
946 prefs: Preferences,
947 theme: Theme,
948 scene: Scene,
949 path: BlockPath,
950 names: Vec<String>,
951 #[cfg(not(target_arch = "wasm32"))]
952 draft: String,
953 #[cfg(not(target_arch = "wasm32"))]
954 renaming: blockworx_store::doc::Renaming,
955 fired: Vec<Act>,
956 browses: usize,
957 }
958
959 impl Session {
960 fn new() -> Self {
961 Session {
962 history: History::doc(),
963 steps: Consequences {
964 undo: Some(of(NEWEST, Kind::Doc)),
965 redo: Some(of("Delete block Filter", Kind::Doc)),
966 },
967 kind: Kind::Doc,
968 writability: Writability::Writable,
969 attachment: Attachment::Attached,
970 viewing: Viewing::Head,
971 navigator: Opened::No,
972 prefs: Preferences::default(),
973 theme: Theme::default(),
974 scene: Scene::new(Vec::new()),
975 path: BlockPath::empty(),
976 names: Vec::new(),
977 #[cfg(not(target_arch = "wasm32"))]
978 draft: String::new(),
979 #[cfg(not(target_arch = "wasm32"))]
980 renaming: blockworx_store::doc::Renaming::Offered,
981 fired: Vec::new(),
982 browses: 0,
983 }
984 }
985
986 fn nested(depth: usize) -> Self {
989 let body = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(60.0, 60.0));
990 let mut ops = Vec::new();
991 let mut path = BlockPath::empty();
992 let mut names = Vec::new();
993 for level in 1..=depth {
994 let id = block_id(level as u32);
995 let scope = match level {
996 1 => PathScope::Root,
997 _ => PathScope::Block(block_id(level as u32 - 1)),
998 };
999 ops.push(fx::block_in(level as u32, scope, body.geom()));
1000 ops.push(fx::titled(level as u32, &format!("Level {level}")));
1001 path.push(id);
1002 names.push(format!("Level {level}"));
1003 }
1004 let mut session = Session::new();
1005 session.scene = Scene::new(ops).inside(block_id(depth as u32));
1008 session.path = path;
1009 session.names = names;
1010 session
1011 }
1012
1013 fn under_the_lens(mut self) -> Self {
1014 self.viewing = Viewing::Past(rev(AT));
1015 self
1016 }
1017
1018 fn frame(&mut self, ui: &mut egui::Ui) {
1019 let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1020 let mut commands = {
1021 let drawing = self.scene.drawing();
1022 CommandSet::available(&CommandContext {
1023 tool: &tool,
1024 data: &drawing,
1025 history: self.history,
1026 current_lock: crate::edit::naming::InterfaceLock::Unlocked,
1027 writability: self.writability,
1028 saving: Saving::Withheld,
1029 viewing: self.viewing,
1030 })
1031 };
1032 let mut chrome = crate::shell::Chrome::over(ui.ctx(), ui.max_rect());
1033 let steps = Consequences {
1034 undo: self.steps.undo.as_ref().map(|of| Consequence {
1035 target: of.target.clone(),
1036 kind: self.kind,
1037 }),
1038 redo: self.steps.redo.as_ref().map(|of| Consequence {
1039 target: of.target.clone(),
1040 kind: self.kind,
1041 }),
1042 };
1043 let model = TopBar {
1044 name: DOCUMENT.to_owned(),
1045 scope: crate::kernel::ScopePath {
1046 path: self.path.clone(),
1047 names: self.names.clone(),
1048 },
1049 steps,
1050 lens: Lens {
1051 viewing: self.viewing,
1052 head: rev(HEAD),
1053 age: "3 hours ago".to_owned(),
1054 },
1055 liveness: Liveness::of(self.viewing, self.writability, self.attachment),
1056 #[cfg(not(target_arch = "wasm32"))]
1057 renaming: self.renaming,
1058 };
1059 let clicked = top_bar(
1060 &mut chrome,
1061 &mut commands,
1062 Docked {
1063 model: &model,
1064 navigator: self.navigator,
1065 theme: &self.theme,
1066 prefs: &mut self.prefs,
1067 #[cfg(not(target_arch = "wasm32"))]
1068 recent: &[],
1069 #[cfg(not(target_arch = "wasm32"))]
1070 document: Document {
1071 draft: &mut self.draft,
1072 renaming: self.renaming,
1073 },
1074 },
1075 );
1076 if let Some(act) = clicked.action {
1077 self.fired.push(act);
1078 }
1079 if clicked.browse {
1080 self.browses += 1;
1081 }
1082 }
1083 }
1084
1085 struct Bar {
1088 chrome: Chrome,
1089 session: Session,
1090 }
1091
1092 impl Bar {
1093 fn over(session: Session) -> Self {
1094 let mut bar = Bar {
1095 chrome: Chrome::new(screen().geom()),
1096 session,
1097 };
1098 bar.chrome.ctx().set_visuals(app_visuals());
1103 bar.settle();
1104 bar
1105 }
1106
1107 fn new() -> Self {
1108 Bar::over(Session::new())
1109 }
1110
1111 fn settle(&mut self) {
1112 let Self { chrome, session } = self;
1113 chrome.settle(|ui| session.frame(ui));
1114 }
1115
1116 fn rect(&self) -> Rect {
1117 crate::shell::berth_rect(self.chrome.ctx(), glass::Berth::TopBar)
1118 .expect("the top bar never laid out")
1119 .geom()
1120 }
1121
1122 fn click_at(&mut self, at: Pos2) {
1123 let Self { chrome, session } = self;
1124 chrome.click_at(at, |ui| session.frame(ui));
1125 }
1126
1127 fn click_on(&mut self, label: &str) {
1128 let Self { chrome, session } = self;
1129 chrome.click_on(label, |ui| session.frame(ui));
1130 }
1131
1132 fn type_text(&mut self, text: &str) {
1133 let Self { chrome, session } = self;
1134 chrome.type_text(text, |ui| session.frame(ui));
1135 }
1136
1137 fn press(&mut self, key: egui::Key) {
1138 let Self { chrome, session } = self;
1139 chrome.press(key, |ui| session.frame(ui));
1140 }
1141
1142 fn scan(&mut self) -> Vec<&'static str> {
1145 let bar = self.rect();
1146 let mut x = bar.left();
1147 while x <= bar.right() {
1148 self.click_at(Pos2::new(x, bar.center().y));
1149 x += 5.0;
1150 }
1151 self.session
1152 .fired
1153 .iter()
1154 .map(crate::tools::commands::act_name)
1155 .collect()
1156 }
1157
1158 fn hovers(&mut self) -> Vec<String> {
1161 let bar = self.rect();
1162 let mut said = Vec::new();
1163 let mut x = bar.left();
1164 while x <= bar.right() {
1165 let Self { chrome, session } = self;
1166 chrome.hover_at(Pos2::new(x, bar.center().y), |ui| session.frame(ui));
1167 said.extend(self.chrome.texts().iter().map(|t| (*t).to_owned()));
1168 x += 5.0;
1169 }
1170 said
1171 }
1172
1173 fn open_the_menu(&mut self) {
1175 let at = self.rect();
1176 self.click_at(Pos2::new(at.left() + glass::TAP * 0.5, at.center().y));
1177 }
1178
1179 fn double_click_the_name(&mut self) {
1181 let at = self.name_rect();
1182 let Self { chrome, session } = self;
1183 chrome.double_click_at(at.center(), |ui| session.frame(ui));
1184 }
1185
1186 fn click_the_name(&mut self) {
1189 let at = self.name_rect();
1190 self.click_at(at.center());
1191 }
1192
1193 fn name_rect(&self) -> Rect {
1194 self.chrome
1195 .rect(DOCUMENT)
1196 .expect("the bar never drew the document's name")
1197 }
1198
1199 fn hover_at(&mut self, at: Pos2) {
1200 let Self { chrome, session } = self;
1201 chrome.hover_at(at, |ui| session.frame(ui));
1202 }
1203
1204 fn trail(&self, names: &[&str]) -> Vec<Rect> {
1206 names
1207 .iter()
1208 .map(|name| {
1209 self.chrome
1210 .rect(name)
1211 .unwrap_or_else(|| panic!("the breadcrumb never drew {name}"))
1212 })
1213 .collect()
1214 }
1215
1216 fn lit(&self, at: Rect) -> bool {
1220 self.chrome.fills().iter().any(|(drawn, _)| {
1221 drawn.contains_rect(at) && drawn.height() <= CRUMB_HEIGHT + PLATE_SLACK
1222 })
1223 }
1224
1225 fn verbs(&self) -> Vec<&'static str> {
1227 self.session
1228 .fired
1229 .iter()
1230 .map(crate::tools::commands::act_name)
1231 .collect()
1232 }
1233 }
1234
1235 #[test]
1240 fn the_level_the_canvas_stands_on_is_a_label_and_its_ancestors_are_not() {
1241 let mut bar = Bar::over(Session::nested(2));
1242 let trail = bar.trail(&["Level 1", "Level 2"]);
1243 let (ancestor, leaf) = (trail[0], trail[1]);
1244 assert!(
1245 !bar.lit(leaf) && !bar.lit(ancestor),
1246 "precondition: an untouched breadcrumb lights nothing up",
1247 );
1248
1249 bar.hover_at(leaf.center());
1250 assert!(
1251 !bar.lit(leaf),
1252 "the level the canvas is standing on lit up under the pointer",
1253 );
1254
1255 bar.hover_at(ancestor.center());
1256 assert!(
1257 bar.lit(ancestor),
1258 "an ancestor gives no sign that it can be pressed",
1259 );
1260 }
1261
1262 #[test]
1266 fn nothing_on_the_breadcrumb_moves_when_the_pointer_crosses_it() {
1267 let names = [DOCUMENT, "Level 1", "Level 2"];
1268 let mut bar = Bar::over(Session::nested(2));
1269 let at_rest = bar.trail(&names);
1270 for hovered in &at_rest {
1271 bar.hover_at(hovered.center());
1272 assert_eq!(
1273 bar.trail(&names),
1274 at_rest,
1275 "the trail moved with the pointer over {hovered:?}",
1276 );
1277 }
1278 }
1279
1280 #[cfg(not(target_arch = "wasm32"))]
1284 #[test]
1285 fn one_click_on_the_document_rises_to_the_root() {
1286 let mut bar = Bar::over(Session::nested(2));
1287 bar.click_the_name();
1288 let rose =
1289 bar.session.fired.iter().any(
1290 |act| matches!(act, Act::Edit(Action::GoToPath(to)) if to.segments().is_empty()),
1291 );
1292 assert!(
1293 rose,
1294 "the document's segment did not rise: {:?}",
1295 bar.verbs()
1296 );
1297 }
1298
1299 #[cfg(not(target_arch = "wasm32"))]
1304 #[test]
1305 fn a_double_click_renames_the_document_without_moving_the_canvas() {
1306 let mut bar = Bar::over(Session::nested(2));
1307 bar.double_click_the_name();
1308 assert_eq!(
1309 bar.session.draft, DOCUMENT,
1310 "the box did not open on the document's own name",
1311 );
1312 assert!(
1313 bar.session.fired.is_empty(),
1314 "the rename navigated on its way to the box: {:?}",
1315 bar.verbs(),
1316 );
1317 }
1318
1319 fn app_visuals() -> egui::Visuals {
1321 crate::canvas::convert::visuals(
1322 &blockworx_paint::Scheme::default().palette(blockworx_paint::Luminance::Dark),
1323 )
1324 }
1325
1326 fn luminance(color: egui::Color32) -> f32 {
1328 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b())
1329 }
1330
1331 #[test]
1341 fn the_document_name_is_painted_in_ink_that_stands_off_the_bar() {
1342 let visuals = app_visuals();
1343 let bar = Bar::new();
1344 let ink = bar
1345 .chrome
1346 .format_of(DOCUMENT)
1347 .expect("the bar drew no document name")
1348 .color;
1349 assert_eq!(
1350 ink,
1351 glass::full_ink(&visuals),
1352 "the name is not painted in the shell's full-strength ink",
1353 );
1354 assert_ne!(
1355 ink,
1356 visuals.strong_text_color(),
1357 "the name went back to egui's strong text colour",
1358 );
1359 let against = luminance(visuals.window_fill);
1360 assert!(
1361 luminance(ink) - against > READABLE,
1362 "the name reads at {} against a bar at {against}",
1363 luminance(ink),
1364 );
1365 }
1366
1367 #[test]
1370 fn a_quieted_segment_still_stands_off_the_bar() {
1371 let visuals = app_visuals();
1372 let bar = Bar::over(Session::nested(2));
1373 let ink = bar
1374 .chrome
1375 .format_of("Level 1")
1376 .expect("the bar drew no ancestor segment")
1377 .color;
1378 let against = luminance(visuals.window_fill);
1379 assert!(
1380 luminance(ink) - against > QUIET,
1381 "an ancestor reads at {} against a bar at {against}",
1382 luminance(ink),
1383 );
1384 assert!(
1385 luminance(ink) < luminance(glass::full_ink(&visuals)),
1386 "an ancestor is as loud as where the canvas is standing",
1387 );
1388 }
1389
1390 const READABLE: f32 = 100.0;
1394 const QUIET: f32 = 40.0;
1395
1396 #[test]
1399 fn the_bar_undoes_redoes_fits_and_opens_the_navigator() {
1400 let mut bar = Bar::new();
1401 let fired = bar.scan();
1402 for verb in ["Undo", "Redo", "ResetView"] {
1403 assert!(
1404 fired.contains(&verb),
1405 "the bar never dispatched {verb}: {fired:?}",
1406 );
1407 }
1408 assert!(bar.session.browses > 0, "Browse never toggled");
1409 }
1410
1411 #[test]
1415 fn go_up_is_dead_at_the_root_and_rises_one_level_from_inside_a_block() {
1416 let from_root = Bar::new().scan();
1417 assert!(
1418 !from_root.contains(&"GoUp"),
1419 "the root offered somewhere to rise to: {from_root:?}",
1420 );
1421
1422 let mut inside = Bar::over(Session::nested(2));
1423 let fired = inside.scan();
1424 assert!(
1425 fired.contains(&"GoUp"),
1426 "a nested scope will not rise: {fired:?}",
1427 );
1428 let first = |verb: &str| fired.iter().position(|f| *f == verb);
1431 let button = fired.iter().rposition(|f| *f == "GoUp");
1432 assert!(
1433 first("Redo") < button && button < first("ResetView"),
1434 "the right run is not in the order the user gave it: {fired:?}",
1435 );
1436 }
1437
1438 #[test]
1441 fn undo_and_redo_keep_their_place_and_their_words_when_their_stacks_empty() {
1442 let mut bar = Bar::over(Session {
1443 history: History::empty(),
1444 steps: Consequences::default(),
1445 ..Session::new()
1446 });
1447 let said = bar.hovers();
1448 for verb in ["Undo", "Redo"] {
1449 assert!(
1450 said.iter()
1451 .any(|word| word.starts_with(verb) && word.contains("nothing to take back")),
1452 "an empty stack hid {verb} instead of disabling it: {said:?}",
1453 );
1454 }
1455 let fired = bar.scan();
1456 assert!(
1457 !fired.contains(&"Undo") && !fired.contains(&"Redo"),
1458 "an empty stack's buttons dispatched anyway: {fired:?}",
1459 );
1460 assert!(
1461 fired.contains(&"ResetView"),
1462 "precondition: the scan reaches the run's live half: {fired:?}",
1463 );
1464 }
1465
1466 #[test]
1469 fn the_undo_hover_names_the_target_and_which_of_the_two_kinds_it_is() {
1470 use crate::history::Offered;
1471 let edit = consequence(
1472 Direction::Back,
1473 Some(&of("Add block Filter", Kind::Doc)),
1474 Offered::Yes,
1475 Viewing::Head,
1476 );
1477 assert_eq!(edit, "Undo Add block Filter \u{2014} authors a rev");
1478
1479 let camera = consequence(
1480 Direction::Back,
1481 Some(&of("zoom to fit", Kind::View)),
1482 Offered::Yes,
1483 Viewing::Head,
1484 );
1485 assert_eq!(camera, "Undo zoom to fit \u{2014} view only, no rev");
1486
1487 assert!(
1488 consequence(Direction::Back, None, Offered::Yes, Viewing::Head)
1489 .contains("nothing to take back"),
1490 "an empty stack's button says so rather than promising a rev",
1491 );
1492 assert_eq!(
1495 consequence(
1496 Direction::Back,
1497 Some(&of("Add block Filter", Kind::Doc)),
1498 Offered::No,
1499 Viewing::Past(rev(AT)),
1500 ),
1501 "Undo \u{2014} return to current first",
1502 );
1503 }
1504
1505 #[test]
1508 fn hovering_the_bar_names_the_edit_undo_would_take_back() {
1509 let mut bar = Bar::new();
1510 assert!(
1511 !bar.chrome.shows(NEWEST),
1512 "precondition: the bar names no commit until a button is hovered",
1513 );
1514 let said = bar.hovers();
1515 assert!(
1516 said.iter().any(|word| word.contains(NEWEST)),
1517 "no button on the bar names the edit undo would take back: {said:?}",
1518 );
1519 }
1520
1521 #[test]
1524 fn under_the_lens_a_view_entry_is_still_undoable_and_a_doc_entry_says_why_not() {
1525 let mut view = Bar::over(Session {
1526 history: History::view(),
1527 kind: Kind::View,
1528 ..Session::new().under_the_lens()
1529 });
1530 let fired = view.scan();
1531 assert!(
1532 fired.contains(&"Undo") && fired.contains(&"Redo"),
1533 "the lens took the camera history with it: {fired:?}",
1534 );
1535
1536 let mut doc = Bar::over(Session::new().under_the_lens());
1537 let said = doc.hovers();
1538 assert!(
1539 said.iter()
1540 .any(|word| word.contains("return to current first")),
1541 "a withheld undo does not say what would free it: {said:?}",
1542 );
1543 let fired = doc.scan();
1544 assert!(
1545 !fired.contains(&"Undo") && !fired.contains(&"Redo"),
1546 "the bar wrote from a session that may not: {fired:?}",
1547 );
1548 assert!(
1549 fired.contains(&"ResetView"),
1550 "the lens took the view controls with it: {fired:?}",
1551 );
1552 }
1553
1554 #[test]
1558 fn the_mode_names_the_rev_and_steps_and_returns() {
1559 assert_eq!(
1560 headline(rev(23), "3 hours ago"),
1561 "Rev 23 \u{00b7} 3 hours ago"
1562 );
1563 assert_eq!(
1564 headline(rev(23), " "),
1565 "Rev 23",
1566 "a session with no clock is not made to guess at one",
1567 );
1568
1569 let mut bar = Bar::over(Session::new().under_the_lens());
1570 assert!(
1571 bar.chrome.shows("Rev 2 \u{00b7} 3 hours ago"),
1572 "the centre does not name the rev on the canvas: {:?}",
1573 bar.chrome.texts(),
1574 );
1575 let fired = bar.scan();
1576 for verb in ["ViewRev", "ViewHead"] {
1577 assert!(
1578 fired.contains(&verb),
1579 "the centre never dispatched {verb}: {fired:?}",
1580 );
1581 }
1582 }
1583
1584 #[test]
1589 fn the_dot_tells_its_states_apart_in_colour_and_in_words() {
1590 let theme = Theme::default();
1591 let states = [
1592 Liveness::Recorded,
1593 Liveness::ReadOnly(Locked::Lens),
1594 Liveness::ReadOnly(Locked::Container),
1595 Liveness::Scratch,
1596 ];
1597 let colours: std::collections::HashSet<[u8; 4]> = states
1598 .iter()
1599 .map(|state| theme.resolve(dot(*state).0).to_array())
1600 .collect();
1601 assert_eq!(
1602 colours.len(),
1603 3,
1604 "the read-only pair share a colour; the other two must not",
1605 );
1606 let words: std::collections::HashSet<&str> =
1607 states.iter().map(|state| dot(*state).1).collect();
1608 assert_eq!(words.len(), states.len(), "two states say the same thing");
1609 assert!(
1610 words.iter().all(|said| !said.is_empty()),
1611 "a state says nothing at all: {words:?}",
1612 );
1613
1614 assert_eq!(
1616 Liveness::of(Viewing::Head, Writability::Writable, Attachment::Scratch,),
1617 Liveness::Scratch,
1618 "a session with nothing behind it is not the recorded state",
1619 );
1620 assert_eq!(
1621 Liveness::of(Viewing::Head, Writability::Writable, Attachment::Attached,),
1622 Liveness::Recorded,
1623 );
1624 assert_eq!(
1625 Liveness::of(Viewing::Head, Writability::ReadOnly, Attachment::Attached,),
1626 Liveness::ReadOnly(Locked::Container),
1627 );
1628 assert_eq!(
1631 Liveness::of(
1632 Viewing::Past(rev(AT)),
1633 Writability::Writable,
1634 Attachment::Attached,
1635 ),
1636 Liveness::ReadOnly(Locked::Lens),
1637 );
1638 }
1639
1640 #[test]
1643 fn the_dot_says_which_state_it_is_in_when_the_pointer_rests_on_it() {
1644 for (state, session) in [
1645 (Liveness::Recorded, Session::new()),
1646 (
1647 Liveness::Scratch,
1648 Session {
1649 attachment: Attachment::Scratch,
1650 ..Session::new()
1651 },
1652 ),
1653 (
1654 Liveness::ReadOnly(Locked::Lens),
1655 Session::new().under_the_lens(),
1656 ),
1657 ] {
1658 let mut bar = Bar::over(session);
1659 let said = bar.hovers();
1660 let wanted = dot(state).1;
1661 assert!(
1662 said.iter().any(|word| word == wanted),
1663 "the dot never said {wanted:?} in {state:?}: {said:?}",
1664 );
1665 }
1666 }
1667
1668 #[test]
1671 fn the_breadcrumb_is_rooted_at_the_document_and_its_ancestors_navigate() {
1672 let mut bar = Bar::over(Session::nested(3));
1673 assert!(
1674 bar.chrome.shows(DOCUMENT),
1675 "the breadcrumb is not rooted at the document: {:?}",
1676 bar.chrome.texts(),
1677 );
1678 for level in ["Level 1", "Level 2", "Level 3"] {
1679 assert!(
1680 bar.chrome.shows(level),
1681 "the breadcrumb lost {level}: {:?}",
1682 bar.chrome.texts(),
1683 );
1684 }
1685 let fired = bar.scan();
1686 assert!(
1687 fired.contains(&"GoUp"),
1688 "the parent segment did not go up: {fired:?}",
1689 );
1690 let jumped: Vec<usize> = bar
1691 .session
1692 .fired
1693 .iter()
1694 .filter_map(|a| match a {
1695 Act::Edit(Action::GoToPath(to)) => Some(to.segments().len()),
1696 _ => None,
1697 })
1698 .collect();
1699 assert!(
1700 jumped.contains(&0) && jumped.contains(&1),
1701 "the document and the grandparent are not reachable: {jumped:?}",
1702 );
1703 assert!(
1704 !jumped.contains(&3),
1705 "the level the canvas is on answered a click: {jumped:?}",
1706 );
1707 }
1708
1709 #[test]
1713 fn a_deep_path_collapses_from_the_middle_and_keeps_its_ends() {
1714 let bar = Bar::over(Session::nested(5));
1715 let said = bar.chrome.texts();
1716 assert!(
1717 said.contains(&DOCUMENT) && said.contains(&"Level 5"),
1718 "the collapse hid one of the ends: {said:?}",
1719 );
1720 assert!(
1721 !said.contains(&"Level 2"),
1722 "the middle did not collapse: {said:?}",
1723 );
1724 assert!(
1725 said.contains(&ELLIPSIS),
1726 "the collapse says nothing about what it hid: {said:?}",
1727 );
1728 }
1729
1730 #[test]
1733 fn a_long_segment_is_capped_rather_than_allowed_to_push_the_bar_around() {
1734 let mut session = Session::nested(1);
1735 session.names = vec!["A block with a preposterously long name on it".to_owned()];
1736 let bar = Bar::over(session);
1737 let long = bar
1738 .chrome
1739 .rect("A block with a preposterously long name on it")
1740 .expect("the long segment never drew");
1741 let short = bar
1742 .chrome
1743 .rect(DOCUMENT)
1744 .expect("the document's own segment never drew");
1745 assert!(
1746 short.width() < CRUMB_WIDTH,
1747 "precondition: a short segment is not capped: {short:?}",
1748 );
1749 assert!(
1750 long.width() <= CRUMB_WIDTH,
1751 "a long segment ran to {} points, past the {CRUMB_WIDTH} cap",
1752 long.width(),
1753 );
1754 }
1755
1756 #[test]
1760 fn the_menu_reaches_every_door_the_document_chip_had() {
1761 let mut bar = Bar::new();
1762 bar.open_the_menu();
1763 #[cfg(not(target_arch = "wasm32"))]
1764 for entry in ["New diagram", "Open diagram\u{2026}"] {
1765 assert!(
1766 bar.chrome.shows(entry),
1767 "the bar's menu offers no {entry:?}: {:?}",
1768 bar.chrome.texts(),
1769 );
1770 }
1771 for entry in ["Import\u{2026}", "Export", "Help", "Preferences"] {
1772 assert!(
1773 bar.chrome.shows(entry),
1774 "the bar's menu lost {entry:?}: {:?}",
1775 bar.chrome.texts(),
1776 );
1777 }
1778 bar.click_on("Export");
1779 for format in crate::export::ExportScope::View.formats() {
1780 assert!(
1781 bar.chrome.shows(format.label()),
1782 "the view's Export menu is missing {}: {:?}",
1783 format.label(),
1784 bar.chrome.texts(),
1785 );
1786 }
1787 }
1788
1789 #[cfg(not(target_arch = "wasm32"))]
1795 #[test]
1796 fn the_menu_opens_one_thing_and_it_is_a_diagram() {
1797 let mut bar = Bar::new();
1798 bar.open_the_menu();
1799 assert!(
1800 bar.chrome.shows("Open diagram\u{2026}"),
1801 "the menu lost the one way in: {:?}",
1802 bar.chrome.texts(),
1803 );
1804 for gone in ["JSON", "Open document", "shared diagram", "Share\u{2026}"] {
1805 assert!(
1806 !bar.chrome.says(gone),
1807 "the menu still offers {gone:?}: {:?}",
1808 bar.chrome.texts(),
1809 );
1810 }
1811 }
1812
1813 #[test]
1816 fn help_and_preferences_keep_their_entries() {
1817 for (menu, entry) in [("Help", "GitHub"), ("Preferences", "Theme")] {
1818 let mut bar = Bar::new();
1819 bar.open_the_menu();
1820 bar.click_on(menu);
1821 assert!(
1822 bar.chrome.shows(entry),
1823 "the {menu} submenu does not open onto {entry:?}: {:?}",
1824 bar.chrome.texts(),
1825 );
1826 }
1827 }
1828
1829 #[test]
1833 fn import_is_listed_in_a_read_only_session_and_does_nothing() {
1834 let mut writable = Bar::new();
1835 writable.open_the_menu();
1836 writable.click_on("Import\u{2026}");
1837 assert!(
1838 matches!(
1839 writable.session.fired.last(),
1840 Some(Act::Effect(Effect::Import))
1841 ),
1842 "a writable session's Import dispatched something else",
1843 );
1844
1845 let mut read_only = Bar::over(Session {
1846 writability: Writability::ReadOnly,
1847 ..Session::new()
1848 });
1849 read_only.open_the_menu();
1850 assert!(
1851 read_only.chrome.shows("Import\u{2026}"),
1852 "a read-only session hid Import instead of disabling it",
1853 );
1854 read_only.click_on("Import\u{2026}");
1855 assert!(
1856 read_only.session.fired.is_empty(),
1857 "a read-only session's Import dispatched",
1858 );
1859 }
1860
1861 #[cfg(not(target_arch = "wasm32"))]
1865 #[test]
1866 fn nothing_on_the_bar_says_saved() {
1867 let mut bar = Bar::new();
1868 for said in bar.chrome.texts() {
1869 assert!(
1870 !said.to_lowercase().contains("sav"),
1871 "the top bar says {said:?}",
1872 );
1873 }
1874 bar.open_the_menu();
1875 assert!(
1877 !bar.chrome.says("document.json"),
1878 "the menu still offers to refresh the projection: {:?}",
1879 bar.chrome.texts(),
1880 );
1881 }
1882
1883 #[cfg(not(target_arch = "wasm32"))]
1886 #[test]
1887 fn save_as_asks_for_the_rev_on_the_canvas() {
1888 use crate::file::{FileRequest, SaveScope};
1889 let mut bar = Bar::new();
1890 bar.open_the_menu();
1891 bar.click_on("Save as\u{2026}");
1892 assert!(
1893 matches!(
1894 bar.session.fired.last(),
1895 Some(Act::Effect(Effect::PickFile(FileRequest::SaveAsContainer(
1896 SaveScope::Whole
1897 )))),
1898 ),
1899 "at the present, Save-as asked for something other than the whole document",
1900 );
1901
1902 let mut under = Bar::over(Session::new().under_the_lens());
1903 under.open_the_menu();
1904 under.click_on("Save as\u{2026}");
1905 assert!(
1906 matches!(
1907 under.session.fired.last(),
1908 Some(Act::Effect(Effect::PickFile(FileRequest::SaveAsContainer(
1909 SaveScope::Through(at)
1910 )))) if *at == rev(AT),
1911 ),
1912 "under the lens, Save-as did not ask for the rev on the canvas",
1913 );
1914 }
1915
1916 #[cfg(not(target_arch = "wasm32"))]
1919 #[test]
1920 fn double_clicking_the_name_renames_the_document_in_place() {
1921 let mut bar = Bar::new();
1922 assert!(
1923 bar.chrome.shows(DOCUMENT),
1924 "precondition: the bar draws the name: {:?}",
1925 bar.chrome.texts(),
1926 );
1927 bar.double_click_the_name();
1928 assert_eq!(
1929 bar.session.draft, DOCUMENT,
1930 "the box did not open on the document's own name",
1931 );
1932 bar.type_text("2");
1933 assert_ne!(bar.session.draft, DOCUMENT, "typing never reached the box");
1934 let typed = bar.session.draft.clone();
1935
1936 bar.press(egui::Key::Enter);
1937 let renamed = bar.session.fired.iter().find_map(|act| match act {
1938 Act::Effect(Effect::RenameDocument(to)) => Some(to.clone()),
1939 _ => None,
1940 });
1941 assert_eq!(
1942 renamed.as_deref(),
1943 Some(typed.as_str()),
1944 "what was typed is not what was sent",
1945 );
1946 }
1947
1948 #[cfg(not(target_arch = "wasm32"))]
1950 #[test]
1951 fn the_menu_no_longer_offers_a_second_way_to_rename() {
1952 let mut bar = Bar::new();
1953 bar.open_the_menu();
1954 assert!(
1955 !bar.chrome.says("Rename"),
1956 "the menu still offers its own rename: {:?}",
1957 bar.chrome.texts(),
1958 );
1959 }
1960
1961 #[test]
1965 fn the_bar_wears_the_mockups_own_glyphs() {
1966 let menu = include_str!("../../icons/icon-menu.svg");
1967 for line in [r#"d="M4 7h16""#, r#"d="M4 12h16""#, r#"d="M4 17h10""#] {
1968 assert!(
1969 menu.contains(line),
1970 "the document menu is not the mockup's: {menu}",
1971 );
1972 }
1973 let older = include_str!("../../icons/icon-step-older.svg");
1974 let newer = include_str!("../../icons/icon-step-newer.svg");
1975 assert!(older.contains(r#"points="7 14 12 19 17 14""#), "{older}");
1977 assert!(newer.contains(r#"points="7 10 12 5 17 10""#), "{newer}");
1978 for icon in [menu, older, newer] {
1979 assert!(icon.contains(r#"viewBox="0 0 24 24""#), "{icon}");
1980 assert!(icon.contains(r#"stroke-width="2""#), "{icon}");
1981 assert!(icon.contains(r#"fill="none""#), "{icon}");
1982 }
1983 }
1984
1985 #[test]
1988 fn the_stepper_says_which_way_each_arrow_goes() {
1989 let mut bar = Bar::over(Session::new().under_the_lens());
1990 let said = bar.hovers();
1991 for way in ["Older rev", "Newer rev"] {
1992 assert!(
1993 said.iter().any(|word| word == way),
1994 "the stepper does not say {way}: {said:?}",
1995 );
1996 }
1997 }
1998
1999 #[test]
2001 fn the_bar_holds_no_tools() {
2002 let mut bar = Bar::new();
2003 bar.open_the_menu();
2004 for tool in crate::tools::names::band_tools() {
2005 assert!(
2006 !bar.chrome.shows(tool.label()),
2007 "{tool:?} strayed into the top bar",
2008 );
2009 }
2010 }
2011}