1use crate::{
2 doc::{At, TimeStep, Viewing},
3 edit::{lower::accent_from_role, naming::InterfaceLock},
4 export::ExportScope,
5 grid::GRID_SIZE,
6 shape::{ShapeId, ShapeRef},
7 theme::{Role, Theme, accent_role},
8 tools::{
9 chrome::Panel,
10 commands::{CommandId, CommandSet},
11 names::{TOOLBAR_TOOLS, ToolName},
12 tool::{Action, RoleTarget},
13 },
14 widget::drawing::Drawing,
15};
16use blockworx_doc::{register::Register, values::Role as AccentRole};
17
18#[derive(Clone, Copy, PartialEq, Eq)]
21pub enum PanelState {
22 Open,
23 Closed,
24}
25
26#[derive(Clone, Copy, PartialEq, Eq)]
29pub enum OpenPicker {
30 Role,
31 PinType,
32}
33
34pub fn toolbar_rect_id() -> egui::Id {
37 Panel::ModeToolbar.measurement("rect")
38}
39
40pub fn drawn_rect(ctx: &egui::Context) -> egui::Rect {
44 ctx.data(|d| d.get_temp(toolbar_rect_id()))
45 .unwrap_or(egui::Rect::NOTHING)
46}
47
48pub struct ViewToggles<'a> {
51 pub debug_marks: &'a mut bool,
52}
53
54pub struct ToolbarProps<'a> {
56 pub selected: ToolName,
59 pub toggles: ViewToggles<'a>,
60 pub nav: NavCluster<'a>,
62}
63
64pub struct ToolbarFrame {
68 pub action: Option<Action>,
69 pub compass: egui::Rect,
70 #[cfg(test)]
71 pub tool_rects: Vec<(ToolName, egui::Rect)>,
72}
73
74pub fn toolbar(
78 commands: &mut CommandSet,
79 props: ToolbarProps<'_>,
80 viewport: egui::Rect,
81 ui: &mut egui::Ui,
82) -> ToolbarFrame {
83 let (frame, rect) = centered_strip(ui, Panel::ModeToolbar, viewport, |ui| {
84 toolbar_contents(ui, commands, props)
85 });
86 ui.ctx()
88 .data_mut(|d| d.insert_temp(toolbar_rect_id(), rect));
89 frame
90}
91
92fn centered_strip<R>(
99 ui: &mut egui::Ui,
100 panel: Panel,
101 bounds: egui::Rect,
102 add: impl FnOnce(&mut egui::Ui) -> R,
103) -> (R, egui::Rect) {
104 let width_key = panel.measurement("width");
105 let left = if let Some(width) = ui.ctx().data(|d| d.get_temp::<f32>(width_key)) {
106 bounds.center().x - width / 2.0
107 } else {
108 ui.ctx().request_repaint();
109 bounds.center().x
110 };
111 let strip = egui::Rect::from_min_max(
112 egui::pos2(left, bounds.top() + GRID_SIZE),
113 bounds.right_bottom(),
114 );
115 let mut child = ui.new_child(
116 panel
117 .ui_builder()
118 .max_rect(strip)
119 .layout(egui::Layout::top_down(egui::Align::Min)),
120 );
121 let result = add(&mut child);
122 let rect = child.min_rect();
123 ui.ctx()
124 .data_mut(|d| d.insert_temp(width_key, rect.width()));
125 (result, rect)
126}
127
128fn toolbar_contents(
133 ui: &mut egui::Ui,
134 commands: &mut CommandSet,
135 props: ToolbarProps<'_>,
136) -> ToolbarFrame {
137 let ToolbarProps {
138 selected,
139 toggles: ViewToggles { debug_marks },
140 nav: NavCluster { history, nav_open },
141 } = props;
142 let mut action: Option<Action> = None;
143 let mut clicked: Option<CommandId> = None;
144 #[cfg(test)]
145 let mut rects = Vec::new();
146 let mut compass_rect = egui::Rect::NOTHING;
147 egui::Frame::popup(ui.style()).show(ui, |ui| {
148 ui.horizontal(|ui| {
149 debug_checkbox(ui, debug_marks);
150 for mode in TOOLBAR_TOOLS {
151 let button = egui::Button::image(icon_image(ui, tool_icon(*mode)))
152 .selected(selected == *mode);
153 let id = CommandId::Arm(*mode);
158 let hover = match crate::tools::commands::binding(id) {
159 Some(chord) => format!("{} ({})", mode, ui.ctx().format_shortcut(chord)),
160 None => mode.to_string(),
161 };
162 let response = ui
163 .add_enabled(commands.contains(id), button)
164 .on_hover_text(hover);
165 #[cfg(test)]
166 rects.push((*mode, response.rect));
167 if response.clicked() {
168 clicked = Some(id);
169 }
170 }
171 ui.separator();
172 let compass = egui::Button::image(icon_image(ui, COMPASS_ICON)).selected(*nav_open);
177 let compass = ui.add(compass).on_hover_text("Navigator");
178 compass_rect = compass.rect;
179 if compass.clicked() {
180 *nav_open = !*nav_open;
181 }
182 for (enabled, icon, hover, step) in [
183 (history.can_back, BACK_ICON, "Back", Action::PathBack),
184 (
185 history.can_forward,
186 FORWARD_ICON,
187 "Forward",
188 Action::PathForward,
189 ),
190 ] {
191 if ui
192 .add_enabled(enabled, icon_button(ui, icon))
193 .on_hover_text(hover)
194 .clicked()
195 {
196 action = Some(step);
197 }
198 }
199 for (id, icon, hover) in [
200 (CommandId::ExpandBlock, EXPAND_ICON, "Enter block"),
201 (CommandId::GoUp, EXIT_ICON, "Go up a level"),
202 ] {
203 if ui
204 .add_enabled(commands.contains(id), icon_button(ui, icon))
205 .on_hover_text(hover)
206 .clicked()
207 {
208 clicked = Some(id);
209 }
210 }
211 });
212 });
213 ToolbarFrame {
214 action: action.or_else(|| clicked.and_then(|id| commands.take(id))),
215 compass: compass_rect,
216 #[cfg(test)]
217 tool_rects: rects,
218 }
219}
220
221pub fn history_overlay(
237 commands: &mut CommandSet,
238 cluster: HistoryCluster<'_>,
239 viewport: egui::Rect,
240 ui: &mut egui::Ui,
241) -> HistoryFrame {
242 let HistoryCluster {
243 panel_open,
244 viewing,
245 head,
246 steps,
247 } = cluster;
248 let mut clicked: Option<CommandId> = None;
249 let mut action: Option<Action> = None;
250 let mut clock = egui::Rect::NOTHING;
251 let past = viewing != Viewing::Head;
252 let corner = egui::Rect::from_min_max(
253 viewport.left_top() + egui::vec2(GRID_SIZE, 0.0),
254 viewport.right_bottom() - egui::vec2(0.0, GRID_SIZE),
255 );
256 let mut child = ui.new_child(
257 Panel::History
258 .ui_builder()
259 .max_rect(corner)
260 .layout(egui::Layout::bottom_up(egui::Align::Min)),
261 );
262 egui::Frame::popup(child.style()).show(&mut child, |ui| {
263 ui.horizontal(|ui| {
264 let toggle = egui::Button::image(icon_image(ui, TIMELINE_ICON)).selected(*panel_open);
267 let toggle = ui.add(toggle).on_hover_text("History");
268 clock = toggle.rect;
269 if toggle.clicked() {
270 *panel_open = !*panel_open;
271 }
272 ui.separator();
273 let mut step = |ui: &mut egui::Ui, icon, hover: &str, to: Option<At>| {
274 if ui
275 .add_enabled(to.is_some(), icon_button(ui, icon))
276 .on_hover_text(hover)
277 .clicked()
278 {
279 action = Some(match to {
280 Some(At::Rev(rev)) => Action::ViewRev(rev),
281 _ => Action::ViewHead,
282 });
283 }
284 };
285 let mut button = |ui: &mut egui::Ui, id, icon, verb: &str, of: Option<&str>| {
286 let hover = match of {
287 Some(label) => format!("{verb} {label}"),
288 None => verb.to_owned(),
289 };
290 if ui
291 .add_enabled(commands.contains(id), icon_button(ui, icon))
292 .on_hover_text(hover)
293 .clicked()
294 {
295 clicked = Some(id);
296 }
297 };
298 if past {
299 step(
300 ui,
301 STEP_BACK_ICON,
302 "Back one rev",
303 viewing.stepped(head, TimeStep::Back),
304 );
305 step(
306 ui,
307 STEP_FORWARD_ICON,
308 "Forward one rev",
309 viewing.stepped(head, TimeStep::Forward),
310 );
311 step(ui, SEEK_LATEST_ICON, "Return to latest", Some(At::Current));
314 } else {
315 button(ui, CommandId::Undo, UNDO_ICON, "Undo", steps.undo);
316 button(ui, CommandId::Redo, REDO_ICON, "Redo", steps.redo);
317 }
318 });
319 });
320 HistoryFrame {
321 action: action.or_else(|| clicked.and_then(|id| commands.take(id))),
322 clock,
323 }
324}
325
326pub struct HistoryCluster<'a> {
329 pub panel_open: &'a mut bool,
330 pub viewing: Viewing,
331 pub head: blockworx_doc::rev::Rev,
333 pub steps: UndoSteps<'a>,
337}
338
339#[derive(Clone, Copy, Default)]
341pub struct UndoSteps<'a> {
342 pub undo: Option<&'a str>,
343 pub redo: Option<&'a str>,
344}
345
346pub struct HistoryFrame {
348 pub action: Option<Action>,
349 pub clock: egui::Rect,
351}
352
353pub struct NavCluster<'a> {
358 pub history: crate::tools::nav_tree::PathHistory,
359 pub nav_open: &'a mut bool,
360}
361
362fn accent_swatch_color(data: &Drawing, target: RoleTarget, theme: &Theme) -> egui::Color32 {
368 let accent = |role: &Register<AccentRole>| accent_from_role(*role.as_ref());
369 let current = match target {
370 RoleTarget::Block(rid) => data.block(rid).and_then(|b| accent(&b.role)),
371 RoleTarget::Port(pid) => match data.shape(ShapeId::Port(pid)) {
372 Some(ShapeRef::Port(port)) => accent(&port.pin.port_accent),
373 _ => None,
374 },
375 RoleTarget::Route(rid) => data.auto_route(rid).and_then(|w| accent(&w.route.role)),
376 RoleTarget::Area(cid) => match data.shape(ShapeId::Area(cid)) {
377 Some(ShapeRef::Area(area)) => accent(&area.role),
378 _ => None,
379 },
380 RoleTarget::Text(tid) => match data.shape(ShapeId::Text(tid)) {
381 Some(ShapeRef::Text(text)) => accent(&text.text.role),
382 _ => None,
383 },
384 };
385 theme.resolve(accent_display_role(target, current))
386}
387
388fn accent_display_role(target: RoleTarget, current: Option<u8>) -> Role {
392 let default_role = match target {
393 RoleTarget::Area(_) => Role::AreaStroke,
394 RoleTarget::Text(_) => Role::TextBoxStroke,
395 _ => Role::AccentDefault,
396 };
397 accent_role(current).unwrap_or(default_role)
398}
399
400const COMPASS_ICON: egui::ImageSource<'static> =
401 egui::include_image!("../../icons/icon-compass.svg");
402const EXPAND_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-expand.svg");
403const EXIT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-exit.svg");
404const LOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-lock.svg");
405const UNLOCK_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-unlock.svg");
406const DELETE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-trash.svg");
407const IMAGE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-image.svg");
408const SELECT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-select.svg");
409const NEW_BLOCK_ICON: egui::ImageSource<'static> =
410 egui::include_image!("../../icons/icon-new-block.svg");
411const AREA_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-area.svg");
412const ADD_PORT_ICON: egui::ImageSource<'static> =
413 egui::include_image!("../../icons/icon-add-port.svg");
414const ADD_TEXT_ICON: egui::ImageSource<'static> =
415 egui::include_image!("../../icons/icon-add-text.svg");
416const ROUTE_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-route.svg");
417const ADD_ICON_ICON: egui::ImageSource<'static> =
418 egui::include_image!("../../icons/icon-add-icon.svg");
419const ROUTE_LABEL_ICON: egui::ImageSource<'static> =
420 egui::include_image!("../../icons/icon-route-label.svg");
421const UNDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-undo.svg");
422const REDO_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-redo.svg");
423const TIMELINE_ICON: egui::ImageSource<'static> =
424 egui::include_image!("../../icons/icon-timeline.svg");
425const STEP_BACK_ICON: egui::ImageSource<'static> =
428 egui::include_image!("../../icons/icon-step-back.svg");
429const STEP_FORWARD_ICON: egui::ImageSource<'static> =
430 egui::include_image!("../../icons/icon-step-forward.svg");
431pub(crate) const SEEK_LATEST_ICON: egui::ImageSource<'static> =
432 egui::include_image!("../../icons/icon-seek-latest.svg");
433const COPY_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-copy.svg");
434const CUT_ICON: egui::ImageSource<'static> = egui::include_image!("../../icons/icon-cut.svg");
435const FLIP_LR_ICON: egui::ImageSource<'static> =
436 egui::include_image!("../../icons/icon-flip-lr.svg");
437const FLIP_UD_ICON: egui::ImageSource<'static> =
438 egui::include_image!("../../icons/icon-flip-ud.svg");
439const BACK_ICON: egui::ImageSource<'static> =
440 egui::include_image!("../../icons/icon-arrow-left.svg");
441const FORWARD_ICON: egui::ImageSource<'static> =
442 egui::include_image!("../../icons/icon-arrow-right.svg");
443pub(crate) const EXPORT_ICON: egui::ImageSource<'static> =
444 egui::include_image!("../../icons/icon-export.svg");
445const REROUTE_ICON: egui::ImageSource<'static> =
446 egui::include_image!("../../icons/icon-reroute.svg");
447
448#[cfg(feature = "ui_debug")]
451fn debug_checkbox(ui: &mut egui::Ui, debug_marks: &mut bool) {
452 ui.checkbox(debug_marks, "Debug");
453 ui.separator();
454}
455
456#[cfg(not(feature = "ui_debug"))]
457fn debug_checkbox(_ui: &mut egui::Ui, _debug_marks: &mut bool) {}
458
459fn tool_icon(tool: ToolName) -> egui::ImageSource<'static> {
462 match tool {
463 ToolName::NewBlock => NEW_BLOCK_ICON,
464 ToolName::NewArea => AREA_ICON,
465 ToolName::AddPort => ADD_PORT_ICON,
466 ToolName::NewImage => IMAGE_ICON,
467 ToolName::AddText => ADD_TEXT_ICON,
468 ToolName::Route => ROUTE_ICON,
469 _ => SELECT_ICON,
470 }
471}
472
473fn lock_toggle_icon(lock: InterfaceLock) -> (egui::ImageSource<'static>, &'static str) {
477 match lock {
478 InterfaceLock::Locked => (LOCK_ICON, "Unlock pins"),
479 InterfaceLock::Unlocked => (UNLOCK_ICON, "Lock pins"),
480 }
481}
482
483pub(crate) fn icon_image(
486 ui: &egui::Ui,
487 source: egui::ImageSource<'static>,
488) -> egui::Image<'static> {
489 egui::Image::new(source)
490 .fit_to_exact_size(egui::vec2(14.0, 14.0))
491 .tint(ui.visuals().widgets.inactive.fg_stroke.color)
492}
493
494pub(crate) fn icon_button(
497 ui: &egui::Ui,
498 source: egui::ImageSource<'static>,
499) -> egui::Button<'static> {
500 egui::Button::image(icon_image(ui, source))
501}
502
503fn accent_swatch_button(
507 ui: &mut egui::Ui,
508 color: egui::Color32,
509 picker: PanelState,
510) -> egui::Response {
511 let (rect, resp) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::click());
512 let painter = ui.painter();
513 painter.rect_filled(rect, 2.0, color);
514 let (width, stroke) = if picker == PanelState::Open {
515 (2.0, ui.visuals().selection.stroke.color)
516 } else {
517 (1.0, ui.visuals().widgets.inactive.fg_stroke.color)
518 };
519 painter.rect_stroke(
520 rect,
521 2.0,
522 egui::Stroke::new(width, stroke),
523 egui::StrokeKind::Inside,
524 );
525 resp.on_hover_text("Accent")
526}
527
528const OVERLAY_BLOCK_GAP_CELLS: f32 = 1.5;
534const OVERLAY_TOP_GAP: f32 = 96.0;
538const OVERLAY_EDGE_GAP: f32 = 8.0;
540const OVERLAY_BOTTOM_GAP: f32 = 16.0;
543
544struct Strip {
547 y: f32,
548 size: egui::Vec2,
549 x_min: f32,
550 x_max: f32,
551}
552
553impl Strip {
554 fn shifted_clear_x(&self, desired: f32, obstacles: &[egui::Rect]) -> Option<f32> {
562 let (y, size, x_min, x_max) = (self.y, self.size, self.x_min, self.x_max);
563 let fits = |x: f32| {
564 let rect = egui::Rect::from_min_size(egui::pos2(x, y), size);
565 x >= x_min && x <= x_max && !obstacles.iter().any(|o| o.intersects(rect))
566 };
567 let right = obstacles
568 .iter()
569 .map(|o| o.right() + OVERLAY_EDGE_GAP)
570 .filter(|&x| x >= desired && fits(x))
571 .min_by(f32::total_cmp);
572 let left = obstacles
573 .iter()
574 .map(|o| o.left() - size.x - OVERLAY_EDGE_GAP)
575 .filter(|&x| x <= desired && fits(x))
576 .max_by(f32::total_cmp);
577 right.or(left)
578 }
579}
580
581fn place_overlay(
591 sel: egui::Rect,
592 viewport: egui::Rect,
593 size: egui::Vec2,
594 block_gap: f32,
595 obstacles: &[egui::Rect],
596) -> Option<egui::Pos2> {
597 let x_min = viewport.left() + OVERLAY_EDGE_GAP;
598 let x_max = viewport.right() - OVERLAY_EDGE_GAP - size.x;
599 if x_max < x_min {
600 return None; }
602 let desired = (sel.center().x - size.x / 2.0).clamp(x_min, x_max);
603 let clear = |pos: egui::Pos2| {
604 let rect = egui::Rect::from_min_size(pos, size);
605 !obstacles.iter().any(|o| o.intersects(rect))
606 };
607
608 let above_y = sel.top() - block_gap - size.y;
610 let above_ok = above_y >= viewport.top() + OVERLAY_TOP_GAP;
611 let below_y = sel.bottom() + block_gap;
612 let below_fits_bottom = below_y + size.y <= viewport.bottom() - OVERLAY_EDGE_GAP;
613 let below_ok = below_y >= viewport.top() + OVERLAY_TOP_GAP && below_fits_bottom;
614
615 if above_ok && clear(egui::pos2(desired, above_y)) {
617 return Some(egui::pos2(desired, above_y));
618 }
619 if below_ok && clear(egui::pos2(desired, below_y)) {
620 return Some(egui::pos2(desired, below_y));
621 }
622 let strip = |y: f32| Strip {
625 y,
626 size,
627 x_min,
628 x_max,
629 };
630 if above_ok && let Some(x) = strip(above_y).shifted_clear_x(desired, obstacles) {
631 return Some(egui::pos2(x, above_y));
632 }
633 if below_ok && let Some(x) = strip(below_y).shifted_clear_x(desired, obstacles) {
634 return Some(egui::pos2(x, below_y));
635 }
636 if below_fits_bottom {
640 return None;
641 }
642 let fallback_y = viewport.bottom() - OVERLAY_BOTTOM_GAP - size.y;
643 if fallback_y < viewport.top() + OVERLAY_TOP_GAP {
644 return None; }
646 let fallback_desired = (viewport.center().x - size.x / 2.0).clamp(x_min, x_max);
647 if clear(egui::pos2(fallback_desired, fallback_y)) {
648 return Some(egui::pos2(fallback_desired, fallback_y));
649 }
650 strip(fallback_y)
651 .shifted_clear_x(fallback_desired, obstacles)
652 .map(|x| egui::pos2(x, fallback_y))
653}
654
655pub(crate) fn export_format_menu(
659 ui: &mut egui::Ui,
660 scope: crate::export::ExportScope,
661) -> Option<crate::export::ExportFormat> {
662 scope
663 .formats()
664 .iter()
665 .copied()
666 .find(|format| ui.button(format.label()).clicked())
667}
668
669#[allow(clippy::too_many_arguments)]
681pub fn selection_overlay(
682 commands: &mut CommandSet,
683 data: &Drawing,
684 theme: &Theme,
685 open_picker: Option<OpenPicker>,
686 sel_screen: Option<egui::Rect>,
687 viewport: egui::Rect,
688 zoom: crate::canvas::Zoom,
689 ui: &mut egui::Ui,
690) -> (Option<Action>, Option<egui::Pos2>) {
691 let ctx = ui.ctx().clone();
692 let ctx = &ctx;
693 let Some(sel_screen) = sel_screen else {
694 return (None, None);
695 };
696 if !viewport.intersects(sel_screen) {
697 return (None, None);
698 }
699 if overlay_controls(commands).next().is_none() {
703 return (None, None);
704 }
705 let visible_area_rect = |name: &str| {
712 let id = egui::Id::new(name);
713 ctx.memory(|m| {
714 m.areas()
715 .visible_last_frame(&egui::LayerId::new(egui::Order::Middle, id))
716 .then(|| m.area_rect(id))
717 .flatten()
718 })
719 };
720 let obstacles: Vec<egui::Rect> = [
721 ctx.data(|d| d.get_temp(toolbar_rect_id())),
722 visible_area_rect("nav_tree"),
723 visible_area_rect("history_panel"),
724 ]
725 .into_iter()
726 .flatten()
727 .collect();
728 let block_gap = OVERLAY_BLOCK_GAP_CELLS * GRID_SIZE * zoom.get();
731 let drawn: Vec<CommandId> = overlay_controls(commands).map(|cmd| cmd.id).collect();
739 let size_key = Panel::SelectionButtons.measurement("rect");
740 let remembered = ctx
741 .data(|d| d.get_temp::<(Vec<CommandId>, egui::Rect)>(size_key))
742 .filter(|(of, _)| *of == drawn)
743 .map(|(_, rect)| rect.size());
744 let (builder, pos) = if let Some(size) = remembered {
745 match place_overlay(sel_screen, viewport, size, block_gap, &obstacles) {
746 Some(pos) => (Panel::SelectionButtons.ui_builder(), pos),
747 None => return (None, None),
748 }
749 } else {
750 ctx.request_repaint();
751 (
756 Panel::SelectionButtons
757 .ui_builder()
758 .sizing_pass()
759 .invisible(),
760 sel_screen.center(),
761 )
762 };
763 let sizing = builder.sizing_pass;
764 let mut clicked: Option<CommandId> = None;
765 let mut child = ui.new_child(builder.max_rect(egui::Rect::from_min_size(pos, viewport.size())));
766 let rect = {
767 let ui = &mut child;
768 egui::Frame::popup(ui.style())
769 .show(ui, |ui| {
770 ui.horizontal(|ui| {
771 for cmd in overlay_controls(commands) {
772 let fired = ui
773 .add_enabled_ui(!cmd.withheld(), |ui| {
774 overlay_command(ui, cmd, data, theme, open_picker)
775 })
776 .inner;
777 if let Some(id) = fired {
778 clicked = Some(id);
779 }
780 }
781 });
782 })
783 .response
784 .rect
785 };
786 tracing::debug!(
787 target: "overlay",
788 pass = ctx.cumulative_pass_nr(),
789 sizing,
790 drawn = drawn.len(),
791 pos = ?pos,
792 rect = ?rect,
793 sel = ?sel_screen,
794 "overlay frame"
795 );
796 ctx.data_mut(|d| d.insert_temp(size_key, (drawn, rect)));
798 if sizing {
799 return (None, None);
800 }
801 (
802 clicked.and_then(|id| commands.take(id)),
803 Some(rect.right_top()),
804 )
805}
806
807fn overlay_controls(
814 commands: &CommandSet,
815) -> impl Iterator<Item = &crate::tools::commands::Command> {
816 commands.iter_drawn().filter(|cmd| drawn_in_overlay(cmd.id))
817}
818
819fn drawn_in_overlay(id: CommandId) -> bool {
823 match id {
824 CommandId::ExportSelection(format) => format == ExportScope::Selection.leading_format(),
827 CommandId::Arm(_)
830 | CommandId::Undo
831 | CommandId::Redo
832 | CommandId::Export(_)
833 | CommandId::Import => false,
834 CommandId::HideTags | CommandId::ShowTags | CommandId::PinType | CommandId::Accent => true,
835 id => overlay_icon(id).is_some(),
836 }
837}
838
839fn overlay_command(
842 ui: &mut egui::Ui,
843 cmd: &crate::tools::commands::Command,
844 data: &Drawing,
845 theme: &Theme,
846 open_picker: Option<OpenPicker>,
847) -> Option<CommandId> {
848 match cmd.id {
849 CommandId::ExportSelection(_) => {
850 let mut picked = None;
851 ui.menu_image_button(icon_image(ui, EXPORT_ICON), |ui| {
852 picked = export_format_menu(ui, ExportScope::Selection);
853 })
854 .response
855 .on_hover_text("Export");
856 picked.map(CommandId::ExportSelection)
857 }
858 CommandId::HideTags | CommandId::ShowTags => {
859 ui.button(cmd.label).clicked().then_some(cmd.id)
860 }
861 CommandId::PinType => {
862 let selected = open_picker == Some(OpenPicker::PinType);
863 (ui.add(egui::Button::new(cmd.label).selected(selected))
864 .clicked()
865 && !selected)
866 .then_some(cmd.id)
867 }
868 CommandId::Accent => {
869 let Action::OpenRolePicker { target } = &cmd.action else {
870 return None;
871 };
872 let swatch = accent_swatch_color(data, *target, theme);
873 let picker = if open_picker == Some(OpenPicker::Role) {
874 PanelState::Open
875 } else {
876 PanelState::Closed
877 };
878 (accent_swatch_button(ui, swatch, picker).clicked() && picker == PanelState::Closed)
879 .then_some(cmd.id)
880 }
881 id => {
882 let icon = overlay_icon(id)?;
883 ui.add(icon_button(ui, icon))
884 .on_hover_text(cmd.label)
885 .clicked()
886 .then_some(id)
887 }
888 }
889}
890
891fn overlay_icon(id: CommandId) -> Option<egui::ImageSource<'static>> {
895 match id {
896 CommandId::Copy => Some(COPY_ICON),
897 CommandId::Cut => Some(CUT_ICON),
898 CommandId::FlipLr => Some(FLIP_LR_ICON),
899 CommandId::FlipUd => Some(FLIP_UD_ICON),
900 CommandId::Reroute | CommandId::RerouteBlock => Some(REROUTE_ICON),
901 CommandId::AddRouteLabel => Some(ROUTE_LABEL_ICON),
902 CommandId::ExpandBlock => Some(EXPAND_ICON),
905 CommandId::Lock => Some(lock_toggle_icon(InterfaceLock::Unlocked).0),
906 CommandId::Unlock => Some(lock_toggle_icon(InterfaceLock::Locked).0),
907 CommandId::AddIcon => Some(ADD_ICON_ICON),
908 CommandId::Delete => Some(DELETE_ICON),
909 _ => None,
910 }
911}
912
913#[cfg(all(test, feature = "kittest"))]
914mod kittest_visual {
915 use super::*;
916 use crate::canvas::palette::Luminance;
917 use crate::font::build_fonts;
918 use crate::preferences::{FontChoice, Theme};
919 use egui::vec2;
920 use egui_kittest::Harness;
921
922 #[test]
927 fn toolbar_strip() {
928 let mut harness = Harness::builder()
931 .with_size(vec2(1240.0, 120.0))
932 .build_ui(move |ui| {
933 let ctx = ui.ctx().clone();
934 egui_extras::install_image_loaders(&ctx);
935 ctx.set_fonts(build_fonts(FontChoice::Basic));
936 ctx.set_visuals(Theme::Catppuccin.palette(Luminance::Dark).egui_visuals());
937
938 let mut debug = false;
941 let mut nav_open = false;
942 let viewport = ui.max_rect();
943 toolbar(
944 &mut CommandSet::writable_toolbar(),
945 ToolbarProps {
946 selected: ToolName::Select,
947 toggles: ViewToggles {
948 debug_marks: &mut debug,
949 },
950 nav: NavCluster {
951 history: crate::tools::nav_tree::PathHistory {
952 can_back: true,
953 can_forward: false,
954 },
955 nav_open: &mut nav_open,
956 },
957 },
958 viewport,
959 ui,
960 );
961 });
962 harness.run();
963 harness.snapshot("toolbar");
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970 use crate::path::Scope;
971 use crate::preferences::Preferences;
972 use egui::{Rect, Vec2, pos2, vec2};
973
974 fn viewport() -> Rect {
975 Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
976 }
977 const SIZE: Vec2 = Vec2::new(200.0, 40.0);
978 const GAP: f32 = OVERLAY_BLOCK_GAP_CELLS * GRID_SIZE;
980
981 #[test]
985 fn idle_chrome_settles() {
986 use crate::path::BlockPath;
987 use crate::tools::commands::{CommandContext, CommandSet, History};
988 use crate::tools::resize_block::ResizeBlock;
989 use crate::widget::test_fixtures::{self as fx, Scene};
990 use blockworx_doc::fixtures::block_id;
991 let b = block_id(1);
992 let mut scene = Scene::new(vec![
993 fx::block_in(
994 1,
995 Scope::Root,
996 Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
997 ),
998 fx::titled(1, "core"),
999 ]);
1000 let mut debug_marks = false;
1001 let mut nav = false;
1002 let mut prefs = Preferences::default();
1003 let mut rename_draft = String::new();
1004 let sel_screen = Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0)));
1005 let settle = crate::tools::settle::probe(30, |ui| {
1006 let viewport = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0));
1007 let tool: crate::tools::tool::Tool = ResizeBlock::Selected {
1008 shape: ShapeId::Rect(b),
1009 }
1010 .into();
1011 let mut path = BlockPath::empty();
1014 path.push(b);
1015 let mut commands = {
1016 let drawing = scene.drawing();
1017 CommandSet::available(&CommandContext {
1018 tool: &tool,
1019 data: &drawing,
1020 history: History {
1021 can_undo: true,
1022 can_redo: false,
1023 },
1024 current_lock: InterfaceLock::Unlocked,
1025 writability: crate::doc::Writability::Writable,
1026 head: blockworx_doc::rev::Rev::ZERO,
1027 saving: crate::doc::Saving::Withheld,
1028 viewing: crate::doc::Viewing::Head,
1029 })
1030 };
1031 let (_, menu_rect) = crate::tools::main_menu::main_menu(
1032 &mut commands,
1033 crate::tools::main_menu::MainMenu {
1034 prefs: &mut prefs,
1035 recent: &[],
1036 saving: crate::doc::Saving::Withheld,
1037 document: crate::tools::file_menu::Document {
1038 name: "engine",
1039 draft: &mut rename_draft,
1040 renaming: crate::doc::Renaming::Withheld,
1041 },
1042 },
1043 viewport,
1044 ui,
1045 );
1046 let compass = toolbar(
1047 &mut commands,
1048 ToolbarProps {
1049 selected: ToolName::Select,
1050 toggles: ViewToggles {
1051 debug_marks: &mut debug_marks,
1052 },
1053 nav: NavCluster {
1054 history: crate::tools::nav_tree::PathHistory {
1055 can_back: false,
1056 can_forward: false,
1057 },
1058 nav_open: &mut nav,
1059 },
1060 },
1061 viewport,
1062 ui,
1063 )
1064 .compass;
1065 assert!(
1066 compass.is_positive(),
1067 "the navigator popup needs the compass rect to hang from"
1068 );
1069 let mut panel_open = false;
1070 let _ = history_overlay(
1071 &mut commands,
1072 HistoryCluster {
1073 panel_open: &mut panel_open,
1074 viewing: Viewing::Head,
1075 head: blockworx_doc::rev::Rev::ZERO,
1076 steps: UndoSteps::default(),
1077 },
1078 viewport,
1079 ui,
1080 );
1081 {
1082 let indexed = scene.indexed();
1083 let block = crate::tools::title_block::TitleBlock {
1084 name: "chrome".to_owned(),
1085 author: "ada".to_owned(),
1086 rev: blockworx_doc::rev::Rev::ZERO,
1087 date: None,
1088 from: None,
1089 };
1090 let theme = Theme::default();
1091 let _ = crate::tools::title_block::draw(
1092 ui,
1093 viewport,
1094 &theme,
1095 &block,
1096 crate::doc::Writability::Writable,
1097 &indexed,
1098 &path,
1099 );
1100 }
1101 crate::tools::notices::draw(
1102 ui,
1103 viewport,
1104 menu_rect,
1105 &[crate::tools::notices::Notice::Standing(
1106 "Read-only \u{2014} another blockworx has this container open".to_owned(),
1107 )],
1108 );
1109 let drawing = scene.drawing();
1110 let theme = Theme::default();
1111 let _ = selection_overlay(
1112 &mut commands,
1113 &drawing,
1114 &theme,
1115 None,
1116 sel_screen,
1117 viewport,
1118 crate::canvas::Zoom::unity(),
1119 ui,
1120 );
1121 });
1122 crate::tools::settle::assert_settles(&settle, 6);
1123 }
1124
1125 #[derive(Clone, Copy)]
1129 struct Controls {
1130 drawn: usize,
1131 invocable: usize,
1132 bar: Rect,
1133 }
1134
1135 impl Default for Controls {
1136 fn default() -> Self {
1137 Controls {
1138 drawn: 0,
1139 invocable: 0,
1140 bar: Rect::NOTHING,
1141 }
1142 }
1143 }
1144
1145 fn route_overlay(writability: crate::doc::Writability) -> (bool, Controls) {
1148 route_overlay_with(
1149 writability,
1150 crate::tools::commands::History {
1151 can_undo: false,
1152 can_redo: false,
1153 },
1154 )
1155 }
1156
1157 fn route_overlay_with(
1161 writability: crate::doc::Writability,
1162 history: crate::tools::commands::History,
1163 ) -> (bool, Controls) {
1164 use crate::tools::commands::{CommandContext, CommandSet};
1165 use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
1166 let ctx = egui::Context::default();
1167 egui_extras::install_image_loaders(&ctx);
1168 let mut scene = two_blocks_with_a_routed_waypoint();
1169 let route = {
1170 let drawing = scene.drawing();
1171 let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1172 assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1173 ids[0]
1174 };
1175 let tool: crate::tools::tool::Tool = crate::tools::EditRoute::Selected {
1176 id: route,
1177 anchor: pos2(0.0, 0.0),
1178 }
1179 .into();
1180 let screen = viewport();
1181 let mut shown = false;
1182 let mut controls = Controls::default();
1183 for _ in 0..2 {
1185 ctx.clone()
1186 .run_ui(
1187 egui::RawInput {
1188 screen_rect: Some(screen),
1189 ..Default::default()
1190 },
1191 |ui| {
1192 let drawing = scene.drawing();
1193 let mut commands = CommandSet::available(&CommandContext {
1194 tool: &tool,
1195 data: &drawing,
1196 history,
1197 current_lock: InterfaceLock::Unlocked,
1198 writability,
1199 head: blockworx_doc::rev::Rev::ZERO,
1200 saving: crate::doc::Saving::Withheld,
1201 viewing: crate::doc::Viewing::Head,
1202 });
1203 controls = Controls {
1204 invocable: commands
1205 .iter()
1206 .filter(|cmd| drawn_in_overlay(cmd.id))
1207 .count(),
1208 drawn: overlay_controls(&commands).count(),
1209 bar: Rect::NOTHING,
1210 };
1211 let theme = Theme::default();
1212 let (_, corner) = selection_overlay(
1213 &mut commands,
1214 &drawing,
1215 &theme,
1216 None,
1217 Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0))),
1218 screen,
1219 crate::canvas::Zoom::unity(),
1220 ui,
1221 );
1222 shown = corner.is_some();
1223 },
1224 )
1225 .drop_without_applying_deltas();
1226 }
1227 controls.bar = ctx
1228 .data(|d| {
1229 d.get_temp::<(Vec<CommandId>, Rect)>(Panel::SelectionButtons.measurement("rect"))
1230 })
1231 .map_or(Rect::NOTHING, |(_, rect)| rect);
1232 (shown, controls)
1233 }
1234
1235 fn overlay_frame(
1240 ctx: &egui::Context,
1241 tool: &crate::tools::tool::Tool,
1242 scene: &mut crate::widget::test_fixtures::Scene,
1243 ) -> (Option<egui::Pos2>, usize) {
1244 use crate::tools::commands::{CommandContext, CommandSet, History};
1245 let screen = viewport();
1246 let mut corner = None;
1247 let mut out = ctx.clone().run_ui(
1248 egui::RawInput {
1249 screen_rect: Some(screen),
1250 ..Default::default()
1251 },
1252 |ui| {
1253 let drawing = scene.drawing();
1254 let mut commands = CommandSet::available(&CommandContext {
1255 tool,
1256 data: &drawing,
1257 history: History {
1258 can_undo: false,
1259 can_redo: false,
1260 },
1261 current_lock: InterfaceLock::Unlocked,
1262 writability: crate::doc::Writability::Writable,
1263 head: blockworx_doc::rev::Rev::ZERO,
1264 saving: crate::doc::Saving::Withheld,
1265 viewing: crate::doc::Viewing::Head,
1266 });
1267 let theme = Theme::default();
1268 let (_, at) = selection_overlay(
1269 &mut commands,
1270 &drawing,
1271 &theme,
1272 None,
1273 Some(Rect::from_min_size(pos2(400.0, 400.0), vec2(120.0, 90.0))),
1274 screen,
1275 crate::canvas::Zoom::unity(),
1276 ui,
1277 );
1278 corner = at;
1279 },
1280 );
1281 out.textures_delta.clear();
1282 (corner, painted(&out.shapes))
1283 }
1284
1285 fn painted(shapes: &[egui::epaint::ClippedShape]) -> usize {
1287 fn leaves(shape: &egui::Shape) -> usize {
1288 match shape {
1289 egui::Shape::Noop => 0,
1290 egui::Shape::Vec(shapes) => shapes.iter().map(leaves).sum(),
1291 _ => 1,
1292 }
1293 }
1294 shapes.iter().map(|clipped| leaves(&clipped.shape)).sum()
1295 }
1296
1297 #[test]
1302 fn switching_selections_takes_a_hidden_sizing_frame_instead_of_flashing() {
1303 use crate::tools::resize_block::ResizeBlock;
1304 use crate::widget::test_fixtures::two_blocks_with_a_routed_waypoint;
1305 let ctx = egui::Context::default();
1306 egui_extras::install_image_loaders(&ctx);
1307 let mut scene = two_blocks_with_a_routed_waypoint();
1308 let route = {
1309 let drawing = scene.drawing();
1310 let ids: Vec<_> = drawing.auto_routes().map(|(id, _)| id).collect();
1311 assert_eq!(ids.len(), 1, "the fixture carries exactly one wire");
1312 ids[0]
1313 };
1314 let wire: crate::tools::tool::Tool = crate::tools::EditRoute::Selected {
1315 id: route,
1316 anchor: pos2(0.0, 0.0),
1317 }
1318 .into();
1319 let block: crate::tools::tool::Tool = ResizeBlock::Selected {
1320 shape: crate::shape::ShapeId::Rect(blockworx_doc::fixtures::block_id(1)),
1321 }
1322 .into();
1323
1324 let (corner, painted) = overlay_frame(&ctx, &wire, &mut scene);
1325 assert!(
1326 corner.is_none(),
1327 "the first frame ever should be a hidden sizing pass",
1328 );
1329 assert_eq!(
1330 painted, 0,
1331 "the sizing pass painted {painted} shape(s) — the flash the user \
1332 sees at the click point before the bar snaps into place",
1333 );
1334 let (corner, painted) = overlay_frame(&ctx, &wire, &mut scene);
1335 let wire_bar = corner.expect("the wire's bar shows once measured");
1336 assert!(painted > 0, "a shown bar paints");
1337 assert!(
1338 overlay_frame(&ctx, &wire, &mut scene).0.is_some(),
1339 "an unchanged selection must not flicker",
1340 );
1341
1342 let (corner, painted) = overlay_frame(&ctx, &block, &mut scene);
1343 assert!(
1344 corner.is_none(),
1345 "the switch frame drew the block's bar with the wire's measurement",
1346 );
1347 assert_eq!(painted, 0, "the switch frame painted {painted} shape(s)");
1348 let block_bar = overlay_frame(&ctx, &block, &mut scene)
1349 .0
1350 .expect("the block's bar shows once measured");
1351 assert_ne!(
1352 wire_bar, block_bar,
1353 "precondition: the two bars measure apart, or a stale placement would be invisible",
1354 );
1355
1356 assert!(
1357 overlay_frame(&ctx, &wire, &mut scene).0.is_none(),
1358 "switching back re-measures too — the memory holds one bar, not a history",
1359 );
1360 assert_eq!(
1361 overlay_frame(&ctx, &wire, &mut scene).0,
1362 Some(wire_bar),
1363 "the wire's bar returns exactly where it was",
1364 );
1365 }
1366
1367 #[test]
1374 fn the_selection_overlay_draws_its_withheld_controls_disabled() {
1375 let (shown, writable) = route_overlay(crate::doc::Writability::Writable);
1376 assert!(
1377 writable.invocable > 0 && shown,
1378 "a writable wire lost its overlay",
1379 );
1380 assert_eq!(writable.invocable, writable.drawn);
1381 let (shown, read_only) = route_overlay(crate::doc::Writability::ReadOnly);
1382 assert!(shown, "the read-only overlay vanished instead of disabling");
1383 assert_eq!(
1384 read_only.drawn, writable.drawn,
1385 "read-only dropped controls instead of disabling them",
1386 );
1387 assert_eq!(
1388 read_only.invocable, 0,
1389 "a read-only wire kept {} invocable verb(s)",
1390 read_only.invocable,
1391 );
1392 }
1393
1394 #[test]
1398 fn a_read_only_overlay_measures_the_same_bar_as_a_writable_one() {
1399 let (_, writable) = route_overlay(crate::doc::Writability::Writable);
1400 let (_, read_only) = route_overlay(crate::doc::Writability::ReadOnly);
1401 assert!(
1402 writable.bar.is_positive(),
1403 "precondition: the overlay measured itself ({:?})",
1404 writable.bar,
1405 );
1406 assert_eq!(read_only.bar.size(), writable.bar.size());
1407 }
1408
1409 #[test]
1415 fn commands_the_overlay_does_not_draw_do_not_widen_it() {
1416 use crate::tools::commands::History;
1417 let (_, quiet) = route_overlay_with(
1418 crate::doc::Writability::Writable,
1419 History {
1420 can_undo: false,
1421 can_redo: false,
1422 },
1423 );
1424 let (_, busy) = route_overlay_with(
1425 crate::doc::Writability::Writable,
1426 History {
1427 can_undo: true,
1428 can_redo: true,
1429 },
1430 );
1431 assert!(quiet.bar.is_positive(), "precondition: the bar measured");
1432 assert_eq!(
1433 busy.drawn, quiet.drawn,
1434 "precondition: undo and redo draw no control in the overlay",
1435 );
1436 assert_eq!(
1437 busy.bar.size(),
1438 quiet.bar.size(),
1439 "two commands the overlay never draws widened it anyway",
1440 );
1441 }
1442
1443 fn click_the_toolbar_tool(
1449 mode: ToolName,
1450 writability: crate::doc::Writability,
1451 ) -> Option<Action> {
1452 use crate::tools::commands::{CommandContext, CommandSet, History};
1453 use crate::widget::test_fixtures::Scene;
1454 const FRAMES: usize = 10;
1455 let ctx = egui::Context::default();
1456 egui_extras::install_image_loaders(&ctx);
1457 let mut scene = Scene::new(Vec::new());
1458 let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1459 let screen = viewport();
1460 let mut debug_marks = false;
1461 let mut nav_open = false;
1462
1463 let mut button = Rect::NOTHING;
1464 let mut fired = None;
1465 for frame in 0..FRAMES {
1466 let clicking = frame == FRAMES - 1;
1467 let mut input = egui::RawInput {
1468 screen_rect: Some(screen),
1469 #[expect(
1470 clippy::cast_precision_loss,
1471 reason = "a frame count, not a measurement"
1472 )]
1473 time: Some(frame as f64 * 0.016),
1474 ..Default::default()
1475 };
1476 if clicking {
1477 assert!(
1478 button.is_positive(),
1479 "the {mode:?} button never laid out: {button:?}",
1480 );
1481 let pos = button.center();
1482 input.events = vec![
1483 egui::Event::PointerMoved(pos),
1484 egui::Event::PointerButton {
1485 pos,
1486 button: egui::PointerButton::Primary,
1487 pressed: true,
1488 modifiers: egui::Modifiers::NONE,
1489 },
1490 egui::Event::PointerButton {
1491 pos,
1492 button: egui::PointerButton::Primary,
1493 pressed: false,
1494 modifiers: egui::Modifiers::NONE,
1495 },
1496 ];
1497 }
1498 ctx.clone()
1499 .run_ui(input, |ui| {
1500 let mut commands = {
1501 let drawing = scene.drawing();
1502 CommandSet::available(&CommandContext {
1503 tool: &tool,
1504 data: &drawing,
1505 history: History {
1506 can_undo: false,
1507 can_redo: false,
1508 },
1509 current_lock: InterfaceLock::Unlocked,
1510 writability,
1511 head: blockworx_doc::rev::Rev::ZERO,
1512 saving: crate::doc::Saving::Withheld,
1513 viewing: crate::doc::Viewing::Head,
1514 })
1515 };
1516 let ToolbarFrame {
1517 action, tool_rects, ..
1518 } = toolbar(
1519 &mut commands,
1520 ToolbarProps {
1521 selected: ToolName::Select,
1522 toggles: ViewToggles {
1523 debug_marks: &mut debug_marks,
1524 },
1525 nav: NavCluster {
1526 history: crate::tools::nav_tree::PathHistory {
1527 can_back: false,
1528 can_forward: false,
1529 },
1530 nav_open: &mut nav_open,
1531 },
1532 },
1533 screen,
1534 ui,
1535 );
1536 button = tool_rects
1537 .iter()
1538 .find_map(|&(name, rect)| (name == mode).then_some(rect))
1539 .expect("every toolbar tool reports its rect, enabled or not");
1540 if clicking {
1541 fired = action;
1542 }
1543 })
1544 .drop_without_applying_deltas();
1545 }
1546 fired
1547 }
1548
1549 #[test]
1554 fn a_read_only_toolbar_arms_select_but_no_authoring_tool() {
1555 use crate::doc::Writability;
1556 assert!(matches!(
1557 click_the_toolbar_tool(ToolName::NewBlock, Writability::Writable),
1558 Some(Action::SwitchTool(_)),
1559 ));
1561 assert!(
1562 click_the_toolbar_tool(ToolName::NewBlock, Writability::ReadOnly).is_none(),
1563 "a read-only toolbar armed the New Block tool",
1564 );
1565 assert!(
1566 matches!(
1567 click_the_toolbar_tool(ToolName::Select, Writability::ReadOnly),
1568 Some(Action::SwitchTool(_)),
1569 ),
1570 "a read-only toolbar refused Select, which authors nothing",
1571 );
1572 }
1573
1574 fn actions_along(
1582 row: egui::Rect,
1583 mut show: impl FnMut(&mut egui::Ui) -> Option<Action>,
1584 ) -> Vec<Action> {
1585 const STEP: f32 = 6.0;
1588 let mut chrome = crate::tools::painted::Chrome::new(viewport());
1589 let mut fired = Vec::new();
1590 chrome.settle(|ui| {
1591 let _ = show(ui);
1592 });
1593 let mut x = row.left();
1594 while x <= row.right() {
1595 chrome.click_at(pos2(x, row.center().y), |ui| {
1596 if let Some(action) = show(ui) {
1597 fired.push(action);
1598 }
1599 });
1600 x += STEP;
1601 }
1602 fired
1603 }
1604
1605 fn history_cluster<R>(
1609 history: crate::tools::commands::History,
1610 viewing: Viewing,
1611 head: blockworx_doc::rev::Rev,
1612 drive: impl FnOnce(Rect, &mut dyn FnMut(&mut egui::Ui) -> Option<Action>) -> R,
1613 ) -> (R, bool) {
1614 use crate::tools::commands::{CommandContext, CommandSet};
1615 use crate::widget::test_fixtures::Scene;
1616 let mut scene = Scene::new(Vec::new());
1617 let tool: crate::tools::tool::Tool = crate::tools::SelectTool.into();
1618 let screen = viewport();
1619 let mut panel_open = false;
1620 let clock = std::cell::Cell::new(Rect::NOTHING);
1623 let mut frame = |ui: &mut egui::Ui| {
1624 let mut commands = {
1625 let drawing = scene.drawing();
1626 CommandSet::available(&CommandContext {
1627 tool: &tool,
1628 data: &drawing,
1629 history,
1630 current_lock: InterfaceLock::Unlocked,
1631 writability: viewing.writability(),
1632 head,
1633 saving: crate::doc::Saving::Withheld,
1634 viewing,
1635 })
1636 };
1637 let frame = history_overlay(
1638 &mut commands,
1639 HistoryCluster {
1640 panel_open: &mut panel_open,
1641 viewing,
1642 head,
1643 steps: UndoSteps::default(),
1644 },
1645 screen,
1646 ui,
1647 );
1648 clock.set(frame.clock);
1649 frame.action
1650 };
1651 let mut chrome = crate::tools::painted::Chrome::new(screen);
1652 chrome.settle(|ui| {
1653 let _ = frame(ui);
1654 });
1655 let clock = clock.get();
1656 assert!(clock.is_positive(), "the history cluster never laid out");
1657 let out = drive(clock, &mut frame);
1658 (out, panel_open)
1659 }
1660
1661 fn history_cluster_run(
1663 history: crate::tools::commands::History,
1664 viewing: Viewing,
1665 head: blockworx_doc::rev::Rev,
1666 ) -> (Vec<Action>, bool) {
1667 history_cluster(history, viewing, head, |clock, frame| {
1668 let row =
1669 Rect::from_min_max(clock.right_top(), clock.right_bottom() + vec2(200.0, 0.0));
1670 actions_along(row, frame)
1671 })
1672 }
1673
1674 fn click_the_clock(viewing: Viewing, head: blockworx_doc::rev::Rev) -> (Option<Action>, bool) {
1676 history_cluster(no_undo(), viewing, head, |clock, frame| {
1677 let mut chrome = crate::tools::painted::Chrome::new(viewport());
1678 let mut fired = None;
1679 chrome.settle(|ui| {
1680 let _ = frame(ui);
1681 });
1682 chrome.click_at(clock.center(), |ui| {
1683 if let Some(action) = frame(ui) {
1684 fired = Some(action);
1685 }
1686 });
1687 fired
1688 })
1689 }
1690
1691 fn no_undo() -> crate::tools::commands::History {
1692 crate::tools::commands::History {
1693 can_undo: false,
1694 can_redo: false,
1695 }
1696 }
1697
1698 fn history_cluster_verbs(history: crate::tools::commands::History) -> Vec<Action> {
1700 history_cluster_run(history, Viewing::Head, blockworx_doc::rev::Rev::ZERO).0
1701 }
1702
1703 #[test]
1706 fn the_history_cluster_undoes_and_redoes() {
1707 let verbs = history_cluster_verbs(crate::tools::commands::History {
1708 can_undo: true,
1709 can_redo: true,
1710 });
1711 assert!(
1712 verbs.iter().any(|a| matches!(a, Action::Undo)),
1713 "the history cluster dispatched no Undo ({} verbs in all)",
1714 verbs.len(),
1715 );
1716 assert!(
1717 verbs.iter().any(|a| matches!(a, Action::Redo)),
1718 "the history cluster dispatched no Redo ({} verbs in all)",
1719 verbs.len(),
1720 );
1721 }
1722
1723 #[test]
1727 fn the_redo_button_is_dead_with_nothing_to_redo() {
1728 let verbs = history_cluster_verbs(crate::tools::commands::History {
1729 can_undo: true,
1730 can_redo: false,
1731 });
1732 assert!(
1733 verbs.iter().any(|a| matches!(a, Action::Undo)),
1734 "precondition: the live half of the cluster still dispatches",
1735 );
1736 assert!(
1737 !verbs.iter().any(|a| matches!(a, Action::Redo)),
1738 "a dead Redo dispatched anyway",
1739 );
1740 }
1741
1742 #[test]
1747 fn viewing_the_past_turns_the_cluster_into_a_tape_player() {
1748 use blockworx_doc::fixtures::rev;
1749 let (verbs, _) = history_cluster_run(no_undo(), Viewing::Past(rev(2)), rev(4));
1750 let dispatched = |wanted: fn(&Action) -> bool| verbs.iter().any(wanted);
1751 assert!(
1752 dispatched(|a| matches!(a, Action::ViewRev(r) if *r == rev(1))),
1753 "the tape player will not step back ({} verbs)",
1754 verbs.len(),
1755 );
1756 assert!(
1757 dispatched(|a| matches!(a, Action::ViewRev(r) if *r == rev(3))),
1758 "the tape player will not step forward ({} verbs)",
1759 verbs.len(),
1760 );
1761 assert!(
1762 dispatched(|a| matches!(a, Action::ViewHead)),
1763 "the tape player will not seek to latest ({} verbs)",
1764 verbs.len(),
1765 );
1766 assert!(
1767 !dispatched(|a| matches!(a, Action::Undo | Action::Redo)),
1768 "undo and redo act on a head nobody is looking at",
1769 );
1770 }
1771
1772 #[test]
1776 fn the_step_back_button_is_dead_at_the_oldest_rev() {
1777 use blockworx_doc::fixtures::rev;
1778 let (verbs, _) = history_cluster_run(no_undo(), Viewing::Past(rev(1)), rev(4));
1779 assert!(
1780 verbs
1781 .iter()
1782 .any(|a| matches!(a, Action::ViewRev(r) if *r == rev(2))),
1783 "precondition: the live half of the tape still steps",
1784 );
1785 assert!(
1786 !verbs
1787 .iter()
1788 .any(|a| matches!(a, Action::ViewRev(r) if *r < rev(1))),
1789 "a dead step-back dispatched anyway",
1790 );
1791 }
1792
1793 #[test]
1799 fn the_clock_only_ever_toggles_the_panel() {
1800 use blockworx_doc::fixtures::rev;
1801 for viewing in [Viewing::Head, Viewing::Past(rev(2))] {
1802 let (fired, open) = click_the_clock(viewing, rev(4));
1803 assert!(
1804 fired.is_none(),
1805 "{viewing:?}: the clock dispatched a verb instead of opening the panel",
1806 );
1807 assert!(open, "{viewing:?}: the clock did not open the panel");
1808 }
1809 }
1810
1811 fn toolbar_nav_verbs(history: crate::tools::nav_tree::PathHistory) -> Vec<Action> {
1814 use crate::tools::commands::{CommandContext, CommandSet};
1815 use crate::tools::resize_block::ResizeBlock;
1816 use crate::widget::test_fixtures::{self as fx, Scene};
1817 use blockworx_doc::fixtures::block_id;
1818 let mut scene = Scene::new(vec![
1822 fx::block_in(
1823 1,
1824 Scope::Root,
1825 Rect::from_min_max(pos2(0.0, 0.0), pos2(200.0, 200.0)),
1826 ),
1827 fx::titled(1, "core"),
1828 fx::block_in(
1829 2,
1830 Scope::Block(block_id(1)),
1831 Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)),
1832 ),
1833 fx::titled(2, "inner"),
1834 ])
1835 .inside(block_id(1));
1836 let tool: crate::tools::tool::Tool = ResizeBlock::Selected {
1838 shape: ShapeId::Rect(block_id(2)),
1839 }
1840 .into();
1841 let screen = viewport();
1842 let mut debug_marks = false;
1843 let mut nav_open = false;
1844 let compass = std::cell::Cell::new(Rect::NOTHING);
1845 let mut frame = |ui: &mut egui::Ui| {
1846 let mut commands = {
1847 let drawing = scene.drawing();
1848 CommandSet::available(&CommandContext {
1849 tool: &tool,
1850 data: &drawing,
1851 history: crate::tools::commands::History {
1852 can_undo: false,
1853 can_redo: false,
1854 },
1855 current_lock: InterfaceLock::Unlocked,
1856 writability: crate::doc::Writability::Writable,
1857 head: blockworx_doc::rev::Rev::ZERO,
1858 saving: crate::doc::Saving::Withheld,
1859 viewing: crate::doc::Viewing::Head,
1860 })
1861 };
1862 let frame = toolbar(
1863 &mut commands,
1864 ToolbarProps {
1865 selected: ToolName::Select,
1866 toggles: ViewToggles {
1867 debug_marks: &mut debug_marks,
1868 },
1869 nav: NavCluster {
1870 history,
1871 nav_open: &mut nav_open,
1872 },
1873 },
1874 screen,
1875 ui,
1876 );
1877 compass.set(frame.compass);
1878 frame.action
1879 };
1880 let mut chrome = crate::tools::painted::Chrome::new(screen);
1881 chrome.settle(|ui| {
1882 let _ = frame(ui);
1883 });
1884 let compass = compass.get();
1885 assert!(
1886 compass.is_positive(),
1887 "the toolbar drew no compass to scan from",
1888 );
1889 let row = Rect::from_min_max(
1890 compass.right_top(),
1891 compass.right_bottom() + vec2(200.0, 0.0),
1892 );
1893 actions_along(row, frame)
1894 }
1895
1896 #[test]
1901 fn the_toolbar_navigates_the_hierarchy_and_the_path_history() {
1902 let verbs = toolbar_nav_verbs(crate::tools::nav_tree::PathHistory {
1903 can_back: true,
1904 can_forward: true,
1905 });
1906 for wanted in ["back", "forward", "into the block", "up a level"] {
1907 let found = verbs.iter().any(|action| {
1908 matches!(
1909 (wanted, action),
1910 ("back", Action::PathBack)
1911 | ("forward", Action::PathForward)
1912 | ("into the block", Action::ExpandBlock(_))
1913 | ("up a level", Action::GoUp)
1914 )
1915 });
1916 assert!(
1917 found,
1918 "the toolbar will not go {wanted} ({} verbs in all)",
1919 verbs.len(),
1920 );
1921 }
1922 }
1923
1924 #[test]
1927 fn the_path_arrows_are_dead_with_no_history_to_walk() {
1928 let verbs = toolbar_nav_verbs(crate::tools::nav_tree::PathHistory {
1929 can_back: false,
1930 can_forward: false,
1931 });
1932 assert!(
1933 verbs.iter().any(|a| matches!(a, Action::GoUp)),
1934 "precondition: the live half of the group still dispatches",
1935 );
1936 assert!(
1937 !verbs
1938 .iter()
1939 .any(|a| matches!(a, Action::PathBack | Action::PathForward)),
1940 "a dead path arrow dispatched anyway",
1941 );
1942 }
1943
1944 #[test]
1945 fn accent_swatch_maps_index_to_its_accent_role() {
1946 use blockworx_doc::id::BlockId;
1947 let block = RoleTarget::Block(BlockId::NULL);
1948 assert_eq!(accent_display_role(block, Some(0)), Role::Accent0);
1949 assert_eq!(accent_display_role(block, Some(7)), Role::Accent7);
1950 assert_eq!(accent_display_role(block, None), Role::AccentDefault);
1952 assert_eq!(accent_display_role(block, Some(9)), Role::AccentDefault);
1953 }
1954
1955 #[test]
1956 fn accent_swatch_uses_each_targets_own_unaccented_stroke() {
1957 use blockworx_doc::id::{AreaId, TextId};
1958 let area = RoleTarget::Area(AreaId::NULL);
1961 let text = RoleTarget::Text(TextId::NULL);
1962 assert_eq!(accent_display_role(area, None), Role::AreaStroke);
1963 assert_eq!(accent_display_role(text, None), Role::TextBoxStroke);
1964 assert_eq!(accent_display_role(area, Some(2)), Role::Accent2);
1966 }
1967
1968 #[test]
1969 fn lock_toggle_icon_depicts_the_blocks_current_state() {
1970 let (locked_icon, locked_hover) = lock_toggle_icon(InterfaceLock::Locked);
1971 let (unlocked_icon, unlocked_hover) = lock_toggle_icon(InterfaceLock::Unlocked);
1972 assert_eq!(locked_icon.uri(), LOCK_ICON.uri());
1975 assert_eq!(unlocked_icon.uri(), UNLOCK_ICON.uri());
1976 assert_ne!(locked_icon.uri(), unlocked_icon.uri());
1977 assert_eq!(locked_hover, "Unlock pins");
1979 assert_eq!(unlocked_hover, "Lock pins");
1980 }
1981
1982 #[test]
1987 fn padlock_svgs_draw_a_closed_and_an_open_shackle() {
1988 let closed = include_str!("../../icons/icon-lock.svg");
1989 let open = include_str!("../../icons/icon-unlock.svg");
1990 assert!(
1991 closed.contains(r#"d="M8 11V7a4 4 0 0 1 8 0v4""#),
1992 "{closed}"
1993 );
1994 assert!(open.contains(r#"d="M8 11V7a4 4 0 0 1 7.5-2""#), "{open}");
1995 }
1996
1997 #[test]
2001 fn the_selection_overlay_offers_enter_but_not_go_up() {
2002 assert!(overlay_icon(CommandId::GoUp).is_none());
2003 assert!(overlay_icon(CommandId::ExpandBlock).is_some());
2004 }
2005
2006 #[test]
2011 fn the_hierarchy_icons_share_a_box_and_oppose_their_arrows() {
2012 let enter = include_str!("../../icons/icon-expand.svg");
2013 let exit = include_str!("../../icons/icon-exit.svg");
2014 let box_path =
2015 r#"d="M9 9H5a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-4""#;
2016 assert!(enter.contains(box_path), "{enter}");
2017 assert!(exit.contains(box_path), "{exit}");
2018 assert!(enter.contains(r#"points="8 11 12 15 16 11""#), "{enter}");
2020 assert!(exit.contains(r#"points="8 6 12 2 16 6""#), "{exit}");
2021 }
2022
2023 #[test]
2028 fn the_tape_icons_bar_the_end_they_travel_to() {
2029 let back = include_str!("../../icons/icon-step-back.svg");
2030 let forward = include_str!("../../icons/icon-step-forward.svg");
2031 let latest = include_str!("../../icons/icon-seek-latest.svg");
2032 assert!(
2033 back.contains(r#"<line x1="7" y1="5" x2="7" y2="19"/>"#),
2034 "{back}",
2035 );
2036 assert!(
2037 forward.contains(r#"<line x1="17" y1="5" x2="17" y2="19"/>"#),
2038 "{forward}",
2039 );
2040 assert!(
2041 latest.contains(r#"<line x1="21" y1="5" x2="21" y2="19"/>"#),
2042 "{latest}",
2043 );
2044 assert_eq!(back.matches("<polyline").count(), 1);
2045 assert_eq!(forward.matches("<polyline").count(), 1);
2046 assert_eq!(
2047 latest.matches("<polyline").count(),
2048 2,
2049 "the seek icon is the double chevron",
2050 );
2051 for icon in [back, forward, latest] {
2053 assert!(icon.contains(r#"viewBox="0 0 24 24""#), "{icon}");
2054 assert!(icon.contains(r#"stroke-width="2""#), "{icon}");
2055 assert!(icon.contains(r#"fill="none""#), "{icon}");
2056 }
2057 }
2058
2059 #[test]
2060 fn the_import_icon_reverses_the_export_arrow_over_the_same_tray() {
2061 let export = include_str!("../../icons/icon-export.svg");
2062 let import = include_str!("../../icons/icon-import.svg");
2063 let tray = r#"d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4""#;
2064 assert!(export.contains(tray), "{export}");
2065 assert!(import.contains(tray), "{import}");
2066 assert!(export.contains(r#"points="17 8 12 3 7 8""#), "{export}");
2068 assert!(import.contains(r#"points="7 10 12 15 17 10""#), "{import}");
2069 }
2070
2071 #[test]
2072 fn places_above_a_mid_canvas_selection() {
2073 let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2074 let p = place_overlay(sel, viewport(), SIZE, GAP, &[]).unwrap();
2075 assert_eq!(p.y, 400.0 - GAP - SIZE.y);
2077 assert_eq!(p.x, 450.0 - SIZE.x / 2.0);
2078 }
2079
2080 #[test]
2081 fn flips_below_when_the_selection_is_too_near_the_top() {
2082 let sel = Rect::from_min_size(pos2(400.0, 100.0), vec2(100.0, 100.0));
2083 let p = place_overlay(sel, viewport(), SIZE, GAP, &[]).unwrap();
2084 assert_eq!(p.y, 200.0 + GAP);
2086 }
2087
2088 #[test]
2089 fn pins_to_bottom_center_when_the_selection_fills_the_screen() {
2090 let sel = Rect::from_min_size(pos2(-100.0, -100.0), vec2(1200.0, 1000.0));
2091 let vp = viewport();
2092 let p = place_overlay(sel, vp, SIZE, GAP, &[]).unwrap();
2093 assert_eq!(p.y, vp.bottom() - OVERLAY_BOTTOM_GAP - SIZE.y);
2094 assert_eq!(p.x, vp.center().x - SIZE.x / 2.0); }
2096
2097 #[test]
2098 fn clamps_horizontally_within_the_viewport() {
2099 let sel = Rect::from_min_size(pos2(950.0, 400.0), vec2(40.0, 40.0));
2100 let vp = viewport();
2101 let p = place_overlay(sel, vp, SIZE, GAP, &[]).unwrap();
2102 assert_eq!(p.x, vp.right() - OVERLAY_EDGE_GAP - SIZE.x);
2103 }
2104
2105 #[test]
2106 fn hides_when_the_overlay_is_wider_than_the_viewport() {
2107 let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2108 assert!(place_overlay(sel, viewport(), vec2(1100.0, 40.0), GAP, &[]).is_none());
2109 }
2110
2111 #[test]
2112 fn hides_when_below_would_sit_over_the_toolbar() {
2113 let sel = Rect::from_min_size(pos2(400.0, -40.0), vec2(100.0, 80.0));
2117 assert!(place_overlay(sel, viewport(), SIZE, GAP, &[]).is_none());
2118 }
2119
2120 #[test]
2121 fn flips_below_when_above_would_overlap_an_obstacle() {
2122 let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2125 let above = Rect::from_min_size(pos2(300.0, 300.0), vec2(400.0, 80.0));
2126 let p = place_overlay(sel, viewport(), SIZE, GAP, &[above]).unwrap();
2127 assert_eq!(p.y, 500.0 + GAP);
2128 }
2129
2130 #[test]
2131 fn shifts_right_past_the_nav_dialog_instead_of_hiding() {
2132 let sel = Rect::from_min_size(pos2(20.0, 400.0), vec2(60.0, 60.0));
2136 let nav = Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 800.0));
2137 let p = place_overlay(sel, viewport(), SIZE, GAP, &[nav]).unwrap();
2138 assert_eq!(p.x, 260.0 + OVERLAY_EDGE_GAP); assert_eq!(p.y, 400.0 - GAP - SIZE.y); }
2141
2142 #[test]
2143 fn hides_when_a_full_width_obstacle_blocks_every_landing() {
2144 let sel = Rect::from_min_size(pos2(400.0, 400.0), vec2(100.0, 100.0));
2147 let everything = Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0));
2148 assert!(place_overlay(sel, viewport(), SIZE, GAP, &[everything]).is_none());
2149 }
2150}