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