Skip to main content

blockworx/tools/
commands.rs

1//! The command registry: every invocable operation as a [`Command`] — a stable
2//! [`CommandId`], a user-facing label, and the [`Action`] it dispatches —
3//! computed against the current selection and mode by [`CommandSet::available`].
4//! The overlays render the set instead of re-deriving availability, so "what
5//! can the user do right now" is encoded exactly once; the planned command
6//! palette, keyboard shortcuts, and the script `command` step consume the same
7//! set.
8//!
9//! Toggle pairs (lock/unlock, hide/show tags) are distinct ids: the direction
10//! that doesn't apply is simply absent, so a consumer can never invoke the
11//! wrong half.
12
13use 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/// What the undo/redo controls stand over this frame: which entry each would
31/// take, and of which of spec §7.1's two kinds. `None` is an empty stack —
32/// the button draws dead rather than vanishing (R19).
33///
34/// The kind is load-bearing, not decoration: a `view` entry writes nothing,
35/// so it survives the read-only lens that withholds every authoring command
36/// (§7.2).
37#[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    /// Nothing to take back either way.
45    #[cfg(test)]
46    pub fn empty() -> Self {
47        Self::default()
48    }
49
50    /// An edit either way — the stack a session that has been editing holds.
51    #[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    /// A camera move either way, which costs the log nothing.
60    #[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/// Stable identity of one invocable operation.
70#[derive(Clone, Copy, PartialEq, Eq, Debug)]
71pub enum CommandId {
72    /// Arm a toolbar tool.
73    Arm(ToolName),
74    Undo,
75    Redo,
76    Copy,
77    Cut,
78    /// Export the current selection as a standalone diagram.
79    ExportSelection(ExportFormat),
80    HideTags,
81    ShowTags,
82    /// Open the in-place editor on the selection's primary label — a shape's
83    /// title, or a pin/port's name.
84    Rename,
85    /// Open the in-place editor on a block's type label.
86    RenameType,
87    /// Open the in-place editor on a pin/port's type label.
88    Retype,
89    /// Open the in-place editor on a pin/port's tag.
90    RenameTag,
91    /// Open a text box's in-place editor.
92    EditText,
93    FlipLr,
94    FlipUd,
95    /// Open the pin-type (I/O) picker for the selected pins.
96    PinType,
97    /// Open the accent-color picker for the selection.
98    Accent,
99    Reroute,
100    /// One accent value, by name — the picker's cells as commands.
101    SetAccent(Option<u8>),
102    /// One I/O direction, by name.
103    SetPinDir(PinDir),
104    AddRouteLabel,
105    ExpandBlock,
106    GoUp,
107    ZoomIn,
108    ZoomOut,
109    /// Frame the whole level in view — the double-click gesture, by name.
110    FitView,
111    Lock,
112    Unlock,
113    AddIcon,
114    RerouteBlock,
115    Delete,
116    /// Export the current view.
117    Export(ExportFormat),
118    Import,
119    /// Refresh the attached container's `document.json` (D11).
120    Save,
121}
122
123impl CommandId {
124    /// The stable spelling consumers type: the palette matches against it, and
125    /// the planned shortcut table names commands by it.
126    pub fn name(self) -> &'static str {
127        match self {
128            // Total despite non-toolbar ToolNames existing: the registry arms
129            // only toolbar tools, all of which carry a command spelling.
130            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            // Unreachable through `ACCENTS`, which is the only source of
182            // these ids; spelled rather than panicked so the name stays total.
183            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    /// Whether invoking this command writes the document — the one list a
191    /// read-only session withholds, so an editing command is *uninvocable*
192    /// there rather than offered and refused. Arming an editing tool counts:
193    /// a tool that cannot commit what it draws is a worse answer than a
194    /// button drawn dead, which is what the toolbar shows instead.
195    ///
196    /// Going up a level is navigation and stays — wholly, now that its one
197    /// writing arm (wrapping the document when there is nowhere above to
198    /// go) is gone; at the root the command is simply not offered.
199    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            // Save writes a *file* beside the log, never the log — it is
227            // gated on there being a writable container instead, which
228            // read-only already fails.
229            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
242/// One available operation: its identity, the label/hover text a view shows,
243/// and the [`Action`] invoking it dispatches.
244pub struct Command {
245    pub id: CommandId,
246    pub label: &'static str,
247    pub action: Action,
248    /// Where this command rides in the selection overlay. Set once, here, so
249    /// the bar and the right-click menu read one policy.
250    pub placement: Placement,
251    rendered: Rendered,
252    authoring: Authoring,
253}
254
255/// Where a selection command rides in the overlay: in the bar's row, or
256/// behind its ellipsis.
257///
258/// The user chose the row by name: *"For sure the primary actions should be
259/// 'Accent, Expand, Icon, Lock, Rip, flip lr, and flip ud'. The 'copy/cut/
260/// delete' can all be put into the overflow menu, as can the 'export to
261/// svg'."* — and, of the port's own controls, *"The selection overlay for the
262/// port should also put the copy/cut/delete stuff behind the extension, and
263/// use the toolbar for the other controls."*
264///
265/// This replaces §3.5's split-at-five, which cut the list wherever the fifth
266/// command happened to fall — so whether Delete sat in the row or behind the
267/// ellipsis depended on how many verbs the selected thing answered to. Where
268/// a verb rides is a property of the verb, not of how many neighbours it
269/// turned out to have (R35).
270///
271/// Orthogonal to applicability: a command this selection does not offer is
272/// simply absent, whichever partition it belongs to.
273#[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            // Three the user's list did not name because they had no icons
292            // to name them by: a route's label, a pin's tag and a port's I/O
293            // direction. They are the selected thing's own verbs the same
294            // way Accent and Lock are, and items 26 and 27 gave the last two
295            // glyphs so they can ride in the row like the rest.
296            | CommandId::AddRouteLabel
297            | CommandId::HideTags
298            | CommandId::ShowTags
299            | CommandId::PinType => Placement::Inline,
300            // Copy, Cut, Delete, Export — the clerical verbs, which every
301            // selection has and none is *about* — and everything that is not
302            // in the overlay at all.
303            _ => Placement::Overflow,
304        }
305    }
306}
307
308impl Command {
309    /// Whether this session withholds the command. A withheld command is
310    /// uninvocable — every taking door skips it — but it is still *known*,
311    /// so a surface can draw its control disabled instead of leaving a
312    /// hole (the familiar paradigm, per docs/ui-issues.md's follow-up).
313    pub fn withheld(&self) -> bool {
314        self.authoring.is_withheld()
315    }
316}
317
318/// Whether a command appears as a control, or only answers to its name.
319#[derive(Clone, Copy, PartialEq, Eq, Debug)]
320enum Rendered {
321    AsAButton,
322    ByNameOnly,
323}
324
325/// Everything [`CommandSet::available`] resolves availability against.
326pub struct CommandContext<'a, 'b> {
327    pub tool: &'a Tool,
328    pub data: &'a Drawing<'b>,
329    pub history: History,
330    /// Whether the block currently viewed keeps its interface frozen — gates
331    /// arming the Add Port tool.
332    pub current_lock: InterfaceLock,
333    /// Whether this session may write the document *at all* — a container
334    /// another process holds, or one whose log will not verify, withholds
335    /// every command in [`CommandId::writes_the_document`]. The time
336    /// machine is the other half of that question and rides in `viewing`.
337    pub writability: Writability,
338    /// Whether there is a projection to refresh — gates [`CommandId::Save`]
339    /// and nothing else.
340    pub saving: Saving,
341    /// Which document is on the canvas. A past rev is read-only through
342    /// `writability`, like any other read-only session.
343    pub viewing: Viewing,
344}
345
346/// What the explicit projection refresh is called wherever it is offered.
347/// Not "Save": there is no save action, and no UI may imply one (invariant
348/// 10, playbook R6). What it actually does is rewrite `document.json`, the
349/// readable projection beside the log (D11).
350pub const REFRESH_PROJECTION: &str = "Refresh document.json";
351
352/// The chord for `key` with the platform command modifier (Ctrl; ⌘ on mac).
353const fn chord(key: egui::Key) -> egui::KeyboardShortcut {
354    egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, key)
355}
356
357/// A bare key, no modifier — how the tool band's digits are pressed (spec §2).
358const fn digit(key: egui::Key) -> egui::KeyboardShortcut {
359    egui::KeyboardShortcut::new(egui::Modifiers::NONE, key)
360}
361
362/// The chord → command bindings, one table: the app's shortcut pass consumes
363/// it and the views print it (tool-band hover text, palette rows), so a chord
364/// can never disagree with what it dispatches. Toolbar arming for now;
365/// selection verbs join here when they earn chords.
366///
367/// Each band tool answers to two keys: the digit of its place in the row, and
368/// the mnemonic chord it has always had (playbook R8). The digits are bare
369/// keys, which is safe because the whole table is only consulted when no text
370/// field holds the keyboard.
371pub 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    // Zoom in is bound twice: `+` is a shifted key on most layouts, so the
387    // unshifted `=` in its place is the chord people actually press.
388    (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
394/// The chord bound to `id`, if any — for the views that print one.
395pub fn binding(id: CommandId) -> Option<&'static egui::KeyboardShortcut> {
396    chords(id).next()
397}
398
399/// Every chord bound to `id`, in table order — for the views that print them
400/// all, so a tool with two keys advertises both.
401pub 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
408/// Consume any bound chord from the frame's input, returning its command. A
409/// reserved chord is consumed even when its command is currently unavailable
410/// (a locked block's Add Port), so it can't leak into other handlers.
411pub 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
418/// Whether arming `tool` is currently allowed: adding a port inside a locked
419/// block is the one refusal — the viewed interface is frozen. Private
420/// because the views ask the resolved [`CommandSet`] instead, so a control
421/// and the command behind it cannot disagree.
422fn arm_available(tool: ToolName, current_lock: InterfaceLock) -> bool {
423    !(current_lock.is_locked() && tool == ToolName::AddPort)
424}
425
426/// The frame's available commands, in the canonical presentation order the
427/// selection overlay renders them in.
428pub struct CommandSet {
429    available: Vec<Command>,
430    /// Applied by [`Self::push`], so the withholding happens once instead of
431    /// at each of the thirty-odd sites that offer a command.
432    writability: Writability,
433    viewing: Viewing,
434    /// Which kind of entry undo and redo stand over, since that is what says
435    /// whether they write (§7.1).
436    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        // Rising a level is navigation, and above the document root there
466        // is nowhere to rise to — so the toolbar's Up draws dead there
467        // rather than doing something else instead (it used to wrap the
468        // whole document in a new top: docs/ui-issues-2.md, item 8). It
469        // lives on the toolbar rather than the selection overlay, so it is
470        // not ordered among the selection verbs.
471        if ctx.data.current_scope() != crate::path::Scope::Root {
472            set.push(CommandId::GoUp, "Go up a level", Action::GoUp);
473        }
474        // View commands: always available, and reachable by chord or palette
475        // rather than by any button.
476        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            // Exporting a selection writes it out as a standalone diagram, so
508            // it needs a block: a lone port, area, or text box is an
509            // annotation on someone else's diagram, not one of its own.
510            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            // "Hide Tags" only when every selected tag is visible; a hidden or
532            // mixed group reads "Show Tags".
533            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        // Only the pin-bearing shapes have anything to mirror.
562        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        // A locked block keeps its pin interface frozen, so the I/O (retype)
573        // control is withheld when any selected pin's owner is locked.
574        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        // Delete is withheld for a locked pin/port selection — locking protects
621        // the interface, not the block's existence, so whole-block Delete (and
622        // every other selection's) is unaffected.
623        if !delete_blocked_by_lock(sel, data) {
624            self.push(CommandId::Delete, "Delete", Action::Delete(sel.clone()));
625        }
626    }
627
628    /// The in-place label editors the selection can open — the same tools the
629    /// double-click path arms. Palette-only: none has an overlay button.
630    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        // A single pin — or a port, whose labels are its one pin's — edits its
658        // own name/type/tag.
659        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    /// Available to name — a chord, the palette's text field, a script's
716    /// `command` step — but not rendered as a control. The accent and I/O
717    /// values are the case this exists for: nine colours and three
718    /// directions are a picker's job on screen, and nine more buttons would
719    /// bury the selection bar, but each still has to be *sayable* or a script
720    /// could not set one.
721    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    /// The one gate every command passes through. A read-only session marks
726    /// the writing half of the registry withheld here — not at the call
727    /// sites — and every door that *invokes* (a chord, the palette, a
728    /// script's `command` step, a click) skips a withheld command, so
729    /// keeping it around for the overlay to draw disabled cannot make it
730    /// reachable.
731    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    /// Whether `id` survives the frame's two read-only questions. They are
748    /// two questions — a container this session may not write, and a past
749    /// rev on the canvas — but since R37 they have one answer: nothing
750    /// writes the document through the lens. The way out of it is Return or
751    /// a Save-as, and a Save-as writes a *file*, never this log. Undo and
752    /// redo go with the rest *when they author* — they would act on the head
753    /// nobody is looking at — and stay when they do not (§7.2).
754    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    /// Whether `id` would write *this frame*. Undo and redo answer with the
762    /// kind of entry they stand over (§7.1): taking back a camera move
763    /// restores a camera and touches no log, so it survives both the lens and
764    /// a container this session may not write. Every other command answers
765    /// from its own identity.
766    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    /// The invocable commands to render. Everything else is reachable by
775    /// name through [`Self::take`].
776    pub fn iter(&self) -> impl Iterator<Item = &Command> {
777        self.iter_drawn().filter(|command| !command.withheld())
778    }
779
780    /// Every command that draws a control, the withheld ones included — the
781    /// selection overlay's view, which renders those disabled rather than
782    /// leaving a hole where a control was.
783    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    /// Consume `id`'s action for dispatch. At most one command fires per frame,
798    /// so taking moves the action out rather than cloning it. A withheld
799    /// command cannot be taken, however it was named.
800    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    /// [`CommandSet::take`] by the command's typeable name — how the script
809    /// `command` step names it.
810    #[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/// Pre-rename command spellings, still resolved so scripts written against
822/// them keep running.
823#[cfg(test)]
824fn legacy_command_name(name: &str) -> &str {
825    match name {
826        "comment" => "area",
827        other => other,
828    }
829}
830
831/// What became of an action offered to [`apply_scripted`]: applied against
832/// the drawing — carrying the tool the arm settles on, where it settles one
833/// — or handed back because it needs app machinery (clipboard, the undo
834/// stack, dialogs, navigation, pickers).
835pub enum ScriptedApply {
836    Applied(Option<Tool>),
837    NeedsApp(Box<Action>),
838}
839
840/// Apply the document-scoped subset of [`Action`] — the arms that write
841/// nothing but the drawing. The app's dispatcher routes these same arms
842/// here, so a scripted session and a live one cannot drift. The gesture the
843/// caller opened owns the sealing: every write lands in its sink, and the
844/// solve rider re-routes what these arms disturbed.
845pub fn apply_scripted(action: Action, drawing: &mut Drawing<'_>) -> ScriptedApply {
846    use crate::tools::SelectTool;
847    let settles_on = match action {
848        // The last gate on arming: a read-only session refuses a tool whose
849        // whole purpose is to write, whichever door asked for it — a chord,
850        // a script's `command` step, or another tool handing off.
851        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        // The same gate, for the cell's other gesture: a session that cannot
859        // arm a creator cannot drop one either, whichever door asked. The
860        // cell already draws dead, so this is the floor under that rather
861        // than the only refusal.
862        Action::StampTool { .. } if drawing.authoring() == Authoring::Withheld => None,
863        // An image has to be picked before there is anything to place, and
864        // the picker is the app's.
865        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        // Keep the pin selection so the label updates and a follow-up click
875        // can finish synchronizing a mixed group.
876        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        // Flipping moves pins to new sides, so re-approach the connected
885        // routes exactly like a drag does: drop the stale approach corners
886        // and let the rider re-route. The shape stays selected, so its bar
887        // stays up.
888        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        // The lock governs only future pin/port edits, so nothing re-routes;
899        // the block stays selected so its overlay (and lock toggle) stays.
900        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        // The selection stays, so the picker that asked stays open and a
909        // second colour can be tried without re-selecting.
910        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
927/// The pin anchors an I/O-style change would retype, or `None` for selections
928/// with no pins to retype. A pin or pin group maps to its anchors directly; a
929/// boundary port maps to its single port pin.
930fn 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
938/// The accent target a selection maps to, or `None` for selections that carry
939/// no accent (an image/icon shape, a multi-selection, or a pin group — the
940/// picker targets one role-bearing object).
941/// The accent values, as commands. The picker's own cells (`role_picker`'s
942/// `CELLS`): "no accent" plus the eight numbered ones, in the order they are
943/// drawn, so `accent-3` on screen and `accent-3` in a script name one colour.
944const 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
956/// The I/O directions, as commands, on the same terms as [`ACCENTS`].
957const 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
980/// Whether the Delete/Cut controls are withheld because the selection is a
981/// locked pin/port interface: any selected child-block pin whose owner is
982/// locked, or a boundary port of a locked current block.
983fn 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/// The [`Action`] variant's own name, for the failure message when a
992/// test dispatched something other than what it meant to.
993#[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        // The File flow is app chrome: a script has no dialogs to drive.
1034        #[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    /// Block `1` ("core") carrying its one West pin `2` — the selection every
1060    /// availability claim below is made against.
1061    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    /// Two wired blocks, each tall enough to offer several pin slots — so a
1074    /// route, a pin group and a block are all selectable, *and* a vertical
1075    /// flip has somewhere to move the pin to. A block one slot tall makes
1076    /// `FlipUd` an exact no-op, which the emitter correctly drops; the fixture
1077    /// has to give the command room to act or the test proves nothing.
1078    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    /// Ripping up one wire is a route verb, offered only to a route
1104    /// selection — which is the edit-route tool's, not the select tool's.
1105    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    /// The tag toggle is a pin-*group* verb, so it needs the group selection
1114    /// the multi-pin tool holds rather than the single-pin one.
1115    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    /// Rising a level is navigation, so a read-only session keeps it —
1146    /// but above the document root there is nowhere to rise to, and the
1147    /// command is simply not there, which draws the toolbar's Up button
1148    /// dead. It used to be offered everywhere and *wrap the document* at
1149    /// the root: a navigation button that quietly wrote a block
1150    /// (docs/ui-issues-2.md, item 8).
1151    #[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    /// R37 leaves the lens with no writing command at all: under it a
1179    /// writable container offers exactly what a read-only one does. Restore
1180    /// was the single exception, and the way out of the past is now Return
1181    /// or a Save-as — which writes a *file*, never this log.
1182    #[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    /// The corner cluster's Enter button is enabled by this command's presence,
1220    /// so it must appear only with a block selected.
1221    #[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, &nothing, 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    /// Exporting a selection writes a standalone diagram, so it is offered for
1237    /// a block but not for a port — which belongs to a diagram rather than
1238    /// being one.
1239    #[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        // The selection verbs it *does* have are untouched.
1268        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        // Locking freezes the interface, not the block's existence.
1319        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        // Tags start hidden in the doc fixtures, and which of the pair is
1327        // offered follows the tag's current state — show it, so the control
1328        // under test is "Hide Tags".
1329        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        // A lock freezes the pin interface, not what the pin's tag shows —
1334        // the emitter agrees (`edit::naming`'s
1335        // `set_tag_visibility_hides_a_group_and_a_locked_owner_does_not_stop_it`).
1336        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    /// A container this session may not write withholds every command that
1365    /// would write it — including the tools that draw. What is left is
1366    /// looking around: select, navigate, zoom, copy, export.
1367    #[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    /// A withheld command is still *drawn* — the overlay grays it out
1416    /// instead of leaving a hole — but no invoking door reaches it: not a
1417    /// click's `take`, not a script's `take_by_name`, not a view's
1418    /// `contains`. Known and untouchable are the same entry.
1419    #[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    /// Availability follows one list, so a new command has to declare which
1448    /// side of it it is on — and a *writing* command that claims to write
1449    /// nothing would sneak past the read-only gate.
1450    #[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        // A locked block freezes its pin labels, so the editors vanish.
1519        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                // The view commands carry no button: a chord and the palette
1545                // are their only routes.
1546                CommandId::ZoomIn | CommandId::ZoomOut | CommandId::FitView => {}
1547                other => panic!("{other:?} is bound but is neither a tool nor a view command"),
1548            }
1549        }
1550        // A new band tool must pick a chord (or explicitly opt out here).
1551        for tool in band_tools() {
1552            assert!(bound.contains(&tool), "{tool:?} has no key binding");
1553        }
1554    }
1555
1556    /// Zoom and fit depend on nothing in the document, so they are always in
1557    /// the set — which is what puts them in the palette.
1558    #[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            // Consumed: a second scan finds nothing.
1588            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    /// Every constructible id, for exhaustive name checks.
1598    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    /// A command that answers to its name but draws no control still has to
1643    /// be *reachable* by that name — that is the whole of its contract, and
1644    /// the scripted `command` step depends on it.
1645    #[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    /// The area tool was spelled `comment` before the rename, and scripted
1671    /// scripts written against that spelling still run.
1672    #[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    /// Digits are allowed since the accents became nameable (`accent-3`):
1696    /// the value is the point of the name, and spelling it out reads worse.
1697    #[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    /// Apply one command the way the scripted driver does, and report whether
1711    /// it authored anything.
1712    ///
1713    /// Deliberately not `Scene::commit`: that runs the gesture's solve rider,
1714    /// which re-solves routes and can change the document all by itself — so a
1715    /// content-hash assertion around it passes for a command that does nothing.
1716    /// (It did: neutering `SetBlockLocked` left the first draft of these tests
1717    /// green.) Measuring the gesture's own ops before the rider is what makes
1718    /// the claim about the command.
1719    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        // The kind of write, not merely *a* write: several arms ride a route
1736        // trim alongside their own edit, so "the gesture is non-empty" is
1737        // satisfied by the rider even when the command itself does nothing.
1738        // (It was: deleting `flip_shape_pins` from its arm left an earlier
1739        // draft of this green, because the trim beside it still authored.)
1740        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    /// Availability is only half of a command. The tests above prove *which*
1752    /// commands a selection offers; this one proves each document-mutating
1753    /// command, resolved from that same set and applied through the same
1754    /// [`apply_scripted`] the scripted driver uses, actually writes the
1755    /// document.
1756    #[test]
1757    fn every_document_mutating_command_writes_the_document() {
1758        /// A command, the selection that offers it, and the narration its
1759        /// *own* edit must produce.
1760        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            // The two rows that used to be written from inside a popup, and
1770            // so could not be driven at all. They are ordinary commands now.
1771            (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    /// A toggle that only works one way is the drift the paired ids exist to
1784    /// prevent, so each direction is driven from the state its partner leaves
1785    /// behind. The fixture's pins start tagged-hidden, hence show-then-hide.
1786    #[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}