Skip to main content

blockworx/tools/
select_tool.rs

1use std::ops::ControlFlow;
2
3use blockworx_geom::{Pos2, Vec2};
4
5use crate::render::{LabelPass, RouteRenderMode, render_route};
6use crate::theme::Style;
7use crate::{
8    shape::ShapeId,
9    tools::{
10        EditRoute, EditTextBox, MoveBlock, MoveBlockType, MoveLabel, MovePin, MoveTitle,
11        MultiSelect, RenameBlockType, RenamePin, RenameRoute, RenameTitle, RetypePin,
12        names::ToolName,
13        rename_pin::Field,
14        resize_block::ResizeBlock,
15        select_pin::SelectPin,
16        tool::{Action, Tool, ToolTrait},
17    },
18    widget::{
19        drawing::Drawing,
20        hit_target::{HitTarget, PinPart},
21    },
22};
23use blockworx_paint::{Canvas, Cursor, Event, Interaction, Renderer};
24pub struct SelectTool;
25
26impl ToolTrait for SelectTool {
27    fn name(&self) -> ToolName {
28        ToolName::Select
29    }
30
31    fn widget<C: Canvas>(
32        &mut self,
33        data: &mut Drawing,
34        interaction: &Interaction,
35        painter: &mut Style<'_, C>,
36    ) -> Option<Action> {
37        crate::widget::display::widget(data, interaction, painter);
38        // Hovering a pin grows a route-start target; pressing/dragging it hands off
39        // to the route tool. Owns the interaction when the cursor is on a target.
40        if let ControlFlow::Break(action) =
41            crate::tools::route_start::widget(data, interaction, painter)
42        {
43            return action;
44        }
45        painter.set_cursor(Cursor::Default);
46        match interaction.event {
47            Some(Event::HoverAt(pos)) => {
48                let _span = tracing::info_span!("hit_test_hover").entered();
49                // Anything a click or double-click acts on in place gets the
50                // pointing finger, without changing tools.
51                match data.resolve_at_pos(pos, painter) {
52                    Some(
53                        HitTarget::Title(_)
54                        | HitTarget::BlockType(_)
55                        | HitTarget::Pin { .. }
56                        | HitTarget::RouteLabel { .. },
57                    ) => painter.set_cursor(Cursor::PointingHand),
58                    Some(HitTarget::Route(rid)) => {
59                        if let Some((wire, geometry)) =
60                            data.auto_route(rid).zip(data.route_geometry(rid))
61                        {
62                            painter.set_cursor(Cursor::PointingHand);
63                            render_route(
64                                painter,
65                                &wire,
66                                geometry,
67                                RouteRenderMode::Highlighted,
68                                LabelPass::Draw,
69                            );
70                        }
71                    }
72                    Some(HitTarget::Port(_) | HitTarget::Shape(_)) | None => {}
73                }
74            }
75            Some(Event::DoubleClicked { pos }) => {
76                if let Some(tool) = editor_at_pos(data, pos, painter) {
77                    return Some(Action::SwitchTool(tool));
78                }
79                // A double-click on an editor target that simply isn't editable
80                // right now (e.g. a locked block's type) is consumed, not a zoom —
81                // only a double-click on truly empty canvas fits the view.
82                if editor_target_at_pos(data, pos, painter) {
83                    return None;
84                }
85                return Some(Action::ResetView);
86            }
87            Some(Event::DragStarted { pos }) => {
88                return Some(drag_to_move(data, pos, painter));
89            }
90            Some(Event::Clicked { pos }) => {
91                let _span = tracing::info_span!("hit_test_click").entered();
92                // A click selects whatever is under it (route / pin / shape), or
93                // nothing for empty canvas or a route label (the label is left for
94                // the upcoming DoubleClicked rename).
95                return click_to_select(data, pos, painter);
96            }
97            _ => {}
98        }
99
100        None
101    }
102}
103
104/// The text editor a double-click at `pos` should open, if it landed on an
105/// editable label: a title, block tag, pin name/type/tag, route label, or a text
106/// box. Shared by every selecting tool so a double-click always opens the right
107/// editor regardless of what is currently selected — including committing one
108/// in-place edit and handing straight off to another thing double-clicked.
109pub(crate) fn editor_at_pos(
110    data: &Drawing,
111    pos: Pos2,
112    painter: &Style<'_, impl Renderer>,
113) -> Option<Tool> {
114    if data.authoring().is_withheld() {
115        return None;
116    }
117    match data.resolve_at_pos(pos, painter)? {
118        HitTarget::Title(shape) => RenameTitle::new_with_shape(data, shape).map(Into::into),
119        HitTarget::BlockType(rect) => RenameBlockType::new_with_rect(data, rect).map(Into::into),
120        HitTarget::Pin { anchor, part, .. } => match part {
121            PinPart::Name => RenamePin::new_with_anchor(data, anchor, Field::Name).map(Into::into),
122            PinPart::Type => RetypePin::new_with_anchor(data, anchor).map(Into::into),
123            PinPart::Tag => RenamePin::new_with_anchor(data, anchor, Field::Tag).map(Into::into),
124            PinPart::Stub => None,
125        },
126        HitTarget::RouteLabel { route, label } => {
127            RenameRoute::new_with_route_and_label(data, route, label, painter).map(Into::into)
128        }
129        HitTarget::Shape(ShapeId::Text(tid)) => EditTextBox::new_for(data, tid).map(Into::into),
130        HitTarget::Port(_) | HitTarget::Route(_) | HitTarget::Shape(_) => None,
131    }
132}
133
134/// Whether `pos` lands on any in-place-editor target — an editable label or a
135/// text box — *regardless* of whether that target is currently editable. Used to
136/// tell an uneditable target (e.g. a locked block's type) from empty canvas so a
137/// double-click on it is consumed rather than treated as a fit-to-view.
138fn editor_target_at_pos<C: Canvas>(data: &Drawing, pos: Pos2, painter: &Style<'_, C>) -> bool {
139    matches!(
140        data.resolve_at_pos(pos, painter),
141        Some(
142            HitTarget::Title(_)
143                | HitTarget::BlockType(_)
144                | HitTarget::Pin {
145                    part: PinPart::Name | PinPart::Type | PinPart::Tag,
146                    ..
147                }
148                | HitTarget::RouteLabel { .. }
149                | HitTarget::Shape(ShapeId::Text(_))
150        )
151    )
152}
153
154/// Resolve a left-click at `pos` to the tool that selects whatever is under it: a
155/// route, a child-block pin, or a shape (block / port / area / image / text).
156/// Returns `None` when nothing selectable is there — empty canvas, or a route
157/// label (left for the double-click rename). Shared by every selecting tool so a
158/// click always lands on the clicked object, even when something else is already
159/// selected.
160pub(crate) fn click_to_select(
161    data: &Drawing,
162    pos: Pos2,
163    painter: &Style<'_, impl Renderer>,
164) -> Option<Action> {
165    // Selecting a child block (or a port) surfaces its block-style selection.
166    let select_shape = |shape| Some(Action::SwitchTool(ResizeBlock::Selected { shape }.into()));
167    match data.resolve_at_pos(pos, painter)? {
168        HitTarget::Title(shape) | HitTarget::Shape(shape) => select_shape(shape),
169        HitTarget::BlockType(rect) => select_shape(ShapeId::Rect(rect)),
170        HitTarget::Port(port) => select_shape(ShapeId::Port(port)),
171        // Clicking anywhere on a child-block pin selects just that pin; a port's
172        // own labels keep the port's block-style selection.
173        HitTarget::Pin { anchor, .. } => match data.pin_shape(anchor) {
174            Some(port @ ShapeId::Port(_)) => select_shape(port),
175            _ => Some(Action::SwitchTool(SelectPin::Selected { anchor }.into())),
176        },
177        // The label is left for the upcoming DoubleClicked rename.
178        HitTarget::RouteLabel { .. } => None,
179        HitTarget::Route(id) => Some(Action::SwitchTool(
180            EditRoute::Selected { id, anchor: pos }.into(),
181        )),
182    }
183}
184
185/// Resolve a drag started at `pos` to the tool that moves/edits whatever is under
186/// it: a title, block tag, port, child-block pin, route label, route edge, or a
187/// shape body — falling back to a marquee on empty canvas. Always returns an
188/// action. Shared by every selecting tool so a drag begins on the grabbed object
189/// immediately, even when something else is already selected.
190pub(crate) fn drag_to_move<C: Canvas>(
191    data: &mut Drawing,
192    pos: Pos2,
193    painter: &Style<'_, C>,
194) -> Action {
195    // Every drag-to-edit in the app resolves here, so a read-only session
196    // refuses them all in one place — and keeps the marquee, which selects.
197    if data.authoring().is_withheld() {
198        return Action::SwitchTool(
199            MultiSelect::Marquee {
200                start: pos,
201                current: pos,
202                base: Vec::new(),
203            }
204            .into(),
205        );
206    }
207    // Dragging a body moves it immediately; MoveBlock selects it on drag stop.
208    let move_shape = |shape| {
209        MoveBlock::Dragging {
210            shape,
211            delta_pos: Vec2::ZERO,
212        }
213        .into()
214    };
215    let tool: Tool = match data.resolve_at_pos(pos, painter) {
216        Some(HitTarget::Title(shape)) => MoveTitle::Dragging {
217            shape,
218            delta_pos: Vec2::ZERO,
219        }
220        .into(),
221        Some(HitTarget::BlockType(rect)) => MoveBlockType {
222            rect,
223            delta_pos: Vec2::ZERO,
224        }
225        .into(),
226        Some(HitTarget::Port(port)) => move_shape(ShapeId::Port(port)),
227        Some(HitTarget::Pin {
228            anchor, location, ..
229        }) => match data.pin_shape(anchor) {
230            // A port's own labels stand for the port: dragging them moves it,
231            // since a port's pin has no edge to slide along.
232            Some(port @ ShapeId::Port(_)) => move_shape(port),
233            _ => MovePin::Dragging {
234                anchor,
235                location,
236                delta_pos: Vec2::ZERO,
237            }
238            .into(),
239        },
240        Some(HitTarget::RouteLabel { route, label }) => {
241            match MoveLabel::drag_from(data, route, label) {
242                Some(dragging) => dragging.into(),
243                None => EditRoute::Selected {
244                    id: route,
245                    anchor: pos,
246                }
247                .into(),
248            }
249        }
250        Some(HitTarget::Route(id)) => {
251            // Dragging a route edge edits it straight away, skipping the
252            // click-to-select step; if the grab isn't on a draggable part it falls
253            // back to selecting the route.
254            EditRoute::drag_from(data, id, pos)
255                .unwrap_or(EditRoute::Selected { id, anchor: pos })
256                .into()
257        }
258        Some(HitTarget::Shape(shape)) => move_shape(shape),
259        // Empty canvas: begin a marquee multi-selection.
260        None => MultiSelect::Marquee {
261            start: pos,
262            current: pos,
263            base: Vec::new(),
264        }
265        .into(),
266    };
267    Action::SwitchTool(tool)
268}
269
270/// Toggle `shape` into the shape selection `base` (shift-click): add it if absent,
271/// remove it if present. Resolves to the right tool for the resulting size —
272/// nothing → select tool, one → block-style single selection, many → multi-select.
273pub(crate) fn extend_with_shape(base: &[ShapeId], shape: ShapeId) -> Tool {
274    let mut shapes: Vec<ShapeId> = base.to_vec();
275    if let Some(i) = shapes.iter().position(|&s| s == shape) {
276        shapes.remove(i);
277    } else {
278        shapes.push(shape);
279    }
280    match shapes.as_slice() {
281        [] => SelectTool.into(),
282        [only] => ResizeBlock::Selected { shape: *only }.into(),
283        _ => MultiSelect::Selected { shapes }.into(),
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::canvas::Painter;
291    use crate::path::Scope;
292    use crate::{
293        grid::GRID_SIZE,
294        shape::pin::PinSide,
295        theme::Theme,
296        widget::test_fixtures::{self as fx, Scene},
297    };
298    use blockworx_doc::fixtures::block_id;
299    use blockworx_geom::{Rect, pos2};
300
301    /// A world-space rect given in whole grid cells.
302    fn cells(min: (f32, f32), max: (f32, f32)) -> Rect {
303        Rect::from_min_max(
304            pos2(min.0 * GRID_SIZE, min.1 * GRID_SIZE),
305            pos2(max.0 * GRID_SIZE, max.1 * GRID_SIZE),
306        )
307    }
308
309    /// Every point where a double-click opens an in-place editor must open it
310    /// *over that point*: the editor tools are armed through the real
311    /// `editor_at_pos` path and run for one frame, and the `EditText` they
312    /// publish has to cover the clicked spot. This is the net for the
313    /// port-rename class of bug — an editor placed by different math than the
314    /// label that was hit — swept across every label kind in one scene:
315    /// block titles and types (including a clamped stale-offset title), pin
316    /// names/types/tags on both sides, boundary ports, an area, and a text
317    /// box.
318    #[test]
319    fn every_editor_opens_over_the_label_it_was_summoned_from() {
320        // Block 1 is the level being drawn, so its own pins are the boundary
321        // ports; the note's measured extent stays unset, as after a load.
322        let mut scene = Scene::new(vec![
323            fx::block_in(1, Scope::Root, cells((0.0, 0.0), (34.0, 30.0))),
324            fx::pin_at(
325                2,
326                Scope::Block(block_id(1)),
327                "po",
328                fx::slot(PinSide::East, 2),
329                cells((2.0, 2.0), (7.0, 4.0)),
330            ),
331            fx::pin_at(
332                3,
333                Scope::Block(block_id(1)),
334                "pi",
335                fx::slot(PinSide::West, 1),
336                cells((2.0, 5.0), (7.0, 7.0)),
337            ),
338            // A roomy child block with labelled pins on both sides.
339            fx::block_in(
340                4,
341                Scope::Block(block_id(1)),
342                cells((8.0, 8.0), (22.0, 20.0)),
343            ),
344            fx::titled(4, "core"),
345            fx::typed(4, "Widget"),
346            fx::pin_at(
347                5,
348                Scope::Block(block_id(4)),
349                "out",
350                fx::slot(PinSide::East, 1),
351                Rect::ZERO,
352            ),
353            fx::pin_typed(5, "u8"),
354            fx::pin_tagged(5, "t1"),
355            fx::pin_tag_shown(5),
356            fx::pin_at(
357                6,
358                Scope::Block(block_id(4)),
359                "in",
360                fx::slot(PinSide::West, 2),
361                Rect::ZERO,
362            ),
363            fx::pin_typed(6, "u8"),
364            fx::pin_tagged(6, "t2"),
365            fx::pin_tag_shown(6),
366            // The clamp case: a narrow block whose wide title carries a stale
367            // offset, drawn (and hit) clamped back over the block.
368            fx::block_in(
369                7,
370                Scope::Block(block_id(1)),
371                cells((26.0, 8.0), (30.0, 14.0)),
372            ),
373            fx::titled(7, "a rather long title"),
374            fx::title_offset(7, 400.0),
375            fx::area(
376                8,
377                Scope::Block(block_id(1)),
378                cells((2.0, 24.0), (14.0, 28.0)),
379            ),
380            fx::text(
381                9,
382                Scope::Block(block_id(1)),
383                "note",
384                pos2(24.0 * GRID_SIZE, 24.0 * GRID_SIZE),
385            ),
386        ])
387        .inside(block_id(1));
388
389        let idle = Interaction {
390            event: None,
391            press: None,
392            lost_focus: false,
393            enter_pressed: false,
394            tab_pressed: false,
395            escape_pressed: false,
396            delete_pressed: false,
397            shift: false,
398        };
399        let ctx = egui::Context::default();
400        ctx.set_fonts(crate::canvas::build_fonts(
401            blockworx_paint::FontChoice::default(),
402        ));
403        ctx.run_ui(egui::RawInput::default(), |_| {})
404            .drop_without_applying_deltas();
405        let mut editors = 0;
406        ctx.run_ui(egui::RawInput::default(), |ui| {
407            let theme = Theme::default();
408            let mut painter = Painter::headless(ui.painter().clone(), theme.palette().clone());
409            let mut drawing = scene.drawing();
410            // Half-cell sweep over the scene.
411            for i in 0..=68 {
412                for j in 0..=60 {
413                    let pos = pos2(i as f32 * GRID_SIZE * 0.5, j as f32 * GRID_SIZE * 0.5);
414                    let armed = {
415                        let style = Style::new(&theme, &mut painter);
416                        editor_at_pos(&drawing, pos, &style)
417                    };
418                    let Some(mut tool) = armed else { continue };
419                    {
420                        let mut style = Style::new(&theme, &mut painter);
421                        let _ =
422                            crate::tools::tool::frame(&mut tool, &mut drawing, &idle, &mut style);
423                    }
424                    let edit = painter
425                        .take_edit_text()
426                        .unwrap_or_else(|| panic!("editor armed at {pos:?} published no EditText"));
427                    assert!(
428                        edit.position.expand(GRID_SIZE).contains(pos),
429                        "editor at {:?} opened away from the {:?} that summoned it",
430                        edit.position,
431                        pos
432                    );
433                    editors += 1;
434                }
435            }
436        })
437        .drop_without_applying_deltas();
438        // The sweep must actually have exercised the label variety above.
439        assert!(editors > 60, "only {editors} editor points found");
440    }
441}