1use std::borrow::Cow;
18
19use blockworx_paint::{Chord, Key, Modifiers};
20use blockworx_store::doc::{Saving, Viewing, Writability};
21use blockworx_store::storage::DocumentRef;
22
23use crate::{
24 block_edit::{EditTarget, tool_for_target},
25 edit::naming::{Authoring, InterfaceLock, TagVisibility},
26 names::{ToolName, band_tools},
27 shape::ShapeId,
28 tool::{Action, Deletable, RoleTarget, Tool, ToolTrait, Transition},
29 widget::drawing::Drawing,
30};
31use blockworx_doc::{
32 id::{BlockId, PinId},
33 values::PinDir,
34};
35
36#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
38pub enum ExportFormat {
39 Svg,
40 Png,
41 Pdf,
42}
43
44#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum ExportScope {
51 View,
52 Selection,
53}
54
55impl ExportScope {
56 pub fn formats(self) -> &'static [ExportFormat] {
58 match self {
59 ExportScope::View => &[ExportFormat::Svg, ExportFormat::Png, ExportFormat::Pdf],
60 ExportScope::Selection => &[ExportFormat::Svg, ExportFormat::Png],
61 }
62 }
63
64 pub fn leading_format(self) -> ExportFormat {
67 self.formats()[0]
68 }
69}
70
71impl ExportFormat {
72 pub fn label(self) -> &'static str {
74 match self {
75 ExportFormat::Svg => "SVG",
76 ExportFormat::Png => "PNG",
77 ExportFormat::Pdf => "PDF",
78 }
79 }
80
81 pub fn extension(self) -> &'static str {
82 match self {
83 ExportFormat::Svg => "svg",
84 ExportFormat::Png => "png",
85 ExportFormat::Pdf => "pdf",
86 }
87 }
88}
89
90#[derive(Clone, Copy, Default, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
98pub struct History {
99 pub undo: Option<crate::history::Kind>,
100 pub redo: Option<crate::history::Kind>,
101}
102
103#[cfg(any(test, feature = "test-support"))]
107impl History {
108 pub fn empty() -> Self {
110 Self::default()
111 }
112
113 pub fn doc() -> Self {
115 Self {
116 undo: Some(crate::history::Kind::Doc),
117 redo: Some(crate::history::Kind::Doc),
118 }
119 }
120
121 pub fn view() -> Self {
123 Self {
124 undo: Some(crate::history::Kind::View),
125 redo: Some(crate::history::Kind::View),
126 }
127 }
128}
129
130#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
132pub enum CommandId {
133 Arm(ToolName),
135 Undo,
136 Redo,
137 Copy,
138 Cut,
139 ExportSelection(ExportFormat),
141 HideTags,
142 ShowTags,
143 Rename,
146 RenameType,
148 Retype,
150 RenameTag,
152 EditText,
154 FlipLr,
155 FlipUd,
156 PinType,
158 Accent,
160 Reroute,
161 ToggleDiagnostic,
165 ToggleFrameRate,
169 SetAccent(Option<u8>),
171 SetPinDir(PinDir),
173 AddRouteLabel,
174 ExpandBlock,
175 GoUp,
176 ZoomIn,
177 ZoomOut,
178 FitView,
180 Lock,
181 Unlock,
182 AddIcon,
183 AddImage,
186 RerouteBlock,
187 Delete,
188 Export(ExportFormat),
190 Import,
191 Save,
193 Search,
197 Nudge(Heading),
200}
201
202#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
204pub enum Heading {
205 Left,
206 Right,
207 Up,
208 Down,
209}
210
211impl Heading {
212 const ALL: [Heading; 4] = [Heading::Left, Heading::Right, Heading::Up, Heading::Down];
213 const ALONG_AN_EDGE: [Heading; 2] = [Heading::Up, Heading::Down];
215
216 fn nudge(self) -> Action {
217 let (dx, dy) = match self {
218 Heading::Left => (-1, 0),
219 Heading::Right => (1, 0),
220 Heading::Up => (0, -1),
221 Heading::Down => (0, 1),
222 };
223 Action::Nudge { dx, dy }
224 }
225
226 fn label(self) -> &'static str {
227 match self {
228 Heading::Left => "Nudge left",
229 Heading::Right => "Nudge right",
230 Heading::Up => "Nudge up",
231 Heading::Down => "Nudge down",
232 }
233 }
234}
235
236impl CommandId {
237 pub fn name(self) -> &'static str {
240 match self {
241 CommandId::Arm(tool) => tool.command_name().unwrap_or("select"),
244 CommandId::Undo => "undo",
245 CommandId::Redo => "redo",
246 CommandId::Copy => "copy",
247 CommandId::Cut => "cut",
248 CommandId::ExportSelection(format) => match format {
249 ExportFormat::Svg => "export-selection-svg",
250 ExportFormat::Png => "export-selection-png",
251 ExportFormat::Pdf => "export-selection-pdf",
252 },
253 CommandId::HideTags => "hide-tags",
254 CommandId::ShowTags => "show-tags",
255 CommandId::Rename => "rename",
256 CommandId::RenameType => "rename-type",
257 CommandId::Retype => "retype",
258 CommandId::RenameTag => "rename-tag",
259 CommandId::EditText => "edit-text",
260 CommandId::FlipLr => "flip-lr",
261 CommandId::FlipUd => "flip-ud",
262 CommandId::PinType => "io",
263 CommandId::Accent => "accent",
264 CommandId::Reroute => "reroute",
265 CommandId::ToggleDiagnostic => "diagnostic",
266 CommandId::ToggleFrameRate => "frame-rate",
267 CommandId::AddRouteLabel => "add-label",
268 CommandId::ExpandBlock => "expand",
269 CommandId::GoUp => "up",
270 CommandId::ZoomIn => "zoom-in",
271 CommandId::ZoomOut => "zoom-out",
272 CommandId::FitView => "fit",
273 CommandId::Lock => "lock",
274 CommandId::Unlock => "unlock",
275 CommandId::AddIcon => "add-icon",
276 CommandId::AddImage => "add-image",
277 CommandId::RerouteBlock => "reroute-block",
278 CommandId::Delete => "delete",
279 CommandId::Export(format) => match format {
280 ExportFormat::Svg => "export-svg",
281 ExportFormat::Png => "export-png",
282 ExportFormat::Pdf => "export-pdf",
283 },
284 CommandId::Import => "import",
285 CommandId::Save => "save",
286 CommandId::Search => "search",
287 CommandId::Nudge(Heading::Left) => "nudge-left",
288 CommandId::Nudge(Heading::Right) => "nudge-right",
289 CommandId::Nudge(Heading::Up) => "nudge-up",
290 CommandId::Nudge(Heading::Down) => "nudge-down",
291 CommandId::SetAccent(None) => "accent-none",
292 CommandId::SetAccent(Some(0)) => "accent-0",
293 CommandId::SetAccent(Some(1)) => "accent-1",
294 CommandId::SetAccent(Some(2)) => "accent-2",
295 CommandId::SetAccent(Some(3)) => "accent-3",
296 CommandId::SetAccent(Some(4)) => "accent-4",
297 CommandId::SetAccent(Some(5)) => "accent-5",
298 CommandId::SetAccent(Some(6)) => "accent-6",
299 CommandId::SetAccent(Some(7)) => "accent-7",
300 CommandId::SetAccent(Some(_)) => "accent-unknown",
303 CommandId::SetPinDir(PinDir::Input) => "io-input",
304 CommandId::SetPinDir(PinDir::Output) => "io-output",
305 CommandId::SetPinDir(PinDir::InOut) => "io-in-out",
306 }
307 }
308
309 fn writes_the_document(self) -> bool {
319 match self {
320 CommandId::Arm(tool) => tool.arming_writes_the_document(),
321 CommandId::Undo
322 | CommandId::Redo
323 | CommandId::Cut
324 | CommandId::HideTags
325 | CommandId::ShowTags
326 | CommandId::Rename
327 | CommandId::RenameType
328 | CommandId::Retype
329 | CommandId::RenameTag
330 | CommandId::EditText
331 | CommandId::FlipLr
332 | CommandId::FlipUd
333 | CommandId::PinType
334 | CommandId::Accent
335 | CommandId::Reroute
336 | CommandId::SetAccent(_)
337 | CommandId::SetPinDir(_)
338 | CommandId::AddRouteLabel
339 | CommandId::Lock
340 | CommandId::Unlock
341 | CommandId::AddIcon
342 | CommandId::AddImage
343 | CommandId::RerouteBlock
344 | CommandId::Delete
345 | CommandId::Nudge(_)
346 | CommandId::Import => true,
347 CommandId::Save
351 | CommandId::Search
352 | CommandId::Copy
353 | CommandId::ExportSelection(_)
354 | CommandId::Export(_)
355 | CommandId::ExpandBlock
356 | CommandId::GoUp
357 | CommandId::ZoomIn
358 | CommandId::ZoomOut
359 | CommandId::FitView
360 | CommandId::ToggleDiagnostic
361 | CommandId::ToggleFrameRate => false,
362 }
363 }
364}
365
366#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
371pub enum Act {
372 Edit(Action),
373 Effect(Effect),
374}
375
376impl From<Action> for Act {
377 fn from(action: Action) -> Self {
378 Act::Edit(action)
379 }
380}
381
382impl From<Effect> for Act {
383 fn from(effect: Effect) -> Self {
384 Act::Effect(effect)
385 }
386}
387
388#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
394pub enum Effect {
395 AddIcon(BlockId),
397 AddImage,
399 Accent(RoleTarget),
403 PinType(Vec<PinId>),
406 Import,
408 Search,
412 NewDocument,
416 RenameDocument(String),
421 PickFile(crate::file::FileRequest),
424 OpenRecent(DocumentRef),
427}
428
429impl Effect {
430 pub fn named(&self) -> &'static str {
433 match self {
434 Effect::AddIcon(_) => "AddIcon",
435 Effect::AddImage => "AddImage",
436 Effect::Accent(_) => "Accent",
437 Effect::PinType(_) => "PinType",
438 Effect::Import => "Import",
439 Effect::Search => "Search",
440 Effect::NewDocument => "NewDocument",
441 Effect::RenameDocument(_) => "RenameDocument",
442 Effect::PickFile(_) => "PickFile",
443 Effect::OpenRecent(_) => "OpenRecent",
444 }
445 }
446}
447
448#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
451pub struct Command {
452 pub id: CommandId,
453 pub label: Cow<'static, str>,
454 pub act: Act,
455 pub precedence: Precedence,
458 rendered: Rendered,
459 authoring: Authoring,
460}
461
462#[derive(
474 Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize,
475)]
476pub enum Precedence {
477 Own,
478 Clerical,
479}
480
481impl Precedence {
482 pub fn of(id: CommandId) -> Self {
483 match id {
484 CommandId::Accent
485 | CommandId::ExpandBlock
486 | CommandId::AddIcon
487 | CommandId::Lock
488 | CommandId::Unlock
489 | CommandId::Reroute
490 | CommandId::RerouteBlock
491 | CommandId::FlipLr
492 | CommandId::FlipUd
493 | CommandId::AddRouteLabel
498 | CommandId::HideTags
499 | CommandId::ShowTags
500 | CommandId::PinType => Precedence::Own,
501 _ => Precedence::Clerical,
505 }
506 }
507}
508
509pub fn in_overlay(id: CommandId) -> bool {
517 match id {
518 CommandId::ExportSelection(format) => format == ExportScope::Selection.leading_format(),
519 CommandId::Accent
520 | CommandId::AddIcon
521 | CommandId::AddRouteLabel
522 | CommandId::Copy
523 | CommandId::Cut
524 | CommandId::Delete
525 | CommandId::ExpandBlock
526 | CommandId::FlipLr
527 | CommandId::FlipUd
528 | CommandId::HideTags
529 | CommandId::Lock
530 | CommandId::PinType
531 | CommandId::Reroute
532 | CommandId::RerouteBlock
533 | CommandId::ShowTags
534 | CommandId::Unlock => true,
535 _ => false,
536 }
537}
538
539impl Command {
540 pub fn withheld(&self) -> bool {
545 self.authoring.is_withheld()
546 }
547
548 pub fn rendered(&self) -> Rendered {
550 self.rendered
551 }
552}
553
554#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
556pub enum Rendered {
557 AsAButton,
558 ByNameOnly,
559}
560
561pub struct CommandContext<'a, 'b> {
563 pub tool: &'a Tool,
564 pub data: &'a Drawing<'b>,
565 pub history: History,
566 pub current_lock: InterfaceLock,
569 pub writability: Writability,
574 pub saving: Saving,
577 pub viewing: Viewing,
580}
581
582const fn chord(key: Key) -> Chord {
584 Chord {
585 modifiers: Modifiers::Command,
586 key,
587 }
588}
589
590const fn bare(key: Key) -> Chord {
593 Chord {
594 modifiers: Modifiers::None,
595 key,
596 }
597}
598
599const fn shifted(key: Key) -> Chord {
601 Chord {
602 modifiers: Modifiers::CommandShift,
603 key,
604 }
605}
606
607pub const BINDINGS: &[(Chord, CommandId)] = &[
616 (bare(Key::Num1), CommandId::Arm(ToolName::Select)),
617 (chord(Key::E), CommandId::Arm(ToolName::Select)),
618 (bare(Key::Num2), CommandId::Arm(ToolName::NewBlock)),
619 (chord(Key::B), CommandId::Arm(ToolName::NewBlock)),
620 (bare(Key::Num3), CommandId::Arm(ToolName::AddPin)),
621 (chord(Key::N), CommandId::Arm(ToolName::AddPin)),
622 (bare(Key::Num4), CommandId::Arm(ToolName::Route)),
623 (chord(Key::R), CommandId::Arm(ToolName::Route)),
624 (bare(Key::Num5), CommandId::Arm(ToolName::AddPort)),
625 (chord(Key::P), CommandId::Arm(ToolName::AddPort)),
626 (bare(Key::Num6), CommandId::Arm(ToolName::AddText)),
627 (chord(Key::T), CommandId::Arm(ToolName::AddText)),
628 (bare(Key::Num7), CommandId::Arm(ToolName::NewArea)),
629 (chord(Key::M), CommandId::Arm(ToolName::NewArea)),
630 (bare(Key::Num8), CommandId::AddImage),
631 (chord(Key::I), CommandId::AddImage),
632 (chord(Key::Equals), CommandId::ZoomIn),
635 (chord(Key::Plus), CommandId::ZoomIn),
636 (chord(Key::Minus), CommandId::ZoomOut),
637 (chord(Key::Num0), CommandId::FitView),
638 (chord(Key::Z), CommandId::Undo),
639 (shifted(Key::Z), CommandId::Redo),
642 (chord(Key::Y), CommandId::Redo),
643 (chord(Key::K), CommandId::Search),
644 (bare(Key::ArrowLeft), CommandId::Nudge(Heading::Left)),
645 (bare(Key::ArrowRight), CommandId::Nudge(Heading::Right)),
646 (bare(Key::ArrowUp), CommandId::Nudge(Heading::Up)),
647 (bare(Key::ArrowDown), CommandId::Nudge(Heading::Down)),
648];
649
650pub fn binding(id: CommandId) -> Option<&'static Chord> {
652 chords(id).next()
653}
654
655pub fn chords(id: CommandId) -> impl Iterator<Item = &'static Chord> {
658 BINDINGS
659 .iter()
660 .filter(move |(_, bound)| *bound == id)
661 .map(|(chord, _)| chord)
662}
663
664pub fn band_command(tool: ToolName) -> CommandId {
670 match tool {
671 ToolName::NewImage => CommandId::AddImage,
672 armed => CommandId::Arm(armed),
673 }
674}
675
676fn band_act(tool: ToolName) -> Act {
678 match tool {
679 ToolName::NewImage => Effect::AddImage.into(),
680 armed => Action::Arm(armed).into(),
681 }
682}
683
684fn arm_available(tool: ToolName, current_lock: InterfaceLock) -> bool {
689 !(current_lock.is_locked() && tool == ToolName::AddPort)
690}
691
692#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
695pub struct CommandSet {
696 available: Vec<Command>,
697 writability: Writability,
700 viewing: Viewing,
701 history: History,
704}
705
706impl CommandSet {
707 pub fn available(ctx: &CommandContext<'_, '_>) -> Self {
708 let mut set = CommandSet {
709 available: Vec::new(),
710 writability: ctx.writability,
711 viewing: ctx.viewing,
712 history: ctx.history,
713 };
714 for tool in band_tools() {
715 if arm_available(tool, ctx.current_lock) {
716 set.push(band_command(tool), tool.label(), band_act(tool));
717 }
718 }
719 if ctx.history.undo.is_some() {
720 set.push(CommandId::Undo, "Undo", Action::Undo);
721 }
722 if ctx.history.redo.is_some() {
723 set.push(CommandId::Redo, "Redo", Action::Redo);
724 }
725 if let Some(sel) = ctx.tool.selection() {
726 set.selection_commands(&sel, ctx.data);
727 }
728 if ctx.data.current_scope() != crate::path::Scope::Root {
734 set.push(CommandId::GoUp, "Go up a level", Action::GoUp);
735 }
736 set.push(
739 CommandId::ZoomIn,
740 "Zoom in",
741 Action::Zoom(blockworx_paint::ZoomStep::In),
742 );
743 set.push(
744 CommandId::ZoomOut,
745 "Zoom out",
746 Action::Zoom(blockworx_paint::ZoomStep::Out),
747 );
748 set.push(CommandId::FitView, "Fit diagram in view", Action::ResetView);
749 set.push(
751 CommandId::ToggleDiagnostic,
752 "Diagnostics: regional router",
753 Action::ToggleDiagnostic,
754 );
755 set.push(
756 CommandId::ToggleFrameRate,
757 "Diagnostics: frame rate",
758 Action::ToggleFrameRate,
759 );
760 for &format in ExportScope::View.formats() {
761 set.push(
762 CommandId::Export(format),
763 format.label(),
764 Action::Export {
765 format,
766 selection: None,
767 },
768 );
769 }
770 set.push(CommandId::Import, "Import", Effect::Import);
771 set.push_by_name_only(CommandId::Search, "Search", Effect::Search);
772 set
773 }
774
775 fn nudges(&mut self, headings: &[Heading]) {
777 for &heading in headings {
778 self.push_by_name_only(CommandId::Nudge(heading), heading.label(), heading.nudge());
779 }
780 }
781
782 fn selection_commands(&mut self, sel: &Deletable, data: &Drawing<'_>) {
783 if let Some(shapes) = sel.shapes() {
784 self.push(CommandId::Copy, "Copy", Action::Copy(shapes.clone()));
785 self.nudges(&Heading::ALL);
786 if shapes.iter().any(|shape| shape.is_block()) {
790 for &format in ExportScope::Selection.formats() {
791 self.push(
792 CommandId::ExportSelection(format),
793 format.label(),
794 Action::Export {
795 format,
796 selection: Some(shapes.clone()),
797 },
798 );
799 }
800 }
801 if !delete_blocked_by_lock(sel, data) {
802 self.push(CommandId::Cut, "Cut", Action::Cut(shapes));
803 }
804 }
805 if let Deletable::Pins(pins) = sel {
806 self.push(CommandId::Copy, "Copy", Action::CopyPins(pins.clone()));
807 self.nudges(&Heading::ALONG_AN_EDGE);
808 if !delete_blocked_by_lock(sel, data) {
809 self.push(CommandId::Cut, "Cut", Action::CutPins(pins.clone()));
810 }
811 let all_visible = pins
814 .iter()
815 .all(|&a| data.pin_on_shape(a).is_some_and(|(_, pin)| !pin.tag_hidden));
816 let (id, label, tags) = if all_visible {
817 (CommandId::HideTags, "Hide Tags", TagVisibility::Hidden)
818 } else {
819 (CommandId::ShowTags, "Show Tags", TagVisibility::Shown)
820 };
821 self.push(
822 id,
823 label,
824 Action::SetPinTags {
825 pins: pins.clone(),
826 tags,
827 },
828 );
829 }
830 if let Deletable::Shape(id) = sel
831 && let Some(hidden) = data.shape_tag_hidden(*id)
832 {
833 let (cid, label, tags) = if hidden {
834 (CommandId::ShowTags, "Show Tag", TagVisibility::Shown)
835 } else {
836 (CommandId::HideTags, "Hide Tag", TagVisibility::Hidden)
837 };
838 self.push(cid, label, Action::SetShapeTagHidden { shape: *id, tags });
839 }
840 self.editor_commands(sel, data);
841 if let Some(pins) = io_pins(sel)
844 && !pins.iter().any(|a| data.pin_owner_locked(*a))
845 {
846 self.push(CommandId::PinType, "I/O", Effect::PinType(pins.clone()));
847 for (id, label, kind) in PIN_DIRS {
848 self.push_by_name_only(
849 id,
850 label,
851 Action::SetPinsKind {
852 pins: pins.clone(),
853 kind,
854 },
855 );
856 }
857 }
858 if let Some(target) = role_target(sel) {
859 self.push(CommandId::Accent, "Accent", Effect::Accent(target));
860 for (id, label, role) in ACCENTS {
861 self.push_by_name_only(id, label, Action::SetRole { target, role });
862 }
863 }
864 if let Deletable::Route(rid) = sel {
865 self.push(
866 CommandId::Reroute,
867 "Rip up and autoroute",
868 Action::Reroute(*rid),
869 );
870 self.push(
871 CommandId::AddRouteLabel,
872 "Add label",
873 Action::ArmAddRouteLabel(*rid),
874 );
875 }
876 if let Deletable::Shape(ShapeId::Rect(rid)) = sel {
877 self.block_commands(*rid, data);
878 }
879 if let Deletable::Shape(id @ (ShapeId::Rect(_) | ShapeId::Port(_))) = sel {
882 self.push(CommandId::FlipLr, "Flip L/R", Action::FlipShapePins(*id));
883 }
884 if let Deletable::Shape(ShapeId::Rect(rid)) = sel {
885 self.push(
886 CommandId::FlipUd,
887 "Flip U/D",
888 Action::FlipBlockVertical(*rid),
889 );
890 }
891 if !delete_blocked_by_lock(sel, data) {
895 self.push(CommandId::Delete, "Delete", Action::Delete(sel.clone()));
896 }
897 }
898
899 fn editor_commands(&mut self, sel: &Deletable, data: &Drawing<'_>) {
902 if let Deletable::Shape(id) = sel {
903 self.offer_editor(data, CommandId::Rename, "Rename", EditTarget::Title(*id));
904 if let ShapeId::Rect(rid) = id {
905 self.offer_editor(
906 data,
907 CommandId::RenameType,
908 "Rename type",
909 EditTarget::BlockType(*rid),
910 );
911 }
912 if let ShapeId::Text(tid) = id {
913 self.offer_editor(
914 data,
915 CommandId::EditText,
916 "Edit text",
917 EditTarget::Text(*tid),
918 );
919 }
920 }
921 let anchor = match sel {
924 Deletable::Pins(pins) if pins.len() == 1 => Some(pins[0]),
925 Deletable::Shape(ShapeId::Port(pid)) => Some(*pid),
926 _ => None,
927 };
928 if let Some(anchor) = anchor {
929 self.offer_editor(
930 data,
931 CommandId::Rename,
932 "Rename",
933 EditTarget::PinName(anchor),
934 );
935 self.offer_editor(
936 data,
937 CommandId::Retype,
938 "Retype",
939 EditTarget::PinType(anchor),
940 );
941 self.offer_editor(
942 data,
943 CommandId::RenameTag,
944 "Rename tag",
945 EditTarget::PinTag(anchor),
946 );
947 }
948 }
949
950 fn offer_editor(
954 &mut self,
955 data: &Drawing<'_>,
956 id: CommandId,
957 label: &'static str,
958 target: EditTarget,
959 ) {
960 if tool_for_target(data, target).is_some() {
961 self.push(id, label, Action::OpenEditor(target));
962 }
963 }
964
965 fn block_commands(&mut self, rid: BlockId, data: &Drawing<'_>) {
966 self.push(
967 CommandId::ExpandBlock,
968 "Expand block",
969 Action::ExpandBlock(rid),
970 );
971 let locked = data.shape_owner_locked(ShapeId::Rect(rid));
972 let (id, label, lock) = if locked {
973 (CommandId::Unlock, "Unlock pins", InterfaceLock::Unlocked)
974 } else {
975 (CommandId::Lock, "Lock pins", InterfaceLock::Locked)
976 };
977 self.push(id, label, Action::SetBlockLocked { block: rid, lock });
978 let label = if data.icon(rid).is_some() {
979 "Replace icon"
980 } else {
981 "Add icon"
982 };
983 self.push(CommandId::AddIcon, label, Effect::AddIcon(rid));
984 self.push(
985 CommandId::RerouteBlock,
986 "Rip up and autoroute every wire on this block",
987 Action::RerouteBlock(rid),
988 );
989 }
990
991 fn push(&mut self, id: CommandId, label: &'static str, act: impl Into<Act>) {
992 self.offer(id, label, act.into(), Rendered::AsAButton);
993 }
994
995 fn push_by_name_only(&mut self, id: CommandId, label: &'static str, act: impl Into<Act>) {
1002 self.offer(id, label, act.into(), Rendered::ByNameOnly);
1003 }
1004
1005 fn offer(&mut self, id: CommandId, label: &'static str, act: Act, rendered: Rendered) {
1012 let authoring = if self.allows(id) {
1013 Authoring::Offered
1014 } else {
1015 Authoring::Withheld
1016 };
1017 self.available.push(Command {
1018 id,
1019 label: Cow::Borrowed(label),
1020 act,
1021 precedence: Precedence::of(id),
1022 rendered,
1023 authoring,
1024 });
1025 }
1026
1027 fn allows(&self, id: CommandId) -> bool {
1035 if !self.writes(id) {
1036 return true;
1037 }
1038 self.writability == Writability::Writable && self.viewing == Viewing::Head
1039 }
1040
1041 fn writes(&self, id: CommandId) -> bool {
1047 match id {
1048 CommandId::Undo => self.history.undo.is_some_and(crate::history::Kind::writes),
1049 CommandId::Redo => self.history.redo.is_some_and(crate::history::Kind::writes),
1050 other => other.writes_the_document(),
1051 }
1052 }
1053
1054 pub fn iter(&self) -> impl Iterator<Item = &Command> {
1057 self.iter_drawn().filter(|command| !command.withheld())
1058 }
1059
1060 pub fn iter_drawn(&self) -> impl Iterator<Item = &Command> {
1064 self.available
1065 .iter()
1066 .filter(|command| command.rendered == Rendered::AsAButton)
1067 }
1068
1069 pub fn iter_all(&self) -> impl Iterator<Item = &Command> {
1073 self.available.iter()
1074 }
1075
1076 pub fn contains(&self, id: CommandId) -> bool {
1077 self.get(id).is_some()
1078 }
1079
1080 pub fn get(&self, id: CommandId) -> Option<&Command> {
1081 self.available.iter().find(|c| c.id == id && !c.withheld())
1082 }
1083
1084 pub fn take(&mut self, id: CommandId) -> Option<Act> {
1088 let index = self
1089 .available
1090 .iter()
1091 .position(|c| c.id == id && !c.withheld())?;
1092 Some(self.available.remove(index).act)
1093 }
1094
1095 #[cfg(test)]
1098 pub fn take_by_name(&mut self, name: &str) -> Option<Act> {
1099 let name = legacy_command_name(name);
1100 let index = self
1101 .available
1102 .iter()
1103 .position(|c| c.id.name() == name && !c.withheld())?;
1104 Some(self.available.remove(index).act)
1105 }
1106}
1107
1108#[cfg(test)]
1111fn legacy_command_name(name: &str) -> &str {
1112 match name {
1113 "comment" => "area",
1114 other => other,
1115 }
1116}
1117
1118pub enum ScriptedApply {
1123 Applied(Option<Tool>),
1124 NeedsApp(Box<Action>),
1125 NotApplicable(Effect),
1128}
1129
1130pub fn apply_scripted(act: Act, drawing: &mut Drawing<'_>) -> ScriptedApply {
1136 use crate::SelectTool;
1137 let action = match act {
1138 Act::Edit(action) => action,
1139 Act::Effect(effect) => return ScriptedApply::NotApplicable(effect),
1140 };
1141 let settles_on = match action {
1142 Action::Arm(name) => armed(Tool::from_name(name), drawing),
1143 Action::ArmAddRouteLabel(route) => {
1144 armed(crate::AddRouteLabel::Armed(route).into(), drawing)
1145 }
1146 Action::OpenEditor(target) => {
1148 tool_for_target(drawing, target).and_then(|tool| armed(tool, drawing))
1149 }
1150 Action::StampTool { .. } if drawing.authoring() == Authoring::Withheld => None,
1155 Action::StampTool { tool, at } => crate::stamp::stamp(drawing, tool, at),
1156 Action::Delete(what) => {
1157 drawing.delete(what);
1158 Some(Tool::Select(SelectTool))
1159 }
1160 Action::SetPinTags { pins, tags } => {
1163 drawing.set_pins_tag_hidden(&pins, tags);
1164 None
1165 }
1166 Action::SetShapeTagHidden { shape, tags } => {
1167 drawing.set_shape_tag_hidden(shape, tags);
1168 None
1169 }
1170 Action::FlipShapePins(shape) => {
1175 drawing.flip_shape_pins(shape);
1176 drawing.trim_partial_route_approaches(&[shape]);
1177 None
1178 }
1179 Action::FlipBlockVertical(block) => {
1180 drawing.flip_block_vertical(block);
1181 drawing.trim_partial_route_approaches(&[ShapeId::Rect(block)]);
1182 None
1183 }
1184 Action::SetBlockLocked { block, lock } => {
1187 drawing.set_block_locked(block, lock);
1188 None
1189 }
1190 Action::Reroute(id) => {
1191 drawing.reroute(id);
1192 None
1193 }
1194 Action::SetRole { target, role } => {
1197 drawing.set_role(target, role);
1198 None
1199 }
1200 Action::SetPinsKind { pins, kind } => {
1201 drawing.set_pins_kind(&pins, kind);
1202 None
1203 }
1204 Action::RerouteBlock(id) => {
1205 drawing.reroute_block(id);
1206 None
1207 }
1208 other => return ScriptedApply::NeedsApp(Box::new(other)),
1209 };
1210 ScriptedApply::Applied(settles_on)
1211}
1212
1213pub fn apply_transition(transition: Transition, drawing: &mut Drawing<'_>) -> ScriptedApply {
1216 match transition {
1217 Transition::SwitchTool(next) => ScriptedApply::Applied(armed(next, drawing)),
1218 Transition::Action(action) => apply_scripted(Act::Edit(action), drawing),
1219 }
1220}
1221
1222fn armed(next: Tool, drawing: &Drawing<'_>) -> Option<Tool> {
1226 let refused =
1227 next.name().arming_writes_the_document() && drawing.authoring() == Authoring::Withheld;
1228 (!refused).then_some(next)
1229}
1230
1231fn io_pins(sel: &Deletable) -> Option<Vec<PinId>> {
1235 match sel {
1236 Deletable::Pins(pins) => Some(pins.clone()),
1237 Deletable::Shape(ShapeId::Port(pid)) => Some(vec![*pid]),
1238 _ => None,
1239 }
1240}
1241
1242pub const ACCENTS: [(CommandId, &str, Option<u8>); 9] = [
1250 (CommandId::SetAccent(None), "No accent", None),
1251 (CommandId::SetAccent(Some(0)), "Accent 0", Some(0)),
1252 (CommandId::SetAccent(Some(1)), "Accent 1", Some(1)),
1253 (CommandId::SetAccent(Some(2)), "Accent 2", Some(2)),
1254 (CommandId::SetAccent(Some(3)), "Accent 3", Some(3)),
1255 (CommandId::SetAccent(Some(4)), "Accent 4", Some(4)),
1256 (CommandId::SetAccent(Some(5)), "Accent 5", Some(5)),
1257 (CommandId::SetAccent(Some(6)), "Accent 6", Some(6)),
1258 (CommandId::SetAccent(Some(7)), "Accent 7", Some(7)),
1259];
1260
1261pub const PIN_DIRS: [(CommandId, &str, PinDir); 3] = [
1265 (CommandId::SetPinDir(PinDir::Input), "Input", PinDir::Input),
1266 (
1267 CommandId::SetPinDir(PinDir::InOut),
1268 "Input Output",
1269 PinDir::InOut,
1270 ),
1271 (
1272 CommandId::SetPinDir(PinDir::Output),
1273 "Output",
1274 PinDir::Output,
1275 ),
1276];
1277
1278fn role_target(sel: &Deletable) -> Option<RoleTarget> {
1279 match sel {
1280 Deletable::Shape(ShapeId::Rect(rid)) => Some(RoleTarget::Block(*rid)),
1281 Deletable::Shape(ShapeId::Port(pid)) => Some(RoleTarget::Port(*pid)),
1282 Deletable::Shape(ShapeId::Area(cid)) => Some(RoleTarget::Area(*cid)),
1283 Deletable::Shape(ShapeId::Text(tid)) => Some(RoleTarget::Text(*tid)),
1284 Deletable::Route(rid) => Some(RoleTarget::Route(*rid)),
1285 Deletable::Shape(ShapeId::Image(_) | ShapeId::Icon(_))
1286 | Deletable::Shapes(_)
1287 | Deletable::Pins(_) => None,
1288 }
1289}
1290
1291fn delete_blocked_by_lock(sel: &Deletable, data: &Drawing<'_>) -> bool {
1295 match sel {
1296 Deletable::Pins(pins) => pins.iter().any(|a| data.pin_owner_locked(*a)),
1297 Deletable::Shape(id @ ShapeId::Port(_)) => data.shape_owner_locked(*id),
1298 _ => false,
1299 }
1300}
1301
1302#[cfg(any(test, feature = "test-support"))]
1305pub fn act_name(act: &Act) -> &'static str {
1306 match act {
1307 Act::Edit(action) => action_name(action),
1308 Act::Effect(effect) => effect.named(),
1309 }
1310}
1311
1312#[cfg(any(test, feature = "test-support"))]
1314pub fn action_name(action: &Action) -> &'static str {
1315 match action {
1316 Action::Arm(_) => "Arm",
1317 Action::ArmAddRouteLabel(_) => "ArmAddRouteLabel",
1318 Action::OpenEditor(_) => "OpenEditor",
1319 Action::StampTool { .. } => "StampTool",
1320 Action::Delete(_) => "Delete",
1321 Action::Copy(_) => "Copy",
1322 Action::CopyPins(_) => "CopyPins",
1323 Action::Cut(_) => "Cut",
1324 Action::CutPins(_) => "CutPins",
1325 Action::SetPinTags { .. } => "SetPinTags",
1326 Action::SetShapeTagHidden { .. } => "SetShapeTagHidden",
1327 Action::FlipShapePins(_) => "FlipShapePins",
1328 Action::FlipBlockVertical(_) => "FlipBlockVertical",
1329 Action::SetBlockLocked { .. } => "SetBlockLocked",
1330 Action::Paste(_) => "Paste",
1331 Action::ExpandBlock(_) => "ExpandBlock",
1332 Action::GoToPath(_) => "GoToPath",
1333 Action::Zoom(_) => "Zoom",
1334 Action::SetRole { .. } => "SetRole",
1335 Action::SetPinsKind { .. } => "SetPinsKind",
1336 Action::GoUp => "GoUp",
1337 Action::AcknowledgeFailure(_) => "AcknowledgeFailure",
1338 Action::FrameRect(_) => "FrameRect",
1339 Action::NavSelect { .. } => "NavSelect",
1340 Action::Undo => "Undo",
1341 Action::Redo => "Redo",
1342 Action::Nudge { .. } => "Nudge",
1343 Action::ResetView => "ResetView",
1344 Action::ToggleDiagnostic => "ToggleDiagnostic",
1345 Action::ToggleFrameRate => "ToggleFrameRate",
1346 Action::Export { .. } => "Export",
1347 Action::SetIcon { .. } => "SetIcon",
1348 Action::PlaceImage { .. } => "PlaceImage",
1349 Action::Reroute(_) => "Reroute",
1350 Action::RerouteBlock(_) => "RerouteBlock",
1351 Action::ViewRev(_) => "ViewRev",
1352 Action::ViewHead => "ViewHead",
1353 Action::TagRev { .. } => "TagRev",
1354 Action::SetPalette(_) => "SetPalette",
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::*;
1361 use crate::path::Scope;
1362 use crate::{
1363 resize_block::ResizeBlock,
1364 select_pin::SelectPin,
1365 widget::test_fixtures::{self as fx, Scene},
1366 };
1367 use blockworx_doc::{
1368 fixtures::{block_id, pin_id, route_id},
1369 values::PinSide,
1370 };
1371 use blockworx_geom::{Rect, pos2};
1372
1373 fn scene_with_block() -> Scene {
1376 Scene::new(vec![
1377 fx::block_in(
1378 1,
1379 Scope::Root,
1380 Rect::from_min_max(pos2(0.0, 0.0), pos2(80.0, 80.0)),
1381 ),
1382 fx::titled(1, "core"),
1383 fx::pin(2, 1, PinSide::West, 0),
1384 ])
1385 }
1386
1387 fn scene_for_effects() -> Scene {
1393 Scene::new(vec![
1394 fx::block_in(
1395 1,
1396 Scope::Root,
1397 Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 150.0)),
1398 ),
1399 fx::block_in(
1400 2,
1401 Scope::Root,
1402 Rect::from_min_max(pos2(180.0, 0.0), pos2(240.0, 150.0)),
1403 ),
1404 fx::pin(3, 1, PinSide::East, 0),
1405 fx::pin(4, 2, PinSide::West, 0),
1406 fx::route(5, Scope::Root, 3, 4, &[(20, 4)]),
1407 ])
1408 }
1409
1410 fn block_tool() -> Tool {
1411 ResizeBlock::Selected {
1412 shape: ShapeId::Rect(block_id(1)),
1413 }
1414 .into()
1415 }
1416
1417 fn route_tool() -> Tool {
1420 crate::EditRoute::Selected {
1421 id: route_id(5),
1422 anchor: pos2(120.0, 40.0),
1423 }
1424 .into()
1425 }
1426
1427 fn pin_group_tool() -> Tool {
1430 crate::MultiPinSelect::Selected {
1431 pins: vec![pin_id(3)],
1432 }
1433 .into()
1434 }
1435
1436 fn no_history() -> History {
1437 History::empty()
1438 }
1439
1440 fn available_for(scene: &mut Scene, tool: &Tool, history: History) -> CommandSet {
1441 let drawing = scene.drawing();
1442 CommandSet::available(&CommandContext {
1443 tool,
1444 data: &drawing,
1445 history,
1446 current_lock: InterfaceLock::Unlocked,
1447 writability: Writability::Writable,
1448 saving: blockworx_store::doc::Saving::Withheld,
1449 viewing: Viewing::Head,
1450 })
1451 }
1452
1453 fn position(set: &CommandSet, id: CommandId) -> usize {
1454 set.iter()
1455 .position(|c| c.id == id)
1456 .unwrap_or_else(|| panic!("{id:?} not in the set"))
1457 }
1458
1459 #[test]
1464 fn rising_a_level_is_offered_everywhere_but_the_document_root() {
1465 let tool: Tool = crate::SelectTool.into();
1466 let offered = |scene: &mut Scene, writability| {
1467 let drawing = scene.drawing();
1468 CommandSet::available(&CommandContext {
1469 tool: &tool,
1470 data: &drawing,
1471 history: no_history(),
1472 current_lock: InterfaceLock::Unlocked,
1473 writability,
1474 saving: blockworx_store::doc::Saving::Withheld,
1475 viewing: Viewing::Head,
1476 })
1477 .contains(CommandId::GoUp)
1478 };
1479 let mut inside = scene_with_block().inside(block_id(1));
1480 assert!(offered(&mut inside, Writability::Writable));
1481 assert!(
1482 offered(&mut inside, Writability::ReadOnly),
1483 "navigation writes nothing, so a reader may still rise",
1484 );
1485 let mut root = scene_with_block();
1486 assert!(!offered(&mut root, Writability::Writable));
1487 assert!(!offered(&mut root, Writability::ReadOnly));
1488 }
1489
1490 #[test]
1495 fn nothing_writes_the_document_through_the_lens() {
1496 let mut scene = scene_with_block();
1497 let tool: Tool = crate::SelectTool.into();
1498 let mut offered = |writability, viewing| {
1499 let drawing = scene.drawing();
1500 CommandSet::available(&CommandContext {
1501 tool: &tool,
1502 data: &drawing,
1503 history: no_history(),
1504 current_lock: InterfaceLock::Unlocked,
1505 writability,
1506 saving: blockworx_store::doc::Saving::Withheld,
1507 viewing,
1508 })
1509 };
1510 let past = Viewing::Past(blockworx_doc::fixtures::rev(2));
1511 assert!(
1512 offered(Writability::Writable, Viewing::Head).contains(CommandId::Import),
1513 "precondition: the writable present offers a command that writes",
1514 );
1515 let under_the_lens: Vec<CommandId> = offered(Writability::Writable, past)
1516 .iter()
1517 .map(|command| command.id)
1518 .collect();
1519 assert!(
1520 !under_the_lens.is_empty(),
1521 "the lens withheld everything, reading included",
1522 );
1523 for id in under_the_lens {
1524 assert!(
1525 !id.writes_the_document(),
1526 "{id:?} writes the document, and it is offered under the lens",
1527 );
1528 }
1529 }
1530
1531 #[test]
1534 fn entering_a_block_needs_a_block_selected() {
1535 let mut scene = scene_with_block();
1536 let nothing: Tool = crate::SelectTool.into();
1537 assert!(
1538 !available_for(&mut scene, ¬hing, no_history()).contains(CommandId::ExpandBlock),
1539 "nothing selected: there is no block to enter"
1540 );
1541 let block: Tool = ResizeBlock::Selected {
1542 shape: ShapeId::Rect(block_id(1)),
1543 }
1544 .into();
1545 assert!(available_for(&mut scene, &block, no_history()).contains(CommandId::ExpandBlock));
1546 }
1547
1548 #[test]
1552 fn only_a_selection_holding_a_block_offers_export() {
1553 let mut scene = scene_with_block();
1554 let port = pin_id(3);
1555 scene.apply(vec![fx::pin_at(
1556 3,
1557 Scope::Root,
1558 "io",
1559 fx::slot(PinSide::East, 0),
1560 Rect::from_min_max(pos2(0.0, 0.0), pos2(75.0, 30.0)),
1561 )]);
1562
1563 let block_tool: Tool = ResizeBlock::Selected {
1564 shape: ShapeId::Rect(block_id(1)),
1565 }
1566 .into();
1567 let set = available_for(&mut scene, &block_tool, no_history());
1568 assert!(set.contains(CommandId::ExportSelection(ExportFormat::Svg)));
1569
1570 let port_tool: Tool = ResizeBlock::Selected {
1571 shape: ShapeId::Port(port),
1572 }
1573 .into();
1574 let set = available_for(&mut scene, &port_tool, no_history());
1575 assert!(
1576 !set.contains(CommandId::ExportSelection(ExportFormat::Svg)),
1577 "a port is not a diagram to export"
1578 );
1579 assert!(set.contains(CommandId::Copy));
1581 assert!(set.contains(CommandId::Delete));
1582 }
1583
1584 #[test]
1585 fn a_selected_block_offers_the_block_verbs_in_overlay_order() {
1586 let mut scene = scene_with_block();
1587 let tool: Tool = ResizeBlock::Selected {
1588 shape: ShapeId::Rect(block_id(1)),
1589 }
1590 .into();
1591 let set = available_for(&mut scene, &tool, no_history());
1592 let order = [
1593 CommandId::Copy,
1594 CommandId::ExportSelection(ExportFormat::Svg),
1595 CommandId::Cut,
1596 CommandId::Accent,
1597 CommandId::ExpandBlock,
1598 CommandId::Lock,
1599 CommandId::AddIcon,
1600 CommandId::RerouteBlock,
1601 CommandId::FlipLr,
1602 CommandId::FlipUd,
1603 CommandId::Delete,
1604 ];
1605 for pair in order.windows(2) {
1606 assert!(
1607 position(&set, pair[0]) < position(&set, pair[1]),
1608 "{:?} should precede {:?}",
1609 pair[0],
1610 pair[1]
1611 );
1612 }
1613 assert!(!set.contains(CommandId::Unlock));
1614 assert!(!set.contains(CommandId::PinType));
1615 assert!(!set.contains(CommandId::Reroute));
1616 }
1617
1618 #[test]
1622 fn add_icon_asks_for_an_image_for_the_block_it_was_offered_for() {
1623 let mut scene = scene_with_block();
1624 let block = block_id(1);
1625 let tool: Tool = ResizeBlock::Selected {
1626 shape: ShapeId::Rect(block),
1627 }
1628 .into();
1629 let mut set = available_for(&mut scene, &tool, no_history());
1630
1631 match set.take(CommandId::AddIcon) {
1632 Some(Act::Effect(Effect::AddIcon(target))) => {
1633 assert_eq!(target, block, "the icon was asked for the wrong block");
1634 }
1635 other => panic!(
1636 "Add icon asked for {}",
1637 other.as_ref().map_or("nothing", act_name)
1638 ),
1639 }
1640 }
1641
1642 #[test]
1643 fn a_locked_block_swaps_lock_for_unlock_but_keeps_delete() {
1644 let mut scene = scene_with_block();
1645 scene.apply(vec![fx::locked(1)]);
1646 let tool: Tool = ResizeBlock::Selected {
1647 shape: ShapeId::Rect(block_id(1)),
1648 }
1649 .into();
1650 let set = available_for(&mut scene, &tool, no_history());
1651 assert!(set.contains(CommandId::Unlock));
1652 assert!(!set.contains(CommandId::Lock));
1653 assert_eq!(set.get(CommandId::Unlock).unwrap().label, "Unlock pins");
1654 assert!(set.contains(CommandId::Delete));
1656 assert!(set.contains(CommandId::Cut));
1657 }
1658
1659 #[test]
1660 fn a_locked_pin_selection_loses_cut_delete_and_io() {
1661 let mut scene = scene_with_block();
1662 scene.apply(vec![fx::pin_tag_shown(2), fx::locked(1)]);
1666 let tool: Tool = SelectPin::Selected { anchor: pin_id(2) }.into();
1667 let set = available_for(&mut scene, &tool, no_history());
1668 assert!(set.contains(CommandId::Copy));
1669 assert!(set.contains(CommandId::HideTags));
1673 assert!(!set.contains(CommandId::Cut));
1674 assert!(!set.contains(CommandId::Delete));
1675 assert!(!set.contains(CommandId::PinType));
1676 }
1677
1678 #[test]
1679 fn arming_add_port_respects_the_current_lock() {
1680 assert!(arm_available(ToolName::AddPort, InterfaceLock::Unlocked));
1681 assert!(!arm_available(ToolName::AddPort, InterfaceLock::Locked));
1682 assert!(arm_available(ToolName::Route, InterfaceLock::Locked));
1683
1684 let mut scene = scene_with_block();
1685 let tool: Tool = crate::SelectTool.into();
1686 let drawing = scene.drawing();
1687 let set = CommandSet::available(&CommandContext {
1688 tool: &tool,
1689 data: &drawing,
1690 history: no_history(),
1691 current_lock: InterfaceLock::Locked,
1692 writability: Writability::Writable,
1693 saving: blockworx_store::doc::Saving::Withheld,
1694 viewing: Viewing::Head,
1695 });
1696 assert!(!set.contains(CommandId::Arm(ToolName::AddPort)));
1697 assert!(set.contains(CommandId::Arm(ToolName::Route)));
1698 }
1699
1700 #[test]
1704 fn a_read_only_session_offers_only_the_commands_that_write_nothing() {
1705 let mut scene = scene_with_block();
1706 let tool = block_tool();
1707 let drawing = scene.drawing();
1708 let set = CommandSet::available(&CommandContext {
1709 tool: &tool,
1710 data: &drawing,
1711 history: History::doc(),
1712 current_lock: InterfaceLock::Unlocked,
1713 writability: Writability::ReadOnly,
1714 saving: blockworx_store::doc::Saving::Withheld,
1715 viewing: Viewing::Head,
1716 });
1717 for withheld in [
1718 CommandId::Undo,
1719 CommandId::Redo,
1720 CommandId::Cut,
1721 CommandId::Delete,
1722 CommandId::Rename,
1723 CommandId::Accent,
1724 CommandId::SetAccent(Some(3)),
1725 CommandId::Lock,
1726 CommandId::Import,
1727 CommandId::Nudge(Heading::Left),
1728 CommandId::Arm(ToolName::NewBlock),
1729 CommandId::Arm(ToolName::Route),
1730 ] {
1731 assert!(
1732 !set.contains(withheld),
1733 "{withheld:?} is invocable on a read-only container",
1734 );
1735 }
1736 for kept in [
1737 CommandId::Arm(ToolName::Select),
1738 CommandId::Copy,
1739 CommandId::ExpandBlock,
1740 CommandId::ZoomIn,
1741 CommandId::FitView,
1742 CommandId::Export(ExportFormat::Svg),
1743 CommandId::ExportSelection(ExportFormat::Svg),
1744 ] {
1745 assert!(
1746 set.contains(kept),
1747 "{kept:?} writes nothing but was withheld",
1748 );
1749 }
1750 }
1751
1752 #[test]
1754 fn a_selected_shape_is_nudged_by_name_every_way() {
1755 let mut scene = scene_with_block();
1756 let set = available_for(&mut scene, &block_tool(), no_history());
1757 for heading in Heading::ALL {
1758 let id = CommandId::Nudge(heading);
1759 assert!(set.contains(id), "{id:?} not offered to a selected block");
1760 assert!(
1761 set.iter_all().any(|cmd| cmd.id == id),
1762 "{id:?} missing from the registry a front end reads chords against",
1763 );
1764 assert!(
1765 !set.iter_drawn().any(|cmd| cmd.id == id),
1766 "{id:?} drew a control"
1767 );
1768 }
1769 }
1770
1771 #[test]
1776 fn a_withheld_command_is_drawn_but_cannot_be_invoked() {
1777 let mut scene = scene_with_block();
1778 let tool = block_tool();
1779 let drawing = scene.drawing();
1780 let mut set = CommandSet::available(&CommandContext {
1781 tool: &tool,
1782 data: &drawing,
1783 history: History::doc(),
1784 current_lock: InterfaceLock::Unlocked,
1785 writability: Writability::ReadOnly,
1786 saving: blockworx_store::doc::Saving::Withheld,
1787 viewing: Viewing::Head,
1788 });
1789 let delete = set
1790 .iter_drawn()
1791 .find(|cmd| cmd.id == CommandId::Delete)
1792 .expect("a selected block draws its Delete control even read-only");
1793 assert!(delete.withheld());
1794 assert!(
1795 !set.iter().any(|cmd| cmd.id == CommandId::Delete),
1796 "a withheld command leaked into the invocable view",
1797 );
1798 let name = CommandId::Delete.name();
1799 assert!(set.take_by_name(name).is_none(), "taken by name");
1800 assert!(set.take(CommandId::Delete).is_none(), "taken by id");
1801 }
1802
1803 #[test]
1807 fn every_document_mutating_command_declares_that_it_writes() {
1808 for (id, _, _) in ACCENTS {
1809 assert!(id.writes_the_document(), "{id:?}");
1810 }
1811 for (id, _, _) in PIN_DIRS {
1812 assert!(id.writes_the_document(), "{id:?}");
1813 }
1814 for id in [
1815 CommandId::Delete,
1816 CommandId::Cut,
1817 CommandId::FlipLr,
1818 CommandId::FlipUd,
1819 CommandId::Lock,
1820 CommandId::Unlock,
1821 CommandId::Reroute,
1822 CommandId::RerouteBlock,
1823 CommandId::ShowTags,
1824 CommandId::HideTags,
1825 ] {
1826 assert!(id.writes_the_document(), "{id:?}");
1827 }
1828 }
1829
1830 #[test]
1831 fn undo_and_redo_follow_the_history() {
1832 let mut scene = scene_with_block();
1833 let tool: Tool = crate::SelectTool.into();
1834 let set = available_for(
1835 &mut scene,
1836 &tool,
1837 History {
1838 undo: Some(crate::history::Kind::Doc),
1839 redo: None,
1840 },
1841 );
1842 assert!(set.contains(CommandId::Undo));
1843 assert!(!set.contains(CommandId::Redo));
1844 assert!(set.contains(CommandId::Import));
1845 }
1846
1847 #[test]
1851 fn a_rename_on_the_stack_is_withheld_wherever_writing_is() {
1852 let mut scene = scene_with_block();
1853 let tool: Tool = crate::SelectTool.into();
1854 let renames = History {
1855 undo: Some(crate::history::Kind::Rename),
1856 redo: Some(crate::history::Kind::Rename),
1857 };
1858 let mut offers = |writability, viewing| {
1859 let drawing = scene.drawing();
1860 let set = CommandSet::available(&CommandContext {
1861 tool: &tool,
1862 data: &drawing,
1863 history: renames,
1864 current_lock: InterfaceLock::Unlocked,
1865 writability,
1866 saving: blockworx_store::doc::Saving::Withheld,
1867 viewing,
1868 });
1869 (set.contains(CommandId::Undo), set.contains(CommandId::Redo))
1870 };
1871 assert_eq!(
1872 offers(Writability::Writable, Viewing::Head),
1873 (true, true),
1874 "precondition: a writable present offers the rename both ways",
1875 );
1876 assert_eq!(
1877 offers(Writability::ReadOnly, Viewing::Head),
1878 (false, false),
1879 "a read-only container offered to rename itself",
1880 );
1881 assert_eq!(
1882 offers(
1883 Writability::Writable,
1884 Viewing::Past(blockworx_doc::fixtures::rev(1))
1885 ),
1886 (false, false),
1887 "the lens offered to rename the document",
1888 );
1889 }
1890
1891 #[test]
1892 fn take_consumes_the_action() {
1893 let mut scene = scene_with_block();
1894 let tool: Tool = crate::SelectTool.into();
1895 let mut set = available_for(&mut scene, &tool, no_history());
1896 assert!(matches!(
1897 set.take(CommandId::Import),
1898 Some(Act::Effect(Effect::Import))
1899 ));
1900 assert!(set.take(CommandId::Import).is_none());
1901 }
1902
1903 #[test]
1904 fn a_selection_offers_its_label_editors() {
1905 let mut scene = scene_with_block();
1906 let pin = pin_id(2);
1907 let tool: Tool = ResizeBlock::Selected {
1908 shape: ShapeId::Rect(block_id(1)),
1909 }
1910 .into();
1911 let set = available_for(&mut scene, &tool, no_history());
1912 assert!(set.contains(CommandId::Rename));
1913 assert!(!set.contains(CommandId::Retype));
1914
1915 let tool: Tool = SelectPin::Selected { anchor: pin }.into();
1916 let set = available_for(&mut scene, &tool, no_history());
1917 for id in [CommandId::Rename, CommandId::Retype, CommandId::RenameTag] {
1918 assert!(set.contains(id), "{id:?} missing for a pin selection");
1919 }
1920
1921 scene.apply(vec![fx::locked(1)]);
1923 let tool: Tool = SelectPin::Selected { anchor: pin }.into();
1924 let set = available_for(&mut scene, &tool, no_history());
1925 for id in [CommandId::Rename, CommandId::Retype, CommandId::RenameTag] {
1926 assert!(!set.contains(id), "{id:?} offered on a locked pin");
1927 }
1928 }
1929
1930 #[test]
1931 fn every_toolbar_tool_is_bound_and_no_chord_is_shared() {
1932 let mut chords = std::collections::HashSet::new();
1933 let mut bound = std::collections::HashSet::new();
1934 for (chord, id) in BINDINGS {
1935 assert!(
1936 chords.insert(format!("{chord:?}")),
1937 "chord {chord:?} bound twice"
1938 );
1939 match id {
1940 CommandId::Arm(tool) => {
1941 assert!(
1942 crate::names::on_the_band(*tool),
1943 "{tool:?} is not on the band"
1944 );
1945 bound.insert(*tool);
1946 }
1947 CommandId::AddImage => {
1950 bound.insert(ToolName::NewImage);
1951 }
1952 CommandId::ZoomIn
1958 | CommandId::ZoomOut
1959 | CommandId::FitView
1960 | CommandId::Undo
1961 | CommandId::Redo
1962 | CommandId::Search
1963 | CommandId::Nudge(_) => {}
1965 other => panic!(
1966 "{other:?} is bound but is neither a cell, a view command nor a history step"
1967 ),
1968 }
1969 }
1970 for tool in band_tools() {
1972 assert!(bound.contains(&tool), "{tool:?} has no key binding");
1973 }
1974 }
1975
1976 #[test]
1979 fn the_view_commands_are_always_available_and_bound() {
1980 let mut scene = scene_with_block();
1981 let tool: Tool = crate::SelectTool.into();
1982 let set = available_for(&mut scene, &tool, no_history());
1983 for id in [CommandId::ZoomIn, CommandId::ZoomOut, CommandId::FitView] {
1984 assert!(set.contains(id), "{id:?} missing with nothing selected");
1985 assert!(binding(id).is_some(), "{id:?} has no chord");
1986 }
1987 }
1988
1989 fn every_id() -> Vec<CommandId> {
1991 let mut ids: Vec<CommandId> = band_tools().map(band_command).collect();
1992 ids.extend([
1993 CommandId::Undo,
1994 CommandId::Redo,
1995 CommandId::Copy,
1996 CommandId::Cut,
1997 CommandId::HideTags,
1998 CommandId::ShowTags,
1999 CommandId::Rename,
2000 CommandId::RenameType,
2001 CommandId::Retype,
2002 CommandId::RenameTag,
2003 CommandId::EditText,
2004 CommandId::FlipLr,
2005 CommandId::FlipUd,
2006 CommandId::PinType,
2007 CommandId::Accent,
2008 CommandId::Reroute,
2009 CommandId::AddRouteLabel,
2010 CommandId::ExpandBlock,
2011 CommandId::GoUp,
2012 CommandId::Lock,
2013 CommandId::Unlock,
2014 CommandId::AddIcon,
2015 CommandId::RerouteBlock,
2016 CommandId::Delete,
2017 CommandId::Import,
2018 ]);
2019 for &format in ExportScope::Selection.formats() {
2020 ids.push(CommandId::ExportSelection(format));
2021 }
2022 for &format in ExportScope::View.formats() {
2023 ids.push(CommandId::Export(format));
2024 }
2025 for (id, _, _) in ACCENTS {
2026 ids.push(id);
2027 }
2028 for (id, _, _) in PIN_DIRS {
2029 ids.push(id);
2030 }
2031 ids
2032 }
2033
2034 #[test]
2038 fn by_name_only_commands_resolve_but_are_not_offered() {
2039 let mut scene = scene_for_effects();
2040 let tool = block_tool();
2041 let set = available_for(&mut scene, &tool, no_history());
2042 assert!(
2043 set.contains(CommandId::SetAccent(Some(3))),
2044 "accent-3 is not in the set for a block selection",
2045 );
2046 assert!(
2047 !set.iter().any(|c| c.id == CommandId::SetAccent(Some(3))),
2048 "accent-3 was rendered as a control; nine colours would bury the bar",
2049 );
2050 assert!(
2051 set.iter().any(|c| c.id == CommandId::Accent),
2052 "the picker that opens them is still a control",
2053 );
2054
2055 let mut set = available_for(&mut scene, &tool, no_history());
2056 assert!(
2057 set.take_by_name("accent-3").is_some(),
2058 "a script naming accent-3 must resolve it",
2059 );
2060 }
2061
2062 #[test]
2065 fn the_pre_rename_comment_spelling_still_arms_the_area_tool() {
2066 let mut scene = scene_for_effects();
2067 let tool = block_tool();
2068 assert_eq!(
2069 CommandId::Arm(ToolName::NewArea).name(),
2070 "area",
2071 "the tool's own spelling is the new one",
2072 );
2073 let set = available_for(&mut scene, &tool, no_history());
2074 assert!(
2075 set.contains(CommandId::Arm(ToolName::NewArea)),
2076 "the area tool is armable in this scene",
2077 );
2078
2079 let mut set = available_for(&mut scene, &tool, no_history());
2080 let armed = set.take_by_name("comment");
2081 assert!(
2082 matches!(armed, Some(Act::Edit(Action::Arm(ToolName::NewArea)))),
2083 "a script naming `comment` must arm the area tool",
2084 );
2085 }
2086
2087 #[test]
2090 fn command_names_are_unique_kebab_case_spellings() {
2091 let mut seen = std::collections::HashSet::new();
2092 for id in every_id() {
2093 let name = id.name();
2094 assert!(seen.insert(name), "duplicate command name {name}");
2095 assert!(
2096 name.chars()
2097 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
2098 "{name} is not kebab-case"
2099 );
2100 }
2101 }
2102 fn command_authored(scene: &mut Scene, tool: &Tool, id: CommandId, writes: &str) -> bool {
2112 let action = available_for(scene, tool, no_history())
2113 .take(id)
2114 .unwrap_or_else(|| {
2115 let have: Vec<CommandId> = available_for(scene, tool, no_history())
2116 .iter()
2117 .map(|command| command.id)
2118 .collect();
2119 panic!("{id:?} is not offered on its fixture; offered: {have:?}")
2120 });
2121 let before = scene.doc.clone();
2122 let (outcome, narrated) = scene.authored(|drawing| apply_scripted(action, drawing));
2123 assert!(
2124 matches!(outcome, ScriptedApply::Applied(_)),
2125 "{id:?} was handed back by the scripted dispatch instead of applied",
2126 );
2127 if !narrated.iter().any(|line| line.starts_with(writes)) {
2133 return false;
2134 }
2135 assert_ne!(
2136 scene.doc.clone(),
2137 before,
2138 "{id:?} pushed ops that folded to no change",
2139 );
2140 true
2141 }
2142
2143 #[test]
2149 fn every_document_mutating_command_writes_the_document() {
2150 type Case = (CommandId, fn() -> Tool, &'static str);
2153 let cases: Vec<Case> = vec![
2154 (CommandId::Delete, block_tool, "block"),
2155 (CommandId::FlipLr, block_tool, "pin"),
2156 (CommandId::FlipUd, block_tool, "pin"),
2157 (CommandId::Lock, block_tool, "block"),
2158 (CommandId::RerouteBlock, block_tool, "route"),
2159 (CommandId::Reroute, route_tool, "route"),
2160 (CommandId::ShowTags, pin_group_tool, "pin"),
2161 (CommandId::SetAccent(Some(3)), block_tool, "block"),
2164 (CommandId::SetPinDir(PinDir::Output), pin_group_tool, "pin"),
2165 ];
2166 for (id, tool_of, writes) in cases {
2167 let mut scene = scene_for_effects();
2168 assert!(
2169 command_authored(&mut scene, &tool_of(), id, writes),
2170 "{id:?} is offered by the registry but authored no {writes} edit",
2171 );
2172 }
2173 }
2174
2175 #[test]
2179 fn the_reverse_of_each_toggle_writes_the_document_too() {
2180 for (first, second, tool_of, writes) in [
2181 (
2182 CommandId::Lock,
2183 CommandId::Unlock,
2184 block_tool as fn() -> Tool,
2185 "block",
2186 ),
2187 (
2188 CommandId::ShowTags,
2189 CommandId::HideTags,
2190 pin_group_tool as fn() -> Tool,
2191 "pin",
2192 ),
2193 ] {
2194 let mut scene = scene_for_effects();
2195 let tool = tool_of();
2196 for id in [first, second] {
2197 assert!(
2198 command_authored(&mut scene, &tool, id, writes),
2199 "{id:?} authored no {writes} edit, so the toggle only works one way",
2200 );
2201 }
2202 }
2203 }
2204}