Skip to main content

blockworx_tools/
select_tool.rs

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        // Hovering a pin grows a route-start target; pressing/dragging it hands off
38        // to the route tool. Owns the interaction when the cursor is on a target.
39        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                // Anything a click or double-click acts on in place gets the
47                // pointing finger, without changing tools.
48                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                // A double-click on an editor target that simply isn't editable
78                // right now (e.g. a locked block's type) is consumed, not a zoom —
79                // only a double-click on truly empty canvas fits the view.
80                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                // A click selects whatever is under it (route / pin / shape), or
91                // nothing for empty canvas or a route label (the label is left for
92                // the upcoming DoubleClicked rename).
93                return click_to_select(data, pos, painter);
94            }
95            _ => {}
96        }
97
98        None
99    }
100}
101
102/// The text editor a double-click at `pos` should open, if it landed on an
103/// editable label: a title, block tag, pin name/type/tag, route label, or a text
104/// box. Shared by every selecting tool so a double-click always opens the right
105/// editor regardless of what is currently selected — including committing one
106/// in-place edit and handing straight off to another thing double-clicked.
107pub(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
132/// Whether `pos` lands on any in-place-editor target — an editable label or a
133/// text box — *regardless* of whether that target is currently editable. Used to
134/// tell an uneditable target (e.g. a locked block's type) from empty canvas so a
135/// double-click on it is consumed rather than treated as a fit-to-view.
136fn 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
152/// Resolve a left-click at `pos` to the tool that selects whatever is under it: a
153/// route, a child-block pin, or a shape (block / port / area / image / text).
154/// Returns `None` when nothing selectable is there — empty canvas, or a route
155/// label (left for the double-click rename). Shared by every selecting tool so a
156/// click always lands on the clicked object, even when something else is already
157/// selected.
158pub(crate) fn click_to_select(
159    data: &Drawing,
160    pos: Pos2,
161    painter: &Style<'_, impl Renderer>,
162) -> Option<Transition> {
163    // Selecting a child block (or a port) surfaces its block-style selection.
164    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        // Clicking anywhere on a child-block pin selects just that pin; a port's
174        // own labels keep the port's block-style selection.
175        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        // The label is left for the upcoming DoubleClicked rename.
182        HitTarget::RouteLabel { .. } => None,
183        HitTarget::Route(id) => Some(Transition::SwitchTool(
184            EditRoute::Selected { id, anchor: pos }.into(),
185        )),
186    }
187}
188
189/// Whether a block's icon is the thing currently selected — the one state in
190/// which a drag on it takes the icon rather than the block under it.
191///
192/// An icon is large, centred, and sits on the very face you press to move a
193/// block, while moving it is rare and moving the block constant: a drag on an
194/// unarmed icon is almost always a drag meant for the block. Clicking an icon
195/// arms it — that is what raises its resize handles — so the gesture that asks
196/// for the icon is the one already in the app.
197#[derive(Clone, Copy, PartialEq, Eq, Debug)]
198pub(crate) enum IconGrab {
199    /// Nothing is selected, or something that is not an icon is.
200    NotArmed,
201    Armed(BlockId),
202}
203
204/// Resolve a drag started at `pos` to the tool that moves/edits whatever is under
205/// it: a title, block tag, port, child-block pin, route label, route edge, or a
206/// shape body — falling back to a marquee on empty canvas. Always returns an
207/// action. Shared by every selecting tool so a drag begins on the grabbed object
208/// immediately, even when something else is already selected.
209pub(crate) fn drag_to_move<C: Canvas>(
210    data: &mut Drawing,
211    pos: Pos2,
212    painter: &Style<'_, C>,
213    icon: IconGrab,
214) -> Transition {
215    // Every drag-to-edit in the app resolves here, so a read-only session
216    // refuses them all in one place — and keeps the marquee, which selects.
217    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    // Dragging a body moves it immediately; MoveBlock selects it on drag stop.
228    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            // A port's own labels stand for the port: dragging them moves it,
251            // since a port's pin has no edge to slide along.
252            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            // Dragging a route edge edits it straight away, skipping the
272            // click-to-select step; if the grab isn't on a draggable part it falls
273            // back to selecting the route.
274            EditRoute::drag_from(data, id, pos)
275                .unwrap_or(EditRoute::Selected { id, anchor: pos })
276                .into()
277        }
278        // An icon nobody armed hands the drag to the block it sits on: the
279        // press was meant for the block nine times in ten, and the icon is
280        // still one click away from taking it.
281        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        // Empty canvas: begin a marquee multi-selection.
286        None => MultiSelect::Marquee {
287            start: pos,
288            current: pos,
289            base: Vec::new(),
290        }
291        .into(),
292    };
293    Transition::SwitchTool(tool)
294}
295
296/// Toggle `shape` into the shape selection `base` (shift-click): add it if absent,
297/// remove it if present. Resolves to the right tool for the resulting size —
298/// nothing → select tool, one → block-style single selection, many → multi-select.
299pub 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    /// A world-space rect given in whole grid cells.
328    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    /// An icon covers the face you press to move its block, and moving the
336    /// icon is rare. So a drag on one hands the block the gesture — unless
337    /// the icon is itself the selection, which is the state its resize
338    /// handles are already raised in.
339    #[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    /// Every point where a double-click opens an in-place editor must open it
389    /// *over that point*: the editor tools are armed through the real
390    /// `editor_at_pos` path and run for one frame, and the `EditText` they
391    /// publish has to cover the clicked spot. This is the net for the
392    /// port-rename class of bug — an editor placed by different math than the
393    /// label that was hit — swept across every label kind in one scene:
394    /// block titles and types (including a clamped stale-offset title), pin
395    /// names/types/tags on both sides, boundary ports, an area, and a text
396    /// box.
397    #[test]
398    fn every_editor_opens_over_the_label_it_was_summoned_from() {
399        // Block 1 is the level being drawn, so its own pins are the boundary
400        // ports; the note's measured extent stays unset, as after a load.
401        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            // A roomy child block with labelled pins on both sides.
418            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            // The clamp case: a narrow block whose wide title carries a stale
446            // offset, drawn (and hit) clamped back over the block.
447            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        // Half-cell sweep over the scene.
485        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        // The sweep must actually have exercised the label variety above.
510        assert!(editors > 60, "only {editors} editor points found");
511    }
512}