Skip to main content

blockworx/tools/
new_pin.rs

1//! Shared "new-pin target" primitives.
2//!
3//! Every free pin slot on a block is offered as a small blue dot one grid cell
4//! outside the block edge — where the pin's connection point will land. The dot
5//! nearest the cursor (within `NEW_PIN_ACTIVATION_RANGE`) animates into a blue
6//! ring that grows out to `PORT_RADIUS`.
7//!
8//! The *resize* tool uses these markers as click/drag buttons to add a pin to
9//! the selected block. The *route* tool reuses them (see
10//! [`draw_route_new_pin_targets`]) to let an in-progress route terminate on a
11//! pin that doesn't exist yet.
12
13use std::time::Duration;
14
15use blockworx_doc::id::BlockId;
16use blockworx_geom::WorldPx;
17use blockworx_geom::{Pos2, Vec2, vec2};
18
19use crate::progress::Progress;
20use crate::{
21    grid::{GRID_SIZE, HIT_RADIUS, PIN_PITCH, PIN_TOP_MARGIN, PORT_RADIUS, ROUTE_HIT_MARGIN},
22    shape::{BaseShape, BlockShape, PinLocation, pin::PinSide},
23    theme::{Role, Style},
24};
25use blockworx_paint::{AnimKey, Canvas, Renderer};
26// The grab-handle affordance itself — resting size, grow range, grow time —
27// is the render layer's, which draws the resize handles to match.
28pub(crate) use crate::render::{NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE};
29
30/// How near the cursor must be for a marker's resting dot to appear.
31pub(crate) const NEW_PIN_ACTIVATION_RANGE: WorldPx = WorldPx::new(3.0 * GRID_SIZE);
32/// How near the pointer must come to a free slot's marker for a press to
33/// place a pin there.
34///
35/// The user asked for it as wide as it will go: *"Increase the hit radius on
36/// the 'add pin' affordance around the blocks. Make it as large as possible
37/// without overlapping other affordances (I assume that it should be at
38/// least 1 grid unit in radius)."* So it is not a number written down here —
39/// it is the smallest clearance the neighbouring affordances leave, computed
40/// from *their* own constants. Move a neighbour closer and this shrinks to
41/// suit rather than silently starting to overlap it.
42pub(crate) fn new_pin_grab() -> WorldPx {
43    WorldPx::new(clearances().into_iter().fold(f32::INFINITY, f32::min))
44}
45
46/// What each affordance around a free slot's marker leaves it: how big the
47/// marker's disc may grow before it touches that neighbour's own.
48fn clearances() -> [f32; 5] {
49    [
50        // The block body, which takes a press with no padding at all and is
51        // exactly one grid cell in from the marker.
52        GRID_SIZE,
53        // The next free slot along the same edge carries a disc of this same
54        // size, so the two meet half way.
55        PIN_PITCH * 0.5,
56        // The nearest resize handle sits on the corner — `PIN_TOP_MARGIN`
57        // along the edge from the outermost slot, and `GRID_SIZE` in from
58        // the marker — and grabs from `HIT_RADIUS`.
59        GRID_SIZE.hypot(PIN_TOP_MARGIN) - HIT_RADIUS.get(),
60        // An occupied neighbour a pitch away: the route-start ring, at the
61        // radius a fingertip gets.
62        PIN_PITCH - NEW_PIN_GROW_RANGE.get(),
63        // and the region that neighbour's stub answers a route drag from.
64        PIN_PITCH - (GRID_SIZE / 3.0 + ROUTE_HIT_MARGIN.get()),
65    ]
66}
67
68/// How far the drag-to-route hint's ghost circle drifts from the marker per loop.
69pub(crate) const HINT_DRIFT: f32 = 2.0 * GRID_SIZE;
70/// Loop period of the drag-to-route hint.
71pub(crate) const HINT_PERIOD: Duration = Duration::from_millis(900);
72/// How far the pointer must pull outward off a pressed target — in the pin's own
73/// direction — to commit to starting a route (rather than adding/selecting on
74/// release). Deliberately more than a jittery hold so the hint stays visible.
75pub(crate) const HINT_ROUTE_THRESHOLD: WorldPx = WorldPx::new(1.5 * GRID_SIZE);
76
77/// The drag-to-route hint's free-running loop phase at frame clock `now` (see
78/// [`Canvas::now`]). The caller must `request_repaint` each frame the hint is
79/// shown to keep it moving.
80pub(crate) fn route_hint_phase(now: Duration) -> Progress {
81    Progress::new((now.as_secs_f64() / HINT_PERIOD.as_secs_f64()).rem_euclid(1.0) as f32)
82}
83
84/// Each free slot's location paired with the world point one grid cell outside the
85/// block edge — where the new pin's connection point will sit.
86pub(crate) fn new_pin_targets(block: &BlockShape<'_>) -> Vec<(PinLocation, Pos2)> {
87    block
88        .new_pin_locations()
89        .into_iter()
90        .filter_map(|loc| {
91            let edge = block.pin_position(loc)?;
92            let outward = match loc.side {
93                PinSide::East => GRID_SIZE,
94                PinSide::West => -GRID_SIZE,
95            };
96            Some((loc, edge + vec2(outward, 0.0)))
97        })
98        .collect()
99}
100
101/// The target nearest `pos` within `NEW_PIN_ACTIVATION_RANGE`, if any. At most one
102/// is ever active, so the markers never fight over the cursor.
103pub(crate) fn active_new_pin_target(targets: &[(PinLocation, Pos2)], pos: Pos2) -> Option<usize> {
104    targets
105        .iter()
106        .enumerate()
107        .map(|(i, (_, p))| (i, p.distance(pos)))
108        .filter(|(_, d)| *d < NEW_PIN_ACTIVATION_RANGE.get())
109        .min_by(|a, b| a.1.total_cmp(&b.1))
110        .map(|(i, _)| i)
111}
112
113/// A `+` glyph centered at `center`, sized to the marker radius.
114pub(crate) fn draw_plus<R: Renderer>(
115    center: Pos2,
116    radius: WorldPx,
117    role: Role,
118    painter: &mut Style<'_, R>,
119) {
120    let arm = radius.get() * 0.6;
121    painter.line_segment(
122        [center - vec2(arm, 0.0), center + vec2(arm, 0.0)],
123        (2.0, role),
124    );
125    painter.line_segment(
126        [center - vec2(0.0, arm), center + vec2(0.0, arm)],
127        (2.0, role),
128    );
129}
130
131/// The ghost circle's offset from a pressed marker at loop `phase` (0..1): it
132/// drifts outward along the pin's side, the way a drag would carry a new route.
133pub(crate) fn hint_offset(side: PinSide, phase: f32) -> Vec2 {
134    let outward = match side {
135        PinSide::East => 1.0,
136        PinSide::West => -1.0,
137    };
138    vec2(outward * phase * HINT_DRIFT, 0.0)
139}
140
141/// Number of ghost circles in the drag-to-route hint's outward stream.
142const HINT_GHOSTS: usize = 3;
143
144/// The drag-to-route discoverability hint, drawn at a *pressed* new-pin marker: a
145/// steady stream of ghost circles flows away from the `+` (each fading in then out)
146/// toward a static arrowhead pointing the way, colored as a route-start target so
147/// it reads as "drag here to start a route". Staggering several circles keeps one
148/// always mid-flight, so the motion reads as continuous. Purely visual.
149pub(crate) fn draw_route_hint<C: Canvas>(
150    center: Pos2,
151    side: PinSide,
152    phase: Progress,
153    painter: &mut Style<'_, C>,
154) {
155    let outward = match side {
156        PinSide::East => 1.0,
157        PinSide::West => -1.0,
158    };
159    // A steady arrowhead at the drift end points the way.
160    let tip = center + vec2(outward * (HINT_DRIFT + PORT_RADIUS.get()), 0.0);
161    let barb = PORT_RADIUS.get() * 0.7;
162    painter.line_segment(
163        [tip, tip - vec2(outward * barb, barb)],
164        (2.0, Role::RouteStartTarget),
165    );
166    painter.line_segment(
167        [tip, tip - vec2(outward * barb, -barb)],
168        (2.0, Role::RouteStartTarget),
169    );
170    // The flowing stream: circles evenly staggered along the loop, each drifting
171    // out and fading as it goes.
172    for k in 0..HINT_GHOSTS {
173        let p = (phase.get() + k as f32 / HINT_GHOSTS as f32).fract();
174        let ghost = center + hint_offset(side, p);
175        let opacity = (p * std::f32::consts::PI).sin();
176        painter.with_opacity(opacity, |painter| {
177            painter.circle_filled(ghost, PORT_RADIUS * 0.55, Role::RouteStartTarget);
178        });
179    }
180}
181
182/// The animation key for one block's resize-tool marker, keyed by slot + side
183/// so each marker animates independently.
184pub(crate) fn target_anim_key(rid: BlockId, loc: PinLocation) -> AnimKey {
185    let slot = (loc.offset / PIN_PITCH).round() as i32;
186    AnimKey::of(("new_pin_target", rid, loc.side, slot))
187}
188
189/// The animation key for the route tool's marker. A distinct tag from
190/// [`target_anim_key`] keeps the two tools' animations independent.
191fn route_target_anim_key(rid: BlockId, loc: PinLocation) -> AnimKey {
192    let slot = (loc.offset / PIN_PITCH).round() as i32;
193    AnimKey::of(("route_new_pin_target", rid, loc.side, slot))
194}
195
196/// Draw `block`'s new-pin markers for the route tool and return the one currently
197/// armed (grown to the full control), if any. A target's resting dot appears once
198/// the pointer is within `NEW_PIN_ACTIVATION_RANGE`; the nearest target grows into
199/// the ring + plus control once the pointer is within `NEW_PIN_GROW_RANGE`.
200pub(crate) fn draw_route_new_pin_targets<C: Canvas>(
201    rid: BlockId,
202    targets: &[(PinLocation, Pos2)],
203    pointer: Option<Pos2>,
204    painter: &mut Style<'_, C>,
205) -> Option<(PinLocation, Pos2)> {
206    let nearest = pointer.and_then(|p| active_new_pin_target(targets, p));
207    let mut armed = None;
208    for (i, (loc, center)) in targets.iter().enumerate() {
209        let dist = pointer.map(|p| p.distance(*center));
210        if !dist.is_some_and(|d| d < NEW_PIN_ACTIVATION_RANGE.get()) {
211            // Keep the animation settled while the dot is hidden so it doesn't
212            // pop on re-approach.
213            painter.animate(route_target_anim_key(rid, *loc), 0.0, NEW_PIN_ANIM_TIME);
214            continue;
215        }
216        let active = nearest == Some(i) && dist.is_some_and(|d| d < NEW_PIN_GROW_RANGE.get());
217        if active {
218            armed = Some((*loc, *center));
219        }
220        let goal = if active { 1.0 } else { 0.0 };
221        let t = painter.animate(route_target_anim_key(rid, *loc), goal, NEW_PIN_ANIM_TIME);
222        // The resting dot fades out as the ring grows in, so it reads as the dot
223        // expanding into the ring.
224        if t < 1.0 {
225            painter.with_opacity(1.0 - t, |p| {
226                p.circle_filled(
227                    *center,
228                    PORT_RADIUS * NEW_PIN_INACTIVE_SCALE,
229                    Role::NewPinPreviewFill,
230                );
231            });
232        }
233        if t > 0.0 {
234            let radius =
235                PORT_RADIUS * (NEW_PIN_INACTIVE_SCALE + (1.0 - NEW_PIN_INACTIVE_SCALE) * t);
236            painter.circle(
237                *center,
238                radius,
239                Role::Transparent,
240                (2.0, Role::NewPinPreviewFill),
241            );
242            painter.with_opacity(t, |p| {
243                draw_plus(*center, PORT_RADIUS, Role::NewPinPreviewFill, p);
244            });
245        }
246    }
247    armed
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::path::Scope;
254    use crate::widget::{
255        drawing::Drawing,
256        test_fixtures::{self as fx, Scene},
257    };
258    use blockworx_doc::fixtures::block_id;
259    use blockworx_geom::{Rect, pos2};
260
261    /// A bare 300×300 block: every slot on both of its edges is free.
262    fn scene_300() -> Scene {
263        Scene::new(vec![
264            fx::block_in(
265                1,
266                Scope::Root,
267                Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)),
268            ),
269            fx::titled(1, "b"),
270        ])
271    }
272
273    /// The scene's lone block, as the geometry layer sees it.
274    fn block_300<'a>(drawing: &'a Drawing<'_>) -> BlockShape<'a> {
275        drawing
276            .block_shape(block_id(1))
277            .expect("the block is in this scope")
278    }
279
280    /// R34, stated as the property the user asked for: as large as it can be
281    /// without overlapping a neighbour, and at least one grid unit.
282    #[test]
283    fn the_add_pin_grab_is_the_widest_radius_its_neighbours_leave_it() {
284        let grab = new_pin_grab().get();
285        assert!(
286            grab >= GRID_SIZE,
287            "the grab is under a grid unit: {grab} against {GRID_SIZE}",
288        );
289        assert!(
290            grab > PORT_RADIUS.get(),
291            "precondition: the grab is wider than the ring it draws \
292             ({grab} against {})",
293            PORT_RADIUS.get(),
294        );
295        for (n, clearance) in clearances().into_iter().enumerate() {
296            assert!(
297                grab <= clearance,
298                "the grab of {grab} overlaps neighbour {n}, which leaves {clearance}",
299            );
300        }
301        assert!(
302            clearances().into_iter().any(|room| room == grab),
303            "the grab is smaller than every clearance, so it is not maximal: \
304             {grab} against {:?}",
305            clearances(),
306        );
307    }
308
309    /// The same property against real geometry rather than against the
310    /// arithmetic that produced it: on a laid-out block, the outermost
311    /// marker's grab reaches neither the corner handle beside it, nor the
312    /// next slot's, nor inside the block.
313    #[test]
314    fn the_add_pin_grab_touches_nothing_on_a_real_block() {
315        let mut scene = scene_300();
316        let drawing = scene.drawing();
317        let block = block_300(&drawing);
318        let bbox = block.gui_rect();
319        let grab = new_pin_grab().get();
320        let east: Vec<Pos2> = new_pin_targets(&block)
321            .into_iter()
322            .filter(|(loc, _)| loc.side == PinSide::East)
323            .map(|(_, at)| at)
324            .collect();
325        assert!(
326            east.len() >= 2,
327            "precondition: the block has two free slots to crowd each other",
328        );
329
330        for at in &east {
331            assert!(
332                at.x - grab >= bbox.right() - 1e-3,
333                "the marker's grab reaches inside the block: {at:?} against {bbox:?}",
334            );
335            for corner in [bbox.right_top(), bbox.right_bottom()] {
336                assert!(
337                    at.distance(corner) >= grab + HIT_RADIUS.get() - 1e-3,
338                    "the marker at {at:?} overlaps the resize handle at {corner:?}",
339                );
340            }
341        }
342        for pair in east.windows(2) {
343            assert!(
344                pair[0].distance(pair[1]) >= 2.0 * grab - 1e-3,
345                "two markers' grabs overlap: {:?} and {:?}",
346                pair[0],
347                pair[1],
348            );
349        }
350    }
351
352    #[test]
353    fn new_pin_targets_sit_one_grid_cell_outside_each_edge() {
354        let mut scene = scene_300();
355        let drawing = scene.drawing();
356        let block = block_300(&drawing);
357        let bbox = block.gui_rect();
358        let targets = new_pin_targets(&block);
359        assert!(!targets.is_empty());
360
361        let (mut west, mut east) = (0, 0);
362        for (loc, p) in &targets {
363            match loc.side {
364                PinSide::West => {
365                    assert!((p.x - (bbox.left() - GRID_SIZE)).abs() < 1e-3);
366                    west += 1;
367                }
368                PinSide::East => {
369                    assert!((p.x - (bbox.right() + GRID_SIZE)).abs() < 1e-3);
370                    east += 1;
371                }
372            }
373        }
374        // A fresh block has free slots on both sides.
375        assert!(west > 0 && east > 0);
376    }
377
378    #[test]
379    fn active_new_pin_target_is_the_nearest_within_range_else_none() {
380        let mut scene = scene_300();
381        let drawing = scene.drawing();
382        let targets = new_pin_targets(&block_300(&drawing));
383
384        // A query right on a target activates exactly that target.
385        let (_, first) = targets[0];
386        assert_eq!(active_new_pin_target(&targets, first), Some(0));
387
388        // Just inside the range of the first target (but nearer to it than any
389        // other) still selects it.
390        let near = first + vec2(NEW_PIN_ACTIVATION_RANGE.get() * 0.5, 0.0);
391        assert_eq!(active_new_pin_target(&targets, near), Some(0));
392
393        // Far beyond every target's activation range → nothing is active.
394        let far = pos2(10_000.0, 10_000.0);
395        assert_eq!(active_new_pin_target(&targets, far), None);
396    }
397
398    #[test]
399    fn hint_offset_drifts_outward_along_the_pin_side() {
400        // No drift at the loop start; the ghost begins on the marker.
401        assert_eq!(hint_offset(PinSide::East, 0.0), vec2(0.0, 0.0));
402        // East drifts right (+x), West drifts left (-x); always horizontal.
403        assert!(hint_offset(PinSide::East, 0.5).x > 0.0);
404        assert!(hint_offset(PinSide::West, 0.5).x < 0.0);
405        assert_eq!(hint_offset(PinSide::East, 0.5).y, 0.0);
406        // Magnitude grows with phase and is bounded by one full drift.
407        assert!(hint_offset(PinSide::East, 0.9).x > hint_offset(PinSide::East, 0.4).x);
408        assert_eq!(hint_offset(PinSide::East, 1.0).x, HINT_DRIFT);
409        assert_eq!(hint_offset(PinSide::West, 1.0).x, -HINT_DRIFT);
410    }
411}