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 [`Act`] invoking it performs —
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//! The registry answers permission and target, never flow: an [`Act::Effect`]
10//! says which picker or dialog the *shell* is to run and on what, and nothing
11//! about the steps of it passes through the core.
12//!
13//! Toggle pairs (lock/unlock, hide/show tags) are distinct ids: the direction
14//! that doesn't apply is simply absent, so a consumer can never invoke the
15//! wrong half.
16
17use 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/// The export formats offered in the toolbar.
37#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
38pub enum ExportFormat {
39    Svg,
40    Png,
41    Pdf,
42}
43
44/// Which surface a format list is offered on. The whole view exports the
45/// document, so it can carry the scope hierarchy PDF is made of; a selection
46/// is an excerpt of shapes with no hierarchy to navigate, so PDF is not
47/// offered there. The two lists live here rather than at the menus, so the
48/// registry and the menus cannot offer different formats.
49#[derive(Clone, Copy, PartialEq, Eq, Debug)]
50pub enum ExportScope {
51    View,
52    Selection,
53}
54
55impl ExportScope {
56    /// Menu order of the formats this surface offers.
57    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    /// The format whose command renders this surface's submenu button; the
65    /// rest are reached inside the menu.
66    pub fn leading_format(self) -> ExportFormat {
67        self.formats()[0]
68    }
69}
70
71impl ExportFormat {
72    /// Menu-entry label.
73    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/// What the undo/redo controls stand over this frame: which entry each would
91/// take, and of which of the two kinds. `None` is an empty stack — the button
92/// draws dead rather than vanishing.
93///
94/// The kind is load-bearing, not decoration: a `view` entry writes nothing,
95/// so it survives the read-only lens that withholds every authoring
96/// command.
97#[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/// The three stacks the suites above this crate build a [`CommandContext`]
104/// with. `cfg(test)` stops reaching across a crate line, so they are behind
105/// the same feature [`action_name`] is.
106#[cfg(any(test, feature = "test-support"))]
107impl History {
108    /// Nothing to take back either way.
109    pub fn empty() -> Self {
110        Self::default()
111    }
112
113    /// An edit either way — the stack a session that has been editing holds.
114    pub fn doc() -> Self {
115        Self {
116            undo: Some(crate::history::Kind::Doc),
117            redo: Some(crate::history::Kind::Doc),
118        }
119    }
120
121    /// A camera move either way, which costs the log nothing.
122    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/// Stable identity of one invocable operation.
131#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
132pub enum CommandId {
133    /// Arm a toolbar tool.
134    Arm(ToolName),
135    Undo,
136    Redo,
137    Copy,
138    Cut,
139    /// Export the current selection as a standalone diagram.
140    ExportSelection(ExportFormat),
141    HideTags,
142    ShowTags,
143    /// Open the in-place editor on the selection's primary label — a shape's
144    /// title, or a pin/port's name.
145    Rename,
146    /// Open the in-place editor on a block's type label.
147    RenameType,
148    /// Open the in-place editor on a pin/port's type label.
149    Retype,
150    /// Open the in-place editor on a pin/port's tag.
151    RenameTag,
152    /// Open a text box's in-place editor.
153    EditText,
154    FlipLr,
155    FlipUd,
156    /// Open the pin-type (I/O) picker for the selected pins.
157    PinType,
158    /// Open the accent-color picker for the selection.
159    Accent,
160    Reroute,
161    /// Toggle the author's diagnostic overlay: what the selection raises, the
162    /// bound the regional router heals it in, and the lattice there. Dev
163    /// tooling, reachable only by name.
164    ToggleDiagnostic,
165    /// Toggle the frame-rate readout: frames in the last second, and what the
166    /// last frame spent in the kernel and on the canvas. Dev tooling,
167    /// reachable only by name.
168    ToggleFrameRate,
169    /// One accent value, by name — the picker's cells as commands.
170    SetAccent(Option<u8>),
171    /// One I/O direction, by name.
172    SetPinDir(PinDir),
173    AddRouteLabel,
174    ExpandBlock,
175    GoUp,
176    ZoomIn,
177    ZoomOut,
178    /// Frame the whole level in view — the double-click gesture, by name.
179    FitView,
180    Lock,
181    Unlock,
182    AddIcon,
183    /// Pick artwork to place as an image of its own — the rail's image cell,
184    /// which arms no tool because there is none to arm.
185    AddImage,
186    RerouteBlock,
187    Delete,
188    /// Export the current view.
189    Export(ExportFormat),
190    Import,
191    /// Write the log, through the rev on the canvas, somewhere of its own.
192    Save,
193    /// Open the front end's search — the ⌘K palette of `docs/cad-ui-spec.md`
194    /// §9. It draws no control of its own: the chord and the palette are its
195    /// two doors, and which of them is open is the shell's own state.
196    Search,
197    /// Move the selection one grid cell — or a pin group one slot — the way
198    /// an arrow key points.
199    Nudge(Heading),
200}
201
202/// The way an arrow key points.
203#[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    /// A pin group slides along its edge, so only these move one.
214    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    /// The stable spelling consumers type: the palette matches against it, and
238    /// the planned shortcut table names commands by it.
239    pub fn name(self) -> &'static str {
240        match self {
241            // Total despite non-toolbar ToolNames existing: the registry arms
242            // only toolbar tools, all of which carry a command spelling.
243            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            // Unreachable through `ACCENTS`, which is the only source of
301            // these ids; spelled rather than panicked so the name stays total.
302            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    /// Whether invoking this command writes the document — the one list a
310    /// read-only session withholds, so an editing command is *uninvocable*
311    /// there rather than offered and refused. Arming an editing tool counts:
312    /// a tool that cannot commit what it draws is a worse answer than a
313    /// button drawn dead, which is what the toolbar shows instead.
314    ///
315    /// Going up a level is navigation and stays — wholly, now that its one
316    /// writing arm (wrapping the document when there is nowhere above to
317    /// go) is gone; at the root the command is simply not offered.
318    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            // Save writes a *file* beside the log, never the log — it is
348            // gated on there being a writable container instead, which
349            // read-only already fails.
350            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/// What invoking a command does. The registry answers *permission* — which
367/// commands this frame offers, and on what target — and never scripts a flow:
368/// what the core executes it hands to the core, and what the shell performs
369/// itself it hands back untouched.
370#[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/// An effect the front end performs: a picker window, a platform dialog, a
389/// download, a document door. Each carries everything the front end needs to
390/// run it, and the front end owns every substate of the flow it opens — what
391/// a cancel means included — sending what the flow ended in back as an
392/// ordinary [`Action`].
393#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
394pub enum Effect {
395    /// Pick artwork to wear as this block's icon.
396    AddIcon(BlockId),
397    /// Pick artwork to place as an image of its own.
398    AddImage,
399    /// Open the accent-color (role) picker for a block, port, route, area or
400    /// text box. The popup anchors itself above the selection overlay, so no
401    /// screen position is carried here.
402    Accent(RoleTarget),
403    /// Open the pin-type (I/O style) picker over the pins it would retype, on
404    /// the same anchoring terms as [`Effect::Accent`].
405    PinType(Vec<PinId>),
406    /// Open the import dialog, whose PNG or SVG is added as a plain image.
407    Import,
408    /// Open — or, when it already is, close — the front end's search
409    /// palette. Which of the two it comes to is the shell's, because whether
410    /// the palette is up is the shell's.
411    Search,
412    /// Close whatever is open and start a fresh unsaved session. With
413    /// append-on-commit there is nothing to prompt about: a container already
414    /// holds everything that was committed to it.
415    NewDocument,
416    /// Call this document something else, which renames its container: the
417    /// container's name *is* the document's name. Carries the name the user
418    /// typed, unvalidated — [`blockworx_store::storage::Name::of_document`]
419    /// is what says whether it is one.
420    RenameDocument(String),
421    /// Open the File menu's dialog for `request`; the pick comes back through
422    /// [`crate::file::PickReceiver`].
423    PickFile(crate::file::FileRequest),
424    /// Reopen a remembered document, by the reference the shell that
425    /// remembered it resolves.
426    OpenRecent(DocumentRef),
427}
428
429impl Effect {
430    /// The variant's own name, for the log line a host with no surface to
431    /// perform it on writes.
432    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/// One available operation: its identity, the label/hover text a view shows,
449/// and what invoking it does.
450#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
451pub struct Command {
452    pub id: CommandId,
453    pub label: Cow<'static, str>,
454    pub act: Act,
455    /// Where this command stands in the selection overlay's order. Set once,
456    /// here, so the bar and the right-click menu read one policy.
457    pub precedence: Precedence,
458    rendered: Rendered,
459    authoring: Authoring,
460}
461
462/// Which of a selection's controls come first in the overlay: the selected
463/// thing's own verbs, then the clerical ones every selection has.
464///
465/// The user named the own verbs: *"For sure the primary actions should be
466/// 'Accent, Expand, Icon, Lock, Rip, flip lr, and flip ud'. The 'copy/cut/
467/// delete' can all be put into the overflow menu, as can the 'export to
468/// svg'."* The bar shows the head of the ordered list and puts the rest behind
469/// its ellipsis, so the clerical verbs are the first to go there.
470///
471/// Orthogonal to applicability: a command this selection does not offer is
472/// simply absent, whichever rank it holds.
473#[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            // Three the user's list did not name because they had no icons
494            // to name them by: a route's label, a pin's tag and a port's I/O
495            // direction. They are the selected thing's own verbs the same
496            // way Accent and Lock are.
497            | CommandId::AddRouteLabel
498            | CommandId::HideTags
499            | CommandId::ShowTags
500            | CommandId::PinType => Precedence::Own,
501            // Copy, Cut, Delete, Export — the clerical verbs, which every
502            // selection has and none is *about* — and everything that is not
503            // in the overlay at all.
504            _ => Precedence::Clerical,
505        }
506    }
507}
508
509/// Whether the selection overlay draws a control for `id`.
510///
511/// Membership is the registry's, not a drawing's: a shell that has no
512/// artwork for a verb still has to know the verb belongs on the bar, or two
513/// front ends would offer two different bars over one selection. The
514/// selection's export offers its leading format only — the rest are reached
515/// through that control's own menu.
516pub 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    /// Whether this session withholds the command. A withheld command is
541    /// uninvocable — every taking door skips it — but it is still *known*,
542    /// so a surface can draw its control disabled instead of leaving a
543    /// hole (the familiar paradigm, per docs/ui-issues.md's follow-up).
544    pub fn withheld(&self) -> bool {
545        self.authoring.is_withheld()
546    }
547
548    /// Whether this command draws a control, or only answers to its name.
549    pub fn rendered(&self) -> Rendered {
550        self.rendered
551    }
552}
553
554/// Whether a command appears as a control, or only answers to its name.
555#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
556pub enum Rendered {
557    AsAButton,
558    ByNameOnly,
559}
560
561/// Everything [`CommandSet::available`] resolves availability against.
562pub struct CommandContext<'a, 'b> {
563    pub tool: &'a Tool,
564    pub data: &'a Drawing<'b>,
565    pub history: History,
566    /// Whether the block currently viewed keeps its interface frozen — gates
567    /// arming the Add Port tool.
568    pub current_lock: InterfaceLock,
569    /// Whether this session may write the document *at all* — a container
570    /// another process holds, or one whose log will not verify, withholds
571    /// every command that writes the document. The time
572    /// machine is the other half of that question and rides in `viewing`.
573    pub writability: Writability,
574    /// Whether there is a projection to refresh — gates [`CommandId::Save`]
575    /// and nothing else.
576    pub saving: Saving,
577    /// Which document is on the canvas. A past rev is read-only through
578    /// `writability`, like any other read-only session.
579    pub viewing: Viewing,
580}
581
582/// The chord for `key` with the platform command modifier.
583const fn chord(key: Key) -> Chord {
584    Chord {
585        modifiers: Modifiers::Command,
586        key,
587    }
588}
589
590/// A bare key, no modifier — how the tool band's digits and the arrows are
591/// pressed.
592const fn bare(key: Key) -> Chord {
593    Chord {
594        modifiers: Modifiers::None,
595        key,
596    }
597}
598
599/// The command modifier with Shift — how a chord's opposite is pressed.
600const fn shifted(key: Key) -> Chord {
601    Chord {
602        modifiers: Modifiers::CommandShift,
603        key,
604    }
605}
606
607/// The chord → command bindings, one table: the app's shortcut pass consumes
608/// it and the views print it (tool-band hover text, palette rows), so a chord
609/// can never disagree with what it dispatches. Toolbar arming, the camera and
610/// the history; selection verbs join here when they earn chords.
611///
612/// Each band tool answers to two keys: the digit of its place in the row, and
613/// its mnemonic chord. The digits are bare keys, which is safe because the
614/// whole table is only consulted when no text field holds the keyboard.
615pub 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    // Zoom in is bound twice: `+` is a shifted key on most layouts, so the
633    // unshifted `=` in its place is the chord people actually press.
634    (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    // Redo is bound twice as well: ⌘⇧Z beside ⌘Z, and ⌘Y where that is the
640    // habit.
641    (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
650/// The chord bound to `id`, if any — for the views that print one.
651pub fn binding(id: CommandId) -> Option<&'static Chord> {
652    chords(id).next()
653}
654
655/// Every chord bound to `id`, in table order — for the views that print them
656/// all, so a tool with two keys advertises both.
657pub 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
664/// The command one cell of the rail invokes. Every cell but the image arms its
665/// tool; there is no image tool — artwork is picked and then placed, and the
666/// picker is the shell's — so that cell asks for the artwork instead. Read by
667/// the rail, the registry and the chord table, so the three cannot disagree
668/// about what a cell does.
669pub fn band_command(tool: ToolName) -> CommandId {
670    match tool {
671        ToolName::NewImage => CommandId::AddImage,
672        armed => CommandId::Arm(armed),
673    }
674}
675
676/// And what invoking it does.
677fn band_act(tool: ToolName) -> Act {
678    match tool {
679        ToolName::NewImage => Effect::AddImage.into(),
680        armed => Action::Arm(armed).into(),
681    }
682}
683
684/// Whether arming `tool` is currently allowed: adding a port inside a locked
685/// block is the one refusal — the viewed interface is frozen. Private
686/// because the views ask the resolved [`CommandSet`] instead, so a control
687/// and the command behind it cannot disagree.
688fn arm_available(tool: ToolName, current_lock: InterfaceLock) -> bool {
689    !(current_lock.is_locked() && tool == ToolName::AddPort)
690}
691
692/// The frame's available commands, in the canonical presentation order the
693/// selection overlay renders them in.
694#[derive(PartialEq, serde::Serialize, serde::Deserialize)]
695pub struct CommandSet {
696    available: Vec<Command>,
697    /// Applied by [`Self::push`], so the withholding happens once instead of
698    /// at each of the thirty-odd sites that offer a command.
699    writability: Writability,
700    viewing: Viewing,
701    /// Which kind of entry undo and redo stand over, since that is what says
702    /// whether they write.
703    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        // Rising a level is navigation, and above the document root there is
729        // nowhere to rise to — so the toolbar's Up draws dead there rather
730        // than doing something else instead. It lives on the toolbar rather
731        // than the selection overlay, so it is not ordered among the
732        // selection verbs.
733        if ctx.data.current_scope() != crate::path::Scope::Root {
734            set.push(CommandId::GoUp, "Go up a level", Action::GoUp);
735        }
736        // View commands: always available, and reachable by chord or palette
737        // rather than by any button.
738        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        // Dev tooling: offered always, bound to nothing, found by name.
750        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    /// The arrow keys' moves, which draw no control of their own.
776    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            // Exporting a selection writes it out as a standalone diagram, so
787            // it needs a block: a lone port, area, or text box is an
788            // annotation on someone else's diagram, not one of its own.
789            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            // "Hide Tags" only when every selected tag is visible; a hidden or
812            // mixed group reads "Show Tags".
813            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        // A locked block keeps its pin interface frozen, so the I/O (retype)
842        // control is withheld when any selected pin's owner is locked.
843        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        // Only the pin-bearing shapes have anything to mirror. The flips come
880        // last of the own verbs, where the user's list put them.
881        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        // Delete is withheld for a locked pin/port selection — locking protects
892        // the interface, not the block's existence, so whole-block Delete (and
893        // every other selection's) is unaffected.
894        if !delete_blocked_by_lock(sel, data) {
895            self.push(CommandId::Delete, "Delete", Action::Delete(sel.clone()));
896        }
897    }
898
899    /// The in-place label editors the selection can open — the same tools the
900    /// double-click path arms. Palette-only: none has an overlay button.
901    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        // A single pin — or a port, whose labels are its one pin's — edits its
922        // own name/type/tag.
923        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    /// Offered where the editor would open now. The command names the field,
951    /// not the editor, so dispatch builds the editor again against the
952    /// drawing as it then stands.
953    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    /// Available to name — a chord, the palette's text field, a script's
996    /// `command` step — but not rendered as a control. The accent and I/O
997    /// values are the case this exists for: nine colours and three
998    /// directions are a picker's job on screen, and nine more buttons would
999    /// bury the selection bar, but each still has to be *sayable* or a script
1000    /// could not set one.
1001    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    /// The one gate every command passes through. A read-only session marks
1006    /// the writing half of the registry withheld here — not at the call
1007    /// sites — and every door that *invokes* (a chord, the palette, a
1008    /// script's `command` step, a click) skips a withheld command, so
1009    /// keeping it around for the overlay to draw disabled cannot make it
1010    /// reachable.
1011    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    /// Whether `id` survives the frame's two read-only questions. They are
1028    /// two questions — a container this session may not write, and a past
1029    /// rev on the canvas — with one answer: nothing writes the document
1030    /// through the lens. The way out of it is Return or a Save-as, and a
1031    /// Save-as writes a *file*, never this log. Undo and redo go with the
1032    /// rest *when they author* — they would act on the head nobody is
1033    /// looking at — and stay when they do not.
1034    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    /// Whether `id` would write *this frame*. Undo and redo answer with the
1042    /// kind of entry they stand over: taking back a camera move
1043    /// restores a camera and touches no log, so it survives both the lens and
1044    /// a container this session may not write. Every other command answers
1045    /// from its own identity.
1046    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    /// The invocable commands to render. Everything else is reachable by
1055    /// name through [`Self::take`].
1056    pub fn iter(&self) -> impl Iterator<Item = &Command> {
1057        self.iter_drawn().filter(|command| !command.withheld())
1058    }
1059
1060    /// Every command that draws a control, the withheld ones included — the
1061    /// selection overlay's view, which renders those disabled rather than
1062    /// leaving a hole where a control was.
1063    pub fn iter_drawn(&self) -> impl Iterator<Item = &Command> {
1064        self.available
1065            .iter()
1066            .filter(|command| command.rendered == Rendered::AsAButton)
1067    }
1068
1069    /// Every command this frame offers, drawn or not — what a front end that
1070    /// carries the whole registry across a seam reads, so a chord bound to a
1071    /// by-name-only command still resolves there.
1072    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    /// Consume what `id` does, to perform it. At most one command fires per
1085    /// frame, so taking moves it out rather than cloning it. A withheld
1086    /// command cannot be taken, however it was named.
1087    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    /// [`CommandSet::take`] by the command's typeable name — how the script
1096    /// `command` step names it.
1097    #[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/// Older command spellings, still resolved so a script that names one keeps
1109/// running.
1110#[cfg(test)]
1111fn legacy_command_name(name: &str) -> &str {
1112    match name {
1113        "comment" => "area",
1114        other => other,
1115    }
1116}
1117
1118/// What became of an [`Act`] offered to [`apply_scripted`]: applied against
1119/// the drawing — carrying the tool the arm settles on, where it settles one —
1120/// handed back because it needs app machinery (clipboard, the undo stack,
1121/// navigation, the lens), or not a document edit at all.
1122pub enum ScriptedApply {
1123    Applied(Option<Tool>),
1124    NeedsApp(Box<Action>),
1125    /// An effect the shell performs. A driver with no shell has no dialog to
1126    /// open and nothing to apply.
1127    NotApplicable(Effect),
1128}
1129
1130/// Apply the document-scoped subset of [`Act`] — the arms that write nothing
1131/// but the drawing. The app's dispatcher routes these same arms here, so a
1132/// scripted session and a live one cannot drift. The gesture the caller
1133/// opened owns the sealing: every write lands in its sink, and the solve
1134/// rider re-routes what these arms disturbed.
1135pub 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        // A field gone since the command was offered opens nothing.
1147        Action::OpenEditor(target) => {
1148            tool_for_target(drawing, target).and_then(|tool| armed(tool, drawing))
1149        }
1150        // The same gate, for the cell's other gesture: a session that cannot
1151        // arm a creator cannot drop one either, whichever door asked. The
1152        // cell already draws dead, so this is the floor under that rather
1153        // than the only refusal.
1154        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        // Keep the pin selection so the label updates and a follow-up click
1161        // can finish synchronizing a mixed group.
1162        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        // Flipping moves pins to new sides, so re-approach the connected
1171        // routes exactly like a drag does: drop the stale approach corners
1172        // and let the rider re-route. The shape stays selected, so its bar
1173        // stays up.
1174        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        // The lock governs only future pin/port edits, so nothing re-routes;
1185        // the block stays selected so its overlay (and lock toggle) stays.
1186        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        // The selection stays, so the picker that asked stays open and a
1195        // second colour can be tried without re-selecting.
1196        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
1213/// [`apply_scripted`] for what a tool's own frame asks for. A hand-off to a
1214/// seeded tool passes the same arming gate an [`Action::Arm`] does.
1215pub 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
1222/// The last gate on arming: a read-only session refuses a tool whose whole
1223/// purpose is to write, whichever door asked for it — a chord, a script's
1224/// `command` step, or another tool handing off.
1225fn 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
1231/// The pin anchors an I/O-style change would retype, or `None` for selections
1232/// with no pins to retype. A pin or pin group maps to its anchors directly; a
1233/// boundary port maps to its single port pin.
1234fn 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
1242/// The accent target a selection maps to, or `None` for selections that carry
1243/// no accent (an image/icon shape, a multi-selection, or a pin group — the
1244/// picker targets one role-bearing object).
1245/// The accent picker's cells: "no accent" plus the eight numbered ones, in
1246/// the order they are drawn, each as the command that sets it — so
1247/// `accent-3` on screen and `accent-3` in a script name one colour, and a
1248/// picker cannot offer a tenth colour the registry cannot be told about.
1249pub 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
1261/// The I/O picker's cells, on the same terms as [`ACCENTS`]: an arrow
1262/// arriving, one arriving and leaving, one leaving — the order a picker lays
1263/// them out in, which is also the order they read in.
1264pub 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
1291/// Whether the Delete/Cut controls are withheld because the selection is a
1292/// locked pin/port interface: any selected child-block pin whose owner is
1293/// locked, or a boundary port of a locked current block.
1294fn 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/// What an [`Act`] does, named — for the failure message when a test invoked
1303/// something other than what it meant to.
1304#[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/// The [`Action`] variant's own name, on the same terms.
1313#[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    /// Block `1` ("core") carrying its one West pin `2` — the selection every
1374    /// availability claim below is made against.
1375    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    /// Two wired blocks, each tall enough to offer several pin slots — so a
1388    /// route, a pin group and a block are all selectable, *and* a vertical
1389    /// flip has somewhere to move the pin to. A block one slot tall makes
1390    /// `FlipUd` an exact no-op, which the emitter correctly drops; the fixture
1391    /// has to give the command room to act or the test proves nothing.
1392    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    /// Ripping up one wire is a route verb, offered only to a route
1418    /// selection — which is the edit-route tool's, not the select tool's.
1419    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    /// The tag toggle is a pin-*group* verb, so it needs the group selection
1428    /// the multi-pin tool holds rather than the single-pin one.
1429    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    /// Rising a level is navigation, so a read-only session keeps it — but
1460    /// above the document root there is nowhere to rise to, and the command
1461    /// is simply not there, which draws the toolbar's Up button dead. A
1462    /// navigation button must never quietly write a block.
1463    #[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    /// The lens has no writing command at all: under it a writable
1491    /// container offers exactly what a read-only one does, and the way out of
1492    /// the past is Return or a Save-as — which writes a *file*, never this
1493    /// log.
1494    #[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    /// The corner cluster's Enter button is enabled by this command's presence,
1532    /// so it must appear only with a block selected.
1533    #[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, &nothing, 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    /// Exporting a selection writes a standalone diagram, so it is offered for
1549    /// a block but not for a port — which belongs to a diagram rather than
1550    /// being one.
1551    #[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        // The selection verbs it *does* have are untouched.
1580        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    /// Adding an icon needs an image, and the registry says so directly: the
1619    /// button is not a tool to arm but the effect the shell's picker runs,
1620    /// already carrying the block it was offered for.
1621    #[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        // Locking freezes the interface, not the block's existence.
1655        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        // Tags start hidden in the doc fixtures, and which of the pair is
1663        // offered follows the tag's current state — show it, so the control
1664        // under test is "Hide Tags".
1665        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        // A lock freezes the pin interface, not what the pin's tag shows —
1670        // the emitter agrees (`edit::naming`'s
1671        // `set_tag_visibility_hides_a_group_and_a_locked_owner_does_not_stop_it`).
1672        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    /// A container this session may not write withholds every command that
1701    /// would write it — including the tools that draw. What is left is
1702    /// looking around: select, navigate, zoom, copy, export.
1703    #[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    /// The arrows reach a selected shape every way, and draw no control.
1753    #[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    /// A withheld command is still *drawn* — the overlay grays it out
1772    /// instead of leaving a hole — but no invoking door reaches it: not a
1773    /// click's `take`, not a script's `take_by_name`, not a view's
1774    /// `contains`. Known and untouchable are the same entry.
1775    #[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    /// Availability follows one list, so a new command has to declare which
1804    /// side of it it is on — and a *writing* command that claims to write
1805    /// nothing would sneak past the read-only gate.
1806    #[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    /// Taking a rename back or forward writes storage, so it is withheld
1848    /// wherever an authored step is: on a read-only container and under the
1849    /// lens.
1850    #[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        // A locked block freezes its pin labels, so the editors vanish.
1922        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                // The image cell's command, which arms no tool: it is on the
1948                // band all the same, and its digit is the cell's.
1949                CommandId::AddImage => {
1950                    bound.insert(ToolName::NewImage);
1951                }
1952                // The view commands carry no button, so a chord and the
1953                // palette are their only routes; the history steps have a
1954                // button on the bar and the chords every editor's hands
1955                // expect; and search is the one command whose chord *is* its
1956                // control, since what it opens is the front end's own.
1957                CommandId::ZoomIn
1958                | CommandId::ZoomOut
1959                | CommandId::FitView
1960                | CommandId::Undo
1961                | CommandId::Redo
1962                | CommandId::Search
1963                // The arrows move a selection, and have no control to press.
1964                | 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        // A new band tool must pick a chord (or explicitly opt out here).
1971        for tool in band_tools() {
1972            assert!(bound.contains(&tool), "{tool:?} has no key binding");
1973        }
1974    }
1975
1976    /// Zoom and fit depend on nothing in the document, so they are always in
1977    /// the set — which is what puts them in the palette.
1978    #[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    /// Every constructible id, for exhaustive name checks.
1990    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    /// A command that answers to its name but draws no control still has to
2035    /// be *reachable* by that name — that is the whole of its contract, and
2036    /// the scripted `command` step depends on it.
2037    #[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    /// The area tool was spelled `comment` before the rename, and scripted
2063    /// scripts written against that spelling still run.
2064    #[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    /// Digits are allowed since the accents became nameable (`accent-3`):
2088    /// the value is the point of the name, and spelling it out reads worse.
2089    #[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    /// Apply one command the way the scripted driver does, and report whether
2103    /// it authored anything.
2104    ///
2105    /// Deliberately not `Scene::commit`: that runs the gesture's solve rider,
2106    /// which re-solves routes and can change the document all by itself — so a
2107    /// content-hash assertion around it passes for a command that does nothing.
2108    /// (It did: neutering `SetBlockLocked` left the first draft of these tests
2109    /// green.) Measuring the gesture's own ops before the rider is what makes
2110    /// the claim about the command.
2111    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        // The kind of write, not merely *a* write: several arms ride a route
2128        // trim alongside their own edit, so "the gesture is non-empty" is
2129        // satisfied by the rider even when the command itself does nothing.
2130        // (It was: deleting `flip_shape_pins` from its arm left an earlier
2131        // draft of this green, because the trim beside it still authored.)
2132        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    /// Availability is only half of a command. The tests above prove *which*
2144    /// commands a selection offers; this one proves each document-mutating
2145    /// command, resolved from that same set and applied through the same
2146    /// [`apply_scripted`] the scripted driver uses, actually writes the
2147    /// document.
2148    #[test]
2149    fn every_document_mutating_command_writes_the_document() {
2150        /// A command, the selection that offers it, and the narration its
2151        /// *own* edit must produce.
2152        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            // The two rows a popup writes: ordinary commands, so a script
2162            // can ask for them by name.
2163            (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    /// A toggle that only works one way is the drift the paired ids exist to
2176    /// prevent, so each direction is driven from the state its partner leaves
2177    /// behind. The fixture's pins start tagged-hidden, hence show-then-hide.
2178    #[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}