Skip to main content

blockworx_tools/
multi_select.rs

1use crate::theme::Style;
2use blockworx_geom::{Pos2, Rect, Vec2, WorldPx};
3use blockworx_paint::{Canvas, Event, Interaction};
4
5use crate::edit::geometry::Moving;
6use crate::edit::naming::Authoring;
7use crate::{
8    MultiPinSelect, SelectTool,
9    grid::{GROUP_SELECTION_PAD, snap_offset},
10    names::ToolName,
11    resize_block::ResizeBlock,
12    shape::ShapeId,
13    state::RenderMode,
14    theme::Role,
15    tool::{Action, Deletable, PreviewPhase, ToolTrait, Transition},
16    widget::{DrawingPasses, drawing::Drawing},
17};
18use blockworx_geom::grid::px_rect;
19
20/// Multi-selection of shapes (blocks and/or ports), entered by drawing a marquee
21/// while the `Select` tool is active. Only two operations are supported on the
22/// group: move it rigidly, or delete it (via the Delete button overlay).
23pub enum MultiSelect {
24    /// Rubber-band in progress; nothing is committed yet. `base` is the existing
25    /// selection a shift-marquee extends — empty for a fresh marquee.
26    Marquee {
27        start: Pos2,
28        current: Pos2,
29        base: Vec<ShapeId>,
30    },
31    /// A committed multi-selection (idle).
32    Selected { shapes: Vec<ShapeId> },
33    /// The whole group is being dragged rigidly.
34    Moving {
35        shapes: Vec<ShapeId>,
36        delta_pos: Vec2,
37    },
38}
39
40impl ToolTrait for MultiSelect {
41    fn name(&self) -> ToolName {
42        ToolName::MultiSelect
43    }
44
45    fn selection(&self) -> Option<Deletable> {
46        match self {
47            MultiSelect::Selected { shapes } => Some(Deletable::Shapes(shapes.clone())),
48            _ => None,
49        }
50    }
51
52    fn preview<C: Canvas>(
53        &mut self,
54        data: &mut Drawing,
55        interaction: &Interaction,
56        _painter: &mut Style<'_, C>,
57        phase: &PreviewPhase,
58    ) {
59        let MultiSelect::Moving { shapes, delta_pos } = self else {
60            return;
61        };
62        if let Some(Event::Dragging { delta, .. }) = interaction.event {
63            *delta_pos += delta;
64        }
65        // Suppose each member at its previewed position. Snap the offset to the
66        // grid so it matches the geometry the on-drop commit produces (no
67        // release flash), exactly like `MoveBlock`.
68        let offset = snap_offset(*delta_pos);
69        let drags: Vec<(ShapeId, Vec2)> = shapes.iter().map(|&id| (id, offset)).collect();
70        data.preview_drag(phase, &drags);
71    }
72
73    fn widget<C: Canvas>(
74        &mut self,
75        data: &mut Drawing,
76        interaction: &Interaction,
77        painter: &mut Style<'_, C>,
78    ) -> Option<Transition> {
79        self.render(data, interaction, painter);
80        match self {
81            MultiSelect::Marquee {
82                start,
83                current,
84                base,
85            } => {
86                if let Some(Event::Dragging { pos, .. }) = interaction.event {
87                    *current = pos;
88                } else if let Some(Event::DragStopped { .. }) = interaction.event {
89                    // Every shape — including top-layer areas — fully enclosed
90                    // by the marquee.
91                    let marquee = Rect::from_two_pos(*start, *current);
92                    let enclosed: Vec<ShapeId> = data
93                        .shape_candidates(marquee)
94                        .into_iter()
95                        .chain(data.areas())
96                        .filter(|(_, s)| marquee.contains_rect(s.gui_rect()))
97                        .map(|(id, _)| id)
98                        .collect();
99                    if enclosed.is_empty() && base.is_empty() {
100                        // No whole object enclosed: fall back to a pin-only
101                        // selection if the marquee covered any child-block pins.
102                        let pins = data.pins_in_rect(marquee);
103                        if !pins.is_empty() {
104                            return Some(Transition::SwitchTool(
105                                MultiPinSelect::Selected { pins }.into(),
106                            ));
107                        }
108                        return Some(Transition::SwitchTool(SelectTool.into()));
109                    }
110                    // Union the marquee's catch with the shift-extended base.
111                    let mut shapes = std::mem::take(base);
112                    for id in enclosed {
113                        if !shapes.contains(&id) {
114                            shapes.push(id);
115                        }
116                    }
117                    if let [shape] = shapes[..] {
118                        // Exactly one shape behaves like a single click: hand off to
119                        // the single-selection tool so it gets the full overlay.
120                        return Some(Transition::SwitchTool(
121                            ResizeBlock::Selected { shape }.into(),
122                        ));
123                    }
124                    *self = MultiSelect::Selected { shapes };
125                }
126            }
127            MultiSelect::Selected { shapes } => {
128                if interaction.delete_pressed {
129                    return Some(Action::Delete(Deletable::Shapes(shapes.clone())).into());
130                }
131                if let Some(Event::DragStarted { pos }) = interaction.event {
132                    if interaction.shift {
133                        // Shift-drag extends the group with a new marquee.
134                        return Some(Transition::SwitchTool(
135                            MultiSelect::Marquee {
136                                start: pos,
137                                current: pos,
138                                base: shapes.clone(),
139                            }
140                            .into(),
141                        ));
142                    } else if data.authoring() == Authoring::Offered
143                        && data
144                            .shape_at_pos(pos)
145                            .is_some_and(|hit| shapes.contains(&hit))
146                    {
147                        // Grabbing a member drags the whole group.
148                        let shapes = std::mem::take(shapes);
149                        *self = MultiSelect::Moving {
150                            shapes,
151                            delta_pos: Vec2::ZERO,
152                        };
153                    } else {
154                        // Dragging another object moves it directly; empty canvas
155                        // re-marquees (the fallback inside `drag_to_move`).
156                        return Some(crate::select_tool::drag_to_move(
157                            data,
158                            pos,
159                            painter,
160                            crate::select_tool::IconGrab::NotArmed,
161                        ));
162                    }
163                } else if let Some(Event::Clicked { pos }) = interaction.event {
164                    if interaction.shift {
165                        // Shift-click toggles a shape in/out of the group; clicking
166                        // elsewhere with shift keeps the current selection.
167                        if let Some(hit) = data.shape_at_pos(pos) {
168                            return Some(Transition::SwitchTool(
169                                crate::select_tool::extend_with_shape(shapes, hit),
170                            ));
171                        }
172                        return None;
173                    }
174                    // Clicking another object selects it directly; empty canvas
175                    // clears the group selection.
176                    return Some(
177                        crate::select_tool::click_to_select(data, pos, painter).unwrap_or_default(),
178                    );
179                }
180            }
181            MultiSelect::Moving { .. } => {
182                if let Some(Event::DragStopped { .. }) = interaction.event {
183                    let MultiSelect::Moving { shapes, delta_pos } = self else {
184                        unreachable!()
185                    };
186                    let shapes = std::mem::take(shapes);
187                    data.move_shapes(&shapes, *delta_pos);
188                    // Keep the group selected after the move.
189                    *self = MultiSelect::Selected { shapes };
190                }
191            }
192        }
193        None
194    }
195}
196
197impl MultiSelect {
198    fn render<C: Canvas>(
199        &self,
200        data: &Drawing,
201        interaction: &Interaction,
202        painter: &mut Style<'_, C>,
203    ) {
204        match self {
205            MultiSelect::Marquee {
206                start,
207                current,
208                base,
209            } => {
210                crate::widget::display::widget(data, interaction, painter);
211                // Keep the already-selected shapes framed while a shift-marquee
212                // extends them, so the user sees what's being added to.
213                if !base.is_empty() {
214                    draw_selection_bounds(data, base, Vec2::ZERO, painter);
215                }
216                let marquee = Rect::from_two_pos(*start, *current);
217                painter.rect(
218                    marquee,
219                    WorldPx::ZERO,
220                    Role::MarqueeFill,
221                    (1.0, Role::SelectionFrame),
222                );
223            }
224            MultiSelect::Selected { shapes } => {
225                crate::widget::display::widget(data, interaction, painter);
226                draw_selection_bounds(data, shapes, Vec2::ZERO, painter);
227            }
228            MultiSelect::Moving { shapes, delta_pos } => {
229                // Each moving block's icon travels with it, so preview it moving
230                // alongside the group.
231                let icons: Vec<ShapeId> = shapes
232                    .iter()
233                    .filter_map(|s| s.block().map(ShapeId::Icon))
234                    .collect();
235                let delta = *delta_pos;
236                let mut dragged_ids: Vec<ShapeId> = shapes.clone();
237                dragged_ids.extend(icons.iter().copied());
238                let offset = snap_offset(*delta_pos);
239                let guides =
240                    crate::widget::alignment::shape_drag_guides(data, &dragged_ids, offset);
241                DrawingPasses::new(data)
242                    .shape_mode(move |id| {
243                        if shapes.contains(&id) || icons.contains(&id) {
244                            RenderMode::Moving { delta }
245                        } else {
246                            RenderMode::Normal
247                        }
248                    })
249                    .draw(painter);
250                // Keep the group frame visible, moved with the (grid-snapped) drag.
251                draw_selection_bounds(data, shapes, offset, painter);
252                // A destination the emitter would refuse says so while the
253                // drag is still in the hand — and only the overlaps the move
254                // *creates*, the group rule exempting one the selection
255                // arrived with.
256                for overlap in data.conflicts(shapes, delta, Moving::AsAGroup) {
257                    crate::render::draw_refused_conflict(px_rect(overlap), painter);
258                }
259                // Guides on top so a center guide through the aligned shapes shows.
260                crate::widget::alignment::draw_guides(&guides, painter);
261            }
262        }
263    }
264}
265
266/// Draw one frame around the union of `shapes` (offset by `offset` for a move
267/// preview). No resize handles — those belong only to a single selection.
268fn draw_selection_bounds<C: Canvas>(
269    data: &Drawing,
270    shapes: &[ShapeId],
271    offset: Vec2,
272    painter: &mut Style<'_, C>,
273) {
274    let bounds = crate::selection_bounds::shapes_bounds(data, shapes);
275    if let Some(bounds) = bounds {
276        // Inflate the frame so it sits clear of the shapes' own edges.
277        crate::render::draw_box_outline(
278            bounds.translate(offset).expand(GROUP_SELECTION_PAD),
279            Role::Transparent,
280            (1.0, Role::SelectionFrameOutline),
281            painter,
282        );
283    }
284}