Skip to main content

blockworx/tools/
stamp.rs

1//! What a tool dropped on the canvas creates.
2//!
3//! The cluster's cells answer two gestures. A click *arms* a tool and the
4//! canvas then draws with it; a press dragged off the cluster and released
5//! on the canvas *stamps* the tool's default thing where it landed — the
6//! user, on the port tool that used to stamp on a bare canvas click: *"It
7//! should require a drag to create (like the other tools) if the tool is
8//! just clicked. If the user drags the tool off the toolbar, then use the
9//! current 'stamp' behavior."*
10//!
11//! One resolver, because the policy has to be one thing: what each creator's
12//! default is (encoded beside its emitter), and which creators a bare drop
13//! cannot finish at all.
14
15use crate::{
16    edit::create,
17    shape::ShapeId,
18    tools::{
19        EditTextBox, RenameTitle,
20        names::ToolName,
21        rename_pin::{Field, RenamePin},
22        tool::{Action, Tool},
23    },
24    widget::drawing::Drawing,
25};
26use blockworx_geom::Pos2;
27
28/// Stamp `tool`'s default thing at the world point `at`, returning the tool
29/// the gesture settles on — the same editor its dragged sibling opens, so a
30/// stamped thing is named the way a drawn one is.
31///
32/// `None` writes nothing: an image has to be picked before it exists (the
33/// app opens that dialog), a route cannot exist without its endpoints, and
34/// Select creates nothing anywhere.
35pub fn stamp(data: &mut Drawing<'_>, tool: ToolName, at: Pos2) -> Option<Tool> {
36    let settles_on = match tool {
37        ToolName::NewBlock => {
38            let box_ = create::stamped_block(at);
39            let block = data.add_block(box_.min, box_.max);
40            RenameTitle::action(data, ShapeId::Rect(block))
41        }
42        ToolName::NewArea => {
43            let box_ = create::stamped_area(at);
44            let area = data.add_area(box_.min, box_.max);
45            RenameTitle::action(data, area)
46        }
47        ToolName::AddText => {
48            let text = data.add_text_box(at);
49            EditTextBox::action(data, text)
50        }
51        ToolName::AddPort => {
52            let anchor = data.add_port_auto_named(create::stamped_port(at));
53            RenamePin::action(data, anchor, Field::Name)
54        }
55        _ => return None,
56    };
57    match settles_on {
58        Action::SwitchTool(tool) => Some(tool),
59        // Every editor above reports itself as a tool switch; nothing else
60        // can arrive here.
61        _ => None,
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::path::Scope;
69    use crate::widget::test_fixtures::{self as fx, Scene};
70    use blockworx_geom::{Rect, pos2};
71
72    /// A roomy level to drop things into.
73    fn scene() -> Scene {
74        Scene::new(vec![fx::block_in(
75            1,
76            Scope::Root,
77            Rect::from_min_max(pos2(0.0, 0.0), pos2(600.0, 400.0)),
78        )])
79        .inside(blockworx_doc::fixtures::block_id(1))
80    }
81
82    fn stamped(tool: ToolName) -> Vec<String> {
83        let mut scene = scene();
84        scene
85            .authored(|data| stamp(data, tool, pos2(200.0, 200.0)))
86            .1
87    }
88
89    /// Every creator the cluster carries has a default, so a drop finishes
90    /// without a second gesture.
91    #[test]
92    fn every_creator_stamps_its_own_default() {
93        for (tool, wrote) in [
94            (ToolName::NewBlock, "block"),
95            (ToolName::NewArea, "area"),
96            (ToolName::AddText, "text"),
97            (ToolName::AddPort, "pin"),
98        ] {
99            let narrated = stamped(tool);
100            assert!(
101                narrated.iter().any(|line| line.starts_with(wrote)),
102                "dropping {tool:?} authored no {wrote}; it authored {narrated:?}",
103            );
104        }
105    }
106
107    /// The two the user ruled out, and the neutral cell: a route has no
108    /// endpoints to join, an image has not been picked yet (the app opens
109    /// that dialog), and Select creates nothing.
110    #[test]
111    fn a_drop_that_cannot_finish_writes_nothing() {
112        for tool in [ToolName::Route, ToolName::NewImage, ToolName::Select] {
113            let narrated = stamped(tool);
114            assert!(
115                narrated.is_empty(),
116                "dropping {tool:?} authored {narrated:?}",
117            );
118        }
119    }
120
121    /// The image's drop is the app's to finish: nothing exists until a file
122    /// is picked, so the action comes back out of the document dispatch
123    /// still carrying the point the picker has to place at.
124    #[test]
125    fn an_image_drop_is_handed_back_carrying_its_drop_point() {
126        use crate::tools::commands::{ScriptedApply, apply_scripted};
127        let mut scene = scene();
128        let at = pos2(200.0, 200.0);
129        let (outcome, narrated) = scene.authored(|data| {
130            apply_scripted(
131                Action::StampTool {
132                    tool: ToolName::NewImage,
133                    at,
134                },
135                data,
136            )
137        });
138        assert!(
139            narrated.is_empty(),
140            "the image drop wrote {narrated:?} before anything was picked",
141        );
142        match outcome {
143            ScriptedApply::NeedsApp(handed_back) => assert!(
144                matches!(
145                    *handed_back,
146                    Action::StampTool {
147                        tool: ToolName::NewImage,
148                        at: point,
149                    } if point == at
150                ),
151                "the drop point did not travel with the request",
152            ),
153            ScriptedApply::Applied(_) => panic!("an image was placed with no file picked"),
154        }
155    }
156
157    /// The registry gates stamping exactly as it gates arming: a session
158    /// that cannot arm a creator cannot drop one either.
159    #[test]
160    fn a_read_only_session_stamps_nothing() {
161        use crate::tools::commands::apply_scripted;
162        for tool in [
163            ToolName::NewBlock,
164            ToolName::NewArea,
165            ToolName::AddText,
166            ToolName::AddPort,
167        ] {
168            let drop = |mut scene: Scene| {
169                scene
170                    .authored(|data| {
171                        apply_scripted(
172                            Action::StampTool {
173                                tool,
174                                at: pos2(200.0, 200.0),
175                            },
176                            data,
177                        )
178                    })
179                    .1
180            };
181            assert!(
182                !drop(scene()).is_empty(),
183                "a writable session refused to stamp {tool:?}, so refusing it below proves nothing",
184            );
185            assert!(
186                drop(scene().read_only()).is_empty(),
187                "a read-only session stamped {tool:?}",
188            );
189        }
190    }
191
192    /// A stamped block is born named like a drawn one (R17), and the
193    /// gesture leaves its name open for editing.
194    #[test]
195    fn a_stamped_block_is_named_and_opens_its_editor() {
196        let mut scene = scene();
197        let at = pos2(200.0, 200.0);
198        let armed = scene.commit(|data| stamp(data, ToolName::NewBlock, at));
199        assert!(
200            matches!(armed, Some(Tool::RenameTitle(_))),
201            "a stamped block did not open its name editor",
202        );
203        let drawing = scene.drawing();
204        let titles: Vec<String> = drawing
205            .shapes()
206            .filter_map(|(_, shape)| shape.title().map(|t| t.name.to_owned()))
207            .collect();
208        assert_eq!(titles, vec!["Block 1".to_owned()]);
209    }
210
211    /// The drop point is where the thing lands, snapped to the grid — the
212    /// same snap a drawn block gets.
213    #[test]
214    fn a_stamped_block_lands_on_the_grid_under_the_drop() {
215        let mut scene = scene();
216        // Deliberately off-grid, so the snap has something to do.
217        let at = pos2(203.0, 197.0);
218        scene.commit(|data| stamp(data, ToolName::NewBlock, at));
219        let drawing = scene.drawing();
220        let placed = drawing
221            .shapes()
222            .map(|(_, shape)| shape.gui_rect())
223            .next()
224            .expect("the stamp landed a shape");
225        let wanted = create::stamped_block(at);
226        assert_eq!(placed.min, wanted.min);
227        assert_ne!(
228            wanted.min, at,
229            "precondition: the drop point is off the grid, so the snap moved it",
230        );
231    }
232}