Skip to main content

blockworx_tools/
route_start.rs

1//! Route-start / route-end hover targets.
2//!
3//! In the select tool, the pin/anchor nearest the cursor grows a green dot at its
4//! stub end out to a [`PORT_RADIUS`] ring; pressing it inverts the ring, and a
5//! click or a drag starts a route anchored there (handing off to the route tool
6//! in its return-to-select mode). The route tool reuses `draw_anchor_target` to put
7//! the same growing target on the pin that will *end* a route in progress.
8
9use std::ops::ControlFlow;
10
11use blockworx_doc::id::PinId;
12use blockworx_geom::{Pos2, WorldPx};
13
14use crate::{
15    grid::PORT_RADIUS,
16    new_pin::{
17        NEW_PIN_ACTIVATION_RANGE, NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE,
18    },
19    route_tool::RouteTool,
20    theme::{Role, Style},
21    tool::Transition,
22    widget::drawing::Drawing,
23};
24use blockworx_paint::{AnimKey, Canvas, Interaction, PointerKind};
25/// The nearest pin/anchor to `pointer` within [`NEW_PIN_ACTIVATION_RANGE`], paired
26/// with its stub-end point. `exclude` skips one anchor (a route's start, which
27/// can't also be its end).
28pub(crate) fn nearest_anchor_target(
29    data: &Drawing,
30    pointer: Pos2,
31    exclude: Option<PinId>,
32) -> Option<(PinId, Pos2)> {
33    data.anchor_targets()
34        .into_iter()
35        .filter(|(a, _)| Some(*a) != exclude)
36        .map(|(a, p)| (a, p, p.distance(pointer)))
37        .filter(|(_, _, d)| *d < NEW_PIN_ACTIVATION_RANGE.get())
38        .min_by(|x, y| x.2.total_cmp(&y.2))
39        .map(|(a, p, _)| (a, p))
40}
41
42/// Draw the grow-out target on the anchor nearest `pointer` (skipping `exclude`),
43/// returning it if one is in range. Shared by the select tool's route-start and the
44/// route tool's hover/end targets so they animate identically.
45pub(crate) fn draw_nearest_anchor_target<C: Canvas>(
46    data: &Drawing,
47    pointer: Pos2,
48    exclude: Option<PinId>,
49    painter: &mut Style<'_, C>,
50) -> Option<(PinId, Pos2)> {
51    let (anchor, center) = nearest_anchor_target(data, pointer, exclude)?;
52    draw_anchor_target(anchor, center, pointer, Grab::Free, painter);
53    Some((anchor, center))
54}
55
56/// The animation key for an anchor's target, tagged distinctly from the
57/// new-pin keys.
58fn anim_key(anchor: PinId) -> AnimKey {
59    AnimKey::of(("route_start_target", anchor))
60}
61
62/// Draw the green grow-out target at `center`. The resting dot grows to a
63/// [`PORT_RADIUS`] ring as `pointer` nears it (within [`NEW_PIN_GROW_RANGE`]); when
64/// `pressed` it is drawn inverted, as a filled disk.
65/// Whether the pointer is currently holding the target down.
66#[derive(Clone, Copy, PartialEq, Eq)]
67pub(crate) enum Grab {
68    Held,
69    Free,
70}
71
72pub(crate) fn draw_anchor_target<C: Canvas>(
73    anchor: PinId,
74    center: Pos2,
75    pointer: Pos2,
76    grab: Grab,
77    painter: &mut Style<'_, C>,
78) {
79    if grab == Grab::Held {
80        painter.circle_filled(center, PORT_RADIUS, Role::RouteStartTarget);
81        return;
82    }
83    let goal = if center.distance(pointer) < NEW_PIN_GROW_RANGE.get() {
84        1.0
85    } else {
86        0.0
87    };
88    let t = painter.animate(anim_key(anchor), goal, NEW_PIN_ANIM_TIME);
89    // The resting dot fades out as the ring grows in, so it reads as the dot
90    // expanding into the ring.
91    if t < 1.0 {
92        painter.with_opacity(1.0 - t, |p| {
93            p.circle_filled(
94                center,
95                PORT_RADIUS * NEW_PIN_INACTIVE_SCALE,
96                Role::RouteStartTarget,
97            );
98        });
99    }
100    if t > 0.0 {
101        let radius = PORT_RADIUS * (NEW_PIN_INACTIVE_SCALE + (1.0 - NEW_PIN_INACTIVE_SCALE) * t);
102        painter.circle(
103            center,
104            radius,
105            Role::Transparent,
106            (2.0, Role::RouteStartTarget),
107        );
108    }
109}
110
111/// Select-tool entry. Renders the route-start target on the nearest anchor and,
112/// on a click or drag of it, hands off to the route tool. `Break` means the pass
113/// owns this frame's interaction (carrying the tool switch, if the gesture
114/// produced one); `Continue` falls through to the caller's own handling.
115pub(crate) fn widget<C: Canvas>(
116    data: &Drawing,
117    interaction: &Interaction,
118    painter: &mut Style<'_, C>,
119) -> ControlFlow<Option<Transition>> {
120    let _span = tracing::info_span!("route_start").entered();
121    // The target is an offer to draw a wire, so a read-only session neither
122    // draws it nor answers a click on where it would have been.
123    if data.authoring().is_withheld() {
124        return ControlFlow::Continue(());
125    }
126    let grab = grab_radius(painter.pointer_kind());
127    if let Some(
128        blockworx_paint::Event::Clicked { pos } | blockworx_paint::Event::DragStarted { pos },
129    ) = interaction.event
130        && let Some((anchor, center)) = target_at(data, pos, grab)
131    {
132        return ControlFlow::Break(Some(start_route(anchor, center)));
133    }
134    let Some(pointer) = painter.pointer_world() else {
135        return ControlFlow::Continue(());
136    };
137    // A press holding a target owns the interaction until it becomes a click
138    // or a drag, so the pin under the ring is not grabbed as well.
139    if let Some(press) = interaction.press
140        && let Some((anchor, center)) = target_at(data, press.origin, grab)
141    {
142        draw_anchor_target(anchor, center, pointer, Grab::Held, painter);
143        return ControlFlow::Break(None);
144    }
145    let Some((anchor, center)) = nearest_anchor_target(data, pointer, None) else {
146        return ControlFlow::Continue(());
147    };
148    draw_anchor_target(anchor, center, pointer, Grab::Free, painter);
149    ControlFlow::Continue(())
150}
151
152/// The anchor target `pos` lands on: the nearest one, within `grab` of it.
153fn target_at(data: &Drawing, pos: Pos2, grab: WorldPx) -> Option<(PinId, Pos2)> {
154    nearest_anchor_target(data, pos, None).filter(|(_, center)| pos.distance(*center) < grab.get())
155}
156
157/// How near a press must land to grab an anchor. A cursor aims at the drawn
158/// ring, having watched it grow out under the pointer, so [`PORT_RADIUS`] is
159/// exactly what it is aiming at. A fingertip has no hover phase to aim by and
160/// covers several times that, so touch grabs anywhere the ring would have
161/// grown — the region the cursor only gets to *see* is armed.
162fn grab_radius(pointer: PointerKind) -> WorldPx {
163    match pointer {
164        PointerKind::Touch => NEW_PIN_GROW_RANGE,
165        PointerKind::Mouse => PORT_RADIUS,
166    }
167}
168
169fn start_route(anchor: PinId, head: Pos2) -> Transition {
170    Transition::SwitchTool(RouteTool::routing_from_select(anchor, head).into())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::path::Scope;
177    use crate::shape::pin::PinSide;
178    use crate::widget::test_fixtures::{self as fx, Scene};
179    use blockworx_geom::{Rect, pos2, vec2};
180    use blockworx_text::measure::{Measured, Scripted};
181
182    // The animated grow-out is purely visual and is not unit-tested here; these
183    // cover the anchor selection that drives it.
184
185    /// One roomy block carrying a single west pin — the scene's lone anchor.
186    fn scene_with_one_pin() -> Scene {
187        Scene::new(vec![
188            fx::block_in(
189                1,
190                Scope::Root,
191                Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)),
192            ),
193            fx::titled(1, "b"),
194            fx::pin(2, 1, PinSide::West, 0),
195        ])
196    }
197
198    fn lone_anchor(scene: &mut Scene) -> (PinId, Pos2) {
199        let mut targets = scene.drawing().anchor_targets().into_iter();
200        let target = targets.next().expect("the pin is an anchor target");
201        assert!(targets.next().is_none(), "the scene holds one anchor only");
202        target
203    }
204
205    #[test]
206    fn nearest_anchor_target_returns_the_in_range_anchor() {
207        let mut scene = scene_with_one_pin();
208        let (anchor, center) = lone_anchor(&mut scene);
209        let drawing = scene.drawing();
210        assert_eq!(
211            nearest_anchor_target(&drawing, center, None),
212            Some((anchor, center))
213        );
214    }
215
216    #[test]
217    fn nearest_anchor_target_skips_the_excluded_anchor() {
218        let mut scene = scene_with_one_pin();
219        let (anchor, center) = lone_anchor(&mut scene);
220        let drawing = scene.drawing();
221        assert_eq!(
222            nearest_anchor_target(&drawing, center, Some(anchor)),
223            None,
224            "excluding the only anchor leaves nothing in range"
225        );
226    }
227
228    #[test]
229    fn nearest_anchor_target_is_none_when_out_of_range() {
230        let mut scene = scene_with_one_pin();
231        let (_anchor, center) = lone_anchor(&mut scene);
232        let drawing = scene.drawing();
233        let far = center + vec2(NEW_PIN_ACTIVATION_RANGE.get() * 2.0, 0.0);
234        assert_eq!(nearest_anchor_target(&drawing, far, None), None);
235    }
236
237    /// Drive `widget` in a headless frame with a scripted painter, the way a
238    /// session's step does.
239    fn widget_under(
240        scene: &mut Scene,
241        pointer: Pos2,
242        interaction: &Interaction,
243        check: impl Fn(ControlFlow<Option<Transition>>),
244    ) {
245        widget_under_scripted(
246            scene,
247            Scripted {
248                pointer: Some(pointer),
249                ..Scripted::default()
250            },
251            interaction,
252            check,
253        );
254    }
255
256    fn widget_under_scripted(
257        scene: &mut Scene,
258        scripted: Scripted,
259        interaction: &Interaction,
260        check: impl Fn(ControlFlow<Option<Transition>>),
261    ) {
262        let theme = crate::theme::Theme::default();
263        let measured = Measured::new(
264            blockworx_paint::FontChoice::default(),
265            theme.palette().clone(),
266        );
267        let drawing = scene.drawing();
268        let mut canvas = measured.canvas(scripted);
269        let mut style = Style::new(&theme, &mut canvas);
270        check(widget(&drawing, interaction, &mut style));
271    }
272
273    fn idle_interaction() -> Interaction {
274        Interaction {
275            event: None,
276            press: None,
277            text: None,
278            escape_pressed: false,
279            delete_pressed: false,
280            shift: false,
281        }
282    }
283
284    fn is_route_handoff(flow: &ControlFlow<Option<Transition>>) -> bool {
285        matches!(
286            flow,
287            ControlFlow::Break(Some(Transition::SwitchTool(crate::tool::Tool::Route(_))))
288        )
289    }
290
291    #[test]
292    fn a_click_on_the_target_starts_a_route() {
293        let mut scene = scene_with_one_pin();
294        let (_anchor, center) = lone_anchor(&mut scene);
295        let interaction = Interaction {
296            event: Some(blockworx_paint::Event::Clicked { pos: center }),
297            ..idle_interaction()
298        };
299        widget_under(&mut scene, center, &interaction, |flow| {
300            assert!(is_route_handoff(&flow), "expected a route-tool handoff");
301        });
302    }
303
304    #[test]
305    fn a_held_press_owns_the_frame_then_its_drag_starts_a_route() {
306        let mut scene = scene_with_one_pin();
307        let (_anchor, center) = lone_anchor(&mut scene);
308        let held = Interaction {
309            press: Some(blockworx_paint::Press { origin: center }),
310            ..idle_interaction()
311        };
312        widget_under(&mut scene, center, &held, |flow| {
313            assert!(
314                matches!(flow, ControlFlow::Break(None)),
315                "a still hold is owned"
316            );
317        });
318        let dragged = Interaction {
319            event: Some(blockworx_paint::Event::DragStarted { pos: center }),
320            ..held
321        };
322        let pointer = center + vec2(0.0, 3.0 * crate::grid::GRID_SIZE);
323        widget_under(&mut scene, pointer, &dragged, |flow| {
324            assert!(is_route_handoff(&flow), "the drag starts the route");
325        });
326    }
327
328    /// A finger lands where the ring is drawn, not on the dot at its middle.
329    /// The press target widens to match once the session has seen a touch —
330    /// and stays a cursor-sized bullseye until it has.
331    #[test]
332    fn a_finger_grabs_the_target_from_where_the_cursor_could_not() {
333        let mut scene = scene_with_one_pin();
334        let (_anchor, center) = lone_anchor(&mut scene);
335        let off = vec2(PORT_RADIUS.get() * 2.0, 0.0);
336        assert!(
337            off.length() > PORT_RADIUS.get() && off.length() < NEW_PIN_GROW_RANGE.get(),
338            "the probe must sit between the two radii to tell them apart"
339        );
340        let held = Interaction {
341            press: Some(blockworx_paint::Press {
342                origin: center + off,
343            }),
344            ..idle_interaction()
345        };
346
347        widget_under(&mut scene, center + off, &held, |flow| {
348            assert!(
349                matches!(flow, ControlFlow::Continue(())),
350                "a cursor this far off the dot grabs nothing"
351            );
352        });
353
354        let touched = Scripted {
355            pointer: Some(center + off),
356            pointer_kind: PointerKind::Touch,
357            ..Scripted::default()
358        };
359        widget_under_scripted(&mut scene, touched, &held, |flow| {
360            assert!(
361                matches!(flow, ControlFlow::Break(_)),
362                "a finger this far off the dot still grabs the target"
363            );
364        });
365    }
366}