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::naming::Authoring;
6use crate::{
7    grid::{GROUP_SELECTION_PAD, snap_offset},
8    shape::ShapeId,
9    state::RenderMode,
10    theme::Role,
11    tools::{
12        MultiPinSelect, SelectTool,
13        names::ToolName,
14        resize_block::ResizeBlock,
15        tool::{Action, Deletable, Supposing, ToolTrait},
16    },
17    widget::{DrawingPasses, drawing::Drawing},
18};
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 suppose<C: Canvas>(
53        &mut self,
54        data: &mut Drawing,
55        interaction: &Interaction,
56        _painter: &mut Style<'_, C>,
57        phase: &Supposing,
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.suppose_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<Action> {
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(Action::SwitchTool(
105                                MultiPinSelect::Selected { pins }.into(),
106                            ));
107                        }
108                        return Some(Action::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(Action::SwitchTool(ResizeBlock::Selected { shape }.into()));
121                    }
122                    *self = MultiSelect::Selected { shapes };
123                }
124            }
125            MultiSelect::Selected { shapes } => {
126                if interaction.delete_pressed {
127                    return Some(Action::Delete(Deletable::Shapes(shapes.clone())));
128                }
129                if let Some(Event::DragStarted { pos }) = interaction.event {
130                    if interaction.shift {
131                        // Shift-drag extends the group with a new marquee.
132                        return Some(Action::SwitchTool(
133                            MultiSelect::Marquee {
134                                start: pos,
135                                current: pos,
136                                base: shapes.clone(),
137                            }
138                            .into(),
139                        ));
140                    } else if data.authoring() == Authoring::Offered
141                        && data
142                            .shape_at_pos(pos)
143                            .is_some_and(|hit| shapes.contains(&hit))
144                    {
145                        // Grabbing a member drags the whole group.
146                        let shapes = std::mem::take(shapes);
147                        *self = MultiSelect::Moving {
148                            shapes,
149                            delta_pos: Vec2::ZERO,
150                        };
151                    } else {
152                        // Dragging another object moves it directly; empty canvas
153                        // re-marquees (the fallback inside `drag_to_move`).
154                        return Some(crate::tools::select_tool::drag_to_move(data, pos, painter));
155                    }
156                } else if let Some(Event::Clicked { pos }) = interaction.event {
157                    if interaction.shift {
158                        // Shift-click toggles a shape in/out of the group; clicking
159                        // elsewhere with shift keeps the current selection.
160                        if let Some(hit) = data.shape_at_pos(pos) {
161                            return Some(Action::SwitchTool(
162                                crate::tools::select_tool::extend_with_shape(shapes, hit),
163                            ));
164                        }
165                        return None;
166                    }
167                    // Clicking another object selects it directly; empty canvas
168                    // clears the group selection.
169                    return Some(
170                        crate::tools::select_tool::click_to_select(data, pos, painter)
171                            .unwrap_or_default(),
172                    );
173                }
174            }
175            MultiSelect::Moving { .. } => {
176                if let Some(Event::DragStopped { .. }) = interaction.event {
177                    let MultiSelect::Moving { shapes, delta_pos } = self else {
178                        unreachable!()
179                    };
180                    let shapes = std::mem::take(shapes);
181                    data.move_shapes(&shapes, *delta_pos);
182                    // Keep the group selected after the move.
183                    *self = MultiSelect::Selected { shapes };
184                }
185            }
186        }
187        None
188    }
189}
190
191impl MultiSelect {
192    fn render<C: Canvas>(
193        &self,
194        data: &Drawing,
195        interaction: &Interaction,
196        painter: &mut Style<'_, C>,
197    ) {
198        match self {
199            MultiSelect::Marquee {
200                start,
201                current,
202                base,
203            } => {
204                crate::widget::display::widget(data, interaction, painter);
205                // Keep the already-selected shapes framed while a shift-marquee
206                // extends them, so the user sees what's being added to.
207                if !base.is_empty() {
208                    draw_selection_bounds(data, base, Vec2::ZERO, painter);
209                }
210                let marquee = Rect::from_two_pos(*start, *current);
211                painter.rect(
212                    marquee,
213                    WorldPx::ZERO,
214                    Role::MarqueeFill,
215                    (1.0, Role::SelectionFrame),
216                );
217            }
218            MultiSelect::Selected { shapes } => {
219                crate::widget::display::widget(data, interaction, painter);
220                draw_selection_bounds(data, shapes, Vec2::ZERO, painter);
221            }
222            MultiSelect::Moving { shapes, delta_pos } => {
223                // Each moving block's icon travels with it, so preview it moving
224                // alongside the group.
225                let icons: Vec<ShapeId> = shapes
226                    .iter()
227                    .filter_map(|s| s.block().map(ShapeId::Icon))
228                    .collect();
229                let delta = *delta_pos;
230                let mut dragged_ids: Vec<ShapeId> = shapes.clone();
231                dragged_ids.extend(icons.iter().copied());
232                let offset = snap_offset(*delta_pos);
233                let guides =
234                    crate::widget::alignment::shape_drag_guides(data, &dragged_ids, offset);
235                DrawingPasses::new(data)
236                    .shape_mode(move |id| {
237                        if shapes.contains(&id) || icons.contains(&id) {
238                            RenderMode::Moving { delta }
239                        } else {
240                            RenderMode::Normal
241                        }
242                    })
243                    .draw(painter);
244                // Keep the group frame visible, moved with the (grid-snapped) drag.
245                draw_selection_bounds(data, shapes, offset, painter);
246                // Guides on top so a center guide through the aligned shapes shows.
247                crate::widget::alignment::draw_guides(&guides, painter);
248            }
249        }
250    }
251}
252
253/// Draw one frame around the union of `shapes` (offset by `offset` for a move
254/// preview). No resize handles — those belong only to a single selection.
255fn draw_selection_bounds<C: Canvas>(
256    data: &Drawing,
257    shapes: &[ShapeId],
258    offset: Vec2,
259    painter: &mut Style<'_, C>,
260) {
261    let bounds = crate::tools::selection_bounds::shapes_bounds(data, shapes);
262    if let Some(bounds) = bounds {
263        // Inflate the frame so it sits clear of the shapes' own edges.
264        crate::render::draw_box_outline(
265            bounds.translate(offset).expand(GROUP_SELECTION_PAD),
266            Role::Transparent,
267            (1.0, Role::SelectionFrameOutline),
268            painter,
269        );
270    }
271}