1use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
27use crate::{
28 canvas::Camera,
29 edit::naming::InterfaceLock,
30 export::ExportScope,
31 panels::chrome::Panel,
32 shell::{SafeArea, glass},
33 tools::commands::{Act, Command, CommandId, CommandSet},
34};
35use blockworx_doc::values::PinDir;
36use blockworx_kernel::bar::{self, Bar};
37use blockworx_tools::commands::in_overlay;
38
39#[derive(Clone, Copy, PartialEq, Eq)]
42pub enum OpenPicker {
43 Role,
44 PinType,
45}
46
47#[derive(Clone, Copy, PartialEq, Eq)]
51pub enum RightClick {
52 Asked,
53 No,
54}
55
56impl From<bool> for RightClick {
57 fn from(asked: bool) -> Self {
58 if asked {
59 RightClick::Asked
60 } else {
61 RightClick::No
62 }
63 }
64}
65
66const EXPAND_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-expand.svg");
67const LOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-lock.svg");
68const UNLOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-unlock.svg");
69const DELETE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-trash.svg");
70const ADD_ICON_ICON: egui::ImageSource<'static> =
71 egui::include_image!("../../icons/icon-add-icon.svg");
72const ROUTE_LABEL_ICON: egui::ImageSource<'static> =
73 egui::include_image!("../../icons/icon-route-label.svg");
74const COPY_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-copy.svg");
75const CUT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-cut.svg");
76const FLIP_LR_ICON: egui::ImageSource<'static> =
77 egui::include_image!("../../icons/icon-flip-lr.svg");
78const FLIP_UD_ICON: egui::ImageSource<'static> =
79 egui::include_image!("../../icons/icon-flip-ud.svg");
80pub(crate) const EXPORT_ICON: egui::ImageSource<'static> =
81 egui::include_image!("../../icons/icon-export.svg");
82const REROUTE_ICON: egui::ImageSource<'static> =
83 egui::include_image!("../../icons/icon-reroute.svg");
84const MORE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-more.svg");
87const EYE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-eye.svg");
88const EYE_OFF_ICON: egui::ImageSource<'static> =
89 egui::include_image!("../../icons/icon-eye-off.svg");
90const PIN_TYPE_ICON: egui::ImageSource<'static> = crate::io_pin_picker::INPUT_ICON;
94
95fn lock_toggle_icon(lock: InterfaceLock) -> (egui::ImageSource<'static>, &'static str) {
99 match lock {
100 InterfaceLock::Locked => (LOCK_ICON, "Unlock pins"),
101 InterfaceLock::Unlocked => (UNLOCK_ICON, "Lock pins"),
102 }
103}
104
105pub(crate) fn icon_image(
108 ui: &egui::Ui,
109 source: egui::ImageSource<'static>,
110) -> egui::Image<'static> {
111 egui::Image::new(source)
112 .fit_to_exact_size(egui::vec2(14.0, 14.0))
113 .tint(ui.visuals().widgets.inactive.fg_stroke.color)
114}
115
116pub(crate) fn group_heading(ui: &mut egui::Ui, text: &str) {
121 ui.add_space(6.0);
122 ui.label(egui::RichText::new(text).small().weak());
123}
124
125const BAR: glass::Shape = glass::Shape::Bar;
128
129const CELL_GAP: f32 = 2.0;
132
133const SWATCH: f32 = 18.0;
135const SWATCH_RADIUS: u8 = 3;
136
137const MENU_ICON: f32 = 18.0;
139
140pub struct Overlay<'a> {
144 pub commands: &'a mut CommandSet,
145 pub bar: Option<&'a crate::kernel::Overlay>,
147 pub open_picker: Option<OpenPicker>,
148 pub safe: SafeArea,
150 pub camera: Camera,
153 pub right_click: RightClick,
154}
155
156fn menu_id() -> egui::Id {
158 egui::Id::from(Panel::SelectionMenu)
159}
160
161fn dismissed(ctx: &egui::Context) -> (Option<Act>, Option<egui::Pos2>) {
164 egui::Popup::close_id(ctx, menu_id());
165 (None, None)
166}
167
168enum Placed {
170 At(egui::Pos2),
172 Nowhere,
175 Measuring,
178}
179
180#[derive(Clone)]
185struct Measured {
186 controls: Vec<CommandId>,
187 count: usize,
188 rect: egui::Rect,
189 pass: u64,
192}
193
194pub(crate) fn bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
198 let measured =
199 ctx.data(|d| d.get_temp::<Measured>(Panel::SelectionButtons.measurement("rect")))?;
200 (measured.pass + 1 == ctx.cumulative_pass_nr()).then_some(measured.rect)
201}
202
203pub(crate) fn export_format_menu(
207 ui: &mut egui::Ui,
208 scope: crate::export::ExportScope,
209) -> Option<crate::export::ExportFormat> {
210 scope
211 .formats()
212 .iter()
213 .copied()
214 .find(|format| ui.button(format.label()).clicked())
215}
216
217pub fn selection_overlay(
224 ui: &mut egui::Ui,
225 overlay: Overlay<'_>,
226) -> (Option<Act>, Option<egui::Pos2>) {
227 let Overlay {
228 commands,
229 bar: model,
230 open_picker,
231 safe,
232 camera,
233 right_click,
234 } = overlay;
235 let ctx = ui.ctx().clone();
236 let ctx = &ctx;
237 let Some(model) = model.filter(|bar| safe.viewport().intersects(bar.selection.screen.egui()))
238 else {
239 return dismissed(ctx);
240 };
241 let selection = model.selection;
242 let screen = selection.screen.egui();
243 if camera == Camera::Moving {
247 return dismissed(ctx);
248 }
249 let offered: Vec<&Command> = overlay_controls(commands).collect();
250 let drawn: Vec<CommandId> = offered.iter().map(|cmd| cmd.id).collect();
251 let count = selection.count;
252 let bar = Bar::of(offered, count, |cmd: &&Command| cmd.precedence);
253
254 let size_key = Panel::SelectionButtons.measurement("rect");
255 let remembered = ctx
256 .data(|d| d.get_temp::<Measured>(size_key))
257 .filter(|was| was.controls == drawn && was.count == count)
258 .map(|was| was.rect.size());
259 let placed = match remembered {
260 Some(size) => match bar::place(screen.geom(), size.geom(), safe.region().geom()) {
261 Some(bar) => Placed::At(bar.min.egui()),
262 None => Placed::Nowhere,
263 },
264 None => Placed::Measuring,
265 };
266 let (at, builder) = match placed {
267 Placed::At(at) => (at, Panel::SelectionButtons.ui_builder()),
268 Placed::Nowhere => return dismissed(ctx),
269 Placed::Measuring => {
274 ctx.request_repaint();
275 (
276 screen.center(),
277 Panel::SelectionButtons
278 .ui_builder()
279 .sizing_pass()
280 .invisible(),
281 )
282 }
283 };
284 let sizing = builder.sizing_pass;
285 let controls = Controls { model, open_picker };
286 let mut clicked: Option<CommandId> = None;
287 let mut child =
288 ui.new_child(builder.max_rect(egui::Rect::from_min_size(at, safe.viewport().size())));
289 let rect = {
290 let ui = &mut child;
291 glass::shell(ui, BAR, glass::Elevation::Floating, glass::Tint::None)
292 .show(ui, |ui| {
293 glass::type_scale(ui, BAR);
294 if let Some(height) = BAR.content_height() {
300 ui.set_min_height(height);
301 }
302 clicked = draw_row(ui, &bar, count, controls);
303 })
304 .response
305 .rect
306 };
307 tracing::debug!(
308 target: "overlay",
309 pass = ctx.cumulative_pass_nr(),
310 sizing,
311 drawn = drawn.len(),
312 at = ?at,
313 rect = ?rect,
314 sel = ?screen,
315 "overlay frame"
316 );
317 let pass = ctx.cumulative_pass_nr();
318 ctx.data_mut(|d| {
319 d.insert_temp(
320 size_key,
321 Measured {
322 controls: drawn,
323 count,
324 rect,
325 pass,
326 },
327 );
328 });
329 if sizing {
330 return (None, None);
331 }
332 if let Some(id) = right_click_menu(ui, &bar, right_click, controls) {
333 clicked = Some(id);
334 }
335 drop(bar);
336 (
337 clicked.and_then(|id| commands.take(id)),
338 Some(rect.right_top()),
339 )
340}
341
342fn draw_row(
344 ui: &mut egui::Ui,
345 bar: &Bar<&Command>,
346 count: usize,
347 controls: Controls<'_>,
348) -> Option<CommandId> {
349 let mut clicked = None;
350 ui.horizontal(|ui| {
351 ui.spacing_mut().item_spacing.x = CELL_GAP;
352 if count > 1 {
353 ui.label(
354 egui::RichText::new(format!("{count} selected"))
355 .small()
356 .weak(),
357 );
358 glass::group_gap(ui);
359 }
360 if !matches!(bar, Bar::Row(_)) {
361 ui.label(
362 egui::RichText::new(bar::nothing_to_say(count))
363 .small()
364 .weak(),
365 );
366 return;
367 }
368 for cmd in bar.row() {
369 if let Some(id) = draw_command(ui, cmd, Shown::InTheBar, controls) {
370 clicked = Some(id);
371 }
372 }
373 let overflow = bar.overflow();
374 if overflow.is_empty() {
375 return;
376 }
377 let more = glass::tap_button(
378 ui,
379 MORE_ICON,
380 glass::Live::Yes,
381 format!("{} more", overflow.len()),
382 );
383 egui::Popup::menu(&more).show(|ui| {
384 for cmd in overflow {
385 if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
386 clicked = Some(id);
387 }
388 }
389 });
390 });
391 clicked
392}
393
394fn right_click_menu(
398 ui: &mut egui::Ui,
399 bar: &Bar<&Command>,
400 right_click: RightClick,
401 controls: Controls<'_>,
402) -> Option<CommandId> {
403 let mut clicked = None;
404 let opened = match (right_click, bar.all().first()) {
407 (RightClick::Asked, Some(_)) => Some(egui::SetOpenCommand::Bool(true)),
408 (RightClick::Asked, None) => Some(egui::SetOpenCommand::Bool(false)),
409 (RightClick::No, _) => None,
410 };
411 egui::Popup::new(
412 menu_id(),
413 ui.ctx().clone(),
414 egui::PopupAnchor::PointerFixed,
415 ui.layer_id(),
416 )
417 .kind(egui::PopupKind::Menu)
418 .layout(egui::Layout::top_down_justified(egui::Align::Min))
419 .open_memory(opened)
420 .show(|ui| {
421 for cmd in bar.all() {
422 if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
423 clicked = Some(id);
424 }
425 }
426 });
427 clicked
428}
429
430pub(crate) fn overlay_controls(commands: &CommandSet) -> impl Iterator<Item = &Command> {
437 commands.iter_drawn().filter(|cmd| in_overlay(cmd.id))
438}
439
440#[derive(Clone, Copy, PartialEq, Eq)]
443enum Shown {
444 InTheBar,
445 InAMenu,
446}
447
448#[derive(Clone, Copy)]
451struct Controls<'a> {
452 model: &'a crate::kernel::Overlay,
453 open_picker: Option<OpenPicker>,
454}
455
456fn draw_command(
461 ui: &mut egui::Ui,
462 cmd: &Command,
463 shown: Shown,
464 controls: Controls<'_>,
465) -> Option<CommandId> {
466 let Controls { model, open_picker } = controls;
467 ui.add_enabled_ui(!cmd.withheld(), |ui| match cmd.id {
468 CommandId::ExportSelection(_) => {
469 let mut picked = None;
470 match shown {
471 Shown::InTheBar => {
472 let button =
473 glass::tap_button(ui, EXPORT_ICON, glass::Live::Yes, cmd.label.as_ref());
474 egui::Popup::menu(&button).show(|ui| {
475 picked = export_format_menu(ui, ExportScope::Selection);
476 });
477 }
478 Shown::InAMenu => {
479 ui.menu_button(cmd.label.as_ref(), |ui| {
480 picked = export_format_menu(ui, ExportScope::Selection);
481 });
482 }
483 }
484 picked.map(CommandId::ExportSelection)
485 }
486 CommandId::Accent => {
487 let accent = model.swatch?;
488 let opened = glass::Opened::from(open_picker == Some(OpenPicker::Role));
489 let response = match shown {
490 Shown::InTheBar => accent_swatch(ui, accent.egui(), opened),
491 Shown::InAMenu => menu_row(ui, None, &cmd.label, opened),
492 };
493 (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
494 }
495 CommandId::PinType => {
496 let opened = glass::Opened::from(open_picker == Some(OpenPicker::PinType));
497 let says = pin_dir_hover(model.pin_dir);
498 let response = match shown {
499 Shown::InTheBar => {
500 glass::tap_button(ui, PIN_TYPE_ICON, glass::Live::Yes, says.clone())
501 }
502 Shown::InAMenu => menu_row(ui, Some(PIN_TYPE_ICON), &cmd.label, opened),
503 };
504 (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
505 }
506 CommandId::HideTags | CommandId::ShowTags => {
507 let icon = tag_icon(cmd.id);
508 let response = match shown {
509 Shown::InTheBar => {
510 glass::tap_button(ui, icon, glass::Live::Yes, cmd.label.as_ref())
511 }
512 Shown::InAMenu => menu_row(ui, Some(icon), &cmd.label, glass::Opened::No),
513 };
514 response.clicked().then_some(cmd.id)
515 }
516 id => {
517 let icon = overlay_icon(id)?;
518 let response = match shown {
519 Shown::InTheBar => {
520 glass::tap_button(ui, icon, glass::Live::Yes, cmd.label.as_ref())
521 }
522 Shown::InAMenu => menu_row(ui, Some(icon), &cmd.label, glass::Opened::No),
523 };
524 response.clicked().then_some(id)
525 }
526 })
527 .inner
528}
529
530fn tag_icon(id: CommandId) -> egui::ImageSource<'static> {
540 match id {
541 CommandId::ShowTags => EYE_OFF_ICON,
542 _ => EYE_ICON,
543 }
544}
545
546fn pin_dir_hover(dir: Option<PinDir>) -> String {
556 let state = match dir {
557 Some(PinDir::Input) => "currently Input",
558 Some(PinDir::Output) => "currently Output",
559 Some(PinDir::InOut) => "currently Input Output",
560 None => "these pins face different ways",
561 };
562 format!("Direction \u{2014} {state}")
563}
564
565fn menu_row(
568 ui: &mut egui::Ui,
569 icon: Option<egui::ImageSource<'static>>,
570 label: &str,
571 opened: glass::Opened,
572) -> egui::Response {
573 let button = match icon {
574 Some(icon) => egui::Button::image_and_text(glass::image(ui, icon, MENU_ICON), label),
575 None => egui::Button::new(label),
576 };
577 ui.add(
578 button
579 .min_size(egui::vec2(0.0, glass::TAP))
580 .selected(opened == glass::Opened::Yes),
581 )
582}
583
584fn accent_swatch(ui: &mut egui::Ui, color: egui::Color32, opened: glass::Opened) -> egui::Response {
588 let (cell, resp) = ui.allocate_exact_size(egui::Vec2::splat(glass::TAP), egui::Sense::click());
589 let rect = egui::Rect::from_center_size(cell.center(), egui::Vec2::splat(SWATCH));
590 let painter = ui.painter();
591 painter.rect_filled(rect, SWATCH_RADIUS, color);
592 let (width, stroke) = if opened == glass::Opened::Yes {
593 (2.0, ui.visuals().selection.stroke.color)
594 } else {
595 (1.0, ui.visuals().widgets.inactive.fg_stroke.color)
596 };
597 painter.rect_stroke(
598 rect,
599 SWATCH_RADIUS,
600 egui::Stroke::new(width, stroke),
601 egui::StrokeKind::Inside,
602 );
603 resp.on_hover_text("Accent")
604}
605
606fn overlay_icon(id: CommandId) -> Option<egui::ImageSource<'static>> {
610 match id {
611 CommandId::Copy => Some(COPY_ICON),
612 CommandId::Cut => Some(CUT_ICON),
613 CommandId::FlipLr => Some(FLIP_LR_ICON),
614 CommandId::FlipUd => Some(FLIP_UD_ICON),
615 CommandId::Reroute | CommandId::RerouteBlock => Some(REROUTE_ICON),
616 CommandId::AddRouteLabel => Some(ROUTE_LABEL_ICON),
617 CommandId::ExpandBlock => Some(EXPAND_ICON),
620 CommandId::Lock => Some(lock_toggle_icon(InterfaceLock::Unlocked).0),
621 CommandId::Unlock => Some(lock_toggle_icon(InterfaceLock::Locked).0),
622 CommandId::AddIcon => Some(ADD_ICON_ICON),
623 CommandId::Delete => Some(DELETE_ICON),
624 _ => None,
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::kernel::Selection;
632 use crate::kernel::chrome::accent_display_role;
633 use crate::shape::ShapeId;
634 use crate::theme::{Role, Theme};
635 use crate::tools::tool::RoleTarget;
636 use blockworx_kernel::bar::{CLEAR, INLINE_CONTROLS};
637 use blockworx_tools::commands::Precedence;
638
639 use blockworx_store::doc::Writability;
640
641 use crate::{
642 tools::{
643 commands::{CommandContext, History},
644 tool::Tool,
645 },
646 widget::test_fixtures::{Scene, two_blocks_with_a_routed_waypoint},
647 };
648 use egui::{Rect, Vec2, pos2, vec2};
649
650 fn viewport() -> Rect {
651 Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
652 }
653
654 const SIZE: Vec2 = Vec2::new(200.0, 40.0);
656
657 const SUBMENU_CHEVRON: &str = "\u{23f5}";
659
660 #[test]
668 fn a_blocks_row_is_its_first_five_primaries_and_the_rest_go_behind_the_ellipsis() {
669 let all: Vec<CommandId> = Harness::block().laid_out().iter().map(|c| c.0).collect();
670 let bar = Bar::of(all.clone(), 1, |id: &CommandId| Precedence::of(*id));
671 assert!(
672 all.len() > INLINE_CONTROLS,
673 "precondition: a block offers more controls than the row holds: {all:?}",
674 );
675 assert_eq!(
676 bar.row().len(),
677 INLINE_CONTROLS,
678 "the row does not hold its fill: {:?}",
679 bar.row(),
680 );
681 assert_eq!(bar.all().len(), all.len(), "the cut lost a control");
682 let lock = if all.contains(&CommandId::Unlock) {
683 CommandId::Unlock
684 } else {
685 CommandId::Lock
686 };
687 assert_eq!(
688 bar.row(),
689 [
690 CommandId::Accent,
691 CommandId::ExpandBlock,
692 lock,
693 CommandId::AddIcon,
694 CommandId::RerouteBlock,
695 ],
696 "the row is not the user's primaries in the user's order",
697 );
698 for behind in [
699 CommandId::FlipLr,
700 CommandId::FlipUd,
701 CommandId::Copy,
702 CommandId::Cut,
703 CommandId::Delete,
704 ] {
705 assert!(
706 bar.overflow().contains(&behind),
707 "{behind:?} is not behind the ellipsis: {:?}",
708 bar.overflow(),
709 );
710 }
711 assert!(
712 bar.overflow()
713 .iter()
714 .any(|id| matches!(id, CommandId::ExportSelection(_))),
715 "the selection export is not behind the ellipsis: {:?}",
716 bar.overflow(),
717 );
718
719 for rank in [Precedence::Own, Precedence::Clerical] {
721 let in_registry_order: Vec<CommandId> = all
722 .iter()
723 .copied()
724 .filter(|&id| Precedence::of(id) == rank)
725 .collect();
726 let in_bar_order: Vec<CommandId> = bar
727 .all()
728 .iter()
729 .copied()
730 .filter(|&id| Precedence::of(id) == rank)
731 .collect();
732 assert_eq!(
733 in_bar_order, in_registry_order,
734 "{rank:?} came out reordered"
735 );
736 }
737 }
738
739 #[test]
747 fn a_ports_own_controls_lead_the_row_and_its_clerical_verbs_follow() {
748 let mut port = Harness::port();
749 let ids: Vec<CommandId> = port.laid_out().iter().map(|c| c.0).collect();
750 let bar = Bar::of(ids.clone(), 1, |id: &CommandId| Precedence::of(*id));
751 assert!(matches!(bar, Bar::Row(_)), "a port raises no row: {ids:?}");
752 let own = [CommandId::PinType, CommandId::Accent, CommandId::FlipLr];
753 for id in own {
754 assert!(
755 bar.row().contains(&id),
756 "the port's own {id:?} is not in the row: {:?} / {:?}",
757 bar.row(),
758 bar.overflow(),
759 );
760 }
761 assert!(
762 bar.row()
763 .iter()
764 .any(|id| matches!(id, CommandId::HideTags | CommandId::ShowTags)),
765 "the port's tag toggle is not in the row: {:?}",
766 bar.row(),
767 );
768 assert!(
769 bar.overflow().contains(&CommandId::Delete),
770 "precondition: the port offers more than the row holds, so Delete is behind: {:?}",
771 bar.all(),
772 );
773 }
774
775 #[test]
781 fn the_tag_eye_shows_the_state_and_the_io_control_keeps_one_glyph() {
782 let source = |icon: egui::ImageSource<'static>| match icon {
783 egui::ImageSource::Bytes { uri, .. } => uri.to_string(),
784 _ => panic!("the overlay's icons are embedded bytes"),
785 };
786 assert!(source(tag_icon(CommandId::HideTags)).contains("icon-eye.svg"));
787 assert!(source(tag_icon(CommandId::ShowTags)).contains("icon-eye-off.svg"));
788 assert_ne!(
789 source(tag_icon(CommandId::HideTags)),
790 source(tag_icon(CommandId::ShowTags)),
791 "the two states share one glyph",
792 );
793
794 assert!(source(PIN_TYPE_ICON).contains("icon-pin-input.svg"));
799 let said: Vec<String> = [
800 Some(PinDir::Input),
801 Some(PinDir::Output),
802 Some(PinDir::InOut),
803 None,
804 ]
805 .into_iter()
806 .map(pin_dir_hover)
807 .collect();
808 assert_eq!(
809 said.iter().collect::<std::collections::HashSet<_>>().len(),
810 said.len(),
811 "two states of the control say the same thing: {said:?}",
812 );
813 for word in &said {
814 assert!(
815 word.starts_with("Direction"),
816 "the control does not name itself: {word}",
817 );
818 }
819 assert!(
820 said[1].contains("Output") && said[3].contains("different"),
821 "the words do not name the state: {said:?}",
822 );
823 }
824
825 #[test]
830 fn the_io_icons_put_the_wall_on_opposite_sides() {
831 let input = include_str!("../../icons/icon-pin-input.svg");
832 let output = include_str!("../../icons/icon-pin-output.svg");
833 assert!(input.contains(r#"d="M5 4v16""#), "{input}");
834 assert!(output.contains(r#"d="M19 4v16""#), "{output}");
835 assert_ne!(input, output, "the two directions share one drawing");
836 let eye = include_str!("../../icons/icon-eye.svg");
837 let eye_off = include_str!("../../icons/icon-eye-off.svg");
838 assert!(
839 eye_off.contains(r#"d="M4 20L20 4""#) && !eye.contains(r#"d="M4 20L20 4""#),
840 "only the hidden state carries the slash",
841 );
842 }
843
844 #[test]
848 fn every_selection_type_puts_its_clerical_verbs_after_its_own() {
849 for (what, harness) in [
850 ("a block", Harness::block()),
851 ("a wire", Harness::wire()),
852 ("an area", Harness::area()),
853 ] {
854 let mut harness = harness;
855 let ids: Vec<CommandId> = harness.laid_out().iter().map(|c| c.0).collect();
856 let bar = Bar::of(ids.clone(), 1, |id: &CommandId| Precedence::of(*id));
857 let ranks: Vec<Precedence> = bar.all().iter().map(|&id| Precedence::of(id)).collect();
858 assert!(
859 ranks.contains(&Precedence::Own) && ranks.contains(&Precedence::Clerical),
860 "precondition: {what} offers verbs of both ranks: {ids:?}",
861 );
862 assert!(
863 ranks.is_sorted(),
864 "{what} puts a clerical verb before one of its own: {:?}",
865 bar.all(),
866 );
867 assert!(
868 bar.all().contains(&CommandId::Delete),
869 "{what} lost Delete altogether: {ids:?}",
870 );
871 }
872 }
873
874 #[test]
878 fn an_area_selection_raises_a_bar_with_its_own_commands() {
879 let mut area = Harness::area();
880 let drawn: Vec<CommandId> = area.laid_out().into_iter().map(|(id, _)| id).collect();
881 assert!(
882 !drawn.is_empty(),
883 "an area selection drew no controls at all",
884 );
885 for wanted in [
886 CommandId::Accent,
887 CommandId::Copy,
888 CommandId::Cut,
889 CommandId::Delete,
890 ] {
891 assert!(
892 drawn.contains(&wanted),
893 "the area's bar is missing {wanted:?}: {drawn:?}",
894 );
895 }
896 assert!(
897 area.corner.is_some(),
898 "the area's bar laid out no controls on screen",
899 );
900 assert!(area.painted > 0, "the area's bar painted nothing");
901 }
902
903 #[test]
907 fn a_selection_taller_than_the_region_still_gets_a_bar() {
908 let mut area = Harness::area();
909 let region = area.safe.region();
910 area.selection.screen = Rect::from_min_max(
913 pos2(region.center().x - 200.0, region.top() - 40.0),
914 pos2(region.center().x + 200.0, region.bottom() + 40.0),
915 )
916 .geom();
917 let screen = area.selection.screen.egui();
918 assert!(
919 screen.top() - CLEAR - SIZE.y < region.top()
920 && screen.bottom() + CLEAR + SIZE.y > region.bottom(),
921 "precondition: neither band outside the selection fits the region",
922 );
923 let placed = bar::place(screen.geom(), SIZE.geom(), region.geom())
924 .expect("a selection bigger than its room still has somewhere lawful")
925 .egui();
926 assert!(
927 region.contains_rect(placed),
928 "the bar landed outside the region at {placed:?}",
929 );
930
931 let settled = settled(area);
932 assert!(
933 settled.corner.is_some() && settled.painted > 0,
934 "the oversized area's bar never drew",
935 );
936 }
937
938 #[test]
941 fn the_row_holds_five_and_only_the_sixth_goes_behind_the_ellipsis() {
942 let own = [
943 CommandId::Accent,
944 CommandId::ExpandBlock,
945 CommandId::Lock,
946 CommandId::AddIcon,
947 CommandId::RerouteBlock,
948 ];
949 assert_eq!(
950 own.len(),
951 INLINE_CONTROLS,
952 "precondition: the list is exactly the row's fill",
953 );
954 let full = Bar::of(own.to_vec(), 1, |id: &CommandId| Precedence::of(*id));
955 assert_eq!(full.row(), own, "a full row lost a control");
956 assert!(
957 full.overflow().is_empty(),
958 "five controls raised an ellipsis: {:?}",
959 full.overflow(),
960 );
961
962 let six: Vec<CommandId> = own.iter().copied().chain([CommandId::Delete]).collect();
963 assert_eq!(
964 six.len(),
965 INLINE_CONTROLS + 1,
966 "precondition: one control more"
967 );
968 let spilled = Bar::of(six, 1, |id: &CommandId| Precedence::of(*id));
969 assert_eq!(
970 spilled.row(),
971 own,
972 "the sixth control took a place in the row"
973 );
974 assert_eq!(spilled.overflow(), [CommandId::Delete]);
975 }
976
977 #[test]
978 fn a_multi_selection_sharing_nothing_says_so_rather_than_showing_an_empty_bar() {
979 assert!(matches!(
980 Bar::of(Vec::<CommandId>::new(), 3, |id: &CommandId| Precedence::of(
981 *id
982 )),
983 Bar::NothingShared
984 ));
985 assert!(matches!(
986 Bar::of(Vec::<CommandId>::new(), 1, |id: &CommandId| Precedence::of(
987 *id
988 )),
989 Bar::Nothing
990 ));
991 }
992
993 #[test]
998 fn a_multi_selection_wears_its_count_and_a_single_one_does_not() {
999 let mut chrome = crate::panels::painted::Chrome::new(viewport().geom());
1000 let mut many = Harness::two_blocks();
1001 chrome.settle(|ui| many.show(ui));
1002 assert!(
1003 many.corner.is_some(),
1004 "precondition: the bar drew for a two-block selection",
1005 );
1006 assert!(
1007 chrome.shows("2 selected"),
1008 "the multi-selection lost its count: {:?}",
1009 chrome.texts(),
1010 );
1011 let mut one = Harness::block();
1012 chrome.settle(|ui| one.show(ui));
1013 assert!(
1014 !chrome.texts().iter().any(|said| said.contains("selected")),
1015 "one block was counted: {:?}",
1016 chrome.texts(),
1017 );
1018 }
1019
1020 #[test]
1027 fn right_click_offers_exactly_the_overlays_commands() {
1028 let mut chrome = crate::panels::painted::Chrome::new(viewport().geom());
1029 let mut block = Harness::block();
1030 chrome.settle(|ui| block.show(ui));
1031 let laid = block.laid_out();
1032 let expected: Vec<&str> = laid.iter().map(|c| c.1.as_ref()).collect();
1033 assert!(
1034 expected.len() > 5,
1035 "precondition: some of these are only reachable through a menu",
1036 );
1037 chrome.hover_at(blockworx_geom::pos2(120.0, 700.0), |ui| block.show(ui));
1038 block.right_click = RightClick::Asked;
1039 chrome.frame(|ui| block.show(ui));
1040 block.right_click = RightClick::No;
1041 chrome.settle(|ui| block.show(ui));
1042
1043 let menu = egui::AreaState::load(chrome.ctx(), menu_id())
1044 .map(|state| state.rect())
1045 .filter(|rect| rect.is_positive())
1046 .expect("the right-click menu never opened");
1047 let mut rows: Vec<&str> = chrome
1050 .texts_inside(menu.geom())
1051 .into_iter()
1052 .filter(|run| *run != SUBMENU_CHEVRON)
1053 .collect();
1054 let mut wanted = expected.clone();
1055 rows.sort_unstable();
1056 wanted.sort_unstable();
1057 assert_eq!(
1058 rows, wanted,
1059 "the menu and the bar disagree about what applies here",
1060 );
1061 }
1062
1063 #[test]
1069 fn the_bar_stands_down_while_the_camera_moves() {
1070 let ctx = egui::Context::default();
1071 egui_extras::install_image_loaders(&ctx);
1072 let mut wire = Harness::wire();
1073 run(&mut wire, &ctx, 2);
1074 let placed = wire.corner.expect("the bar shows once it has measured");
1075
1076 wire.camera = Camera::Moving;
1077 let painted = run(&mut wire, &ctx, 1);
1078 assert!(wire.corner.is_none(), "the bar tracked the pan");
1079 assert_eq!(painted, 0, "a hidden bar painted {painted} shape(s)");
1080
1081 wire.camera = Camera::Settled;
1082 run(&mut wire, &ctx, 1);
1083 assert_eq!(
1084 wire.corner,
1085 Some(placed),
1086 "the bar took a sizing pass to come back, or came back elsewhere",
1087 );
1088 }
1089
1090 #[test]
1097 fn switching_selections_takes_a_hidden_sizing_frame_instead_of_flashing() {
1098 let ctx = egui::Context::default();
1099 egui_extras::install_image_loaders(&ctx);
1100 let mut wire = Harness::wire();
1101 let mut block = Harness::block();
1102
1103 let painted = run(&mut wire, &ctx, 1);
1104 assert!(
1105 wire.corner.is_none(),
1106 "the first frame ever should be a hidden sizing pass",
1107 );
1108 assert_eq!(
1109 painted, 0,
1110 "the sizing pass painted {painted} shape(s) — the flash the user \
1111 sees at the click point before the bar snaps into place",
1112 );
1113 let painted = run(&mut wire, &ctx, 1);
1114 let wire_bar = wire.corner.expect("the wire's bar shows once measured");
1115 assert!(painted > 0, "a shown bar paints");
1116 run(&mut wire, &ctx, 1);
1117 assert!(
1118 wire.corner.is_some(),
1119 "an unchanged selection must not flicker"
1120 );
1121
1122 let painted = run(&mut block, &ctx, 1);
1123 assert!(
1124 block.corner.is_none(),
1125 "the switch frame drew the block's bar with the wire's measurement",
1126 );
1127 assert_eq!(painted, 0, "the switch frame painted {painted} shape(s)");
1128 run(&mut block, &ctx, 1);
1129 let block_bar = block.corner.expect("the block's bar shows once measured");
1130 assert_ne!(
1131 wire_bar, block_bar,
1132 "precondition: the two bars measure apart, or a stale placement would be invisible",
1133 );
1134
1135 run(&mut wire, &ctx, 1);
1136 assert!(
1137 wire.corner.is_none(),
1138 "switching back re-measures too — the memory holds one bar, not a history",
1139 );
1140 run(&mut wire, &ctx, 1);
1141 assert_eq!(
1142 wire.corner,
1143 Some(wire_bar),
1144 "the wire's bar returns exactly where it was",
1145 );
1146 }
1147
1148 #[test]
1154 fn the_selection_overlay_draws_its_withheld_controls_disabled() {
1155 let writable = settled(Harness::wire());
1156 assert!(
1157 writable.invocable > 0 && writable.corner.is_some(),
1158 "a writable wire lost its overlay",
1159 );
1160 assert_eq!(writable.invocable, writable.drawn.len());
1161 let mut read_only = Harness::wire();
1162 read_only.writability = Writability::ReadOnly;
1163 let read_only = settled(read_only);
1164 assert!(
1165 read_only.corner.is_some(),
1166 "the read-only overlay vanished instead of disabling",
1167 );
1168 assert_eq!(
1169 read_only.drawn, writable.drawn,
1170 "read-only dropped controls instead of disabling them",
1171 );
1172 assert_eq!(
1173 read_only.invocable, 0,
1174 "a read-only wire kept {} invocable verb(s)",
1175 read_only.invocable,
1176 );
1177 }
1178
1179 #[test]
1183 fn a_read_only_overlay_measures_the_same_bar_as_a_writable_one() {
1184 let writable = settled(Harness::wire());
1185 let mut read_only = Harness::wire();
1186 read_only.writability = Writability::ReadOnly;
1187 let read_only = settled(read_only);
1188 assert!(
1189 writable.bar.is_positive(),
1190 "precondition: the overlay measured itself ({:?})",
1191 writable.bar,
1192 );
1193 assert_eq!(read_only.bar.size(), writable.bar.size());
1194 }
1195
1196 #[test]
1202 fn commands_the_overlay_does_not_draw_do_not_widen_it() {
1203 let quiet = settled(Harness::wire());
1204 let mut busy = Harness::wire();
1205 busy.history = History::doc();
1206 let busy = settled(busy);
1207 assert!(quiet.bar.is_positive(), "precondition: the bar measured");
1208 assert_eq!(
1209 busy.drawn, quiet.drawn,
1210 "precondition: undo and redo draw no control in the overlay",
1211 );
1212 assert_eq!(
1213 busy.bar.size(),
1214 quiet.bar.size(),
1215 "two commands the overlay never draws widened it anyway",
1216 );
1217 }
1218
1219 #[test]
1222 fn accent_swatch_maps_index_to_its_accent_role() {
1223 use blockworx_doc::id::BlockId;
1224 let block = RoleTarget::Block(BlockId::NULL);
1225 assert_eq!(accent_display_role(block, Some(0)), Role::Accent0);
1226 assert_eq!(accent_display_role(block, Some(7)), Role::Accent7);
1227 assert_eq!(accent_display_role(block, None), Role::AccentDefault);
1229 assert_eq!(accent_display_role(block, Some(9)), Role::AccentDefault);
1230 }
1231
1232 #[test]
1233 fn accent_swatch_uses_each_targets_own_unaccented_stroke() {
1234 use blockworx_doc::id::{AreaId, TextId};
1235 let area = RoleTarget::Area(AreaId::NULL);
1238 let text = RoleTarget::Text(TextId::NULL);
1239 assert_eq!(accent_display_role(area, None), Role::AreaStroke);
1240 assert_eq!(accent_display_role(text, None), Role::TextBoxStroke);
1241 assert_eq!(accent_display_role(area, Some(2)), Role::Accent2);
1243 }
1244
1245 #[test]
1246 fn lock_toggle_icon_depicts_the_blocks_current_state() {
1247 let (locked_icon, locked_hover) = lock_toggle_icon(InterfaceLock::Locked);
1248 let (unlocked_icon, unlocked_hover) = lock_toggle_icon(InterfaceLock::Unlocked);
1249 assert_eq!(locked_icon.uri(), LOCK_ICON.uri());
1252 assert_eq!(unlocked_icon.uri(), UNLOCK_ICON.uri());
1253 assert_ne!(locked_icon.uri(), unlocked_icon.uri());
1254 assert_eq!(locked_hover, "Unlock pins");
1256 assert_eq!(unlocked_hover, "Lock pins");
1257 }
1258
1259 #[test]
1264 fn padlock_svgs_draw_a_closed_and_an_open_shackle() {
1265 let closed = include_str!("../../icons/icon-lock.svg");
1266 let open = include_str!("../../icons/icon-unlock.svg");
1267 assert!(
1268 closed.contains(r#"d="M8 11V7a4 4 0 0 1 8 0v4""#),
1269 "{closed}"
1270 );
1271 assert!(open.contains(r#"d="M8 11V7a4 4 0 0 1 7.5-2""#), "{open}");
1272 }
1273
1274 #[test]
1278 fn the_selection_overlay_offers_enter_but_not_go_up() {
1279 assert!(overlay_icon(CommandId::GoUp).is_none());
1280 assert!(overlay_icon(CommandId::ExpandBlock).is_some());
1281 }
1282
1283 #[test]
1289 fn the_hierarchy_icons_share_a_box_and_oppose_their_arrows() {
1290 let enter = include_str!("../../icons/icon-expand.svg");
1291 let rise = include_str!("../../icons/icon-level-up.svg");
1292 let box_path = r#"d="M10 5H5v14h14v-5""#;
1293 let shaft = r#"d="M20 4l-9 9""#;
1294 for icon in [enter, rise] {
1295 assert!(icon.contains(box_path), "{icon}");
1296 assert!(icon.contains(shaft), "{icon}");
1297 }
1298 assert!(enter.contains(r#"d="M11 7v6h6""#), "{enter}");
1301 assert!(rise.contains(r#"d="M14 4h6v6""#), "{rise}");
1302 }
1303
1304 #[test]
1305 fn the_import_icon_reverses_the_export_arrow_over_the_same_tray() {
1306 let export = include_str!("../../icons/icon-export.svg");
1307 let import = include_str!("../../icons/icon-import.svg");
1308 let tray = r#"d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4""#;
1309 assert!(export.contains(tray), "{export}");
1310 assert!(import.contains(tray), "{import}");
1311 assert!(export.contains(r#"points="17 8 12 3 7 8""#), "{export}");
1313 assert!(import.contains(r#"points="7 10 12 15 17 10""#), "{import}");
1314 }
1315
1316 #[test]
1319 fn the_overflow_glyph_is_an_ellipsis_of_its_own() {
1320 let more = include_str!("../../icons/icon-more.svg");
1321 assert!(
1322 MORE_ICON
1323 .uri()
1324 .is_some_and(|uri| uri.ends_with("icon-more.svg")),
1325 "the overflow button borrowed another surface's glyph: {:?}",
1326 MORE_ICON.uri(),
1327 );
1328 assert_eq!(more.matches("<circle").count(), 3);
1329 assert!(more.contains(r#"viewBox="0 0 24 24""#), "{more}");
1330 assert!(more.contains(r#"stroke-width="2""#), "{more}");
1331 }
1332
1333 struct Harness {
1339 scene: Scene,
1340 tool: Tool,
1341 selection: Selection,
1342 camera: Camera,
1343 right_click: RightClick,
1344 writability: Writability,
1345 history: History,
1346 safe: SafeArea,
1347 drawn: Vec<(CommandId, std::borrow::Cow<'static, str>)>,
1350 invocable: usize,
1352 corner: Option<egui::Pos2>,
1355 painted: usize,
1358 bar: Rect,
1360 fired: Vec<Act>,
1362 }
1363
1364 impl Harness {
1365 fn over(scene: Scene, tool: Tool, count: usize) -> Self {
1366 Harness {
1367 scene,
1368 tool,
1369 selection: Selection {
1370 screen: Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0)).geom(),
1371 count,
1372 },
1373 camera: Camera::Settled,
1374 right_click: RightClick::No,
1375 writability: Writability::Writable,
1376 history: History::empty(),
1377 safe: SafeArea::over(viewport()),
1378 drawn: Vec::new(),
1379 invocable: 0,
1380 corner: None,
1381 painted: 0,
1382 bar: Rect::NOTHING,
1383 fired: Vec::new(),
1384 }
1385 }
1386
1387 fn wire() -> Self {
1389 let mut scene = two_blocks_with_a_routed_waypoint();
1390 let route = {
1391 let drawing = scene.drawing();
1392 let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1393 assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1394 ids[0]
1395 };
1396 let tool = crate::tools::EditRoute::Selected {
1397 id: route,
1398 anchor: pos2(0.0, 0.0).geom(),
1399 }
1400 .into();
1401 Harness::over(scene, tool, 1)
1402 }
1403
1404 fn block() -> Self {
1406 let mut scene = two_blocks_with_a_routed_waypoint();
1407 let shape = ShapeId::Rect(blockworx_doc::fixtures::block_id(1));
1408 assert!(
1409 scene.drawing().shape(shape).is_some(),
1410 "precondition: the fixture holds the block this selects",
1411 );
1412 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1413 Harness::over(scene, tool, 1)
1414 }
1415
1416 fn area() -> Self {
1418 let mut scene = Scene::new(vec![crate::widget::test_fixtures::area(
1419 9,
1420 crate::path::Scope::Root,
1421 blockworx_geom::Rect::from_min_size(
1422 blockworx_geom::pos2(0.0, 0.0),
1423 blockworx_geom::vec2(200.0, 140.0),
1424 ),
1425 )]);
1426 let shape = ShapeId::Area(blockworx_doc::fixtures::area_id(9));
1427 assert!(
1428 scene.drawing().shape(shape).is_some(),
1429 "precondition: the fixture holds the area this selects",
1430 );
1431 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1432 Harness::over(scene, tool, 1)
1433 }
1434
1435 fn port() -> Self {
1438 let body = Rect::from_min_size(pos2(0.0, 0.0), vec2(60.0, 60.0));
1439 let mut scene = Scene::new(vec![crate::widget::test_fixtures::pin_at(
1440 7,
1441 crate::path::Scope::Root,
1442 "clk",
1443 crate::widget::test_fixtures::slot(crate::shape::pin::PinSide::West, 0),
1444 body.geom(),
1445 )]);
1446 let shape = ShapeId::Port(blockworx_doc::fixtures::pin_id(7));
1447 assert!(
1448 scene.drawing().shape(shape).is_some(),
1449 "precondition: the fixture holds the port this selects",
1450 );
1451 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1452 Harness::over(scene, tool, 1)
1453 }
1454
1455 fn two_blocks() -> Self {
1457 let mut scene = two_blocks_with_a_routed_waypoint();
1458 let shapes: Vec<ShapeId> = [1, 2]
1459 .map(|n| ShapeId::Rect(blockworx_doc::fixtures::block_id(n)))
1460 .into();
1461 assert!(
1462 shapes.iter().all(|&id| scene.drawing().shape(id).is_some()),
1463 "precondition: the fixture holds both blocks",
1464 );
1465 let count = shapes.len();
1466 let tool = crate::tools::MultiSelect::Selected { shapes }.into();
1467 Harness::over(scene, tool, count)
1468 }
1469
1470 fn show(&mut self, ui: &mut egui::Ui) {
1472 let drawing = self.scene.drawing();
1473 let mut commands = CommandSet::available(&CommandContext {
1474 tool: &self.tool,
1475 data: &drawing,
1476 history: self.history,
1477 current_lock: InterfaceLock::Unlocked,
1478 writability: self.writability,
1479 saving: blockworx_store::doc::Saving::Withheld,
1480 viewing: blockworx_store::doc::Viewing::Head,
1481 });
1482 self.drawn = overlay_controls(&commands)
1483 .map(|cmd| (cmd.id, cmd.label.clone()))
1484 .collect();
1485 self.invocable = commands.iter().filter(|cmd| in_overlay(cmd.id)).count();
1486 let theme = Theme::default();
1487 let model = crate::kernel::Overlay::of(&drawing, &theme, self.selection, &commands);
1488 let (action, corner) = selection_overlay(
1489 ui,
1490 Overlay {
1491 commands: &mut commands,
1492 bar: Some(&model),
1493 open_picker: None,
1494 safe: self.safe,
1495 camera: self.camera,
1496 right_click: self.right_click,
1497 },
1498 );
1499 if let Some(fired) = action {
1500 self.fired.push(fired);
1501 }
1502 self.corner = corner;
1503 }
1504
1505 fn laid_out(&mut self) -> Vec<(CommandId, std::borrow::Cow<'static, str>)> {
1508 let ctx = egui::Context::default();
1509 egui_extras::install_image_loaders(&ctx);
1510 run(self, &ctx, 2);
1511 self.drawn.clone()
1512 }
1513 }
1514
1515 fn run(harness: &mut Harness, ctx: &egui::Context, frames: usize) -> usize {
1518 for _ in 0..frames {
1519 let mut out = ctx.clone().run_ui(
1520 egui::RawInput {
1521 screen_rect: Some(harness.safe.viewport()),
1522 ..Default::default()
1523 },
1524 |ui| harness.show(ui),
1525 );
1526 out.textures_delta.clear();
1527 harness.painted = painted(&out.shapes);
1528 }
1529 harness.bar = ctx
1530 .data(|d| d.get_temp::<Measured>(Panel::SelectionButtons.measurement("rect")))
1531 .map_or(Rect::NOTHING, |was| was.rect);
1532 harness.painted
1533 }
1534
1535 fn settled(mut harness: Harness) -> Harness {
1538 let ctx = egui::Context::default();
1539 egui_extras::install_image_loaders(&ctx);
1540 run(&mut harness, &ctx, 2);
1541 harness
1542 }
1543
1544 fn painted(shapes: &[egui::epaint::ClippedShape]) -> usize {
1546 fn leaves(shape: &egui::Shape) -> usize {
1547 match shape {
1548 egui::Shape::Noop => 0,
1549 egui::Shape::Vec(shapes) => shapes.iter().map(leaves).sum(),
1550 _ => 1,
1551 }
1552 }
1553 shapes.iter().map(|clipped| leaves(&clipped.shape)).sum()
1554 }
1555}