Skip to main content

blockworx_tools/
tool.rs

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