Skip to main content

ferrum_flow/plugins/selection/
mod.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use gpui::{
5    AnyElement, Bounds, Element, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, Point, Size,
6    Styled, div, px, rgb, rgba,
7};
8
9use crate::{
10    FlowTheme, Graph, Node, NodeId,
11    canvas::{Interaction, InteractionResult},
12    plugin::{
13        EventResult, FlowEvent, InputEvent, Plugin, PluginContext, RenderContext, RenderLayer,
14    },
15};
16
17const DRAG_THRESHOLD: Pixels = px(2.0);
18const DRAG_COMMAND_INTERVAL: Duration = Duration::from_millis(50);
19
20pub struct SelectionPlugin {
21    selected: Option<Selected>,
22}
23
24struct Selected {
25    bounds: Bounds<Pixels>,
26    nodes: HashMap<NodeId, Point<Pixels>>,
27}
28
29impl SelectionPlugin {
30    pub fn new() -> Self {
31        Self { selected: None }
32    }
33}
34
35impl Plugin for SelectionPlugin {
36    fn name(&self) -> &'static str {
37        "selection"
38    }
39    fn setup(&mut self, _ctx: &mut crate::plugin::InitPluginContext) {}
40    fn on_event(
41        &mut self,
42        event: &FlowEvent,
43        ctx: &mut crate::plugin::PluginContext,
44    ) -> EventResult {
45        if let FlowEvent::Input(InputEvent::MouseDown(ev)) = event {
46            if ev.button != MouseButton::Left {
47                return EventResult::Continue;
48            }
49            if !ev.modifiers.shift {
50                let start = ctx.viewport.screen_to_world(ev.position);
51                if let Some(Selected { bounds, nodes }) = self.selected.take() {
52                    if bounds.contains(&start) {
53                        ctx.start_interaction(SelectionInteraction::start_move(
54                            start, bounds, nodes,
55                        ));
56
57                        return EventResult::Stop;
58                    }
59                }
60
61                ctx.start_interaction(SelectionInteraction::new(start));
62                return EventResult::Stop;
63            }
64        } else if let Some(SelectedEvent { bounds, nodes }) = event.as_custom() {
65            self.selected = Some(Selected {
66                bounds: *bounds,
67                nodes: nodes.clone(),
68            });
69            return EventResult::Stop;
70        }
71
72        EventResult::Continue
73    }
74    fn priority(&self) -> i32 {
75        100
76    }
77    fn render_layer(&self) -> RenderLayer {
78        RenderLayer::Selection
79    }
80    fn render(&mut self, ctx: &mut RenderContext) -> Option<AnyElement> {
81        self.selected.as_ref().map(|Selected { bounds, .. }| {
82            let top_left = ctx.world_to_screen(bounds.origin);
83
84            let size = Size::new(
85                bounds.size.width * ctx.viewport.zoom,
86                bounds.size.height * ctx.viewport.zoom,
87            );
88            render_rect(Bounds::new(top_left, size), ctx.theme)
89        })
90    }
91}
92
93pub struct SelectionInteraction {
94    state: SelectionState,
95    last_drag_command_at: Option<Instant>,
96}
97
98enum SelectionState {
99    Pending {
100        start: Point<Pixels>,
101    },
102    Selecting {
103        start: Point<Pixels>,
104        end: Point<Pixels>,
105    },
106    Moving {
107        start_mouse: Point<Pixels>,
108        start_bounds: Bounds<Pixels>,
109        bounds: Bounds<Pixels>,
110        nodes: HashMap<NodeId, Point<Pixels>>,
111    },
112}
113struct SelectedEvent {
114    bounds: Bounds<Pixels>,
115    nodes: HashMap<NodeId, Point<Pixels>>,
116}
117
118impl SelectionInteraction {
119    pub fn new(start: Point<Pixels>) -> Self {
120        Self {
121            state: SelectionState::Pending { start },
122            last_drag_command_at: None,
123        }
124    }
125    pub fn start_move(
126        mouse: Point<Pixels>,
127        bounds: Bounds<Pixels>,
128        nodes: HashMap<NodeId, Point<Pixels>>,
129    ) -> Self {
130        Self {
131            state: SelectionState::Moving {
132                start_mouse: mouse,
133                start_bounds: bounds.clone(),
134                bounds: bounds,
135                nodes,
136            },
137            last_drag_command_at: None,
138        }
139    }
140}
141
142impl Interaction for SelectionInteraction {
143    fn on_mouse_move(&mut self, ev: &MouseMoveEvent, ctx: &mut PluginContext) -> InteractionResult {
144        let mouse_world = ctx.screen_to_world(ev.position);
145        match &mut self.state {
146            SelectionState::Pending { start } => {
147                let delta = mouse_world - *start;
148
149                if delta.x.abs() > DRAG_THRESHOLD && delta.y.abs() > DRAG_THRESHOLD {
150                    self.state = SelectionState::Selecting {
151                        start: *start,
152                        end: mouse_world,
153                    };
154
155                    ctx.notify();
156                }
157            }
158
159            SelectionState::Selecting { end, .. } => {
160                *end = mouse_world;
161                ctx.notify();
162            }
163
164            SelectionState::Moving {
165                start_mouse,
166                start_bounds,
167                bounds,
168                nodes,
169            } => {
170                let delta = mouse_world - *start_mouse;
171
172                for (id, start_pos) in nodes.iter_mut() {
173                    if let Some(node) = ctx.get_node_mut(id) {
174                        node.x = start_pos.x + delta.x;
175                        node.y = start_pos.y + delta.y;
176                    }
177                }
178                *bounds = Bounds::new(start_bounds.origin + delta, start_bounds.size);
179
180                if ctx.has_sync_plugin() {
181                    let now = Instant::now();
182                    let should_command = self
183                        .last_drag_command_at
184                        .map(|t| now.duration_since(t) >= DRAG_COMMAND_INTERVAL)
185                        .unwrap_or(true);
186                    if should_command {
187                        let start_position: Vec<_> = nodes
188                            .iter()
189                            .map(|(id, point)| (id.clone(), point.clone()))
190                            .collect();
191                        ctx.execute_command(super::node::DragNodesCommand::new(
192                            &start_position,
193                            &ctx,
194                        ));
195                        self.last_drag_command_at = Some(now);
196                    }
197                }
198
199                ctx.notify();
200            }
201        }
202
203        InteractionResult::Continue
204    }
205    fn on_mouse_up(&mut self, _ev: &MouseUpEvent, ctx: &mut PluginContext) -> InteractionResult {
206        match &mut self.state {
207            SelectionState::Pending { .. } => {
208                return InteractionResult::End;
209            }
210
211            SelectionState::Selecting { start, end } => {
212                let rect = normalize_rect(*start, *end);
213
214                let mut selected = HashMap::new();
215
216                ctx.clear_selected_node();
217
218                for node in ctx.graph.nodes().values() {
219                    let bounds = node_world_bounds(node);
220
221                    if rect.intersects(&bounds) {
222                        selected.insert(node.id, node.point());
223                    }
224                }
225
226                for (id, _) in selected.iter() {
227                    ctx.add_selected_node(*id, true);
228                }
229
230                let bounds = compute_nodes_bounds(&selected, ctx.graph);
231
232                ctx.cancel_interaction();
233                ctx.emit(FlowEvent::custom(SelectedEvent {
234                    bounds,
235                    nodes: selected,
236                }));
237
238                return InteractionResult::End;
239            }
240
241            SelectionState::Moving { bounds, nodes, .. } => {
242                let bounds = *bounds;
243
244                let mut new_nodes = HashMap::new();
245                for (id, _) in nodes.iter() {
246                    ctx.add_selected_node(*id, true);
247                    if let Some(node) = ctx.graph.nodes().get(id) {
248                        new_nodes.insert(id.clone(), node.point());
249                    }
250                }
251
252                let start_position: Vec<_> = nodes
253                    .iter()
254                    .map(|(id, point)| (id.clone(), point.clone()))
255                    .collect();
256
257                ctx.execute_command(super::node::DragNodesCommand::new(&start_position, ctx));
258
259                ctx.emit(FlowEvent::custom(SelectedEvent {
260                    bounds,
261                    nodes: new_nodes,
262                }));
263
264                return InteractionResult::End;
265            }
266        }
267    }
268    fn render(&self, ctx: &mut RenderContext) -> Option<AnyElement> {
269        match &self.state {
270            SelectionState::Selecting { start, end } => {
271                let rect = normalize_rect(*start, *end);
272
273                let top_left = ctx.world_to_screen(rect.origin);
274
275                let size = Size::new(
276                    rect.size.width * ctx.viewport.zoom,
277                    rect.size.height * ctx.viewport.zoom,
278                );
279
280                Some(render_rect(Bounds::new(top_left, size), ctx.theme))
281            }
282
283            SelectionState::Moving { bounds, .. } => {
284                let top_left = ctx.world_to_screen(bounds.origin);
285
286                let size = Size::new(
287                    bounds.size.width * ctx.viewport.zoom,
288                    bounds.size.height * ctx.viewport.zoom,
289                );
290                Some(render_rect(Bounds::new(top_left, size), ctx.theme))
291            }
292
293            _ => None,
294        }
295    }
296}
297
298fn normalize_rect(start: Point<Pixels>, end: Point<Pixels>) -> Bounds<Pixels> {
299    let x = start.x.min(end.x);
300    let y = start.y.min(end.y);
301
302    let w = (end.x - start.x).abs();
303    let h = (end.y - start.y).abs();
304
305    Bounds::new(Point::new(x, y), Size::new(w, h))
306}
307
308fn render_rect(bounds: Bounds<Pixels>, theme: &FlowTheme) -> AnyElement {
309    div()
310        .absolute()
311        .left(bounds.origin.x)
312        .top(bounds.origin.y)
313        .w(bounds.size.width)
314        .h(bounds.size.height)
315        .border(px(1.0))
316        .border_color(rgb(theme.selection_rect_border))
317        .bg(rgba(theme.selection_rect_fill_rgba))
318        .into_any()
319}
320
321fn node_world_bounds(node: &Node) -> Bounds<Pixels> {
322    Bounds::new(Point::new(node.x, node.y), node.size)
323}
324
325fn compute_nodes_bounds(nodes: &HashMap<NodeId, Point<Pixels>>, graph: &Graph) -> Bounds<Pixels> {
326    let mut min_x = f32::MAX;
327    let mut min_y = f32::MAX;
328    let mut max_x = f32::MIN;
329    let mut max_y = f32::MIN;
330
331    for id in nodes.keys() {
332        let Some(node) = &graph.nodes().get(id) else {
333            continue;
334        };
335
336        min_x = min_x.min(node.x.into());
337        min_y = min_y.min(node.y.into());
338
339        max_x = max_x.max((node.x + node.size.width).into());
340        max_y = max_y.max((node.y + node.size.height).into());
341    }
342
343    Bounds::new(
344        Point::new(px(min_x), px(min_y)),
345        Size::new(px(max_x - min_x), px(max_y - min_y)),
346    )
347}