Skip to main content

blockworx/tools/
tool.rs

1use blockworx_geom::{Pos2, Rect};
2use enum_dispatch::enum_dispatch;
3
4use crate::edit::naming::Authoring;
5use crate::{
6    edit::naming::{InterfaceLock, TagVisibility},
7    shape::ShapeId,
8    theme::Style,
9    tools::{
10        AddPort, AddRouteLabel, AddText, EditRoute, EditTextBox, IconTool, MoveBlock,
11        MoveBlockType, MoveLabel, MovePin, MoveTitle, NewArea, NewBlock, NewImage, RenameBlockType,
12        RenamePin, RenameRoute, RenameTitle, RetypePin, RouteTool, SelectTool,
13        move_multi_pin::MoveMultiPin, multi_pin_select::MultiPinSelect, multi_select::MultiSelect,
14        names::ToolName, resize_block::ResizeBlock, select_pin::SelectPin,
15    },
16    widget::drawing::Drawing,
17};
18use blockworx_doc::{
19    id::{BlockId, PinId, RouteId},
20    values::PinDir,
21};
22use blockworx_paint::{Canvas, Interaction};
23
24// The selection vocabulary and the supposition phase are the editor's — a
25// `Deletable` is a set of shapes and `Supposing` gates its preview writers —
26// and every tool reaches them through this module.
27pub use crate::shape::{Deletable, RoleTarget};
28pub use crate::widget::Supposing;
29
30#[enum_dispatch(Tool)]
31pub trait ToolTrait {
32    fn name(&self) -> ToolName;
33    /// Advance this frame's drag state and suppose the geometry it implies.
34    /// Runs before [`Self::widget`] paints, on every frame of a drag —
35    /// including one carrying no event at all, which is what a repaint
36    /// mid-drag looks like.
37    fn suppose<C: Canvas>(
38        &mut self,
39        _data: &mut Drawing<'_>,
40        _interaction: &Interaction,
41        _painter: &mut Style<'_, C>,
42        _phase: &Supposing,
43    ) {
44    }
45    fn widget<C: Canvas>(
46        &mut self,
47        data: &mut Drawing<'_>,
48        interaction: &Interaction,
49        painter: &mut Style<'_, C>,
50    ) -> Option<Action>;
51    /// The item this tool currently has selected that can be deleted, if any.
52    /// Used to drive the delete button overlay.
53    fn selection(&self) -> Option<Deletable> {
54        None
55    }
56    /// A world-space point the selection overlay should anchor to, overriding the
57    /// selection's bounding box. A route spans a wide bbox but is picked at one
58    /// spot, so its overlay anchors at the click instead of the whole polyline.
59    fn overlay_anchor(&self) -> Option<Pos2> {
60        None
61    }
62}
63
64/// One frame of a tool: suppose, then act. The drivers call this and never
65/// [`ToolTrait::widget`] directly, which is what makes the order structural —
66/// a tool has no way to reach the preview writers from `widget`, because the
67/// [`Supposing`] they require is minted here and handed only to `suppose`.
68pub fn frame<C: Canvas>(
69    tool: &mut Tool,
70    data: &mut Drawing<'_>,
71    interaction: &Interaction,
72    painter: &mut Style<'_, C>,
73) -> Option<Action> {
74    // Measurement first: a text box whose text changed under it — an undo, a
75    // restore — carries a stale extent until something measures it, and
76    // everything below this line reads extents. Only stale entries cost
77    // anything, so an ordinary frame pays a comparison per box.
78    data.refresh_text_extents(painter);
79    let interaction = &offered_gestures(interaction, data.authoring());
80    tool.suppose(data, interaction, painter, &Supposing::frame());
81    tool.widget(data, interaction, painter)
82}
83
84/// The interaction a frame hands its tool. A read-only session keeps the
85/// pointer — selecting and navigating are free — but loses the two channels
86/// that exist only to author: the held press every add-affordance arms from
87/// (the new-pin markers, the route-start targets) and the Delete key. Taken
88/// away here rather than in each of the tools that read them, so a new tool
89/// cannot forget.
90fn offered_gestures(interaction: &Interaction, authoring: Authoring) -> Interaction {
91    match authoring {
92        Authoring::Offered => *interaction,
93        Authoring::Withheld => Interaction {
94            press: None,
95            delete_pressed: false,
96            ..*interaction
97        },
98    }
99}
100
101#[enum_dispatch]
102pub enum Tool {
103    NewBlock(NewBlock),
104    NewArea(NewArea),
105    AddPort(AddPort),
106    NewImage(NewImage),
107    AddText(AddText),
108    EditTextBox(EditTextBox),
109    MovePin(MovePin),
110    RenamePin(RenamePin),
111    RetypePin(RetypePin),
112    MoveTitle(MoveTitle),
113    MoveBlockType(MoveBlockType),
114    RenameTitle(RenameTitle),
115    RenameBlockType(RenameBlockType),
116    Route(RouteTool),
117    MoveBlock(MoveBlock),
118    ResizeBlock(ResizeBlock),
119    Icon(IconTool),
120    EditRoute(EditRoute),
121    AddRouteLabel(AddRouteLabel),
122    RenameRoute(RenameRoute),
123    MoveLabel(MoveLabel),
124    Select(SelectTool),
125    SelectPin(SelectPin),
126    MultiSelect(MultiSelect),
127    MultiPinSelect(MultiPinSelect),
128    MoveMultiPin(MoveMultiPin),
129}
130
131impl Tool {
132    /// The fresh (idle) tool a toolbar press on `name` arms. Tools that are
133    /// never armed from the toolbar — armed instead from selection overlays
134    /// (`Icon`, `AddRouteLabel`), a marquee drag (`MultiSelect`), a text box
135    /// (`EditTextBox`), and the rest of the selection family — fall back to
136    /// `Select`. Shared by the toolbar and the headless tool suites so
137    /// there is exactly one `ToolName → Tool` mapping.
138    pub fn from_name(name: ToolName) -> Tool {
139        match name {
140            ToolName::NewBlock => Tool::NewBlock(NewBlock::Idle),
141            ToolName::NewArea => Tool::NewArea(NewArea::Idle),
142            ToolName::AddPort => Tool::AddPort(AddPort::Idle),
143            ToolName::NewImage => Tool::NewImage(NewImage::Idle),
144            ToolName::AddText => Tool::AddText(AddText::Idle),
145            ToolName::MovePin => Tool::MovePin(MovePin::Idle),
146            ToolName::RenamePin => Tool::RenamePin(RenamePin::Idle),
147            ToolName::MoveTitle => Tool::MoveTitle(MoveTitle::Idle),
148            ToolName::RenameTitle => Tool::RenameTitle(RenameTitle::Idle),
149            ToolName::Route => Tool::Route(RouteTool::default()),
150            ToolName::MoveBlock => Tool::MoveBlock(MoveBlock::Idle),
151            ToolName::ResizeBlock => Tool::ResizeBlock(ResizeBlock::Idle),
152            ToolName::EditRoute => Tool::EditRoute(EditRoute::Idle),
153            ToolName::RenameRoute => Tool::RenameRoute(RenameRoute::Idle),
154            ToolName::MoveLabel => Tool::MoveLabel(MoveLabel::Idle),
155            ToolName::Select
156            | ToolName::Icon
157            | ToolName::AddRouteLabel
158            | ToolName::RetypePin
159            | ToolName::MoveBlockType
160            | ToolName::RenameBlockType
161            | ToolName::MultiSelect
162            | ToolName::MultiPinSelect
163            | ToolName::MoveMultiPin
164            | ToolName::EditTextBox
165            | ToolName::SelectPin => Tool::Select(SelectTool),
166        }
167    }
168}
169
170/// Where an export goes. The history row offers a rev both ways, and the
171/// payload is the same either way — this is the whole difference between
172/// "Copy" and "Save…".
173#[derive(Clone, Copy, PartialEq, Eq, Debug)]
174pub enum ExportTo {
175    Clipboard,
176    File,
177}
178
179pub enum Action {
180    SwitchTool(Tool),
181    /// Stamp `tool`'s default thing at the world point `at` — what a cell
182    /// dragged off the tool cluster and dropped on the canvas comes to
183    /// (`crate::tools::stamp`). Arming and stamping are the cell's two
184    /// gestures, and this is the one that does not arm.
185    StampTool {
186        tool: ToolName,
187        at: Pos2,
188    },
189    Delete(Deletable),
190    /// Copy the given shapes (and the routes among them) to the clipboard.
191    Copy(Vec<ShapeId>),
192    /// Copy a group of child-block pins to the clipboard (as a pin payload).
193    CopyPins(Vec<PinId>),
194    /// Copy the given shapes to the clipboard, then delete them (one undo step).
195    Cut(Vec<ShapeId>),
196    /// Copy a group of child-block pins to the clipboard, then delete them.
197    CutPins(Vec<PinId>),
198    /// Set every listed pin's tag to `tags`.
199    SetPinTags {
200        pins: Vec<PinId>,
201        tags: TagVisibility,
202    },
203    /// Set a selected shape's own label — a child block's tag, or a port's pin
204    /// tag — to `tags`.
205    SetShapeTagHidden {
206        shape: ShapeId,
207        tags: TagVisibility,
208    },
209    /// Flip a selected shape's pins to the opposite edge — a port's single pin,
210    /// or all of a child block's pins — and re-route.
211    FlipShapePins(ShapeId),
212    /// Vertically mirror a selected child block's pins — a pin near the top moves
213    /// to the bottom and vice versa, each keeping its East/West side — and
214    /// re-route. Scoped to blocks (ports carry a single pin, nothing to mirror).
215    FlipBlockVertical(BlockId),
216    /// Lock or unlock a child block: a locked block keeps its pin/port interface
217    /// frozen (no rename/retype/tag/add/delete of pins or ports) while still
218    /// allowing whole-block moves, deletes, resizes, and aesthetic edits.
219    SetBlockLocked {
220        block: BlockId,
221        lock: InterfaceLock,
222    },
223    /// Paste a clipboard JSON payload into the current level.
224    Paste(String),
225    ExpandBlock(BlockId),
226    /// Frame this world rect — the palette's `camera <x> <y> <w> <h>`, the same
227    /// framing a scripted `camera` step applies.
228    Camera(Rect),
229    /// Step the zoom in or out about the pointer — the keyboard's zoom, where
230    /// the wheel zooms about the cursor continuously.
231    Zoom(blockworx_paint::ZoomStep),
232    /// Navigate to a level by its whole path — the palette's `go <path>`,
233    /// which parses a content-path string (see [`crate::tools::content_path`]).
234    GoToPath(crate::path::BlockPath),
235    /// Open the accent-color (role) picker for the selected block, port, route,
236    /// area, or text box. The popup anchors itself above the selection
237    /// overlay, so no screen position is carried here.
238    OpenRolePicker {
239        target: RoleTarget,
240    },
241    /// Accent `target` — what the picker reports, rather than what it writes.
242    /// A popup that wrote the document itself was the only mutation in the
243    /// app reachable by no other route, and so the only one no test could
244    /// drive; as an action it dispatches like every other write and a script
245    /// can ask for it by name.
246    SetRole {
247        target: RoleTarget,
248        /// The accent index the picker reports; `None` is "no accent", the
249        /// target's own un-accented stroke. Spelled as the setter spells it —
250        /// naming this index is queued with the rest of the nomenclature
251        /// sweep, and it reaches the legacy layer phase 7 deletes.
252        role: Option<u8>,
253    },
254    /// Set the I/O direction of every listed pin — the pin-type picker's
255    /// report, on the same terms as [`Action::SetRole`].
256    SetPinsKind {
257        pins: Vec<PinId>,
258        kind: PinDir,
259    },
260
261    /// Open the pin-type (I/O style) picker for the current pin/port selection.
262    /// Carries the anchors of every pin the picker will retype. Like
263    /// [`Action::OpenRolePicker`], the popup anchors itself above the selection
264    /// overlay, so no screen position is carried here.
265    OpenPinTypePicker {
266        pins: Vec<PinId>,
267    },
268    GoUp,
269    /// Select a block chosen in the navigation dialog: replace the selection (or,
270    /// with `extend`, toggle it into the current selection like a canvas
271    /// shift-click), then pan/zoom the canvas to frame that block.
272    NavSelect {
273        block: BlockId,
274        extend: bool,
275    },
276    /// Restore the previous document/navigation snapshot.
277    Undo,
278    /// Re-apply a snapshot undone by [`Action::Undo`].
279    Redo,
280    /// Arrow-key move of the current selection: shapes shift by `(dx, dy)` grid
281    /// cells; a pin selection shifts by `dy` slots (`dx` ignored).
282    Nudge {
283        dx: i32,
284        dy: i32,
285    },
286    /// Reset zoom/pan so the whole document fits in the viewport.
287    ResetView,
288    /// Export in the chosen format via a save dialog / download. `selection` is
289    /// the shapes to export as a standalone diagram (`None` = the current view).
290    Export {
291        format: crate::export::ExportFormat,
292        selection: Option<Vec<ShapeId>>,
293    },
294    /// Open the import dialog (JSON / PNG / SVG). A diagram lands as one
295    /// new block in the active scope (D19); a PNG/SVG is added as a plain
296    /// image.
297    Import,
298    /// Write the flattened document at a rev — the history row's Copy and
299    /// Save… (D19). The rev's fold is the only thing that separates this from
300    /// [`Action::Export`] of the current view.
301    ExportRev {
302        at: blockworx_doc::rev::Rev,
303        to: ExportTo,
304    },
305    /// Close whatever is open and start a fresh unsaved session. With
306    /// append-on-commit there is nothing to prompt about: a container already
307    /// holds everything that was committed to it.
308    #[cfg(not(target_arch = "wasm32"))]
309    NewDocument,
310    /// Open the File menu's dialog for `request`; the pick comes back
311    /// through [`crate::file::PickReceiver`].
312    #[cfg(not(target_arch = "wasm32"))]
313    PickFile(crate::file::FileRequest),
314    /// Reopen a remembered container.
315    #[cfg(not(target_arch = "wasm32"))]
316    OpenRecent(std::path::PathBuf),
317    /// Call this document something else, which renames its container: the
318    /// directory's name *is* the document's name (D20). Carries the name
319    /// the user typed, unvalidated — [`crate::file::renamed_beside`] is
320    /// what says whether it is one.
321    #[cfg(not(target_arch = "wasm32"))]
322    RenameDocument(String),
323    /// Rewrite the attached container's `document.json` from the head fold.
324    /// The commits are already durable; this refreshes the readable view of
325    /// them (D11). Unlike its File-menu neighbours this variant exists on
326    /// every target, because the *registry* offers it — and the registry is
327    /// one list, compiled everywhere. The browser simply never has a
328    /// container for [`Saving`](blockworx_store::doc::Saving) to offer it on.
329    SaveProjection,
330    /// Rip up a route and autoroute it fresh with no waypoints.
331    Reroute(RouteId),
332    /// Rip up and autoroute every route with an endpoint on this block.
333    RerouteBlock(BlockId),
334    /// Put a past rev on the canvas, read-only — the time machine (F7).
335    ViewRev(blockworx_doc::rev::Rev),
336    /// Leave the past for the writable head.
337    ViewHead,
338    /// Put one of §8.1's names on a rev, or take one off (D18). Not an
339    /// edit: it spends no rev and the undo stack does not move.
340    TagRev {
341        at: blockworx_doc::rev::Rev,
342        name: String,
343        how: blockworx_store::tags::Tagging,
344    },
345}
346
347impl Default for Action {
348    fn default() -> Self {
349        Action::SwitchTool(SelectTool.into())
350    }
351}
352
353impl Action {
354    /// What the commit this action produces is called — the stable command
355    /// name where a named command triggered it, so the log, undo, and review
356    /// all read the same word for the same gesture. Actions that write
357    /// nothing still carry one: the gesture opens before the dispatcher
358    /// knows which arm will take it.
359    pub fn label(&self) -> &'static str {
360        match self {
361            Action::SwitchTool(_) => "switch tool",
362            // Labelled by the tool that made it, exactly as the canvas
363            // labels the same tool's drawn gesture — one word for one kind
364            // of thing, however it was created.
365            Action::StampTool { tool, .. } => tool.verb(),
366            Action::Delete(_) => "delete",
367            Action::SetRole { .. } => "accent",
368            Action::SetPinsKind { .. } => "io",
369            Action::Copy(_) | Action::CopyPins(_) => "copy",
370            Action::Cut(_) | Action::CutPins(_) => "cut",
371            Action::SetPinTags {
372                tags: TagVisibility::Hidden,
373                ..
374            }
375            | Action::SetShapeTagHidden {
376                tags: TagVisibility::Hidden,
377                ..
378            } => "hide-tags",
379            Action::SetPinTags {
380                tags: TagVisibility::Shown,
381                ..
382            }
383            | Action::SetShapeTagHidden {
384                tags: TagVisibility::Shown,
385                ..
386            } => "show-tags",
387            Action::FlipShapePins(_) => "flip-lr",
388            Action::FlipBlockVertical(_) => "flip-ud",
389            Action::SetBlockLocked {
390                lock: InterfaceLock::Locked,
391                ..
392            } => "lock",
393            Action::SetBlockLocked {
394                lock: InterfaceLock::Unlocked,
395                ..
396            } => "unlock",
397            Action::Paste(_) => "paste",
398            // The one arm here that writes: a restore is an ordinary
399            // forward commit, and the log should say which rev it went
400            // back to rather than merely "restore".
401            Action::Reroute(_) => "reroute",
402            Action::RerouteBlock(_) => "reroute-block",
403            Action::GoUp => "up",
404            Action::Nudge { .. } => "nudge",
405            Action::Import => "import",
406            // The File flow writes through the store rather than through a
407            // gesture, so the gesture it opens seals to nothing.
408            Action::SaveProjection => "file",
409            #[cfg(not(target_arch = "wasm32"))]
410            Action::NewDocument
411            | Action::PickFile(_)
412            | Action::OpenRecent(_)
413            | Action::RenameDocument(_) => "file",
414            // Navigation, view, dialogs and the history stack author nothing.
415            Action::ExpandBlock(_)
416            | Action::Camera(_)
417            | Action::Zoom(_)
418            | Action::GoToPath(_)
419            | Action::OpenRolePicker { .. }
420            | Action::OpenPinTypePicker { .. }
421            | Action::NavSelect { .. }
422            | Action::Undo
423            | Action::Redo
424            | Action::ResetView
425            | Action::Export { .. }
426            | Action::ExportRev { .. }
427            | Action::ViewRev(_)
428            | Action::ViewHead
429            | Action::TagRev { .. } => "edit",
430        }
431    }
432}
433
434/// The selection tool a freshly renamed/moved pin should return to, so the pin
435/// stays the standing target for further edits. A block pin returns to the
436/// per-pin [`SelectPin`] selection; a port returns to its block-style
437/// [`ResizeBlock`] selection.
438pub fn select_tool_for_anchor(data: &Drawing<'_>, anchor: PinId) -> Tool {
439    match data.pin_shape(anchor) {
440        Some(shape @ ShapeId::Port(_)) => ResizeBlock::Selected { shape }.into(),
441        _ => SelectPin::Selected { anchor }.into(),
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    /// Every tool is stored inline in `Tool`, which is moved on each tool switch
450    /// and carried by value inside `Action`, so one wide tool taxes the whole
451    /// layer. The route tool's cross-frame preview cache — 312 bytes of router
452    /// and fixed legs, far more than any other tool holds — is boxed for exactly
453    /// that reason; this fails if the next wide field lands inline instead.
454    #[test]
455    fn a_tool_switch_stays_cheap_to_move() {
456        let tool = size_of::<Tool>();
457        assert!(tool <= 256, "Tool grew to {tool} bytes");
458        // `Action::SwitchTool` carries a whole `Tool`, so it tracks that budget.
459        assert!(size_of::<Action>() <= tool + 16);
460    }
461
462    /// A mis-wired arm in [`Tool::from_name`] (arming `MoveTitle` for
463    /// `ToolName::MovePin`, say) is invisible until a script or toolbar press
464    /// runs the wrong tool.
465    #[test]
466    fn from_name_arms_the_named_tool_or_falls_back_to_select() {
467        for name in crate::tools::names::ALL_TOOLS {
468            let armed = Tool::from_name(name).name();
469            assert!(
470                armed == name || armed == ToolName::Select,
471                "from_name({name:?}) armed {armed:?}"
472            );
473        }
474    }
475
476    /// The toolbar's own tools have no fallback: pressing a toolbar button must
477    /// arm exactly that tool.
478    #[test]
479    fn toolbar_tools_round_trip_through_from_name() {
480        for name in crate::tools::names::band_tools() {
481            assert_eq!(Tool::from_name(name).name(), name);
482        }
483    }
484}