1use blockworx_store::doc::{Saving, Viewing, Writability};
14
15use crate::{
16 edit::naming::{Authoring, InterfaceLock, TagVisibility},
17 export::{ExportFormat, ExportScope},
18 shape::ShapeId,
19 tools::{
20 names::{ToolName, band_tools},
21 tool::{Action, Deletable, RoleTarget, Tool, ToolTrait},
22 },
23 widget::drawing::Drawing,
24};
25use blockworx_doc::{
26 id::{BlockId, PinId},
27 values::PinDir,
28};
29
30#[derive(Clone, Copy, Default)]
38pub struct History {
39 pub undo: Option<crate::history::Kind>,
40 pub redo: Option<crate::history::Kind>,
41}
42
43impl History {
44 #[cfg(test)]
46 pub fn empty() -> Self {
47 Self::default()
48 }
49
50 #[cfg(test)]
52 pub fn doc() -> Self {
53 Self {
54 undo: Some(crate::history::Kind::Doc),
55 redo: Some(crate::history::Kind::Doc),
56 }
57 }
58
59 #[cfg(test)]
61 pub fn view() -> Self {
62 Self {
63 undo: Some(crate::history::Kind::View),
64 redo: Some(crate::history::Kind::View),
65 }
66 }
67}
68
69#[derive(Clone, Copy, PartialEq, Eq, Debug)]
71pub enum CommandId {
72 Arm(ToolName),
74 Undo,
75 Redo,
76 Copy,
77 Cut,
78 ExportSelection(ExportFormat),
80 HideTags,
81 ShowTags,
82 Rename,
85 RenameType,
87 Retype,
89 RenameTag,
91 EditText,
93 FlipLr,
94 FlipUd,
95 PinType,
97 Accent,
99 Reroute,
100 SetAccent(Option<u8>),
102 SetPinDir(PinDir),
104 AddRouteLabel,
105 ExpandBlock,
106 GoUp,
107 ZoomIn,
108 ZoomOut,
109 FitView,
111 Lock,
112 Unlock,
113 AddIcon,
114 RerouteBlock,
115 Delete,
116 Export(ExportFormat),
118 Import,
119 Save,
121}
122
123impl CommandId {
124 pub fn name(self) -> &'static str {
127 match self {
128 CommandId::Arm(tool) => tool.command_name().unwrap_or("select"),
131 CommandId::Undo => "undo",
132 CommandId::Redo => "redo",
133 CommandId::Copy => "copy",
134 CommandId::Cut => "cut",
135 CommandId::ExportSelection(format) => match format {
136 ExportFormat::Svg => "export-selection-svg",
137 ExportFormat::Png => "export-selection-png",
138 ExportFormat::Json => "export-selection-json",
139 ExportFormat::Pdf => "export-selection-pdf",
140 },
141 CommandId::HideTags => "hide-tags",
142 CommandId::ShowTags => "show-tags",
143 CommandId::Rename => "rename",
144 CommandId::RenameType => "rename-type",
145 CommandId::Retype => "retype",
146 CommandId::RenameTag => "rename-tag",
147 CommandId::EditText => "edit-text",
148 CommandId::FlipLr => "flip-lr",
149 CommandId::FlipUd => "flip-ud",
150 CommandId::PinType => "io",
151 CommandId::Accent => "accent",
152 CommandId::Reroute => "reroute",
153 CommandId::AddRouteLabel => "add-label",
154 CommandId::ExpandBlock => "expand",
155 CommandId::GoUp => "up",
156 CommandId::ZoomIn => "zoom-in",
157 CommandId::ZoomOut => "zoom-out",
158 CommandId::FitView => "fit",
159 CommandId::Lock => "lock",
160 CommandId::Unlock => "unlock",
161 CommandId::AddIcon => "add-icon",
162 CommandId::RerouteBlock => "reroute-block",
163 CommandId::Delete => "delete",
164 CommandId::Export(format) => match format {
165 ExportFormat::Svg => "export-svg",
166 ExportFormat::Png => "export-png",
167 ExportFormat::Json => "export-json",
168 ExportFormat::Pdf => "export-pdf",
169 },
170 CommandId::Import => "import",
171 CommandId::Save => "save",
172 CommandId::SetAccent(None) => "accent-none",
173 CommandId::SetAccent(Some(0)) => "accent-0",
174 CommandId::SetAccent(Some(1)) => "accent-1",
175 CommandId::SetAccent(Some(2)) => "accent-2",
176 CommandId::SetAccent(Some(3)) => "accent-3",
177 CommandId::SetAccent(Some(4)) => "accent-4",
178 CommandId::SetAccent(Some(5)) => "accent-5",
179 CommandId::SetAccent(Some(6)) => "accent-6",
180 CommandId::SetAccent(Some(7)) => "accent-7",
181 CommandId::SetAccent(Some(_)) => "accent-unknown",
184 CommandId::SetPinDir(PinDir::Input) => "io-input",
185 CommandId::SetPinDir(PinDir::Output) => "io-output",
186 CommandId::SetPinDir(PinDir::InOut) => "io-in-out",
187 }
188 }
189
190 fn writes_the_document(self) -> bool {
200 match self {
201 CommandId::Arm(tool) => tool.arming_writes_the_document(),
202 CommandId::Undo
203 | CommandId::Redo
204 | CommandId::Cut
205 | CommandId::HideTags
206 | CommandId::ShowTags
207 | CommandId::Rename
208 | CommandId::RenameType
209 | CommandId::Retype
210 | CommandId::RenameTag
211 | CommandId::EditText
212 | CommandId::FlipLr
213 | CommandId::FlipUd
214 | CommandId::PinType
215 | CommandId::Accent
216 | CommandId::Reroute
217 | CommandId::SetAccent(_)
218 | CommandId::SetPinDir(_)
219 | CommandId::AddRouteLabel
220 | CommandId::Lock
221 | CommandId::Unlock
222 | CommandId::AddIcon
223 | CommandId::RerouteBlock
224 | CommandId::Delete
225 | CommandId::Import => true,
226 CommandId::Save
230 | CommandId::Copy
231 | CommandId::ExportSelection(_)
232 | CommandId::Export(_)
233 | CommandId::ExpandBlock
234 | CommandId::GoUp
235 | CommandId::ZoomIn
236 | CommandId::ZoomOut
237 | CommandId::FitView => false,
238 }
239 }
240}
241
242pub struct Command {
245 pub id: CommandId,
246 pub label: &'static str,
247 pub action: Action,
248 pub placement: Placement,
251 rendered: Rendered,
252 authoring: Authoring,
253}
254
255#[derive(Clone, Copy, PartialEq, Eq, Debug)]
274pub enum Placement {
275 Inline,
276 Overflow,
277}
278
279impl Placement {
280 pub fn of(id: CommandId) -> Self {
281 match id {
282 CommandId::Accent
283 | CommandId::ExpandBlock
284 | CommandId::AddIcon
285 | CommandId::Lock
286 | CommandId::Unlock
287 | CommandId::Reroute
288 | CommandId::RerouteBlock
289 | CommandId::FlipLr
290 | CommandId::FlipUd
291 | CommandId::AddRouteLabel
297 | CommandId::HideTags
298 | CommandId::ShowTags
299 | CommandId::PinType => Placement::Inline,
300 _ => Placement::Overflow,
304 }
305 }
306}
307
308impl Command {
309 pub fn withheld(&self) -> bool {
314 self.authoring.is_withheld()
315 }
316}
317
318#[derive(Clone, Copy, PartialEq, Eq, Debug)]
320enum Rendered {
321 AsAButton,
322 ByNameOnly,
323}
324
325pub struct CommandContext<'a, 'b> {
327 pub tool: &'a Tool,
328 pub data: &'a Drawing<'b>,
329 pub history: History,
330 pub current_lock: InterfaceLock,
333 pub writability: Writability,
338 pub saving: Saving,
341 pub viewing: Viewing,
344}
345
346pub const REFRESH_PROJECTION: &str = "Refresh document.json";
351
352const fn chord(key: egui::Key) -> egui::KeyboardShortcut {
354 egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, key)
355}
356
357const fn digit(key: egui::Key) -> egui::KeyboardShortcut {
359 egui::KeyboardShortcut::new(egui::Modifiers::NONE, key)
360}
361
362pub const BINDINGS: &[(egui::KeyboardShortcut, CommandId)] = &[
372 (digit(egui::Key::Num1), CommandId::Arm(ToolName::Select)),
373 (chord(egui::Key::E), CommandId::Arm(ToolName::Select)),
374 (digit(egui::Key::Num2), CommandId::Arm(ToolName::NewBlock)),
375 (chord(egui::Key::B), CommandId::Arm(ToolName::NewBlock)),
376 (digit(egui::Key::Num3), CommandId::Arm(ToolName::NewArea)),
377 (chord(egui::Key::M), CommandId::Arm(ToolName::NewArea)),
378 (digit(egui::Key::Num4), CommandId::Arm(ToolName::AddPort)),
379 (chord(egui::Key::P), CommandId::Arm(ToolName::AddPort)),
380 (digit(egui::Key::Num5), CommandId::Arm(ToolName::NewImage)),
381 (chord(egui::Key::I), CommandId::Arm(ToolName::NewImage)),
382 (digit(egui::Key::Num6), CommandId::Arm(ToolName::AddText)),
383 (chord(egui::Key::T), CommandId::Arm(ToolName::AddText)),
384 (digit(egui::Key::Num7), CommandId::Arm(ToolName::Route)),
385 (chord(egui::Key::R), CommandId::Arm(ToolName::Route)),
386 (chord(egui::Key::Equals), CommandId::ZoomIn),
389 (chord(egui::Key::Plus), CommandId::ZoomIn),
390 (chord(egui::Key::Minus), CommandId::ZoomOut),
391 (chord(egui::Key::Num0), CommandId::FitView),
392];
393
394pub fn binding(id: CommandId) -> Option<&'static egui::KeyboardShortcut> {
396 chords(id).next()
397}
398
399pub fn chords(id: CommandId) -> impl Iterator<Item = &'static egui::KeyboardShortcut> {
402 BINDINGS
403 .iter()
404 .filter(move |(_, bound)| *bound == id)
405 .map(|(chord, _)| chord)
406}
407
408pub fn consume_binding(ctx: &egui::Context) -> Option<CommandId> {
412 BINDINGS
413 .iter()
414 .find(|(chord, _)| ctx.input_mut(|i| i.consume_shortcut(chord)))
415 .map(|(_, id)| *id)
416}
417
418fn arm_available(tool: ToolName, current_lock: InterfaceLock) -> bool {
423 !(current_lock.is_locked() && tool == ToolName::AddPort)
424}
425
426pub struct CommandSet {
429 available: Vec<Command>,
430 writability: Writability,
433 viewing: Viewing,
434 history: History,
437}
438
439impl CommandSet {
440 pub fn available(ctx: &CommandContext<'_, '_>) -> Self {
441 let mut set = CommandSet {
442 available: Vec::new(),
443 writability: ctx.writability,
444 viewing: ctx.viewing,
445 history: ctx.history,
446 };
447 for tool in band_tools() {
448 if arm_available(tool, ctx.current_lock) {
449 set.push(
450 CommandId::Arm(tool),
451 tool.label(),
452 Action::SwitchTool(Tool::from_name(tool)),
453 );
454 }
455 }
456 if ctx.history.undo.is_some() {
457 set.push(CommandId::Undo, "Undo", Action::Undo);
458 }
459 if ctx.history.redo.is_some() {
460 set.push(CommandId::Redo, "Redo", Action::Redo);
461 }
462 if let Some(sel) = ctx.tool.selection() {
463 set.selection_commands(&sel, ctx.data);
464 }
465 if ctx.data.current_scope() != crate::path::Scope::Root {
472 set.push(CommandId::GoUp, "Go up a level", Action::GoUp);
473 }
474 set.push(
477 CommandId::ZoomIn,
478 "Zoom in",
479 Action::Zoom(blockworx_paint::ZoomStep::In),
480 );
481 set.push(
482 CommandId::ZoomOut,
483 "Zoom out",
484 Action::Zoom(blockworx_paint::ZoomStep::Out),
485 );
486 set.push(CommandId::FitView, "Fit diagram in view", Action::ResetView);
487 for &format in ExportScope::View.formats() {
488 set.push(
489 CommandId::Export(format),
490 format.label(),
491 Action::Export {
492 format,
493 selection: None,
494 },
495 );
496 }
497 set.push(CommandId::Import, "Import", Action::Import);
498 if ctx.saving == Saving::Offered {
499 set.push_by_name_only(CommandId::Save, REFRESH_PROJECTION, Action::SaveProjection);
500 }
501 set
502 }
503
504 fn selection_commands(&mut self, sel: &Deletable, data: &Drawing<'_>) {
505 if let Some(shapes) = sel.shapes() {
506 self.push(CommandId::Copy, "Copy", Action::Copy(shapes.clone()));
507 if shapes.iter().any(|shape| shape.is_block()) {
511 for &format in ExportScope::Selection.formats() {
512 self.push(
513 CommandId::ExportSelection(format),
514 format.label(),
515 Action::Export {
516 format,
517 selection: Some(shapes.clone()),
518 },
519 );
520 }
521 }
522 if !delete_blocked_by_lock(sel, data) {
523 self.push(CommandId::Cut, "Cut", Action::Cut(shapes));
524 }
525 }
526 if let Deletable::Pins(pins) = sel {
527 self.push(CommandId::Copy, "Copy", Action::CopyPins(pins.clone()));
528 if !delete_blocked_by_lock(sel, data) {
529 self.push(CommandId::Cut, "Cut", Action::CutPins(pins.clone()));
530 }
531 let all_visible = pins
534 .iter()
535 .all(|&a| data.pin_on_shape(a).is_some_and(|(_, pin)| !pin.tag_hidden));
536 let (id, label, tags) = if all_visible {
537 (CommandId::HideTags, "Hide Tags", TagVisibility::Hidden)
538 } else {
539 (CommandId::ShowTags, "Show Tags", TagVisibility::Shown)
540 };
541 self.push(
542 id,
543 label,
544 Action::SetPinTags {
545 pins: pins.clone(),
546 tags,
547 },
548 );
549 }
550 if let Deletable::Shape(id) = sel
551 && let Some(hidden) = data.shape_tag_hidden(*id)
552 {
553 let (cid, label, tags) = if hidden {
554 (CommandId::ShowTags, "Show Tag", TagVisibility::Shown)
555 } else {
556 (CommandId::HideTags, "Hide Tag", TagVisibility::Hidden)
557 };
558 self.push(cid, label, Action::SetShapeTagHidden { shape: *id, tags });
559 }
560 self.editor_commands(sel, data);
561 if let Deletable::Shape(id @ (ShapeId::Rect(_) | ShapeId::Port(_))) = sel {
563 self.push(CommandId::FlipLr, "Flip L/R", Action::FlipShapePins(*id));
564 }
565 if let Deletable::Shape(ShapeId::Rect(rid)) = sel {
566 self.push(
567 CommandId::FlipUd,
568 "Flip U/D",
569 Action::FlipBlockVertical(*rid),
570 );
571 }
572 if let Some(pins) = io_pins(sel)
575 && !pins.iter().any(|a| data.pin_owner_locked(*a))
576 {
577 self.push(
578 CommandId::PinType,
579 "I/O",
580 Action::OpenPinTypePicker { pins: pins.clone() },
581 );
582 for (id, label, kind) in PIN_DIRS {
583 self.push_by_name_only(
584 id,
585 label,
586 Action::SetPinsKind {
587 pins: pins.clone(),
588 kind,
589 },
590 );
591 }
592 }
593 if let Some(target) = role_target(sel) {
594 self.push(
595 CommandId::Accent,
596 "Accent",
597 Action::OpenRolePicker { target },
598 );
599 for (id, label, role) in ACCENTS {
600 self.push_by_name_only(id, label, Action::SetRole { target, role });
601 }
602 }
603 if let Deletable::Route(rid) = sel {
604 self.push(
605 CommandId::Reroute,
606 "Rip up and autoroute",
607 Action::Reroute(*rid),
608 );
609 self.push(
610 CommandId::AddRouteLabel,
611 "Add label",
612 Action::SwitchTool(Tool::AddRouteLabel(crate::tools::AddRouteLabel::Armed(
613 *rid,
614 ))),
615 );
616 }
617 if let Deletable::Shape(ShapeId::Rect(rid)) = sel {
618 self.block_commands(*rid, data);
619 }
620 if !delete_blocked_by_lock(sel, data) {
624 self.push(CommandId::Delete, "Delete", Action::Delete(sel.clone()));
625 }
626 }
627
628 fn editor_commands(&mut self, sel: &Deletable, data: &Drawing<'_>) {
631 use crate::tools::{
632 EditTextBox, RenameBlockType, RenamePin, RenameTitle, RetypePin, rename_pin::Field,
633 };
634 if let Deletable::Shape(id) = sel {
635 if let Some(tool) = RenameTitle::new_with_shape(data, *id) {
636 self.push(CommandId::Rename, "Rename", Action::SwitchTool(tool.into()));
637 }
638 if let ShapeId::Rect(rid) = id
639 && let Some(tool) = RenameBlockType::new_with_rect(data, *rid)
640 {
641 self.push(
642 CommandId::RenameType,
643 "Rename type",
644 Action::SwitchTool(tool.into()),
645 );
646 }
647 if let ShapeId::Text(tid) = id
648 && let Some(tool) = EditTextBox::new_for(data, *tid)
649 {
650 self.push(
651 CommandId::EditText,
652 "Edit text",
653 Action::SwitchTool(tool.into()),
654 );
655 }
656 }
657 let anchor = match sel {
660 Deletable::Pins(pins) if pins.len() == 1 => Some(pins[0]),
661 Deletable::Shape(ShapeId::Port(pid)) => Some(*pid),
662 _ => None,
663 };
664 if let Some(anchor) = anchor {
665 if let Some(tool) = RenamePin::new_with_anchor(data, anchor, Field::Name) {
666 self.push(CommandId::Rename, "Rename", Action::SwitchTool(tool.into()));
667 }
668 if let Some(tool) = RetypePin::new_with_anchor(data, anchor) {
669 self.push(CommandId::Retype, "Retype", Action::SwitchTool(tool.into()));
670 }
671 if let Some(tool) = RenamePin::new_with_anchor(data, anchor, Field::Tag) {
672 self.push(
673 CommandId::RenameTag,
674 "Rename tag",
675 Action::SwitchTool(tool.into()),
676 );
677 }
678 }
679 }
680
681 fn block_commands(&mut self, rid: BlockId, data: &Drawing<'_>) {
682 self.push(
683 CommandId::ExpandBlock,
684 "Expand block",
685 Action::ExpandBlock(rid),
686 );
687 let locked = data.shape_owner_locked(ShapeId::Rect(rid));
688 let (id, label, lock) = if locked {
689 (CommandId::Unlock, "Unlock pins", InterfaceLock::Unlocked)
690 } else {
691 (CommandId::Lock, "Lock pins", InterfaceLock::Locked)
692 };
693 self.push(id, label, Action::SetBlockLocked { block: rid, lock });
694 let label = if data.icon(rid).is_some() {
695 "Replace icon"
696 } else {
697 "Add icon"
698 };
699 self.push(
700 CommandId::AddIcon,
701 label,
702 Action::SwitchTool(Tool::Icon(crate::tools::IconTool::Armed(rid))),
703 );
704 self.push(
705 CommandId::RerouteBlock,
706 "Rip up and autoroute every wire on this block",
707 Action::RerouteBlock(rid),
708 );
709 }
710
711 fn push(&mut self, id: CommandId, label: &'static str, action: Action) {
712 self.offer(id, label, action, Rendered::AsAButton);
713 }
714
715 fn push_by_name_only(&mut self, id: CommandId, label: &'static str, action: Action) {
722 self.offer(id, label, action, Rendered::ByNameOnly);
723 }
724
725 fn offer(&mut self, id: CommandId, label: &'static str, action: Action, rendered: Rendered) {
732 let authoring = if self.allows(id) {
733 Authoring::Offered
734 } else {
735 Authoring::Withheld
736 };
737 self.available.push(Command {
738 id,
739 label,
740 action,
741 placement: Placement::of(id),
742 rendered,
743 authoring,
744 });
745 }
746
747 fn allows(&self, id: CommandId) -> bool {
755 if !self.writes(id) {
756 return true;
757 }
758 self.writability == Writability::Writable && self.viewing == Viewing::Head
759 }
760
761 fn writes(&self, id: CommandId) -> bool {
767 match id {
768 CommandId::Undo => self.history.undo == Some(crate::history::Kind::Doc),
769 CommandId::Redo => self.history.redo == Some(crate::history::Kind::Doc),
770 other => other.writes_the_document(),
771 }
772 }
773
774 pub fn iter(&self) -> impl Iterator<Item = &Command> {
777 self.iter_drawn().filter(|command| !command.withheld())
778 }
779
780 pub fn iter_drawn(&self) -> impl Iterator<Item = &Command> {
784 self.available
785 .iter()
786 .filter(|command| command.rendered == Rendered::AsAButton)
787 }
788
789 pub fn contains(&self, id: CommandId) -> bool {
790 self.get(id).is_some()
791 }
792
793 pub fn get(&self, id: CommandId) -> Option<&Command> {
794 self.available.iter().find(|c| c.id == id && !c.withheld())
795 }
796
797 pub fn take(&mut self, id: CommandId) -> Option<Action> {
801 let index = self
802 .available
803 .iter()
804 .position(|c| c.id == id && !c.withheld())?;
805 Some(self.available.remove(index).action)
806 }
807
808 #[cfg(test)]
811 pub fn take_by_name(&mut self, name: &str) -> Option<Action> {
812 let name = legacy_command_name(name);
813 let index = self
814 .available
815 .iter()
816 .position(|c| c.id.name() == name && !c.withheld())?;
817 Some(self.available.remove(index).action)
818 }
819}
820
821#[cfg(test)]
824fn legacy_command_name(name: &str) -> &str {
825 match name {
826 "comment" => "area",
827 other => other,
828 }
829}
830
831pub enum ScriptedApply {
836 Applied(Option<Tool>),
837 NeedsApp(Box<Action>),
838}
839
840pub fn apply_scripted(action: Action, drawing: &mut Drawing<'_>) -> ScriptedApply {
846 use crate::tools::SelectTool;
847 let settles_on = match action {
848 Action::SwitchTool(next)
852 if next.name().arming_writes_the_document()
853 && drawing.authoring() == Authoring::Withheld =>
854 {
855 None
856 }
857 Action::SwitchTool(next) => Some(next),
858 Action::StampTool { .. } if drawing.authoring() == Authoring::Withheld => None,
863 image @ Action::StampTool {
866 tool: ToolName::NewImage,
867 ..
868 } => return ScriptedApply::NeedsApp(Box::new(image)),
869 Action::StampTool { tool, at } => crate::tools::stamp::stamp(drawing, tool, at),
870 Action::Delete(what) => {
871 drawing.delete(what);
872 Some(Tool::Select(SelectTool))
873 }
874 Action::SetPinTags { pins, tags } => {
877 drawing.set_pins_tag_hidden(&pins, tags);
878 None
879 }
880 Action::SetShapeTagHidden { shape, tags } => {
881 drawing.set_shape_tag_hidden(shape, tags);
882 None
883 }
884 Action::FlipShapePins(shape) => {
889 drawing.flip_shape_pins(shape);
890 drawing.trim_partial_route_approaches(&[shape]);
891 None
892 }
893 Action::FlipBlockVertical(block) => {
894 drawing.flip_block_vertical(block);
895 drawing.trim_partial_route_approaches(&[ShapeId::Rect(block)]);
896 None
897 }
898 Action::SetBlockLocked { block, lock } => {
901 drawing.set_block_locked(block, lock);
902 None
903 }
904 Action::Reroute(id) => {
905 drawing.reroute(id);
906 None
907 }
908 Action::SetRole { target, role } => {
911 drawing.set_role(target, role);
912 None
913 }
914 Action::SetPinsKind { pins, kind } => {
915 drawing.set_pins_kind(&pins, kind);
916 None
917 }
918 Action::RerouteBlock(id) => {
919 drawing.reroute_block(id);
920 None
921 }
922 other => return ScriptedApply::NeedsApp(Box::new(other)),
923 };
924 ScriptedApply::Applied(settles_on)
925}
926
927fn io_pins(sel: &Deletable) -> Option<Vec<PinId>> {
931 match sel {
932 Deletable::Pins(pins) => Some(pins.clone()),
933 Deletable::Shape(ShapeId::Port(pid)) => Some(vec![*pid]),
934 _ => None,
935 }
936}
937
938const ACCENTS: [(CommandId, &str, Option<u8>); 9] = [
945 (CommandId::SetAccent(None), "No accent", None),
946 (CommandId::SetAccent(Some(0)), "Accent 0", Some(0)),
947 (CommandId::SetAccent(Some(1)), "Accent 1", Some(1)),
948 (CommandId::SetAccent(Some(2)), "Accent 2", Some(2)),
949 (CommandId::SetAccent(Some(3)), "Accent 3", Some(3)),
950 (CommandId::SetAccent(Some(4)), "Accent 4", Some(4)),
951 (CommandId::SetAccent(Some(5)), "Accent 5", Some(5)),
952 (CommandId::SetAccent(Some(6)), "Accent 6", Some(6)),
953 (CommandId::SetAccent(Some(7)), "Accent 7", Some(7)),
954];
955
956const PIN_DIRS: [(CommandId, &str, PinDir); 3] = [
958 (CommandId::SetPinDir(PinDir::Input), "Input", PinDir::Input),
959 (
960 CommandId::SetPinDir(PinDir::Output),
961 "Output",
962 PinDir::Output,
963 ),
964 (CommandId::SetPinDir(PinDir::InOut), "In/out", PinDir::InOut),
965];
966
967fn role_target(sel: &Deletable) -> Option<RoleTarget> {
968 match sel {
969 Deletable::Shape(ShapeId::Rect(rid)) => Some(RoleTarget::Block(*rid)),
970 Deletable::Shape(ShapeId::Port(pid)) => Some(RoleTarget::Port(*pid)),
971 Deletable::Shape(ShapeId::Area(cid)) => Some(RoleTarget::Area(*cid)),
972 Deletable::Shape(ShapeId::Text(tid)) => Some(RoleTarget::Text(*tid)),
973 Deletable::Route(rid) => Some(RoleTarget::Route(*rid)),
974 Deletable::Shape(ShapeId::Image(_) | ShapeId::Icon(_))
975 | Deletable::Shapes(_)
976 | Deletable::Pins(_) => None,
977 }
978}
979
980fn delete_blocked_by_lock(sel: &Deletable, data: &Drawing<'_>) -> bool {
984 match sel {
985 Deletable::Pins(pins) => pins.iter().any(|a| data.pin_owner_locked(*a)),
986 Deletable::Shape(id @ ShapeId::Port(_)) => data.shape_owner_locked(*id),
987 _ => false,
988 }
989}
990
991#[cfg(test)]
994pub(crate) fn action_name(action: &Action) -> &'static str {
995 match action {
996 Action::SwitchTool(_) => "SwitchTool",
997 Action::StampTool { .. } => "StampTool",
998 Action::Delete(_) => "Delete",
999 Action::Copy(_) => "Copy",
1000 Action::CopyPins(_) => "CopyPins",
1001 Action::Cut(_) => "Cut",
1002 Action::CutPins(_) => "CutPins",
1003 Action::SetPinTags { .. } => "SetPinTags",
1004 Action::SetShapeTagHidden { .. } => "SetShapeTagHidden",
1005 Action::FlipShapePins(_) => "FlipShapePins",
1006 Action::FlipBlockVertical(_) => "FlipBlockVertical",
1007 Action::SetBlockLocked { .. } => "SetBlockLocked",
1008 Action::Paste(_) => "Paste",
1009 Action::ExpandBlock(_) => "ExpandBlock",
1010 Action::GoToPath(_) => "GoToPath",
1011 Action::Zoom(_) => "Zoom",
1012 Action::Camera(_) => "Camera",
1013 Action::SetRole { .. } => "SetRole",
1014 Action::SetPinsKind { .. } => "SetPinsKind",
1015 Action::OpenRolePicker { .. } => "OpenRolePicker",
1016 Action::OpenPinTypePicker { .. } => "OpenPinTypePicker",
1017 Action::GoUp => "GoUp",
1018 Action::NavSelect { .. } => "NavSelect",
1019 Action::Undo => "Undo",
1020 Action::Redo => "Redo",
1021 Action::Nudge { .. } => "Nudge",
1022 Action::ResetView => "ResetView",
1023 Action::Export { .. } => "Export",
1024 Action::ExportRev { .. } => "ExportRev",
1025 Action::Import => "Import",
1026 Action::Reroute(_) => "Reroute",
1027 Action::RerouteBlock(_) => "RerouteBlock",
1028 Action::ViewRev(_) => "ViewRev",
1029 Action::ViewHead => "ViewHead",
1030 Action::TagRev { .. } => "TagRev",
1031 #[cfg(not(target_arch = "wasm32"))]
1032 Action::SaveProjection => "SaveProjection",
1033 #[cfg(not(target_arch = "wasm32"))]
1035 Action::NewDocument => "NewDocument",
1036 #[cfg(not(target_arch = "wasm32"))]
1037 Action::PickFile(_) => "PickFile",
1038 #[cfg(not(target_arch = "wasm32"))]
1039 Action::OpenRecent(_) => "OpenRecent",
1040 #[cfg(not(target_arch = "wasm32"))]
1041 Action::RenameDocument(_) => "RenameDocument",
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::path::Scope;
1049 use crate::{
1050 tools::{resize_block::ResizeBlock, select_pin::SelectPin},
1051 widget::test_fixtures::{self as fx, Scene},
1052 };
1053 use blockworx_doc::{
1054 fixtures::{block_id, pin_id, route_id},
1055 values::PinSide,
1056 };
1057 use blockworx_geom::{Rect, pos2};
1058
1059 fn scene_with_block() -> Scene {
1062 Scene::new(vec![
1063 fx::block_in(
1064 1,
1065 Scope::Root,
1066 Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
1067 ),
1068 fx::titled(1, "core"),
1069 fx::pin(2, 1, PinSide::West, 0),
1070 ])
1071 }
1072
1073 fn scene_for_effects() -> Scene {
1079 Scene::new(vec![
1080 fx::block_in(
1081 1,
1082 Scope::Root,
1083 Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 150.0)),
1084 ),
1085 fx::block_in(
1086 2,
1087 Scope::Root,
1088 Rect::from_min_max(pos2(180.0, 0.0), pos2(240.0, 150.0)),
1089 ),
1090 fx::pin(3, 1, PinSide::East, 0),
1091 fx::pin(4, 2, PinSide::West, 0),
1092 fx::route(5, Scope::Root, 3, 4, &[(20, 4)]),
1093 ])
1094 }
1095
1096 fn block_tool() -> Tool {
1097 ResizeBlock::Selected {
1098 shape: ShapeId::Rect(block_id(1)),
1099 }
1100 .into()
1101 }
1102
1103 fn route_tool() -> Tool {
1106 crate::tools::EditRoute::Selected {
1107 id: route_id(5),
1108 anchor: pos2(120.0, 40.0),
1109 }
1110 .into()
1111 }
1112
1113 fn pin_group_tool() -> Tool {
1116 crate::tools::MultiPinSelect::Selected {
1117 pins: vec![pin_id(3)],
1118 }
1119 .into()
1120 }
1121
1122 fn no_history() -> History {
1123 History::empty()
1124 }
1125
1126 fn available_for(scene: &mut Scene, tool: &Tool, history: History) -> CommandSet {
1127 let drawing = scene.drawing();
1128 CommandSet::available(&CommandContext {
1129 tool,
1130 data: &drawing,
1131 history,
1132 current_lock: InterfaceLock::Unlocked,
1133 writability: Writability::Writable,
1134 saving: blockworx_store::doc::Saving::Withheld,
1135 viewing: Viewing::Head,
1136 })
1137 }
1138
1139 fn position(set: &CommandSet, id: CommandId) -> usize {
1140 set.iter()
1141 .position(|c| c.id == id)
1142 .unwrap_or_else(|| panic!("{id:?} not in the set"))
1143 }
1144
1145 #[test]
1152 fn rising_a_level_is_offered_everywhere_but_the_document_root() {
1153 let tool: Tool = crate::tools::SelectTool.into();
1154 let offered = |scene: &mut Scene, writability| {
1155 let drawing = scene.drawing();
1156 CommandSet::available(&CommandContext {
1157 tool: &tool,
1158 data: &drawing,
1159 history: no_history(),
1160 current_lock: InterfaceLock::Unlocked,
1161 writability,
1162 saving: blockworx_store::doc::Saving::Withheld,
1163 viewing: Viewing::Head,
1164 })
1165 .contains(CommandId::GoUp)
1166 };
1167 let mut inside = scene_with_block().inside(block_id(1));
1168 assert!(offered(&mut inside, Writability::Writable));
1169 assert!(
1170 offered(&mut inside, Writability::ReadOnly),
1171 "navigation writes nothing, so a reader may still rise",
1172 );
1173 let mut root = scene_with_block();
1174 assert!(!offered(&mut root, Writability::Writable));
1175 assert!(!offered(&mut root, Writability::ReadOnly));
1176 }
1177
1178 #[test]
1183 fn nothing_writes_the_document_through_the_lens() {
1184 let mut scene = scene_with_block();
1185 let tool: Tool = crate::tools::SelectTool.into();
1186 let mut offered = |writability, viewing| {
1187 let drawing = scene.drawing();
1188 CommandSet::available(&CommandContext {
1189 tool: &tool,
1190 data: &drawing,
1191 history: no_history(),
1192 current_lock: InterfaceLock::Unlocked,
1193 writability,
1194 saving: blockworx_store::doc::Saving::Withheld,
1195 viewing,
1196 })
1197 };
1198 let past = Viewing::Past(blockworx_doc::fixtures::rev(2));
1199 assert!(
1200 offered(Writability::Writable, Viewing::Head).contains(CommandId::Import),
1201 "precondition: the writable present offers a command that writes",
1202 );
1203 let under_the_lens: Vec<CommandId> = offered(Writability::Writable, past)
1204 .iter()
1205 .map(|command| command.id)
1206 .collect();
1207 assert!(
1208 !under_the_lens.is_empty(),
1209 "the lens withheld everything, reading included",
1210 );
1211 for id in under_the_lens {
1212 assert!(
1213 !id.writes_the_document(),
1214 "{id:?} writes the document, and it is offered under the lens",
1215 );
1216 }
1217 }
1218
1219 #[test]
1222 fn entering_a_block_needs_a_block_selected() {
1223 let mut scene = scene_with_block();
1224 let nothing: Tool = crate::tools::SelectTool.into();
1225 assert!(
1226 !available_for(&mut scene, ¬hing, no_history()).contains(CommandId::ExpandBlock),
1227 "nothing selected: there is no block to enter"
1228 );
1229 let block: Tool = ResizeBlock::Selected {
1230 shape: ShapeId::Rect(block_id(1)),
1231 }
1232 .into();
1233 assert!(available_for(&mut scene, &block, no_history()).contains(CommandId::ExpandBlock));
1234 }
1235
1236 #[test]
1240 fn only_a_selection_holding_a_block_offers_export() {
1241 let mut scene = scene_with_block();
1242 let port = pin_id(3);
1243 scene.apply(vec![fx::pin_at(
1244 3,
1245 Scope::Root,
1246 "io",
1247 fx::slot(PinSide::East, 0),
1248 Rect::from_min_max(pos2(0.0, 0.0), pos2(75.0, 30.0)),
1249 )]);
1250
1251 let block_tool: Tool = ResizeBlock::Selected {
1252 shape: ShapeId::Rect(block_id(1)),
1253 }
1254 .into();
1255 let set = available_for(&mut scene, &block_tool, no_history());
1256 assert!(set.contains(CommandId::ExportSelection(ExportFormat::Svg)));
1257
1258 let port_tool: Tool = ResizeBlock::Selected {
1259 shape: ShapeId::Port(port),
1260 }
1261 .into();
1262 let set = available_for(&mut scene, &port_tool, no_history());
1263 assert!(
1264 !set.contains(CommandId::ExportSelection(ExportFormat::Svg)),
1265 "a port is not a diagram to export"
1266 );
1267 assert!(set.contains(CommandId::Copy));
1269 assert!(set.contains(CommandId::Delete));
1270 }
1271
1272 #[test]
1273 fn a_selected_block_offers_the_block_verbs_in_overlay_order() {
1274 let mut scene = scene_with_block();
1275 let tool: Tool = ResizeBlock::Selected {
1276 shape: ShapeId::Rect(block_id(1)),
1277 }
1278 .into();
1279 let set = available_for(&mut scene, &tool, no_history());
1280 let order = [
1281 CommandId::Copy,
1282 CommandId::ExportSelection(ExportFormat::Svg),
1283 CommandId::Cut,
1284 CommandId::FlipLr,
1285 CommandId::FlipUd,
1286 CommandId::Accent,
1287 CommandId::ExpandBlock,
1288 CommandId::Lock,
1289 CommandId::AddIcon,
1290 CommandId::RerouteBlock,
1291 CommandId::Delete,
1292 ];
1293 for pair in order.windows(2) {
1294 assert!(
1295 position(&set, pair[0]) < position(&set, pair[1]),
1296 "{:?} should precede {:?}",
1297 pair[0],
1298 pair[1]
1299 );
1300 }
1301 assert!(!set.contains(CommandId::Unlock));
1302 assert!(!set.contains(CommandId::PinType));
1303 assert!(!set.contains(CommandId::Reroute));
1304 }
1305
1306 #[test]
1307 fn a_locked_block_swaps_lock_for_unlock_but_keeps_delete() {
1308 let mut scene = scene_with_block();
1309 scene.apply(vec![fx::locked(1)]);
1310 let tool: Tool = ResizeBlock::Selected {
1311 shape: ShapeId::Rect(block_id(1)),
1312 }
1313 .into();
1314 let set = available_for(&mut scene, &tool, no_history());
1315 assert!(set.contains(CommandId::Unlock));
1316 assert!(!set.contains(CommandId::Lock));
1317 assert_eq!(set.get(CommandId::Unlock).unwrap().label, "Unlock pins");
1318 assert!(set.contains(CommandId::Delete));
1320 assert!(set.contains(CommandId::Cut));
1321 }
1322
1323 #[test]
1324 fn a_locked_pin_selection_loses_cut_delete_and_io() {
1325 let mut scene = scene_with_block();
1326 scene.apply(vec![fx::pin_tag_shown(2), fx::locked(1)]);
1330 let tool: Tool = SelectPin::Selected { anchor: pin_id(2) }.into();
1331 let set = available_for(&mut scene, &tool, no_history());
1332 assert!(set.contains(CommandId::Copy));
1333 assert!(set.contains(CommandId::HideTags));
1337 assert!(!set.contains(CommandId::Cut));
1338 assert!(!set.contains(CommandId::Delete));
1339 assert!(!set.contains(CommandId::PinType));
1340 }
1341
1342 #[test]
1343 fn arming_add_port_respects_the_current_lock() {
1344 assert!(arm_available(ToolName::AddPort, InterfaceLock::Unlocked));
1345 assert!(!arm_available(ToolName::AddPort, InterfaceLock::Locked));
1346 assert!(arm_available(ToolName::Route, InterfaceLock::Locked));
1347
1348 let mut scene = scene_with_block();
1349 let tool: Tool = crate::tools::SelectTool.into();
1350 let drawing = scene.drawing();
1351 let set = CommandSet::available(&CommandContext {
1352 tool: &tool,
1353 data: &drawing,
1354 history: no_history(),
1355 current_lock: InterfaceLock::Locked,
1356 writability: Writability::Writable,
1357 saving: blockworx_store::doc::Saving::Withheld,
1358 viewing: Viewing::Head,
1359 });
1360 assert!(!set.contains(CommandId::Arm(ToolName::AddPort)));
1361 assert!(set.contains(CommandId::Arm(ToolName::Route)));
1362 }
1363
1364 #[test]
1368 fn a_read_only_session_offers_only_the_commands_that_write_nothing() {
1369 let mut scene = scene_with_block();
1370 let tool = block_tool();
1371 let drawing = scene.drawing();
1372 let set = CommandSet::available(&CommandContext {
1373 tool: &tool,
1374 data: &drawing,
1375 history: History::doc(),
1376 current_lock: InterfaceLock::Unlocked,
1377 writability: Writability::ReadOnly,
1378 saving: blockworx_store::doc::Saving::Withheld,
1379 viewing: Viewing::Head,
1380 });
1381 for withheld in [
1382 CommandId::Undo,
1383 CommandId::Redo,
1384 CommandId::Cut,
1385 CommandId::Delete,
1386 CommandId::Rename,
1387 CommandId::Accent,
1388 CommandId::SetAccent(Some(3)),
1389 CommandId::Lock,
1390 CommandId::Import,
1391 CommandId::Arm(ToolName::NewBlock),
1392 CommandId::Arm(ToolName::Route),
1393 ] {
1394 assert!(
1395 !set.contains(withheld),
1396 "{withheld:?} is invocable on a read-only container",
1397 );
1398 }
1399 for kept in [
1400 CommandId::Arm(ToolName::Select),
1401 CommandId::Copy,
1402 CommandId::ExpandBlock,
1403 CommandId::ZoomIn,
1404 CommandId::FitView,
1405 CommandId::Export(ExportFormat::Svg),
1406 CommandId::ExportSelection(ExportFormat::Svg),
1407 ] {
1408 assert!(
1409 set.contains(kept),
1410 "{kept:?} writes nothing but was withheld",
1411 );
1412 }
1413 }
1414
1415 #[test]
1420 fn a_withheld_command_is_drawn_but_cannot_be_invoked() {
1421 let mut scene = scene_with_block();
1422 let tool = block_tool();
1423 let drawing = scene.drawing();
1424 let mut set = CommandSet::available(&CommandContext {
1425 tool: &tool,
1426 data: &drawing,
1427 history: History::doc(),
1428 current_lock: InterfaceLock::Unlocked,
1429 writability: Writability::ReadOnly,
1430 saving: blockworx_store::doc::Saving::Withheld,
1431 viewing: Viewing::Head,
1432 });
1433 let delete = set
1434 .iter_drawn()
1435 .find(|cmd| cmd.id == CommandId::Delete)
1436 .expect("a selected block draws its Delete control even read-only");
1437 assert!(delete.withheld());
1438 assert!(
1439 !set.iter().any(|cmd| cmd.id == CommandId::Delete),
1440 "a withheld command leaked into the invocable view",
1441 );
1442 let name = CommandId::Delete.name();
1443 assert!(set.take_by_name(name).is_none(), "taken by name");
1444 assert!(set.take(CommandId::Delete).is_none(), "taken by id");
1445 }
1446
1447 #[test]
1451 fn every_document_mutating_command_declares_that_it_writes() {
1452 for (id, _, _) in ACCENTS {
1453 assert!(id.writes_the_document(), "{id:?}");
1454 }
1455 for (id, _, _) in PIN_DIRS {
1456 assert!(id.writes_the_document(), "{id:?}");
1457 }
1458 for id in [
1459 CommandId::Delete,
1460 CommandId::Cut,
1461 CommandId::FlipLr,
1462 CommandId::FlipUd,
1463 CommandId::Lock,
1464 CommandId::Unlock,
1465 CommandId::Reroute,
1466 CommandId::RerouteBlock,
1467 CommandId::ShowTags,
1468 CommandId::HideTags,
1469 ] {
1470 assert!(id.writes_the_document(), "{id:?}");
1471 }
1472 }
1473
1474 #[test]
1475 fn undo_and_redo_follow_the_history() {
1476 let mut scene = scene_with_block();
1477 let tool: Tool = crate::tools::SelectTool.into();
1478 let set = available_for(
1479 &mut scene,
1480 &tool,
1481 History {
1482 undo: Some(crate::history::Kind::Doc),
1483 redo: None,
1484 },
1485 );
1486 assert!(set.contains(CommandId::Undo));
1487 assert!(!set.contains(CommandId::Redo));
1488 assert!(set.contains(CommandId::Import));
1489 }
1490
1491 #[test]
1492 fn take_consumes_the_action() {
1493 let mut scene = scene_with_block();
1494 let tool: Tool = crate::tools::SelectTool.into();
1495 let mut set = available_for(&mut scene, &tool, no_history());
1496 assert!(matches!(set.take(CommandId::Import), Some(Action::Import)));
1497 assert!(set.take(CommandId::Import).is_none());
1498 }
1499
1500 #[test]
1501 fn a_selection_offers_its_label_editors() {
1502 let mut scene = scene_with_block();
1503 let pin = pin_id(2);
1504 let tool: Tool = ResizeBlock::Selected {
1505 shape: ShapeId::Rect(block_id(1)),
1506 }
1507 .into();
1508 let set = available_for(&mut scene, &tool, no_history());
1509 assert!(set.contains(CommandId::Rename));
1510 assert!(!set.contains(CommandId::Retype));
1511
1512 let tool: Tool = SelectPin::Selected { anchor: pin }.into();
1513 let set = available_for(&mut scene, &tool, no_history());
1514 for id in [CommandId::Rename, CommandId::Retype, CommandId::RenameTag] {
1515 assert!(set.contains(id), "{id:?} missing for a pin selection");
1516 }
1517
1518 scene.apply(vec![fx::locked(1)]);
1520 let tool: Tool = SelectPin::Selected { anchor: pin }.into();
1521 let set = available_for(&mut scene, &tool, no_history());
1522 for id in [CommandId::Rename, CommandId::Retype, CommandId::RenameTag] {
1523 assert!(!set.contains(id), "{id:?} offered on a locked pin");
1524 }
1525 }
1526
1527 #[test]
1528 fn every_toolbar_tool_is_bound_and_no_chord_is_shared() {
1529 let mut chords = std::collections::HashSet::new();
1530 let mut bound = std::collections::HashSet::new();
1531 for (chord, id) in BINDINGS {
1532 assert!(
1533 chords.insert(format!("{chord:?}")),
1534 "chord {chord:?} bound twice"
1535 );
1536 match id {
1537 CommandId::Arm(tool) => {
1538 assert!(
1539 crate::tools::names::on_the_band(*tool),
1540 "{tool:?} is not on the band"
1541 );
1542 bound.insert(*tool);
1543 }
1544 CommandId::ZoomIn | CommandId::ZoomOut | CommandId::FitView => {}
1547 other => panic!("{other:?} is bound but is neither a tool nor a view command"),
1548 }
1549 }
1550 for tool in band_tools() {
1552 assert!(bound.contains(&tool), "{tool:?} has no key binding");
1553 }
1554 }
1555
1556 #[test]
1559 fn the_view_commands_are_always_available_and_bound() {
1560 let mut scene = scene_with_block();
1561 let tool: Tool = crate::tools::SelectTool.into();
1562 let set = available_for(&mut scene, &tool, no_history());
1563 for id in [CommandId::ZoomIn, CommandId::ZoomOut, CommandId::FitView] {
1564 assert!(set.contains(id), "{id:?} missing with nothing selected");
1565 assert!(binding(id).is_some(), "{id:?} has no chord");
1566 }
1567 }
1568
1569 #[test]
1570 fn bound_chords_consume_from_the_input() {
1571 let ctx = egui::Context::default();
1572 let press = |key| egui::RawInput {
1573 events: vec![egui::Event::Key {
1574 key,
1575 physical_key: None,
1576 pressed: true,
1577 repeat: false,
1578 modifiers: egui::Modifiers::COMMAND,
1579 }],
1580 ..Default::default()
1581 };
1582 ctx.run_ui(press(egui::Key::B), |ui| {
1583 assert_eq!(
1584 consume_binding(ui.ctx()),
1585 Some(CommandId::Arm(ToolName::NewBlock))
1586 );
1587 assert_eq!(consume_binding(ui.ctx()), None);
1589 })
1590 .drop_without_applying_deltas();
1591 ctx.run_ui(press(egui::Key::Z), |ui| {
1592 assert_eq!(consume_binding(ui.ctx()), None, "ctrl-z is not a binding");
1593 })
1594 .drop_without_applying_deltas();
1595 }
1596
1597 fn every_id() -> Vec<CommandId> {
1599 let mut ids: Vec<CommandId> = band_tools().map(CommandId::Arm).collect();
1600 ids.extend([
1601 CommandId::Undo,
1602 CommandId::Redo,
1603 CommandId::Copy,
1604 CommandId::Cut,
1605 CommandId::HideTags,
1606 CommandId::ShowTags,
1607 CommandId::Rename,
1608 CommandId::RenameType,
1609 CommandId::Retype,
1610 CommandId::RenameTag,
1611 CommandId::EditText,
1612 CommandId::FlipLr,
1613 CommandId::FlipUd,
1614 CommandId::PinType,
1615 CommandId::Accent,
1616 CommandId::Reroute,
1617 CommandId::AddRouteLabel,
1618 CommandId::ExpandBlock,
1619 CommandId::GoUp,
1620 CommandId::Lock,
1621 CommandId::Unlock,
1622 CommandId::AddIcon,
1623 CommandId::RerouteBlock,
1624 CommandId::Delete,
1625 CommandId::Import,
1626 ]);
1627 for &format in ExportScope::Selection.formats() {
1628 ids.push(CommandId::ExportSelection(format));
1629 }
1630 for &format in ExportScope::View.formats() {
1631 ids.push(CommandId::Export(format));
1632 }
1633 for (id, _, _) in ACCENTS {
1634 ids.push(id);
1635 }
1636 for (id, _, _) in PIN_DIRS {
1637 ids.push(id);
1638 }
1639 ids
1640 }
1641
1642 #[test]
1646 fn by_name_only_commands_resolve_but_are_not_offered() {
1647 let mut scene = scene_for_effects();
1648 let tool = block_tool();
1649 let set = available_for(&mut scene, &tool, no_history());
1650 assert!(
1651 set.contains(CommandId::SetAccent(Some(3))),
1652 "accent-3 is not in the set for a block selection",
1653 );
1654 assert!(
1655 !set.iter().any(|c| c.id == CommandId::SetAccent(Some(3))),
1656 "accent-3 was rendered as a control; nine colours would bury the bar",
1657 );
1658 assert!(
1659 set.iter().any(|c| c.id == CommandId::Accent),
1660 "the picker that opens them is still a control",
1661 );
1662
1663 let mut set = available_for(&mut scene, &tool, no_history());
1664 assert!(
1665 set.take_by_name("accent-3").is_some(),
1666 "a script naming accent-3 must resolve it",
1667 );
1668 }
1669
1670 #[test]
1673 fn the_pre_rename_comment_spelling_still_arms_the_area_tool() {
1674 let mut scene = scene_for_effects();
1675 let tool = block_tool();
1676 assert_eq!(
1677 CommandId::Arm(ToolName::NewArea).name(),
1678 "area",
1679 "the tool's own spelling is the new one",
1680 );
1681 let set = available_for(&mut scene, &tool, no_history());
1682 assert!(
1683 set.contains(CommandId::Arm(ToolName::NewArea)),
1684 "the area tool is armable in this scene",
1685 );
1686
1687 let mut set = available_for(&mut scene, &tool, no_history());
1688 let armed = set.take_by_name("comment");
1689 assert!(
1690 matches!(armed, Some(Action::SwitchTool(t)) if t.name() == ToolName::NewArea),
1691 "a script naming `comment` must arm the area tool",
1692 );
1693 }
1694
1695 #[test]
1698 fn command_names_are_unique_kebab_case_spellings() {
1699 let mut seen = std::collections::HashSet::new();
1700 for id in every_id() {
1701 let name = id.name();
1702 assert!(seen.insert(name), "duplicate command name {name}");
1703 assert!(
1704 name.chars()
1705 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
1706 "{name} is not kebab-case"
1707 );
1708 }
1709 }
1710 fn command_authored(scene: &mut Scene, tool: &Tool, id: CommandId, writes: &str) -> bool {
1720 let action = available_for(scene, tool, no_history())
1721 .take(id)
1722 .unwrap_or_else(|| {
1723 let have: Vec<CommandId> = available_for(scene, tool, no_history())
1724 .iter()
1725 .map(|command| command.id)
1726 .collect();
1727 panic!("{id:?} is not offered on its fixture; offered: {have:?}")
1728 });
1729 let before = scene.doc.clone();
1730 let (outcome, narrated) = scene.authored(|drawing| apply_scripted(action, drawing));
1731 assert!(
1732 matches!(outcome, ScriptedApply::Applied(_)),
1733 "{id:?} was handed back by the scripted dispatch instead of applied",
1734 );
1735 if !narrated.iter().any(|line| line.starts_with(writes)) {
1741 return false;
1742 }
1743 assert_ne!(
1744 scene.doc.clone(),
1745 before,
1746 "{id:?} pushed ops that folded to no change",
1747 );
1748 true
1749 }
1750
1751 #[test]
1757 fn every_document_mutating_command_writes_the_document() {
1758 type Case = (CommandId, fn() -> Tool, &'static str);
1761 let cases: Vec<Case> = vec![
1762 (CommandId::Delete, block_tool, "block"),
1763 (CommandId::FlipLr, block_tool, "pin"),
1764 (CommandId::FlipUd, block_tool, "pin"),
1765 (CommandId::Lock, block_tool, "block"),
1766 (CommandId::RerouteBlock, block_tool, "route"),
1767 (CommandId::Reroute, route_tool, "route"),
1768 (CommandId::ShowTags, pin_group_tool, "pin"),
1769 (CommandId::SetAccent(Some(3)), block_tool, "block"),
1772 (CommandId::SetPinDir(PinDir::Output), pin_group_tool, "pin"),
1773 ];
1774 for (id, tool_of, writes) in cases {
1775 let mut scene = scene_for_effects();
1776 assert!(
1777 command_authored(&mut scene, &tool_of(), id, writes),
1778 "{id:?} is offered by the registry but authored no {writes} edit",
1779 );
1780 }
1781 }
1782
1783 #[test]
1787 fn the_reverse_of_each_toggle_writes_the_document_too() {
1788 for (first, second, tool_of, writes) in [
1789 (
1790 CommandId::Lock,
1791 CommandId::Unlock,
1792 block_tool as fn() -> Tool,
1793 "block",
1794 ),
1795 (
1796 CommandId::ShowTags,
1797 CommandId::HideTags,
1798 pin_group_tool as fn() -> Tool,
1799 "pin",
1800 ),
1801 ] {
1802 let mut scene = scene_for_effects();
1803 let tool = tool_of();
1804 for id in [first, second] {
1805 assert!(
1806 command_authored(&mut scene, &tool, id, writes),
1807 "{id:?} authored no {writes} edit, so the toggle only works one way",
1808 );
1809 }
1810 }
1811 }
1812}