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}