1use crate::canvas::convert::IntoEgui as _;
26use crate::{
27 canvas::Camera,
28 edit::{lower::accent_from_role, naming::InterfaceLock},
29 export::ExportScope,
30 shape::{ShapeId, ShapeRef},
31 shell::{SafeArea, glass},
32 theme::{Role, Theme, accent_role},
33 tools::{
34 chrome::Panel,
35 commands::{Command, CommandId, CommandSet, Placement},
36 tool::{Action, RoleTarget},
37 },
38 widget::drawing::Drawing,
39};
40use blockworx_doc::{
41 id::PinId,
42 values::{PinDir, Role as AccentRole},
43};
44
45#[derive(Clone, Copy, PartialEq, Eq)]
48pub enum OpenPicker {
49 Role,
50 PinType,
51}
52
53#[derive(Clone, Copy, PartialEq, Eq)]
57pub enum RightClick {
58 Asked,
59 No,
60}
61
62impl From<bool> for RightClick {
63 fn from(asked: bool) -> Self {
64 if asked {
65 RightClick::Asked
66 } else {
67 RightClick::No
68 }
69 }
70}
71
72fn accent_swatch_color(data: &Drawing, target: RoleTarget, theme: &Theme) -> egui::Color32 {
78 let accent = |role: AccentRole| accent_from_role(role);
79 let current = match target {
80 RoleTarget::Block(rid) => data.block(rid).and_then(|b| accent(b.role)),
81 RoleTarget::Port(pid) => match data.shape(ShapeId::Port(pid)) {
82 Some(ShapeRef::Port(port)) => accent(port.pin.port_accent),
83 _ => None,
84 },
85 RoleTarget::Route(rid) => data.auto_route(rid).and_then(|w| accent(w.route.role)),
86 RoleTarget::Area(cid) => match data.shape(ShapeId::Area(cid)) {
87 Some(ShapeRef::Area(area)) => accent(area.role),
88 _ => None,
89 },
90 RoleTarget::Text(tid) => match data.shape(ShapeId::Text(tid)) {
91 Some(ShapeRef::Text(text)) => accent(text.text.role),
92 _ => None,
93 },
94 };
95 theme.resolve(accent_display_role(target, current)).egui()
96}
97
98fn accent_display_role(target: RoleTarget, current: Option<u8>) -> Role {
102 let default_role = match target {
103 RoleTarget::Area(_) => Role::AreaStroke,
104 RoleTarget::Text(_) => Role::TextBoxStroke,
105 _ => Role::AccentDefault,
106 };
107 accent_role(current).unwrap_or(default_role)
108}
109
110const EXPAND_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-expand.svg");
111const LOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-lock.svg");
112const UNLOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-unlock.svg");
113const DELETE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-trash.svg");
114const ADD_ICON_ICON: egui::ImageSource<'static> =
115 egui::include_image!("../../icons/icon-add-icon.svg");
116const ROUTE_LABEL_ICON: egui::ImageSource<'static> =
117 egui::include_image!("../../icons/icon-route-label.svg");
118const COPY_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-copy.svg");
119const CUT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-cut.svg");
120const FLIP_LR_ICON: egui::ImageSource<'static> =
121 egui::include_image!("../../icons/icon-flip-lr.svg");
122const FLIP_UD_ICON: egui::ImageSource<'static> =
123 egui::include_image!("../../icons/icon-flip-ud.svg");
124pub(crate) const EXPORT_ICON: egui::ImageSource<'static> =
125 egui::include_image!("../../icons/icon-export.svg");
126const REROUTE_ICON: egui::ImageSource<'static> =
127 egui::include_image!("../../icons/icon-reroute.svg");
128const MORE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-more.svg");
131const EYE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-eye.svg");
132const EYE_OFF_ICON: egui::ImageSource<'static> =
133 egui::include_image!("../../icons/icon-eye-off.svg");
134const PIN_TYPE_ICON: egui::ImageSource<'static> = crate::io_pin_picker::INPUT_ICON;
138
139fn lock_toggle_icon(lock: InterfaceLock) -> (egui::ImageSource<'static>, &'static str) {
143 match lock {
144 InterfaceLock::Locked => (LOCK_ICON, "Unlock pins"),
145 InterfaceLock::Unlocked => (UNLOCK_ICON, "Lock pins"),
146 }
147}
148
149pub(crate) fn icon_image(
152 ui: &egui::Ui,
153 source: egui::ImageSource<'static>,
154) -> egui::Image<'static> {
155 egui::Image::new(source)
156 .fit_to_exact_size(egui::vec2(14.0, 14.0))
157 .tint(ui.visuals().widgets.inactive.fg_stroke.color)
158}
159
160pub(crate) fn group_heading(ui: &mut egui::Ui, text: &str) {
165 ui.add_space(6.0);
166 ui.label(egui::RichText::new(text).small().weak());
167}
168
169const CLEAR: f32 = 14.0;
171
172const BAR: glass::Shape = glass::Shape::Bar;
175
176fn nothing_to_say(count: usize) -> &'static str {
184 if count > 1 {
185 "No actions shared by this selection"
186 } else {
187 "No actions for this selection"
188 }
189}
190
191const CELL_GAP: f32 = 2.0;
194
195const SWATCH: f32 = 18.0;
197const SWATCH_RADIUS: u8 = 3;
198
199const MENU_ICON: f32 = 18.0;
201
202#[derive(Clone, Copy)]
205pub struct Selection {
206 pub screen: egui::Rect,
207 pub count: usize,
208}
209
210pub struct Overlay<'a, 'b> {
212 pub commands: &'a mut CommandSet,
213 pub data: &'a Drawing<'b>,
214 pub theme: &'a Theme,
215 pub open_picker: Option<OpenPicker>,
216 pub selection: Option<Selection>,
217 pub safe: SafeArea,
219 pub camera: Camera,
222 pub right_click: RightClick,
223}
224
225fn place(sel: egui::Rect, size: egui::Vec2, safe: &SafeArea) -> Option<egui::Rect> {
244 let region = safe.region();
245 let band =
246 |top: f32| egui::Rect::from_min_size(egui::pos2(sel.center().x - size.x / 2.0, top), size);
247 let fits = |bar: &egui::Rect| bar.top() >= region.top() && bar.bottom() <= region.bottom();
248 let outside = [band(sel.top() - CLEAR - size.y), band(sel.bottom() + CLEAR)]
249 .into_iter()
250 .find(fits);
251 let inside = || {
252 let bar = band(sel.top().max(region.top()) + CLEAR);
253 fits(&bar).then_some(bar)
254 };
255 outside.or_else(inside).map(|bar| safe.clamp(bar))
256}
257
258enum Bar<T> {
263 Row {
267 inline: Vec<T>,
268 overflow: Vec<T>,
269 all: Vec<T>,
270 },
271 NothingShared,
274 Nothing,
278}
279
280impl<T: Copy> Bar<T> {
281 fn of(commands: Vec<T>, count: usize, placement: impl Fn(T) -> Placement) -> Self {
284 if commands.is_empty() {
285 return if count > 1 {
286 Bar::NothingShared
287 } else {
288 Bar::Nothing
289 };
290 }
291 let (inline, overflow) = commands
292 .iter()
293 .partition(|cmd| placement(**cmd) == Placement::Inline);
294 Bar::Row {
295 inline,
296 overflow,
297 all: commands,
298 }
299 }
300
301 fn all(&self) -> impl Iterator<Item = &T> {
304 match self {
305 Bar::Row { all, .. } => all.as_slice(),
306 Bar::NothingShared | Bar::Nothing => &[],
307 }
308 .iter()
309 }
310}
311
312fn menu_id() -> egui::Id {
314 egui::Id::from(Panel::SelectionMenu)
315}
316
317fn dismissed(ctx: &egui::Context) -> (Option<Action>, Option<egui::Pos2>) {
320 egui::Popup::close_id(ctx, menu_id());
321 (None, None)
322}
323
324enum Placed {
326 At(egui::Pos2),
328 Nowhere,
331 Measuring,
334}
335
336#[derive(Clone)]
341struct Measured {
342 controls: Vec<CommandId>,
343 count: usize,
344 rect: egui::Rect,
345}
346
347pub(crate) fn export_format_menu(
351 ui: &mut egui::Ui,
352 scope: crate::export::ExportScope,
353) -> Option<crate::export::ExportFormat> {
354 scope
355 .formats()
356 .iter()
357 .copied()
358 .find(|format| ui.button(format.label()).clicked())
359}
360
361pub fn selection_overlay(
368 ui: &mut egui::Ui,
369 overlay: Overlay<'_, '_>,
370) -> (Option<Action>, Option<egui::Pos2>) {
371 let Overlay {
372 commands,
373 data,
374 theme,
375 open_picker,
376 selection,
377 safe,
378 camera,
379 right_click,
380 } = overlay;
381 let ctx = ui.ctx().clone();
382 let ctx = &ctx;
383 let Some(selection) = selection.filter(|sel| safe.viewport().intersects(sel.screen)) else {
384 return dismissed(ctx);
385 };
386 if camera == Camera::Moving {
390 return dismissed(ctx);
391 }
392 let offered: Vec<&Command> = overlay_controls(commands).collect();
393 let drawn: Vec<CommandId> = offered.iter().map(|cmd| cmd.id).collect();
394 let count = selection.count;
395 let bar = Bar::of(offered, count, |cmd: &Command| cmd.placement);
396
397 let size_key = Panel::SelectionButtons.measurement("rect");
398 let remembered = ctx
399 .data(|d| d.get_temp::<Measured>(size_key))
400 .filter(|was| was.controls == drawn && was.count == count)
401 .map(|was| was.rect.size());
402 let placed = match remembered {
403 Some(size) => match place(selection.screen, size, &safe) {
404 Some(bar) => Placed::At(bar.min),
405 None => Placed::Nowhere,
406 },
407 None => Placed::Measuring,
408 };
409 let (at, builder) = match placed {
410 Placed::At(at) => (at, Panel::SelectionButtons.ui_builder()),
411 Placed::Nowhere => return dismissed(ctx),
412 Placed::Measuring => {
417 ctx.request_repaint();
418 (
419 selection.screen.center(),
420 Panel::SelectionButtons
421 .ui_builder()
422 .sizing_pass()
423 .invisible(),
424 )
425 }
426 };
427 let sizing = builder.sizing_pass;
428 let controls = Controls {
429 data,
430 theme,
431 open_picker,
432 };
433 let mut clicked: Option<CommandId> = None;
434 let mut child =
435 ui.new_child(builder.max_rect(egui::Rect::from_min_size(at, safe.viewport().size())));
436 let rect = {
437 let ui = &mut child;
438 glass::shell(ui, BAR, glass::Elevation::Floating, glass::Tint::None)
439 .show(ui, |ui| {
440 glass::type_scale(ui, BAR);
441 if let Some(height) = BAR.content_height() {
447 ui.set_min_height(height);
448 }
449 clicked = draw_row(ui, &bar, count, controls);
450 })
451 .response
452 .rect
453 };
454 tracing::debug!(
455 target: "overlay",
456 pass = ctx.cumulative_pass_nr(),
457 sizing,
458 drawn = drawn.len(),
459 at = ?at,
460 rect = ?rect,
461 sel = ?selection.screen,
462 "overlay frame"
463 );
464 ctx.data_mut(|d| {
465 d.insert_temp(
466 size_key,
467 Measured {
468 controls: drawn,
469 count,
470 rect,
471 },
472 );
473 });
474 if sizing {
475 return (None, None);
476 }
477 if let Some(id) = right_click_menu(ui, &bar, right_click, controls) {
478 clicked = Some(id);
479 }
480 drop(bar);
481 (
482 clicked.and_then(|id| commands.take(id)),
483 Some(rect.right_top()),
484 )
485}
486
487fn draw_row(
489 ui: &mut egui::Ui,
490 bar: &Bar<&Command>,
491 count: usize,
492 controls: Controls<'_, '_>,
493) -> Option<CommandId> {
494 let mut clicked = None;
495 ui.horizontal(|ui| {
496 ui.spacing_mut().item_spacing.x = CELL_GAP;
497 if count > 1 {
498 ui.label(
499 egui::RichText::new(format!("{count} selected"))
500 .small()
501 .weak(),
502 );
503 glass::group_gap(ui);
504 }
505 let Bar::Row {
506 inline, overflow, ..
507 } = bar
508 else {
509 ui.label(egui::RichText::new(nothing_to_say(count)).small().weak());
510 return;
511 };
512 for cmd in inline {
513 if let Some(id) = draw_command(ui, cmd, Shown::InTheBar, controls) {
514 clicked = Some(id);
515 }
516 }
517 if overflow.is_empty() {
518 return;
519 }
520 let more = glass::tap_button(
521 ui,
522 MORE_ICON,
523 glass::Live::Yes,
524 format!("{} more", overflow.len()),
525 );
526 egui::Popup::menu(&more).show(|ui| {
527 for cmd in overflow {
528 if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
529 clicked = Some(id);
530 }
531 }
532 });
533 });
534 clicked
535}
536
537fn right_click_menu(
541 ui: &mut egui::Ui,
542 bar: &Bar<&Command>,
543 right_click: RightClick,
544 controls: Controls<'_, '_>,
545) -> Option<CommandId> {
546 let mut clicked = None;
547 let opened = match (right_click, bar.all().next()) {
550 (RightClick::Asked, Some(_)) => Some(egui::SetOpenCommand::Bool(true)),
551 (RightClick::Asked, None) => Some(egui::SetOpenCommand::Bool(false)),
552 (RightClick::No, _) => None,
553 };
554 egui::Popup::new(
555 menu_id(),
556 ui.ctx().clone(),
557 egui::PopupAnchor::PointerFixed,
558 ui.layer_id(),
559 )
560 .kind(egui::PopupKind::Menu)
561 .layout(egui::Layout::top_down_justified(egui::Align::Min))
562 .open_memory(opened)
563 .show(|ui| {
564 for cmd in bar.all() {
565 if let Some(id) = draw_command(ui, cmd, Shown::InAMenu, controls) {
566 clicked = Some(id);
567 }
568 }
569 });
570 clicked
571}
572
573fn overlay_controls(commands: &CommandSet) -> impl Iterator<Item = &Command> {
580 commands.iter_drawn().filter(|cmd| drawn_in_overlay(cmd.id))
581}
582
583fn drawn_in_overlay(id: CommandId) -> bool {
587 match id {
588 CommandId::ExportSelection(format) => format == ExportScope::Selection.leading_format(),
591 CommandId::Arm(_)
594 | CommandId::Undo
595 | CommandId::Redo
596 | CommandId::Export(_)
597 | CommandId::Import => false,
598 CommandId::HideTags | CommandId::ShowTags | CommandId::PinType | CommandId::Accent => true,
599 id => overlay_icon(id).is_some(),
600 }
601}
602
603#[derive(Clone, Copy, PartialEq, Eq)]
606enum Shown {
607 InTheBar,
608 InAMenu,
609}
610
611#[derive(Clone, Copy)]
615struct Controls<'a, 'b> {
616 data: &'a Drawing<'b>,
617 theme: &'a Theme,
618 open_picker: Option<OpenPicker>,
619}
620
621fn draw_command(
626 ui: &mut egui::Ui,
627 cmd: &Command,
628 shown: Shown,
629 controls: Controls<'_, '_>,
630) -> Option<CommandId> {
631 let Controls {
632 data,
633 theme,
634 open_picker,
635 } = controls;
636 ui.add_enabled_ui(!cmd.withheld(), |ui| match cmd.id {
637 CommandId::ExportSelection(_) => {
638 let mut picked = None;
639 match shown {
640 Shown::InTheBar => {
641 let button = glass::tap_button(ui, EXPORT_ICON, glass::Live::Yes, cmd.label);
642 egui::Popup::menu(&button).show(|ui| {
643 picked = export_format_menu(ui, ExportScope::Selection);
644 });
645 }
646 Shown::InAMenu => {
647 ui.menu_button(cmd.label, |ui| {
648 picked = export_format_menu(ui, ExportScope::Selection);
649 });
650 }
651 }
652 picked.map(CommandId::ExportSelection)
653 }
654 CommandId::Accent => {
655 let Action::OpenRolePicker { target } = &cmd.action else {
656 return None;
657 };
658 let opened = glass::Opened::from(open_picker == Some(OpenPicker::Role));
659 let response = match shown {
660 Shown::InTheBar => {
661 accent_swatch(ui, accent_swatch_color(data, *target, theme), opened)
662 }
663 Shown::InAMenu => menu_row(ui, None, cmd.label, opened),
664 };
665 (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
666 }
667 CommandId::PinType => {
668 let opened = glass::Opened::from(open_picker == Some(OpenPicker::PinType));
669 let says = pin_dir_hover(pins_of(&cmd.action).and_then(|pins| shared_dir(data, pins)));
670 let response = match shown {
671 Shown::InTheBar => {
672 glass::tap_button(ui, PIN_TYPE_ICON, glass::Live::Yes, says.clone())
673 }
674 Shown::InAMenu => menu_row(ui, Some(PIN_TYPE_ICON), cmd.label, opened),
675 };
676 (response.clicked() && opened == glass::Opened::No).then_some(cmd.id)
677 }
678 CommandId::HideTags | CommandId::ShowTags => {
679 let icon = tag_icon(cmd.id);
680 let response = match shown {
681 Shown::InTheBar => glass::tap_button(ui, icon, glass::Live::Yes, cmd.label),
682 Shown::InAMenu => menu_row(ui, Some(icon), cmd.label, glass::Opened::No),
683 };
684 response.clicked().then_some(cmd.id)
685 }
686 id => {
687 let icon = overlay_icon(id)?;
688 let response = match shown {
689 Shown::InTheBar => glass::tap_button(ui, icon, glass::Live::Yes, cmd.label),
690 Shown::InAMenu => menu_row(ui, Some(icon), cmd.label, glass::Opened::No),
691 };
692 response.clicked().then_some(id)
693 }
694 })
695 .inner
696}
697
698fn tag_icon(id: CommandId) -> egui::ImageSource<'static> {
708 match id {
709 CommandId::ShowTags => EYE_OFF_ICON,
710 _ => EYE_ICON,
711 }
712}
713
714fn pin_dir_hover(dir: Option<PinDir>) -> String {
725 let state = match dir {
726 Some(PinDir::Input) => "currently Input",
727 Some(PinDir::Output) => "currently Output",
728 Some(PinDir::InOut) => "currently Input Output",
729 None => "these pins face different ways",
730 };
731 format!("Direction \u{2014} {state}")
732}
733
734fn pins_of(action: &Action) -> Option<&[PinId]> {
736 match action {
737 Action::OpenPinTypePicker { pins } => Some(pins),
738 _ => None,
739 }
740}
741
742fn shared_dir(data: &Drawing<'_>, pins: &[PinId]) -> Option<PinDir> {
745 let mut dirs = pins
746 .iter()
747 .map(|&pin| data.pin_on_shape(pin).map(|(_, pin)| pin.dir));
748 let first = dirs.next()??;
749 dirs.all(|dir| dir == Some(first)).then_some(first)
750}
751
752fn menu_row(
755 ui: &mut egui::Ui,
756 icon: Option<egui::ImageSource<'static>>,
757 label: &str,
758 opened: glass::Opened,
759) -> egui::Response {
760 let button = match icon {
761 Some(icon) => egui::Button::image_and_text(glass::image(ui, icon, MENU_ICON), label),
762 None => egui::Button::new(label),
763 };
764 ui.add(
765 button
766 .min_size(egui::vec2(0.0, glass::TAP))
767 .selected(opened == glass::Opened::Yes),
768 )
769}
770
771fn accent_swatch(ui: &mut egui::Ui, color: egui::Color32, opened: glass::Opened) -> egui::Response {
775 let (cell, resp) = ui.allocate_exact_size(egui::Vec2::splat(glass::TAP), egui::Sense::click());
776 let rect = egui::Rect::from_center_size(cell.center(), egui::Vec2::splat(SWATCH));
777 let painter = ui.painter();
778 painter.rect_filled(rect, SWATCH_RADIUS, color);
779 let (width, stroke) = if opened == glass::Opened::Yes {
780 (2.0, ui.visuals().selection.stroke.color)
781 } else {
782 (1.0, ui.visuals().widgets.inactive.fg_stroke.color)
783 };
784 painter.rect_stroke(
785 rect,
786 SWATCH_RADIUS,
787 egui::Stroke::new(width, stroke),
788 egui::StrokeKind::Inside,
789 );
790 resp.on_hover_text("Accent")
791}
792
793fn overlay_icon(id: CommandId) -> Option<egui::ImageSource<'static>> {
797 match id {
798 CommandId::Copy => Some(COPY_ICON),
799 CommandId::Cut => Some(CUT_ICON),
800 CommandId::FlipLr => Some(FLIP_LR_ICON),
801 CommandId::FlipUd => Some(FLIP_UD_ICON),
802 CommandId::Reroute | CommandId::RerouteBlock => Some(REROUTE_ICON),
803 CommandId::AddRouteLabel => Some(ROUTE_LABEL_ICON),
804 CommandId::ExpandBlock => Some(EXPAND_ICON),
807 CommandId::Lock => Some(lock_toggle_icon(InterfaceLock::Unlocked).0),
808 CommandId::Unlock => Some(lock_toggle_icon(InterfaceLock::Locked).0),
809 CommandId::AddIcon => Some(ADD_ICON_ICON),
810 CommandId::Delete => Some(DELETE_ICON),
811 _ => None,
812 }
813}
814
815#[cfg(test)]
816mod tests {
817 use super::*;
818 use crate::canvas::convert::IntoGeom as _;
819
820 use blockworx_store::doc::Writability;
821
822 use crate::{
823 shell::insets::Edge,
824 tools::{
825 commands::{CommandContext, History},
826 tool::Tool,
827 },
828 widget::test_fixtures::{Scene, two_blocks_with_a_routed_waypoint},
829 };
830 use egui::{Rect, Vec2, pos2, vec2};
831
832 fn viewport() -> Rect {
833 Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
834 }
835
836 const SIZE: Vec2 = Vec2::new(200.0, 40.0);
838
839 const SUBMENU_CHEVRON: &str = "\u{23f5}";
841
842 #[test]
845 fn places_the_bar_above_the_selection_with_the_specs_air() {
846 let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
847 let safe = SafeArea::over(viewport());
848 assert!(
849 safe.region().contains_rect(sel),
850 "precondition: the selection is inside the room the chrome left",
851 );
852 let bar = place(sel, SIZE, &safe).expect("a mid-canvas selection has room above it");
853 assert_eq!(
854 bar.bottom(),
855 sel.top() - CLEAR,
856 "the air is not §3.3's 14px"
857 );
858 assert_eq!(
859 bar.center().x,
860 sel.center().x,
861 "the bar is not centred on it"
862 );
863 assert_eq!(bar.size(), SIZE, "placing resized the bar");
864 }
865
866 #[test]
867 fn flips_below_when_above_would_land_under_the_top_chrome() {
868 let mut safe = SafeArea::over(viewport());
869 safe.covered_by(
870 Edge::Top,
871 Rect::from_min_size(pos2(16.0, 16.0), vec2(300.0, 52.0)),
872 );
873 let sel = Rect::from_min_size(pos2(400.0, 100.0), vec2(100.0, 100.0));
874 assert!(
875 sel.top() - CLEAR - SIZE.y < safe.region().top(),
876 "precondition: above is under the chrome",
877 );
878 let bar = place(sel, SIZE, &safe).expect("there is room below it");
879 assert_eq!(bar.top(), sel.bottom() + CLEAR, "it did not flip below");
880 }
881
882 #[test]
883 fn clamps_sideways_into_the_room_the_chrome_left() {
884 let mut safe = SafeArea::over(viewport());
885 safe.covered_by(
886 Edge::Right,
887 Rect::from_min_size(pos2(700.0, 16.0), vec2(284.0, 700.0)),
888 );
889 let sel = Rect::from_min_size(pos2(640.0, 400.0), vec2(40.0, 40.0));
890 let region = safe.region();
891 assert!(
892 sel.center().x + SIZE.x / 2.0 > region.right(),
893 "precondition: centred, the bar would run under the navigator",
894 );
895 let bar = place(sel, SIZE, &safe).expect("a clamped bar still has a place");
896 assert!(
897 region.contains_rect(bar),
898 "the bar landed at {bar:?}, outside {region:?}",
899 );
900 assert_eq!(bar.size(), SIZE, "clamping resized the bar");
901 }
902
903 #[test]
908 fn the_bar_never_covers_what_is_selected() {
909 let mut safe = SafeArea::over(viewport());
910 safe.covered_by(
911 Edge::Top,
912 Rect::from_min_size(pos2(16.0, 16.0), vec2(300.0, 52.0)),
913 );
914 safe.covered_by(
915 Edge::Left,
916 Rect::from_min_size(pos2(16.0, 300.0), vec2(64.0, 200.0)),
917 );
918 let mut placed = 0;
919 for x in [-60.0, 0.0, 120.0, 500.0, 900.0, 980.0] {
920 for y in [-60.0, 0.0, 90.0, 400.0, 700.0, 780.0] {
921 for size in [vec2(24.0, 24.0), vec2(320.0, 180.0)] {
922 let sel = Rect::from_min_size(pos2(x, y), size);
923 let Some(bar) = place(sel, SIZE, &safe) else {
924 continue;
925 };
926 placed += 1;
927 assert!(
928 !bar.intersects(sel),
929 "the bar at {bar:?} covers the selection at {sel:?}",
930 );
931 assert!(
932 safe.region().contains_rect(bar),
933 "the bar at {bar:?} left {:?}",
934 safe.region(),
935 );
936 }
937 }
938 }
939 assert!(
940 placed > 0,
941 "no selection was placed — the sweep proved nothing"
942 );
943 }
944
945 #[test]
952 fn a_selection_with_no_room_either_side_takes_the_band_inside_itself() {
953 let sel = Rect::from_min_size(pos2(-100.0, -100.0), vec2(1200.0, 1000.0));
954 let safe = SafeArea::over(viewport());
955 let region = safe.region();
956 assert!(
957 !region.contains_rect(sel),
958 "precondition: the selection overruns the region",
959 );
960 let placed = place(sel, SIZE, &safe).expect("an oversized selection still gets a bar");
961 assert!(
962 region.contains_rect(placed),
963 "the bar landed outside the region at {placed:?}",
964 );
965 assert!(
966 placed.top() >= region.top() + CLEAR,
967 "the bar sits flush against the region's own edge: {placed:?}",
968 );
969 }
970
971 #[test]
980 fn the_row_holds_the_primaries_and_the_clerical_verbs_go_behind_the_ellipsis() {
981 let all: Vec<CommandId> = Harness::block().laid_out().iter().map(|c| c.0).collect();
982 let Bar::Row {
983 inline,
984 overflow,
985 all: whole,
986 } = Bar::of(all.clone(), 1, Placement::of)
987 else {
988 panic!("a block's commands are a row");
989 };
990 for wanted in [
991 CommandId::Accent,
992 CommandId::ExpandBlock,
993 CommandId::AddIcon,
994 CommandId::FlipLr,
995 CommandId::FlipUd,
996 ] {
997 assert!(
998 inline.contains(&wanted),
999 "{wanted:?} is not in the row: {inline:?}",
1000 );
1001 }
1002 assert!(
1003 inline
1004 .iter()
1005 .any(|id| matches!(id, CommandId::Lock | CommandId::Unlock)),
1006 "the lock toggle is not in the row: {inline:?}",
1007 );
1008 for clerical in [CommandId::Copy, CommandId::Cut, CommandId::Delete] {
1009 assert!(
1010 overflow.contains(&clerical),
1011 "{clerical:?} is still in the row: {inline:?}",
1012 );
1013 }
1014 assert!(
1015 overflow
1016 .iter()
1017 .any(|id| matches!(id, CommandId::ExportSelection(_))),
1018 "the selection export is still in the row: {inline:?}",
1019 );
1020
1021 assert_eq!(whole, all, "the split disturbed the one list");
1025 for half in [&inline, &overflow] {
1026 let mut in_registry_order: Vec<CommandId> =
1027 all.iter().copied().filter(|id| half.contains(id)).collect();
1028 in_registry_order.dedup();
1029 assert_eq!(*half, in_registry_order, "a half came out reordered");
1030 }
1031 }
1032
1033 #[test]
1041 fn a_ports_own_controls_ride_the_row_and_its_clerical_verbs_do_not() {
1042 let mut port = Harness::port();
1043 let ids: Vec<CommandId> = port.laid_out().iter().map(|c| c.0).collect();
1044 let Bar::Row {
1045 inline, overflow, ..
1046 } = Bar::of(ids.clone(), 1, Placement::of)
1047 else {
1048 panic!("a port raises no row: {ids:?}");
1049 };
1050 for own in [CommandId::PinType, CommandId::Accent, CommandId::FlipLr] {
1051 assert!(
1052 inline.contains(&own),
1053 "the port's own {own:?} is not in the row: {inline:?} / {overflow:?}",
1054 );
1055 }
1056 assert!(
1057 inline
1058 .iter()
1059 .any(|id| matches!(id, CommandId::HideTags | CommandId::ShowTags)),
1060 "the port's tag toggle is not in the row: {inline:?}",
1061 );
1062 for clerical in [CommandId::Copy, CommandId::Cut, CommandId::Delete] {
1063 assert!(
1064 overflow.contains(&clerical),
1065 "the port keeps {clerical:?} in the row: {inline:?}",
1066 );
1067 }
1068 }
1069
1070 #[test]
1076 fn the_tag_eye_shows_the_state_and_the_io_control_keeps_one_glyph() {
1077 let source = |icon: egui::ImageSource<'static>| match icon {
1078 egui::ImageSource::Bytes { uri, .. } => uri.to_string(),
1079 _ => panic!("the overlay's icons are embedded bytes"),
1080 };
1081 assert!(source(tag_icon(CommandId::HideTags)).contains("icon-eye.svg"));
1082 assert!(source(tag_icon(CommandId::ShowTags)).contains("icon-eye-off.svg"));
1083 assert_ne!(
1084 source(tag_icon(CommandId::HideTags)),
1085 source(tag_icon(CommandId::ShowTags)),
1086 "the two states share one glyph",
1087 );
1088
1089 assert!(source(PIN_TYPE_ICON).contains("icon-pin-input.svg"));
1094 let said: Vec<String> = [
1095 Some(PinDir::Input),
1096 Some(PinDir::Output),
1097 Some(PinDir::InOut),
1098 None,
1099 ]
1100 .into_iter()
1101 .map(pin_dir_hover)
1102 .collect();
1103 assert_eq!(
1104 said.iter().collect::<std::collections::HashSet<_>>().len(),
1105 said.len(),
1106 "two states of the control say the same thing: {said:?}",
1107 );
1108 for word in &said {
1109 assert!(
1110 word.starts_with("Direction"),
1111 "the control does not name itself: {word}",
1112 );
1113 }
1114 assert!(
1115 said[1].contains("Output") && said[3].contains("different"),
1116 "the words do not name the state: {said:?}",
1117 );
1118 }
1119
1120 #[test]
1125 fn the_io_icons_put_the_wall_on_opposite_sides() {
1126 let input = include_str!("../../icons/icon-pin-input.svg");
1127 let output = include_str!("../../icons/icon-pin-output.svg");
1128 assert!(input.contains(r#"d="M5 4v16""#), "{input}");
1129 assert!(output.contains(r#"d="M19 4v16""#), "{output}");
1130 assert_ne!(input, output, "the two directions share one drawing");
1131 let eye = include_str!("../../icons/icon-eye.svg");
1132 let eye_off = include_str!("../../icons/icon-eye-off.svg");
1133 assert!(
1134 eye_off.contains(r#"d="M4 20L20 4""#) && !eye.contains(r#"d="M4 20L20 4""#),
1135 "only the hidden state carries the slash",
1136 );
1137 }
1138
1139 #[test]
1143 fn every_selection_type_puts_its_clerical_verbs_in_the_same_place() {
1144 for (what, harness) in [
1145 ("a block", Harness::block()),
1146 ("a wire", Harness::wire()),
1147 ("an area", Harness::area()),
1148 ] {
1149 let mut harness = harness;
1150 let ids: Vec<CommandId> = harness.laid_out().iter().map(|c| c.0).collect();
1151 let Bar::Row {
1152 inline, overflow, ..
1153 } = Bar::of(ids.clone(), 1, Placement::of)
1154 else {
1155 panic!("{what} raises no row: {ids:?}");
1156 };
1157 for clerical in [CommandId::Copy, CommandId::Cut, CommandId::Delete] {
1158 assert!(
1159 !inline.contains(&clerical),
1160 "{what} keeps {clerical:?} in the row: {inline:?}",
1161 );
1162 }
1163 assert!(
1164 overflow.contains(&CommandId::Delete),
1165 "{what} lost Delete altogether: {ids:?}",
1166 );
1167 }
1168 }
1169
1170 #[test]
1177 fn an_area_selection_raises_a_bar_with_its_own_commands() {
1178 let mut area = Harness::area();
1179 let drawn: Vec<CommandId> = area.laid_out().into_iter().map(|(id, _)| id).collect();
1180 assert!(
1181 !drawn.is_empty(),
1182 "an area selection drew no controls at all",
1183 );
1184 for wanted in [
1185 CommandId::Accent,
1186 CommandId::Copy,
1187 CommandId::Cut,
1188 CommandId::Delete,
1189 ] {
1190 assert!(
1191 drawn.contains(&wanted),
1192 "the area's bar is missing {wanted:?}: {drawn:?}",
1193 );
1194 }
1195 assert!(
1196 area.corner.is_some(),
1197 "the area's bar laid out no controls on screen",
1198 );
1199 assert!(area.painted > 0, "the area's bar painted nothing");
1200 }
1201
1202 #[test]
1207 fn a_selection_taller_than_the_region_still_gets_a_bar() {
1208 let mut area = Harness::area();
1209 let region = area.safe.region();
1210 area.selection.screen = Rect::from_min_max(
1213 pos2(region.center().x - 200.0, region.top() - 40.0),
1214 pos2(region.center().x + 200.0, region.bottom() + 40.0),
1215 );
1216 assert!(
1217 area.selection.screen.top() - CLEAR - SIZE.y < region.top()
1218 && area.selection.screen.bottom() + CLEAR + SIZE.y > region.bottom(),
1219 "precondition: neither band outside the selection fits the region",
1220 );
1221 let placed = place(area.selection.screen, SIZE, &area.safe)
1222 .expect("a selection bigger than its room still has somewhere lawful");
1223 assert!(
1224 region.contains_rect(placed),
1225 "the bar landed outside the region at {placed:?}",
1226 );
1227
1228 let settled = settled(area);
1229 assert!(
1230 settled.corner.is_some() && settled.painted > 0,
1231 "the oversized area's bar never drew",
1232 );
1233 }
1234
1235 #[test]
1236 fn a_multi_selection_sharing_nothing_says_so_rather_than_showing_an_empty_bar() {
1237 assert!(matches!(
1238 Bar::of(Vec::<CommandId>::new(), 3, Placement::of),
1239 Bar::NothingShared
1240 ));
1241 assert!(matches!(
1242 Bar::of(Vec::<CommandId>::new(), 1, Placement::of),
1243 Bar::Nothing
1244 ));
1245 }
1246
1247 #[test]
1252 fn a_multi_selection_wears_its_count_and_a_single_one_does_not() {
1253 let mut chrome = crate::tools::painted::Chrome::new(viewport().geom());
1254 let mut many = Harness::two_blocks();
1255 chrome.settle(|ui| many.show(ui));
1256 assert!(
1257 many.corner.is_some(),
1258 "precondition: the bar drew for a two-block selection",
1259 );
1260 assert!(
1261 chrome.shows("2 selected"),
1262 "the multi-selection lost its count: {:?}",
1263 chrome.texts(),
1264 );
1265 let mut one = Harness::block();
1266 chrome.settle(|ui| one.show(ui));
1267 assert!(
1268 !chrome.texts().iter().any(|said| said.contains("selected")),
1269 "one block was counted: {:?}",
1270 chrome.texts(),
1271 );
1272 }
1273
1274 #[test]
1281 fn right_click_offers_exactly_the_overlays_commands() {
1282 let mut chrome = crate::tools::painted::Chrome::new(viewport().geom());
1283 let mut block = Harness::block();
1284 chrome.settle(|ui| block.show(ui));
1285 let expected: Vec<&str> = block.laid_out().iter().map(|c| c.1).collect();
1286 assert!(
1287 expected.len() > 5,
1288 "precondition: some of these are only reachable through a menu",
1289 );
1290 chrome.hover_at(blockworx_geom::pos2(120.0, 700.0), |ui| block.show(ui));
1291 block.right_click = RightClick::Asked;
1292 chrome.frame(|ui| block.show(ui));
1293 block.right_click = RightClick::No;
1294 chrome.settle(|ui| block.show(ui));
1295
1296 let menu = egui::AreaState::load(chrome.ctx(), menu_id())
1297 .map(|state| state.rect())
1298 .filter(|rect| rect.is_positive())
1299 .expect("the right-click menu never opened");
1300 let mut rows: Vec<&str> = chrome
1303 .texts_inside(menu.geom())
1304 .into_iter()
1305 .filter(|run| *run != SUBMENU_CHEVRON)
1306 .collect();
1307 let mut wanted = expected.clone();
1308 rows.sort_unstable();
1309 wanted.sort_unstable();
1310 assert_eq!(
1311 rows, wanted,
1312 "the menu and the bar disagree about what applies here",
1313 );
1314 }
1315
1316 #[test]
1322 fn the_bar_stands_down_while_the_camera_moves() {
1323 let ctx = egui::Context::default();
1324 egui_extras::install_image_loaders(&ctx);
1325 let mut wire = Harness::wire();
1326 run(&mut wire, &ctx, 2);
1327 let placed = wire.corner.expect("the bar shows once it has measured");
1328
1329 wire.camera = Camera::Moving;
1330 let painted = run(&mut wire, &ctx, 1);
1331 assert!(wire.corner.is_none(), "the bar tracked the pan");
1332 assert_eq!(painted, 0, "a hidden bar painted {painted} shape(s)");
1333
1334 wire.camera = Camera::Settled;
1335 run(&mut wire, &ctx, 1);
1336 assert_eq!(
1337 wire.corner,
1338 Some(placed),
1339 "the bar took a sizing pass to come back, or came back elsewhere",
1340 );
1341 }
1342
1343 #[test]
1350 fn switching_selections_takes_a_hidden_sizing_frame_instead_of_flashing() {
1351 let ctx = egui::Context::default();
1352 egui_extras::install_image_loaders(&ctx);
1353 let mut wire = Harness::wire();
1354 let mut block = Harness::block();
1355
1356 let painted = run(&mut wire, &ctx, 1);
1357 assert!(
1358 wire.corner.is_none(),
1359 "the first frame ever should be a hidden sizing pass",
1360 );
1361 assert_eq!(
1362 painted, 0,
1363 "the sizing pass painted {painted} shape(s) — the flash the user \
1364 sees at the click point before the bar snaps into place",
1365 );
1366 let painted = run(&mut wire, &ctx, 1);
1367 let wire_bar = wire.corner.expect("the wire's bar shows once measured");
1368 assert!(painted > 0, "a shown bar paints");
1369 run(&mut wire, &ctx, 1);
1370 assert!(
1371 wire.corner.is_some(),
1372 "an unchanged selection must not flicker"
1373 );
1374
1375 let painted = run(&mut block, &ctx, 1);
1376 assert!(
1377 block.corner.is_none(),
1378 "the switch frame drew the block's bar with the wire's measurement",
1379 );
1380 assert_eq!(painted, 0, "the switch frame painted {painted} shape(s)");
1381 run(&mut block, &ctx, 1);
1382 let block_bar = block.corner.expect("the block's bar shows once measured");
1383 assert_ne!(
1384 wire_bar, block_bar,
1385 "precondition: the two bars measure apart, or a stale placement would be invisible",
1386 );
1387
1388 run(&mut wire, &ctx, 1);
1389 assert!(
1390 wire.corner.is_none(),
1391 "switching back re-measures too — the memory holds one bar, not a history",
1392 );
1393 run(&mut wire, &ctx, 1);
1394 assert_eq!(
1395 wire.corner,
1396 Some(wire_bar),
1397 "the wire's bar returns exactly where it was",
1398 );
1399 }
1400
1401 #[test]
1407 fn the_selection_overlay_draws_its_withheld_controls_disabled() {
1408 let writable = settled(Harness::wire());
1409 assert!(
1410 writable.invocable > 0 && writable.corner.is_some(),
1411 "a writable wire lost its overlay",
1412 );
1413 assert_eq!(writable.invocable, writable.drawn.len());
1414 let mut read_only = Harness::wire();
1415 read_only.writability = Writability::ReadOnly;
1416 let read_only = settled(read_only);
1417 assert!(
1418 read_only.corner.is_some(),
1419 "the read-only overlay vanished instead of disabling",
1420 );
1421 assert_eq!(
1422 read_only.drawn, writable.drawn,
1423 "read-only dropped controls instead of disabling them",
1424 );
1425 assert_eq!(
1426 read_only.invocable, 0,
1427 "a read-only wire kept {} invocable verb(s)",
1428 read_only.invocable,
1429 );
1430 }
1431
1432 #[test]
1436 fn a_read_only_overlay_measures_the_same_bar_as_a_writable_one() {
1437 let writable = settled(Harness::wire());
1438 let mut read_only = Harness::wire();
1439 read_only.writability = Writability::ReadOnly;
1440 let read_only = settled(read_only);
1441 assert!(
1442 writable.bar.is_positive(),
1443 "precondition: the overlay measured itself ({:?})",
1444 writable.bar,
1445 );
1446 assert_eq!(read_only.bar.size(), writable.bar.size());
1447 }
1448
1449 #[test]
1455 fn commands_the_overlay_does_not_draw_do_not_widen_it() {
1456 let quiet = settled(Harness::wire());
1457 let mut busy = Harness::wire();
1458 busy.history = History::doc();
1459 let busy = settled(busy);
1460 assert!(quiet.bar.is_positive(), "precondition: the bar measured");
1461 assert_eq!(
1462 busy.drawn, quiet.drawn,
1463 "precondition: undo and redo draw no control in the overlay",
1464 );
1465 assert_eq!(
1466 busy.bar.size(),
1467 quiet.bar.size(),
1468 "two commands the overlay never draws widened it anyway",
1469 );
1470 }
1471
1472 #[test]
1475 fn accent_swatch_maps_index_to_its_accent_role() {
1476 use blockworx_doc::id::BlockId;
1477 let block = RoleTarget::Block(BlockId::NULL);
1478 assert_eq!(accent_display_role(block, Some(0)), Role::Accent0);
1479 assert_eq!(accent_display_role(block, Some(7)), Role::Accent7);
1480 assert_eq!(accent_display_role(block, None), Role::AccentDefault);
1482 assert_eq!(accent_display_role(block, Some(9)), Role::AccentDefault);
1483 }
1484
1485 #[test]
1486 fn accent_swatch_uses_each_targets_own_unaccented_stroke() {
1487 use blockworx_doc::id::{AreaId, TextId};
1488 let area = RoleTarget::Area(AreaId::NULL);
1491 let text = RoleTarget::Text(TextId::NULL);
1492 assert_eq!(accent_display_role(area, None), Role::AreaStroke);
1493 assert_eq!(accent_display_role(text, None), Role::TextBoxStroke);
1494 assert_eq!(accent_display_role(area, Some(2)), Role::Accent2);
1496 }
1497
1498 #[test]
1499 fn lock_toggle_icon_depicts_the_blocks_current_state() {
1500 let (locked_icon, locked_hover) = lock_toggle_icon(InterfaceLock::Locked);
1501 let (unlocked_icon, unlocked_hover) = lock_toggle_icon(InterfaceLock::Unlocked);
1502 assert_eq!(locked_icon.uri(), LOCK_ICON.uri());
1505 assert_eq!(unlocked_icon.uri(), UNLOCK_ICON.uri());
1506 assert_ne!(locked_icon.uri(), unlocked_icon.uri());
1507 assert_eq!(locked_hover, "Unlock pins");
1509 assert_eq!(unlocked_hover, "Lock pins");
1510 }
1511
1512 #[test]
1517 fn padlock_svgs_draw_a_closed_and_an_open_shackle() {
1518 let closed = include_str!("../../icons/icon-lock.svg");
1519 let open = include_str!("../../icons/icon-unlock.svg");
1520 assert!(
1521 closed.contains(r#"d="M8 11V7a4 4 0 0 1 8 0v4""#),
1522 "{closed}"
1523 );
1524 assert!(open.contains(r#"d="M8 11V7a4 4 0 0 1 7.5-2""#), "{open}");
1525 }
1526
1527 #[test]
1531 fn the_selection_overlay_offers_enter_but_not_go_up() {
1532 assert!(overlay_icon(CommandId::GoUp).is_none());
1533 assert!(overlay_icon(CommandId::ExpandBlock).is_some());
1534 }
1535
1536 #[test]
1542 fn the_hierarchy_icons_share_a_box_and_oppose_their_arrows() {
1543 let enter = include_str!("../../icons/icon-expand.svg");
1544 let rise = include_str!("../../icons/icon-level-up.svg");
1545 let box_path = r#"d="M10 5H5v14h14v-5""#;
1546 let shaft = r#"d="M20 4l-9 9""#;
1547 for icon in [enter, rise] {
1548 assert!(icon.contains(box_path), "{icon}");
1549 assert!(icon.contains(shaft), "{icon}");
1550 }
1551 assert!(enter.contains(r#"d="M11 7v6h6""#), "{enter}");
1554 assert!(rise.contains(r#"d="M14 4h6v6""#), "{rise}");
1555 }
1556
1557 #[test]
1558 fn the_import_icon_reverses_the_export_arrow_over_the_same_tray() {
1559 let export = include_str!("../../icons/icon-export.svg");
1560 let import = include_str!("../../icons/icon-import.svg");
1561 let tray = r#"d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4""#;
1562 assert!(export.contains(tray), "{export}");
1563 assert!(import.contains(tray), "{import}");
1564 assert!(export.contains(r#"points="17 8 12 3 7 8""#), "{export}");
1566 assert!(import.contains(r#"points="7 10 12 15 17 10""#), "{import}");
1567 }
1568
1569 #[test]
1572 fn the_overflow_glyph_is_an_ellipsis_of_its_own() {
1573 let more = include_str!("../../icons/icon-more.svg");
1574 assert!(
1575 MORE_ICON
1576 .uri()
1577 .is_some_and(|uri| uri.ends_with("icon-more.svg")),
1578 "the overflow button borrowed another surface's glyph: {:?}",
1579 MORE_ICON.uri(),
1580 );
1581 assert_eq!(more.matches("<circle").count(), 3);
1582 assert!(more.contains(r#"viewBox="0 0 24 24""#), "{more}");
1583 assert!(more.contains(r#"stroke-width="2""#), "{more}");
1584 }
1585
1586 struct Harness {
1592 scene: Scene,
1593 tool: Tool,
1594 selection: Selection,
1595 camera: Camera,
1596 right_click: RightClick,
1597 writability: Writability,
1598 history: History,
1599 safe: SafeArea,
1600 drawn: Vec<(CommandId, &'static str)>,
1603 invocable: usize,
1605 corner: Option<egui::Pos2>,
1608 painted: usize,
1611 bar: Rect,
1613 fired: Vec<Action>,
1615 }
1616
1617 impl Harness {
1618 fn over(scene: Scene, tool: Tool, count: usize) -> Self {
1619 Harness {
1620 scene,
1621 tool,
1622 selection: Selection {
1623 screen: Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0)),
1624 count,
1625 },
1626 camera: Camera::Settled,
1627 right_click: RightClick::No,
1628 writability: Writability::Writable,
1629 history: History::empty(),
1630 safe: SafeArea::over(viewport()),
1631 drawn: Vec::new(),
1632 invocable: 0,
1633 corner: None,
1634 painted: 0,
1635 bar: Rect::NOTHING,
1636 fired: Vec::new(),
1637 }
1638 }
1639
1640 fn wire() -> Self {
1642 let mut scene = two_blocks_with_a_routed_waypoint();
1643 let route = {
1644 let drawing = scene.drawing();
1645 let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1646 assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1647 ids[0]
1648 };
1649 let tool = crate::tools::EditRoute::Selected {
1650 id: route,
1651 anchor: pos2(0.0, 0.0).geom(),
1652 }
1653 .into();
1654 Harness::over(scene, tool, 1)
1655 }
1656
1657 fn block() -> Self {
1659 let mut scene = two_blocks_with_a_routed_waypoint();
1660 let shape = ShapeId::Rect(blockworx_doc::fixtures::block_id(1));
1661 assert!(
1662 scene.drawing().shape(shape).is_some(),
1663 "precondition: the fixture holds the block this selects",
1664 );
1665 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1666 Harness::over(scene, tool, 1)
1667 }
1668
1669 fn area() -> Self {
1671 let mut scene = Scene::new(vec![crate::widget::test_fixtures::area(
1672 9,
1673 crate::path::Scope::Root,
1674 blockworx_geom::Rect::from_min_size(
1675 blockworx_geom::pos2(0.0, 0.0),
1676 blockworx_geom::vec2(200.0, 140.0),
1677 ),
1678 )]);
1679 let shape = ShapeId::Area(blockworx_doc::fixtures::area_id(9));
1680 assert!(
1681 scene.drawing().shape(shape).is_some(),
1682 "precondition: the fixture holds the area this selects",
1683 );
1684 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1685 Harness::over(scene, tool, 1)
1686 }
1687
1688 fn port() -> Self {
1691 let body = Rect::from_min_size(pos2(0.0, 0.0), vec2(60.0, 60.0));
1692 let mut scene = Scene::new(vec![crate::widget::test_fixtures::pin_at(
1693 7,
1694 crate::path::Scope::Root,
1695 "clk",
1696 crate::widget::test_fixtures::slot(crate::shape::pin::PinSide::West, 0),
1697 body.geom(),
1698 )]);
1699 let shape = ShapeId::Port(blockworx_doc::fixtures::pin_id(7));
1700 assert!(
1701 scene.drawing().shape(shape).is_some(),
1702 "precondition: the fixture holds the port this selects",
1703 );
1704 let tool = crate::tools::resize_block::ResizeBlock::Selected { shape }.into();
1705 Harness::over(scene, tool, 1)
1706 }
1707
1708 fn two_blocks() -> Self {
1710 let mut scene = two_blocks_with_a_routed_waypoint();
1711 let shapes: Vec<ShapeId> = [1, 2]
1712 .map(|n| ShapeId::Rect(blockworx_doc::fixtures::block_id(n)))
1713 .into();
1714 assert!(
1715 shapes.iter().all(|&id| scene.drawing().shape(id).is_some()),
1716 "precondition: the fixture holds both blocks",
1717 );
1718 let count = shapes.len();
1719 let tool = crate::tools::MultiSelect::Selected { shapes }.into();
1720 Harness::over(scene, tool, count)
1721 }
1722
1723 fn show(&mut self, ui: &mut egui::Ui) {
1725 let drawing = self.scene.drawing();
1726 let mut commands = CommandSet::available(&CommandContext {
1727 tool: &self.tool,
1728 data: &drawing,
1729 history: self.history,
1730 current_lock: InterfaceLock::Unlocked,
1731 writability: self.writability,
1732 saving: blockworx_store::doc::Saving::Withheld,
1733 viewing: blockworx_store::doc::Viewing::Head,
1734 });
1735 self.drawn = overlay_controls(&commands)
1736 .map(|cmd| (cmd.id, cmd.label))
1737 .collect();
1738 self.invocable = commands
1739 .iter()
1740 .filter(|cmd| drawn_in_overlay(cmd.id))
1741 .count();
1742 let theme = Theme::default();
1743 let (action, corner) = selection_overlay(
1744 ui,
1745 Overlay {
1746 commands: &mut commands,
1747 data: &drawing,
1748 theme: &theme,
1749 open_picker: None,
1750 selection: Some(self.selection),
1751 safe: self.safe,
1752 camera: self.camera,
1753 right_click: self.right_click,
1754 },
1755 );
1756 if let Some(fired) = action {
1757 self.fired.push(fired);
1758 }
1759 self.corner = corner;
1760 }
1761
1762 fn laid_out(&mut self) -> Vec<(CommandId, &'static str)> {
1765 let ctx = egui::Context::default();
1766 egui_extras::install_image_loaders(&ctx);
1767 run(self, &ctx, 2);
1768 self.drawn.clone()
1769 }
1770 }
1771
1772 fn run(harness: &mut Harness, ctx: &egui::Context, frames: usize) -> usize {
1775 for _ in 0..frames {
1776 let mut out = ctx.clone().run_ui(
1777 egui::RawInput {
1778 screen_rect: Some(harness.safe.viewport()),
1779 ..Default::default()
1780 },
1781 |ui| harness.show(ui),
1782 );
1783 out.textures_delta.clear();
1784 harness.painted = painted(&out.shapes);
1785 }
1786 harness.bar = ctx
1787 .data(|d| d.get_temp::<Measured>(Panel::SelectionButtons.measurement("rect")))
1788 .map_or(Rect::NOTHING, |was| was.rect);
1789 harness.painted
1790 }
1791
1792 fn settled(mut harness: Harness) -> Harness {
1795 let ctx = egui::Context::default();
1796 egui_extras::install_image_loaders(&ctx);
1797 run(&mut harness, &ctx, 2);
1798 harness
1799 }
1800
1801 fn painted(shapes: &[egui::epaint::ClippedShape]) -> usize {
1803 fn leaves(shape: &egui::Shape) -> usize {
1804 match shape {
1805 egui::Shape::Noop => 0,
1806 egui::Shape::Vec(shapes) => shapes.iter().map(leaves).sum(),
1807 _ => 1,
1808 }
1809 }
1810 shapes.iter().map(|clipped| leaves(&clipped.shape)).sum()
1811 }
1812}