Skip to main content

blockworx_tools/
stamp.rs

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