1use std::ops::ControlFlow;
2
3use blockworx_doc::id::BlockId;
4use blockworx_geom::{Pos2, Vec2};
5
6use crate::render::{LabelPass, RouteRenderMode, render_route};
7use crate::theme::Style;
8use crate::{
9 EditRoute, EditTextBox, MoveBlock, MoveBlockType, MoveLabel, MovePin, MoveTitle, MultiSelect,
10 RenameBlockType, RenamePin, RenameRoute, RenameTitle, RetypePin,
11 names::ToolName,
12 rename_pin::Field,
13 resize_block::ResizeBlock,
14 select_pin::SelectPin,
15 shape::ShapeId,
16 tool::{Action, Tool, ToolTrait, Transition},
17 widget::{
18 drawing::Drawing,
19 hit_target::{HitTarget, PinPart},
20 },
21};
22use blockworx_paint::{Canvas, Cursor, Event, Interaction, Renderer};
23pub struct SelectTool;
24
25impl ToolTrait for SelectTool {
26 fn name(&self) -> ToolName {
27 ToolName::Select
28 }
29
30 fn widget<C: Canvas>(
31 &mut self,
32 data: &mut Drawing,
33 interaction: &Interaction,
34 painter: &mut Style<'_, C>,
35 ) -> Option<Transition> {
36 crate::widget::display::widget(data, interaction, painter);
37 if let ControlFlow::Break(action) = crate::route_start::widget(data, interaction, painter) {
40 return action;
41 }
42 painter.set_cursor(Cursor::Default);
43 match interaction.event {
44 Some(Event::HoverAt(pos)) => {
45 let _span = tracing::info_span!("hit_test_hover").entered();
46 match data.resolve_at_pos(pos, painter) {
49 Some(
50 HitTarget::Title(_)
51 | HitTarget::BlockType(_)
52 | HitTarget::Pin { .. }
53 | HitTarget::RouteLabel { .. },
54 ) => painter.set_cursor(Cursor::PointingHand),
55 Some(HitTarget::Route(rid)) => {
56 let hops = data.hops_of(rid);
57 if let Some((wire, course)) =
58 data.auto_route(rid).zip(data.course(rid, &hops))
59 {
60 painter.set_cursor(Cursor::PointingHand);
61 render_route(
62 painter,
63 &wire,
64 course,
65 RouteRenderMode::Highlighted,
66 LabelPass::Draw,
67 );
68 }
69 }
70 Some(HitTarget::Port(_) | HitTarget::Shape(_)) | None => {}
71 }
72 }
73 Some(Event::DoubleClicked { pos }) => {
74 if let Some(tool) = editor_at_pos(data, pos, painter) {
75 return Some(Transition::SwitchTool(tool));
76 }
77 if editor_target_at_pos(data, pos, painter) {
81 return None;
82 }
83 return Some(Action::ResetView.into());
84 }
85 Some(Event::DragStarted { pos }) => {
86 return Some(drag_to_move(data, pos, painter, IconGrab::NotArmed));
87 }
88 Some(Event::Clicked { pos }) => {
89 let _span = tracing::info_span!("hit_test_click").entered();
90 return click_to_select(data, pos, painter);
94 }
95 _ => {}
96 }
97
98 None
99 }
100}
101
102pub(crate) fn editor_at_pos(
108 data: &Drawing,
109 pos: Pos2,
110 painter: &Style<'_, impl Renderer>,
111) -> Option<Tool> {
112 if data.authoring().is_withheld() {
113 return None;
114 }
115 match data.resolve_at_pos(pos, painter)? {
116 HitTarget::Title(shape) => RenameTitle::new_with_shape(data, shape).map(Into::into),
117 HitTarget::BlockType(rect) => RenameBlockType::new_with_rect(data, rect).map(Into::into),
118 HitTarget::Pin { anchor, part, .. } => match part {
119 PinPart::Name => RenamePin::new_with_anchor(data, anchor, Field::Name).map(Into::into),
120 PinPart::Type => RetypePin::new_with_anchor(data, anchor).map(Into::into),
121 PinPart::Tag => RenamePin::new_with_anchor(data, anchor, Field::Tag).map(Into::into),
122 PinPart::Stub => None,
123 },
124 HitTarget::RouteLabel { route, label } => {
125 RenameRoute::new_with_route_and_label(data, route, label, painter).map(Into::into)
126 }
127 HitTarget::Shape(ShapeId::Text(tid)) => EditTextBox::new_for(data, tid).map(Into::into),
128 HitTarget::Port(_) | HitTarget::Route(_) | HitTarget::Shape(_) => None,
129 }
130}
131
132fn editor_target_at_pos<C: Canvas>(data: &Drawing, pos: Pos2, painter: &Style<'_, C>) -> bool {
137 matches!(
138 data.resolve_at_pos(pos, painter),
139 Some(
140 HitTarget::Title(_)
141 | HitTarget::BlockType(_)
142 | HitTarget::Pin {
143 part: PinPart::Name | PinPart::Type | PinPart::Tag,
144 ..
145 }
146 | HitTarget::RouteLabel { .. }
147 | HitTarget::Shape(ShapeId::Text(_))
148 )
149 )
150}
151
152pub(crate) fn click_to_select(
159 data: &Drawing,
160 pos: Pos2,
161 painter: &Style<'_, impl Renderer>,
162) -> Option<Transition> {
163 let select_shape = |shape| {
165 Some(Transition::SwitchTool(
166 ResizeBlock::Selected { shape }.into(),
167 ))
168 };
169 match data.resolve_at_pos(pos, painter)? {
170 HitTarget::Title(shape) | HitTarget::Shape(shape) => select_shape(shape),
171 HitTarget::BlockType(rect) => select_shape(ShapeId::Rect(rect)),
172 HitTarget::Port(port) => select_shape(ShapeId::Port(port)),
173 HitTarget::Pin { anchor, .. } => match data.pin_shape(anchor) {
176 Some(port @ ShapeId::Port(_)) => select_shape(port),
177 _ => Some(Transition::SwitchTool(
178 SelectPin::Selected { anchor }.into(),
179 )),
180 },
181 HitTarget::RouteLabel { .. } => None,
183 HitTarget::Route(id) => Some(Transition::SwitchTool(
184 EditRoute::Selected { id, anchor: pos }.into(),
185 )),
186 }
187}
188
189#[derive(Clone, Copy, PartialEq, Eq, Debug)]
198pub(crate) enum IconGrab {
199 NotArmed,
201 Armed(BlockId),
202}
203
204pub(crate) fn drag_to_move<C: Canvas>(
210 data: &mut Drawing,
211 pos: Pos2,
212 painter: &Style<'_, C>,
213 icon: IconGrab,
214) -> Transition {
215 if data.authoring().is_withheld() {
218 return Transition::SwitchTool(
219 MultiSelect::Marquee {
220 start: pos,
221 current: pos,
222 base: Vec::new(),
223 }
224 .into(),
225 );
226 }
227 let move_shape = |shape| {
229 MoveBlock::Dragging {
230 shape,
231 delta_pos: Vec2::ZERO,
232 }
233 .into()
234 };
235 let tool: Tool = match data.resolve_at_pos(pos, painter) {
236 Some(HitTarget::Title(shape)) => MoveTitle::Dragging {
237 shape,
238 delta_pos: Vec2::ZERO,
239 }
240 .into(),
241 Some(HitTarget::BlockType(rect)) => MoveBlockType {
242 rect,
243 delta_pos: Vec2::ZERO,
244 }
245 .into(),
246 Some(HitTarget::Port(port)) => move_shape(ShapeId::Port(port)),
247 Some(HitTarget::Pin {
248 anchor, location, ..
249 }) => match data.pin_shape(anchor) {
250 Some(port @ ShapeId::Port(_)) => move_shape(port),
253 _ => MovePin::Dragging {
254 anchor,
255 location,
256 delta_pos: Vec2::ZERO,
257 }
258 .into(),
259 },
260 Some(HitTarget::RouteLabel { route, label }) => {
261 match MoveLabel::drag_from(data, route, label) {
262 Some(dragging) => dragging.into(),
263 None => EditRoute::Selected {
264 id: route,
265 anchor: pos,
266 }
267 .into(),
268 }
269 }
270 Some(HitTarget::Route(id)) => {
271 EditRoute::drag_from(data, id, pos)
275 .unwrap_or(EditRoute::Selected { id, anchor: pos })
276 .into()
277 }
278 Some(HitTarget::Shape(ShapeId::Icon(rid))) if icon != IconGrab::Armed(rid) => {
282 move_shape(ShapeId::Rect(rid))
283 }
284 Some(HitTarget::Shape(shape)) => move_shape(shape),
285 None => MultiSelect::Marquee {
287 start: pos,
288 current: pos,
289 base: Vec::new(),
290 }
291 .into(),
292 };
293 Transition::SwitchTool(tool)
294}
295
296pub fn extend_with_shape(base: &[ShapeId], shape: ShapeId) -> Tool {
300 let mut shapes: Vec<ShapeId> = base.to_vec();
301 if let Some(i) = shapes.iter().position(|&s| s == shape) {
302 shapes.remove(i);
303 } else {
304 shapes.push(shape);
305 }
306 match shapes.as_slice() {
307 [] => SelectTool.into(),
308 [only] => ResizeBlock::Selected { shape: *only }.into(),
309 _ => MultiSelect::Selected { shapes }.into(),
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::path::Scope;
317 use crate::{
318 grid::GRID_SIZE,
319 shape::pin::PinSide,
320 theme::Theme,
321 widget::test_fixtures::{self as fx, Scene},
322 };
323 use blockworx_doc::fixtures::block_id;
324 use blockworx_geom::{Rect, pos2};
325 use blockworx_text::measure::{Measured, Scripted};
326
327 fn cells(min: (f32, f32), max: (f32, f32)) -> Rect {
329 Rect::from_min_max(
330 pos2(min.0 * GRID_SIZE, min.1 * GRID_SIZE),
331 pos2(max.0 * GRID_SIZE, max.1 * GRID_SIZE),
332 )
333 }
334
335 #[test]
340 fn an_icon_takes_a_drag_only_while_it_is_the_selection() {
341 let inside = cells((10.0, 10.0), (18.0, 16.0));
342 let scene = || {
343 Scene::new(vec![
344 fx::block_in(4, Scope::Root, cells((6.0, 6.0), (22.0, 20.0))),
345 fx::titled(4, "core"),
346 fx::icon(4, inside),
347 ])
348 };
349 let on_the_icon = inside.center();
350 let icon = ShapeId::Icon(block_id(4));
351
352 let hit = crate::headless::Canvas::new(scene())
353 .probe(|drawing, style| drawing.resolve_at_pos(on_the_icon, style));
354 assert!(
355 matches!(hit, Some(HitTarget::Shape(ShapeId::Icon(_)))),
356 "precondition: the point hits the icon, so the gate is the only thing \
357 that decides what the drag takes: {hit:?}",
358 );
359
360 let dragged = |armed: IconGrab| {
361 crate::headless::Canvas::new(scene()).probe_mut(|drawing, style| {
362 match drag_to_move(drawing, on_the_icon, style, armed) {
363 Transition::SwitchTool(Tool::MoveBlock(MoveBlock::Dragging {
364 shape, ..
365 })) => Some(shape),
366 _ => None,
367 }
368 })
369 };
370
371 assert_eq!(
372 dragged(IconGrab::NotArmed),
373 Some(ShapeId::Rect(block_id(4))),
374 "an unarmed icon must hand the drag to the block it sits on",
375 );
376 assert_eq!(
377 dragged(IconGrab::Armed(block_id(4))),
378 Some(icon),
379 "the icon is the selection, so the drag is the one that was asked for",
380 );
381 assert_eq!(
382 dragged(IconGrab::Armed(block_id(1))),
383 Some(ShapeId::Rect(block_id(4))),
384 "another block's icon being armed is not this one being armed",
385 );
386 }
387
388 #[test]
398 fn every_editor_opens_over_the_label_it_was_summoned_from() {
399 let mut scene = Scene::new(vec![
402 fx::block_in(1, Scope::Root, cells((0.0, 0.0), (34.0, 30.0))),
403 fx::pin_at(
404 2,
405 Scope::Block(block_id(1)),
406 "po",
407 fx::slot(PinSide::East, 2),
408 cells((2.0, 2.0), (7.0, 4.0)),
409 ),
410 fx::pin_at(
411 3,
412 Scope::Block(block_id(1)),
413 "pi",
414 fx::slot(PinSide::West, 1),
415 cells((2.0, 5.0), (7.0, 7.0)),
416 ),
417 fx::block_in(
419 4,
420 Scope::Block(block_id(1)),
421 cells((8.0, 8.0), (22.0, 20.0)),
422 ),
423 fx::titled(4, "core"),
424 fx::typed(4, "Widget"),
425 fx::pin_at(
426 5,
427 Scope::Block(block_id(4)),
428 "out",
429 fx::slot(PinSide::East, 1),
430 Rect::ZERO,
431 ),
432 fx::pin_typed(5, "u8"),
433 fx::pin_tagged(5, "t1"),
434 fx::pin_tag_shown(5),
435 fx::pin_at(
436 6,
437 Scope::Block(block_id(4)),
438 "in",
439 fx::slot(PinSide::West, 2),
440 Rect::ZERO,
441 ),
442 fx::pin_typed(6, "u8"),
443 fx::pin_tagged(6, "t2"),
444 fx::pin_tag_shown(6),
445 fx::block_in(
448 7,
449 Scope::Block(block_id(1)),
450 cells((26.0, 8.0), (30.0, 14.0)),
451 ),
452 fx::titled(7, "a rather long title"),
453 fx::title_offset(7, 400.0),
454 fx::area(
455 8,
456 Scope::Block(block_id(1)),
457 cells((2.0, 24.0), (14.0, 28.0)),
458 ),
459 fx::text(
460 9,
461 Scope::Block(block_id(1)),
462 "note",
463 pos2(24.0 * GRID_SIZE, 24.0 * GRID_SIZE),
464 ),
465 ])
466 .inside(block_id(1));
467
468 let idle = Interaction {
469 event: None,
470 press: None,
471 text: None,
472 escape_pressed: false,
473 delete_pressed: false,
474 shift: false,
475 };
476 let theme = Theme::default();
477 let measured = Measured::new(
478 blockworx_paint::FontChoice::default(),
479 theme.palette().clone(),
480 );
481 let mut canvas = measured.canvas(Scripted::default());
482 let mut editors = 0;
483 let mut drawing = scene.drawing();
484 for i in 0..=68 {
486 for j in 0..=60 {
487 let pos = pos2(i as f32 * GRID_SIZE * 0.5, j as f32 * GRID_SIZE * 0.5);
488 let armed = {
489 let style = Style::new(&theme, &mut canvas);
490 editor_at_pos(&drawing, pos, &style)
491 };
492 let Some(mut tool) = armed else { continue };
493 {
494 let mut style = Style::new(&theme, &mut canvas);
495 let _ = crate::tool::frame(&mut tool, &mut drawing, &idle, &mut style);
496 }
497 let edit = canvas
498 .take_edit_text()
499 .unwrap_or_else(|| panic!("editor armed at {pos:?} published no EditText"));
500 assert!(
501 edit.position.expand(GRID_SIZE).contains(pos),
502 "editor at {:?} opened away from the {:?} that summoned it",
503 edit.position,
504 pos
505 );
506 editors += 1;
507 }
508 }
509 assert!(editors > 60, "only {editors} editor points found");
511 }
512}