1use 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};
28use crate::render::HANDLE_GRAB;
31pub(crate) use crate::render::{NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE};
32
33pub(crate) const NEW_PIN_ACTIVATION_RANGE: WorldPx = WorldPx::new(3.0 * GRID_SIZE);
35pub(crate) fn new_pin_grab() -> WorldPx {
46 WorldPx::new(clearances().into_iter().fold(f32::INFINITY, f32::min))
47}
48
49fn clearances() -> [f32; 5] {
52 [
53 GRID_SIZE,
56 PIN_PITCH * 0.5,
59 GRID_SIZE.hypot(PIN_TOP_MARGIN) - HANDLE_GRAB.get(),
63 PIN_PITCH - NEW_PIN_GROW_RANGE.get(),
66 PIN_PITCH - (GRID_SIZE / 3.0 + ROUTE_HIT_MARGIN.get()),
68 ]
69}
70
71#[derive(Clone, Copy, Debug)]
74pub struct SlotMarker {
75 pub block: BlockId,
76 pub loc: PinLocation,
77 pub center: Pos2,
78}
79
80pub 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
103pub 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
116pub fn markers_in_scope(data: &Drawing) -> Vec<SlotMarker> {
118 offered_markers(data, data.current_blocks().map(|(block, _)| block))
119}
120
121fn 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
133pub 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
141pub(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
148fn 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
161fn 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
188fn 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
196pub(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
229pub(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
244pub(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 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 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 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 #[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 #[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 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 let first = markers[0].center;
409 assert_eq!(active_marker(&markers, first), Some(0));
410
411 let near = first + vec2(NEW_PIN_ACTIVATION_RANGE.get() * 0.5, 0.0);
414 assert_eq!(active_marker(&markers, near), Some(0));
415
416 let far = pos2(10_000.0, 10_000.0);
418 assert_eq!(active_marker(&markers, far), None);
419 }
420
421 #[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}