Skip to main content

blockworx_tools/
new_pin.rs

1//! The free-slot markers: where a pin can be added to a block.
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
5//! dot nearest the cursor animates into a blue ring that grows out to
6//! `PORT_RADIUS`.
7//!
8//! Three tools offer them: a selected block offers its own (the "(+)"), the
9//! add-pin tool offers every block's in scope, and the route tool offers the
10//! nearest block's so a route can end on a pin that does not exist yet. Which
11//! blocks offer, where a marker sits, which one a press lands on and how one
12//! is drawn are decided here, once.
13
14use blockworx_doc::id::BlockId;
15use blockworx_geom::WorldPx;
16use blockworx_geom::{Pos2, vec2};
17
18use crate::{
19    edit::naming::Authoring,
20    grid::{GRID_SIZE, PIN_PITCH, PIN_TOP_MARGIN, PORT_RADIUS, ROUTE_HIT_MARGIN},
21    rename_pin::{Field, RenamePin},
22    shape::{BaseShape, PinLocation, ShapeId, pin::PinSide},
23    theme::{Role, Style},
24    tool::Transition,
25    widget::drawing::Drawing,
26};
27use blockworx_paint::{AnimKey, Canvas, Event, Interaction, Renderer};
28// The grab-handle affordance itself — resting size, grow range, grow time —
29// is the render layer's, which draws the resize handles to match.
30use crate::render::HANDLE_GRAB;
31pub(crate) use crate::render::{NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE};
32
33/// How near the cursor must be for a marker's resting dot to appear.
34pub(crate) const NEW_PIN_ACTIVATION_RANGE: WorldPx = WorldPx::new(3.0 * GRID_SIZE);
35/// How near the pointer must come to a free slot's marker for a press to
36/// place a pin there.
37///
38/// The user asked for it as wide as it will go: *"Increase the hit radius on
39/// the 'add pin' affordance around the blocks. Make it as large as possible
40/// without overlapping other affordances (I assume that it should be at
41/// least 1 grid unit in radius)."* So it is not a number written down here —
42/// it is the smallest clearance the neighbouring affordances leave, computed
43/// from *their* own constants. Move a neighbour closer and this shrinks to
44/// suit rather than silently starting to overlap it.
45pub(crate) fn new_pin_grab() -> WorldPx {
46    WorldPx::new(clearances().into_iter().fold(f32::INFINITY, f32::min))
47}
48
49/// What each affordance around a free slot's marker leaves it: how big the
50/// marker's disc may grow before it touches that neighbour's own.
51fn clearances() -> [f32; 5] {
52    [
53        // The block body, which takes a press with no padding at all and is
54        // exactly one grid cell in from the marker.
55        GRID_SIZE,
56        // The next free slot along the same edge carries a disc of this same
57        // size, so the two meet half way.
58        PIN_PITCH * 0.5,
59        // The nearest resize handle sits on the corner — `PIN_TOP_MARGIN`
60        // along the edge from the outermost slot, and `GRID_SIZE` in from
61        // the marker — and grabs from `HANDLE_GRAB`.
62        GRID_SIZE.hypot(PIN_TOP_MARGIN) - HANDLE_GRAB.get(),
63        // An occupied neighbour a pitch away: the route-start ring, at the
64        // radius a fingertip gets.
65        PIN_PITCH - NEW_PIN_GROW_RANGE.get(),
66        // and the region that neighbour's stub answers a route drag from.
67        PIN_PITCH - (GRID_SIZE / 3.0 + ROUTE_HIT_MARGIN.get()),
68    ]
69}
70
71/// One free slot's marker: the block a pin would be added to, the slot, and
72/// the world point the new pin's connection will sit at.
73#[derive(Clone, Copy, Debug)]
74pub struct SlotMarker {
75    pub block: BlockId,
76    pub loc: PinLocation,
77    pub center: Pos2,
78}
79
80/// Every free slot's marker on `block`, whether or not the block offers them.
81pub fn markers_on(data: &Drawing, block: BlockId) -> Vec<SlotMarker> {
82    let Some(shape) = data.block_shape(block) else {
83        return Vec::new();
84    };
85    shape
86        .new_pin_locations()
87        .into_iter()
88        .filter_map(|loc| {
89            let edge = shape.pin_position(loc)?;
90            let outward = match loc.side {
91                PinSide::East => GRID_SIZE,
92                PinSide::West => -GRID_SIZE,
93            };
94            Some(SlotMarker {
95                block,
96                loc,
97                center: edge + vec2(outward, 0.0),
98            })
99        })
100        .collect()
101}
102
103/// The markers `blocks` offer. A block offers none when nothing may be added
104/// to it — a locked interface and a read-only session both mean that.
105pub fn offered_markers(
106    data: &Drawing,
107    blocks: impl IntoIterator<Item = BlockId>,
108) -> Vec<SlotMarker> {
109    blocks
110        .into_iter()
111        .filter(|block| data.authoring_of(ShapeId::Rect(*block)) == Authoring::Offered)
112        .flat_map(|block| markers_on(data, block))
113        .collect()
114}
115
116/// The markers every block in the current scope offers.
117pub fn markers_in_scope(data: &Drawing) -> Vec<SlotMarker> {
118    offered_markers(data, data.current_blocks().map(|(block, _)| block))
119}
120
121/// The marker nearest `pos` within `NEW_PIN_ACTIVATION_RANGE`, if any. At most
122/// one is ever active, so the markers never fight over the cursor.
123fn active_marker(markers: &[SlotMarker], pos: Pos2) -> Option<usize> {
124    markers
125        .iter()
126        .enumerate()
127        .map(|(i, marker)| (i, marker.center.distance(pos)))
128        .filter(|(_, d)| *d < NEW_PIN_ACTIVATION_RANGE.get())
129        .min_by(|a, b| a.1.total_cmp(&b.1))
130        .map(|(i, _)| i)
131}
132
133/// The marker a press at `pos` lands on: the active one, within the widest
134/// grab its neighbours leave it — wider than the ring draws at, like every
135/// other target on the canvas.
136pub fn marker_at(markers: &[SlotMarker], pos: Pos2) -> Option<SlotMarker> {
137    let marker = markers[active_marker(markers, pos)?];
138    (pos.distance(marker.center) < new_pin_grab().get()).then_some(marker)
139}
140
141/// Add the pin `marker` offers and open its name for editing. `None` when the
142/// block declines it, as a block locked since the marker was drawn does.
143pub(crate) fn add_pin(data: &mut Drawing, marker: SlotMarker) -> Option<Transition> {
144    let pin = data.add_named_pin(marker.block, marker.loc)?;
145    Some(RenamePin::action(data, pin, Field::Name))
146}
147
148/// A `+` glyph centered at `center`, sized to the marker radius.
149fn draw_plus<R: Renderer>(center: Pos2, radius: WorldPx, role: Role, painter: &mut Style<'_, R>) {
150    let arm = radius.get() * 0.6;
151    painter.line_segment(
152        [center - vec2(arm, 0.0), center + vec2(arm, 0.0)],
153        (2.0, role),
154    );
155    painter.line_segment(
156        [center - vec2(0.0, arm), center + vec2(0.0, arm)],
157        (2.0, role),
158    );
159}
160
161/// One marker `t` of the way through growing: the resting dot fades out as
162/// the ring and its `+` grow in, so it reads as the dot expanding into the
163/// ring.
164fn draw_marker<C: Canvas>(center: Pos2, t: f32, painter: &mut Style<'_, C>) {
165    if t < 1.0 {
166        painter.with_opacity(1.0 - t, |p| {
167            p.circle_filled(
168                center,
169                PORT_RADIUS * NEW_PIN_INACTIVE_SCALE,
170                Role::NewPinPreviewFill,
171            );
172        });
173    }
174    if t > 0.0 {
175        let radius = PORT_RADIUS * (NEW_PIN_INACTIVE_SCALE + (1.0 - NEW_PIN_INACTIVE_SCALE) * t);
176        painter.circle(
177            center,
178            radius,
179            Role::Transparent,
180            (2.0, Role::NewPinPreviewFill),
181        );
182        painter.with_opacity(t, |p| {
183            draw_plus(center, PORT_RADIUS, Role::NewPinPreviewFill, p);
184        });
185    }
186}
187
188/// The animation key for one marker, keyed by block, side and slot so each
189/// marker animates independently. `tag` keeps one tool's animation apart from
190/// another's.
191fn anim_key(tag: &'static str, marker: &SlotMarker) -> AnimKey {
192    let slot = (marker.loc.offset / PIN_PITCH).round() as i32;
193    AnimKey::of((tag, marker.block, marker.loc.side, slot))
194}
195
196/// Draw `markers` as buttons: the one nearest the hover grows its ring, and
197/// the one a press holds down is drawn inverted — a filled disk with a light
198/// `+` — and locks out the hover.
199pub(crate) fn draw_markers<C: Canvas>(
200    markers: &[SlotMarker],
201    interaction: &Interaction,
202    painter: &mut Style<'_, C>,
203) {
204    let held = interaction
205        .press
206        .and_then(|press| marker_at(markers, press.origin))
207        .map(|marker| marker.center);
208    let active = match (held, interaction.event) {
209        (None, Some(Event::HoverAt(pos))) => active_marker(markers, pos),
210        _ => None,
211    };
212    for (i, marker) in markers.iter().enumerate() {
213        if Some(marker.center) == held {
214            painter.circle_filled(marker.center, PORT_RADIUS, Role::NewPinPreviewFill);
215            draw_plus(
216                marker.center,
217                PORT_RADIUS,
218                Role::NewPinPreviewStroke,
219                painter,
220            );
221            continue;
222        }
223        let goal = if active == Some(i) { 1.0 } else { 0.0 };
224        let t = painter.animate(anim_key("new_pin_target", marker), goal, NEW_PIN_ANIM_TIME);
225        draw_marker(marker.center, t, painter);
226    }
227}
228
229/// The block whose markers the route tool shows with the pointer at `pos`:
230/// the one owning the nearest marker in range, so the cursor is never crowded
231/// by several blocks' markers.
232pub(crate) fn nearest_block_markers(markers: &[SlotMarker], pos: Pos2) -> Vec<SlotMarker> {
233    let Some(nearest) = active_marker(markers, pos) else {
234        return Vec::new();
235    };
236    let block = markers[nearest].block;
237    markers
238        .iter()
239        .filter(|marker| marker.block == block)
240        .copied()
241        .collect()
242}
243
244/// Draw the route tool's markers and return the one armed — grown to the
245/// full control — if any. A marker's resting dot appears once the pointer is
246/// within `NEW_PIN_ACTIVATION_RANGE`; the nearest grows into the ring once the
247/// pointer is within `NEW_PIN_GROW_RANGE`.
248pub(crate) fn draw_route_markers<C: Canvas>(
249    markers: &[SlotMarker],
250    pointer: Pos2,
251    painter: &mut Style<'_, C>,
252) -> Option<SlotMarker> {
253    let nearest = active_marker(markers, pointer);
254    let mut armed = None;
255    for (i, marker) in markers.iter().enumerate() {
256        let key = anim_key("route_new_pin_target", marker);
257        let dist = pointer.distance(marker.center);
258        if dist >= NEW_PIN_ACTIVATION_RANGE.get() {
259            // Keep the animation settled while the dot is hidden so it doesn't
260            // pop on re-approach.
261            painter.animate(key, 0.0, NEW_PIN_ANIM_TIME);
262            continue;
263        }
264        let active = nearest == Some(i) && dist < NEW_PIN_GROW_RANGE.get();
265        if active {
266            armed = Some(*marker);
267        }
268        let t = painter.animate(key, if active { 1.0 } else { 0.0 }, NEW_PIN_ANIM_TIME);
269        draw_marker(marker.center, t, painter);
270    }
271    armed
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::path::Scope;
278    use crate::widget::{
279        drawing::Drawing,
280        test_fixtures::{self as fx, Scene},
281    };
282    use blockworx_doc::fixtures::block_id;
283    use blockworx_geom::{Rect, pos2};
284
285    /// A bare 300×300 block: every slot on both of its edges is free.
286    fn scene_300() -> Scene {
287        Scene::new(vec![
288            fx::block_in(
289                1,
290                Scope::Root,
291                Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)),
292            ),
293            fx::titled(1, "b"),
294        ])
295    }
296
297    /// The scene's lone block's outline and markers.
298    fn block_300(drawing: &Drawing<'_>) -> (Rect, Vec<SlotMarker>) {
299        let bbox = drawing
300            .block_shape(block_id(1))
301            .expect("the block is in this scope")
302            .gui_rect();
303        (bbox, markers_on(drawing, block_id(1)))
304    }
305
306    /// The property the user asked for: as large as it can be without
307    /// overlapping a neighbour, and at least one grid unit.
308    #[test]
309    fn the_add_pin_grab_is_the_widest_radius_its_neighbours_leave_it() {
310        let grab = new_pin_grab().get();
311        assert!(
312            grab >= GRID_SIZE,
313            "the grab is under a grid unit: {grab} against {GRID_SIZE}",
314        );
315        assert!(
316            grab > PORT_RADIUS.get(),
317            "precondition: the grab is wider than the ring it draws \
318             ({grab} against {})",
319            PORT_RADIUS.get(),
320        );
321        for (n, clearance) in clearances().into_iter().enumerate() {
322            assert!(
323                grab <= clearance,
324                "the grab of {grab} overlaps neighbour {n}, which leaves {clearance}",
325            );
326        }
327        assert!(
328            clearances().into_iter().any(|room| room == grab),
329            "the grab is smaller than every clearance, so it is not maximal: \
330             {grab} against {:?}",
331            clearances(),
332        );
333    }
334
335    /// The same property against real geometry rather than against the
336    /// arithmetic that produced it: on a laid-out block, the outermost
337    /// marker's grab reaches neither the corner handle beside it, nor the
338    /// next slot's, nor inside the block.
339    #[test]
340    fn the_add_pin_grab_touches_nothing_on_a_real_block() {
341        let mut scene = scene_300();
342        let drawing = scene.drawing();
343        let (bbox, markers) = block_300(&drawing);
344        let grab = new_pin_grab().get();
345        let east: Vec<Pos2> = markers
346            .into_iter()
347            .filter(|marker| marker.loc.side == PinSide::East)
348            .map(|marker| marker.center)
349            .collect();
350        assert!(
351            east.len() >= 2,
352            "precondition: the block has two free slots to crowd each other",
353        );
354
355        for at in &east {
356            assert!(
357                at.x - grab >= bbox.right() - 1e-3,
358                "the marker's grab reaches inside the block: {at:?} against {bbox:?}",
359            );
360            for corner in [bbox.right_top(), bbox.right_bottom()] {
361                assert!(
362                    at.distance(corner) >= grab + HANDLE_GRAB.get() - 1e-3,
363                    "the marker at {at:?} overlaps the resize handle at {corner:?}",
364                );
365            }
366        }
367        for pair in east.windows(2) {
368            assert!(
369                pair[0].distance(pair[1]) >= 2.0 * grab - 1e-3,
370                "two markers' grabs overlap: {:?} and {:?}",
371                pair[0],
372                pair[1],
373            );
374        }
375    }
376
377    #[test]
378    fn new_pin_targets_sit_one_grid_cell_outside_each_edge() {
379        let mut scene = scene_300();
380        let drawing = scene.drawing();
381        let (bbox, markers) = block_300(&drawing);
382        assert!(!markers.is_empty());
383
384        let (mut west, mut east) = (0, 0);
385        for SlotMarker { loc, center: p, .. } in &markers {
386            match loc.side {
387                PinSide::West => {
388                    assert!((p.x - (bbox.left() - GRID_SIZE)).abs() < 1e-3);
389                    west += 1;
390                }
391                PinSide::East => {
392                    assert!((p.x - (bbox.right() + GRID_SIZE)).abs() < 1e-3);
393                    east += 1;
394                }
395            }
396        }
397        // A fresh block has free slots on both sides.
398        assert!(west > 0 && east > 0);
399    }
400
401    #[test]
402    fn the_active_marker_is_the_nearest_within_range_else_none() {
403        let mut scene = scene_300();
404        let drawing = scene.drawing();
405        let (_, markers) = block_300(&drawing);
406
407        // A query right on a marker activates exactly that marker.
408        let first = markers[0].center;
409        assert_eq!(active_marker(&markers, first), Some(0));
410
411        // Just inside the range of the first marker (but nearer to it than any
412        // other) still selects it.
413        let near = first + vec2(NEW_PIN_ACTIVATION_RANGE.get() * 0.5, 0.0);
414        assert_eq!(active_marker(&markers, near), Some(0));
415
416        // Far beyond every marker's activation range → nothing is active.
417        let far = pos2(10_000.0, 10_000.0);
418        assert_eq!(active_marker(&markers, far), None);
419    }
420
421    /// A locked block keeps its pin interface frozen, so it offers no
422    /// markers even though its slots are free.
423    #[test]
424    fn a_locked_block_offers_no_markers() {
425        let mut scene = scene_300();
426        assert!(
427            !offered_markers(&scene.drawing(), [block_id(1)]).is_empty(),
428            "precondition: the unlocked block offers markers",
429        );
430        scene.apply(vec![fx::locked(1)]);
431        let drawing = scene.drawing();
432        assert!(
433            !markers_on(&drawing, block_id(1)).is_empty(),
434            "precondition: the locked block still has free slots",
435        );
436        assert!(offered_markers(&drawing, [block_id(1)]).is_empty());
437        assert!(markers_in_scope(&drawing).is_empty());
438    }
439}