1use 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};
25pub(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
42pub(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
56fn anim_key(anchor: PinId) -> AnimKey {
59 AnimKey::of(("route_start_target", anchor))
60}
61
62#[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 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
111pub(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 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 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
152fn 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
157fn 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 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 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 #[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}