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}