1use 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};
28pub(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
45pub(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
59fn anim_key(anchor: PinId) -> AnimKey {
62 AnimKey::of(("route_start_target", anchor))
63}
64
65#[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 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
114pub(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 if data.authoring().is_withheld() {
127 return ControlFlow::Continue(());
128 }
129 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 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
166fn 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
171fn 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 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 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 #[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}