Skip to main content

open_gpui_canvas/
snap.rs

1use crate::transform::{CanvasResizeHandle, resize_bounds_by_handle};
2use crate::{
3    CanvasDefaultEdgeRouter, CanvasDocument, CanvasGeometryFacts, CanvasKindRegistry,
4    CanvasRecordGeometry, CanvasRecordId, CanvasSelection,
5    record_scope::{CanvasRecordScopeOptions, collect_selection_record_scope},
6};
7use indexmap::IndexSet;
8use open_gpui::{Bounds, Pixels, Point, px};
9use serde::{Deserialize, Serialize};
10
11pub const DEFAULT_SNAP_THRESHOLD: Pixels = px(6.0);
12
13#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
14pub enum CanvasSnapAxis {
15    Horizontal,
16    Vertical,
17}
18
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20pub struct CanvasSnapGuide {
21    pub axis: CanvasSnapAxis,
22    pub document_start: Point<Pixels>,
23    pub document_end: Point<Pixels>,
24}
25
26#[derive(Clone, Debug, Default, PartialEq)]
27pub struct CanvasSnapResult {
28    pub delta: Point<Pixels>,
29    pub guides: Vec<CanvasSnapGuide>,
30}
31
32pub fn snap_delta_for_selection(
33    document: &CanvasDocument,
34    selection: &CanvasSelection,
35    delta: Point<Pixels>,
36    threshold: Pixels,
37    kind_registry: Option<&CanvasKindRegistry>,
38) -> CanvasSnapResult {
39    let selected_record_ids = selected_record_ids(document, selection);
40    let Some(active_bounds) =
41        record_selection_bounds(document, &selected_record_ids, kind_registry)
42    else {
43        return CanvasSnapResult {
44            delta,
45            guides: Vec::new(),
46        };
47    };
48
49    let moved_bounds = Bounds::new(active_bounds.origin + delta, active_bounds.size);
50    let mut horizontal = None;
51    let mut vertical = None;
52
53    for candidate in candidate_bounds(document, kind_registry) {
54        if selected_record_ids.contains(candidate.record_id()) {
55            continue;
56        }
57
58        horizontal = best_snap(
59            horizontal,
60            snap_axis(
61                moved_bounds,
62                candidate.bounds(),
63                CanvasSnapAxis::Horizontal,
64                threshold,
65            ),
66        );
67        vertical = best_snap(
68            vertical,
69            snap_axis(
70                moved_bounds,
71                candidate.bounds(),
72                CanvasSnapAxis::Vertical,
73                threshold,
74            ),
75        );
76    }
77
78    let mut snapped_delta = delta;
79    let mut guides = Vec::new();
80    if let Some(snap) = horizontal {
81        snapped_delta.x += snap.adjustment;
82        guides.push(snap.guide);
83    }
84    if let Some(snap) = vertical {
85        snapped_delta.y += snap.adjustment;
86        guides.push(snap.guide);
87    }
88
89    CanvasSnapResult {
90        delta: snapped_delta,
91        guides,
92    }
93}
94
95pub fn snap_delta_for_resize_selection(
96    document: &CanvasDocument,
97    selection: &CanvasSelection,
98    handle: CanvasResizeHandle,
99    delta: Point<Pixels>,
100    threshold: Pixels,
101    kind_registry: Option<&CanvasKindRegistry>,
102) -> CanvasSnapResult {
103    let Some(active_bounds) = explicit_selection_bounds(document, selection, kind_registry) else {
104        return CanvasSnapResult {
105            delta,
106            guides: Vec::new(),
107        };
108    };
109
110    let resized_bounds = resize_bounds_by_handle(active_bounds, handle, delta);
111    let selected_record_ids = selected_record_ids(document, selection);
112    let mut horizontal = None;
113    let mut vertical = None;
114
115    for candidate in candidate_bounds(document, kind_registry) {
116        if selected_record_ids.contains(candidate.record_id()) {
117            continue;
118        }
119
120        horizontal = best_snap(
121            horizontal,
122            snap_resize_axis(
123                resized_bounds,
124                candidate.bounds(),
125                CanvasSnapAxis::Horizontal,
126                handle,
127                threshold,
128            ),
129        );
130        vertical = best_snap(
131            vertical,
132            snap_resize_axis(
133                resized_bounds,
134                candidate.bounds(),
135                CanvasSnapAxis::Vertical,
136                handle,
137                threshold,
138            ),
139        );
140    }
141
142    let mut snapped_delta = delta;
143    let mut guides = Vec::new();
144    if let Some(snap) = horizontal {
145        snapped_delta.x += snap.adjustment;
146        guides.push(snap.guide);
147    }
148    if let Some(snap) = vertical {
149        snapped_delta.y += snap.adjustment;
150        guides.push(snap.guide);
151    }
152
153    CanvasSnapResult {
154        delta: snapped_delta,
155        guides,
156    }
157}
158
159fn selected_record_ids(
160    document: &CanvasDocument,
161    selection: &CanvasSelection,
162) -> IndexSet<CanvasRecordId> {
163    collect_selection_record_scope(
164        document,
165        selection,
166        CanvasRecordScopeOptions::structural(),
167        |record_id| is_snap_scope_record(document, record_id),
168    )
169}
170
171fn is_snap_scope_record(document: &CanvasDocument, record_id: &CanvasRecordId) -> bool {
172    match record_id {
173        CanvasRecordId::Node(id) => document.contains_node(id),
174        CanvasRecordId::Shape(id) => document.contains_shape(id),
175        CanvasRecordId::Edge(_) => false,
176    }
177}
178
179fn record_selection_bounds(
180    document: &CanvasDocument,
181    record_ids: &IndexSet<CanvasRecordId>,
182    kind_registry: Option<&CanvasKindRegistry>,
183) -> Option<Bounds<Pixels>> {
184    let facts = CanvasGeometryFacts::with_router_and_kind_registry(
185        document,
186        CanvasDefaultEdgeRouter,
187        kind_registry,
188    );
189    facts.node_shape_bounds_for_records(record_ids)
190}
191
192fn explicit_selection_bounds(
193    document: &CanvasDocument,
194    selection: &CanvasSelection,
195    kind_registry: Option<&CanvasKindRegistry>,
196) -> Option<Bounds<Pixels>> {
197    let facts = CanvasGeometryFacts::with_router_and_kind_registry(
198        document,
199        CanvasDefaultEdgeRouter,
200        kind_registry,
201    );
202    facts.selected_bounds(selection)
203}
204
205#[derive(Clone, Debug)]
206struct CandidateBounds(CanvasRecordGeometry);
207
208impl CandidateBounds {
209    fn record_id(&self) -> &CanvasRecordId {
210        &self.0.id
211    }
212
213    fn bounds(&self) -> Bounds<Pixels> {
214        self.0.bounds
215    }
216}
217
218fn candidate_bounds(
219    document: &CanvasDocument,
220    kind_registry: Option<&CanvasKindRegistry>,
221) -> Vec<CandidateBounds> {
222    let facts = CanvasGeometryFacts::with_router_and_kind_registry(
223        document,
224        CanvasDefaultEdgeRouter,
225        kind_registry,
226    );
227    facts
228        .record_geometries()
229        .into_iter()
230        .filter(CanvasRecordGeometry::is_node_or_shape)
231        .filter(CanvasRecordGeometry::is_visible_unlocked)
232        .map(CandidateBounds)
233        .collect()
234}
235
236#[derive(Clone, Debug, PartialEq)]
237struct SnapCandidate {
238    distance: Pixels,
239    adjustment: Pixels,
240    guide: CanvasSnapGuide,
241}
242
243fn best_snap(current: Option<SnapCandidate>, next: Option<SnapCandidate>) -> Option<SnapCandidate> {
244    match (current, next) {
245        (None, None) => None,
246        (Some(current), None) => Some(current),
247        (None, Some(next)) => Some(next),
248        (Some(current), Some(next)) => {
249            if next.distance < current.distance {
250                Some(next)
251            } else {
252                Some(current)
253            }
254        }
255    }
256}
257
258fn snap_axis(
259    active: Bounds<Pixels>,
260    candidate: Bounds<Pixels>,
261    axis: CanvasSnapAxis,
262    threshold: Pixels,
263) -> Option<SnapCandidate> {
264    let mut best = None;
265    for active_anchor in anchors(active, axis) {
266        for candidate_anchor in anchors(candidate, axis) {
267            let adjustment = candidate_anchor.value - active_anchor.value;
268            let distance = adjustment.abs();
269            if distance <= threshold {
270                best = best_snap(
271                    best,
272                    Some(SnapCandidate {
273                        distance,
274                        adjustment,
275                        guide: snap_guide(axis, active, candidate, candidate_anchor.value),
276                    }),
277                );
278            }
279        }
280    }
281    best
282}
283
284fn snap_resize_axis(
285    active: Bounds<Pixels>,
286    candidate: Bounds<Pixels>,
287    axis: CanvasSnapAxis,
288    handle: CanvasResizeHandle,
289    threshold: Pixels,
290) -> Option<SnapCandidate> {
291    let active_anchor = resize_anchor(active, axis, handle);
292    let mut best = None;
293    for candidate_anchor in anchors(candidate, axis) {
294        let adjustment = candidate_anchor.value - active_anchor.value;
295        let distance = adjustment.abs();
296        if distance <= threshold {
297            best = best_snap(
298                best,
299                Some(SnapCandidate {
300                    distance,
301                    adjustment,
302                    guide: snap_guide(axis, active, candidate, candidate_anchor.value),
303                }),
304            );
305        }
306    }
307    best
308}
309
310#[derive(Clone, Copy, Debug)]
311struct AnchorValue {
312    value: Pixels,
313}
314
315fn anchors(bounds: Bounds<Pixels>, axis: CanvasSnapAxis) -> [AnchorValue; 3] {
316    match axis {
317        CanvasSnapAxis::Horizontal => {
318            let left = bounds.origin.x;
319            let right = bounds.origin.x + bounds.size.width;
320            [
321                AnchorValue { value: left },
322                AnchorValue {
323                    value: left + bounds.size.width * 0.5,
324                },
325                AnchorValue { value: right },
326            ]
327        }
328        CanvasSnapAxis::Vertical => {
329            let top = bounds.origin.y;
330            let bottom = bounds.origin.y + bounds.size.height;
331            [
332                AnchorValue { value: top },
333                AnchorValue {
334                    value: top + bounds.size.height * 0.5,
335                },
336                AnchorValue { value: bottom },
337            ]
338        }
339    }
340}
341
342fn resize_anchor(
343    bounds: Bounds<Pixels>,
344    axis: CanvasSnapAxis,
345    handle: CanvasResizeHandle,
346) -> AnchorValue {
347    match (axis, handle) {
348        (
349            CanvasSnapAxis::Horizontal,
350            CanvasResizeHandle::TopLeft | CanvasResizeHandle::BottomLeft,
351        ) => AnchorValue {
352            value: bounds.origin.x,
353        },
354        (
355            CanvasSnapAxis::Horizontal,
356            CanvasResizeHandle::TopRight | CanvasResizeHandle::BottomRight,
357        ) => AnchorValue {
358            value: bounds.origin.x + bounds.size.width,
359        },
360        (CanvasSnapAxis::Vertical, CanvasResizeHandle::TopLeft | CanvasResizeHandle::TopRight) => {
361            AnchorValue {
362                value: bounds.origin.y,
363            }
364        }
365        (
366            CanvasSnapAxis::Vertical,
367            CanvasResizeHandle::BottomLeft | CanvasResizeHandle::BottomRight,
368        ) => AnchorValue {
369            value: bounds.origin.y + bounds.size.height,
370        },
371    }
372}
373
374fn snap_guide(
375    axis: CanvasSnapAxis,
376    active: Bounds<Pixels>,
377    candidate: Bounds<Pixels>,
378    value: Pixels,
379) -> CanvasSnapGuide {
380    match axis {
381        CanvasSnapAxis::Horizontal => {
382            let top = active.origin.y.min(candidate.origin.y);
383            let bottom = (active.origin.y + active.size.height)
384                .max(candidate.origin.y + candidate.size.height);
385            CanvasSnapGuide {
386                axis,
387                document_start: Point::new(value, top),
388                document_end: Point::new(value, bottom),
389            }
390        }
391        CanvasSnapAxis::Vertical => {
392            let left = active.origin.x.min(candidate.origin.x);
393            let right = (active.origin.x + active.size.width)
394                .max(candidate.origin.x + candidate.size.width);
395            CanvasSnapGuide {
396                axis,
397                document_start: Point::new(left, value),
398                document_end: Point::new(right, value),
399            }
400        }
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::{
408        CanvasNode, CanvasNodeGeometryPolicy, CanvasNodeKind, CanvasShape, CanvasTransaction,
409        DocumentCommand, NodeId, ShapeId, test_support::document_fixture,
410    };
411    use open_gpui::{point, size};
412
413    #[test]
414    fn snaps_selected_bounds_to_nearby_left_edge() {
415        let document = document_fixture()
416            .node(CanvasNode::new(
417                "active",
418                point(px(0.0), px(0.0)),
419                size(px(40.0), px(40.0)),
420            ))
421            .node(CanvasNode::new(
422                "target",
423                point(px(100.0), px(0.0)),
424                size(px(40.0), px(40.0)),
425            ))
426            .build();
427        let mut selection = CanvasSelection::default();
428        selection.insert_node(NodeId::from("active"));
429
430        let result = snap_delta_for_selection(
431            &document,
432            &selection,
433            point(px(96.0), px(0.0)),
434            DEFAULT_SNAP_THRESHOLD,
435            None,
436        );
437
438        assert_eq!(result.delta, point(px(100.0), px(0.0)));
439        assert!(
440            result
441                .guides
442                .iter()
443                .any(|guide| guide.axis == CanvasSnapAxis::Horizontal)
444        );
445    }
446
447    #[test]
448    fn ignores_selected_and_locked_records() {
449        let mut active =
450            CanvasNode::new("active", point(px(0.0), px(0.0)), size(px(40.0), px(40.0)));
451        active.locked = false;
452        let mut locked = CanvasNode::new(
453            "locked",
454            point(px(100.0), px(0.0)),
455            size(px(40.0), px(40.0)),
456        );
457        locked.locked = true;
458        let document = document_fixture().node(active).node(locked).build();
459        let mut selection = CanvasSelection::default();
460        selection.insert_node(NodeId::from("active"));
461
462        let result = snap_delta_for_selection(
463            &document,
464            &selection,
465            point(px(96.0), px(0.0)),
466            DEFAULT_SNAP_THRESHOLD,
467            None,
468        );
469
470        assert_eq!(result.delta, point(px(96.0), px(0.0)));
471        assert!(result.guides.is_empty());
472    }
473
474    #[test]
475    fn ignores_related_descendants_for_selection_snap() {
476        let mut document = document_fixture()
477            .shape(CanvasShape::new(
478                "frame",
479                Bounds::new(point(px(0.0), px(0.0)), size(px(40.0), px(40.0))),
480            ))
481            .node(CanvasNode::new(
482                "child",
483                point(px(100.0), px(0.0)),
484                size(px(40.0), px(40.0)),
485            ))
486            .build();
487        document
488            .apply_transaction(CanvasTransaction::single(
489                DocumentCommand::SetRecordParent {
490                    child: CanvasRecordId::Node(NodeId::from("child")),
491                    parent: CanvasRecordId::Shape(ShapeId::from("frame")),
492                },
493            ))
494            .unwrap();
495        let mut selection = CanvasSelection::default();
496        selection.insert_shape(ShapeId::from("frame"));
497
498        let result = snap_delta_for_selection(
499            &document,
500            &selection,
501            point(px(96.0), px(0.0)),
502            DEFAULT_SNAP_THRESHOLD,
503            None,
504        );
505
506        assert_eq!(result.delta, point(px(96.0), px(0.0)));
507        assert!(result.guides.is_empty());
508    }
509
510    #[test]
511    fn selection_snap_bounds_include_related_descendants() {
512        let mut document = document_fixture()
513            .shape(CanvasShape::new(
514                "frame",
515                Bounds::new(point(px(0.0), px(0.0)), size(px(40.0), px(40.0))),
516            ))
517            .node(CanvasNode::new(
518                "child",
519                point(px(80.0), px(0.0)),
520                size(px(40.0), px(40.0)),
521            ))
522            .node(CanvasNode::new(
523                "target",
524                point(px(196.0), px(0.0)),
525                size(px(40.0), px(40.0)),
526            ))
527            .build();
528        document
529            .apply_transaction(CanvasTransaction::single(
530                DocumentCommand::SetRecordParent {
531                    child: CanvasRecordId::Node(NodeId::from("child")),
532                    parent: CanvasRecordId::Shape(ShapeId::from("frame")),
533                },
534            ))
535            .unwrap();
536        let mut selection = CanvasSelection::default();
537        selection.insert_shape(ShapeId::from("frame"));
538
539        let result = snap_delta_for_selection(
540            &document,
541            &selection,
542            point(px(72.0), px(0.0)),
543            DEFAULT_SNAP_THRESHOLD,
544            None,
545        );
546
547        assert_eq!(result.delta, point(px(76.0), px(0.0)));
548        assert!(!result.guides.is_empty());
549    }
550
551    #[test]
552    fn ignores_related_descendants_for_resize_snap() {
553        let mut document = document_fixture()
554            .shape(CanvasShape::new(
555                "frame",
556                Bounds::new(point(px(100.0), px(0.0)), size(px(40.0), px(40.0))),
557            ))
558            .node(CanvasNode::new(
559                "child",
560                point(px(50.0), px(0.0)),
561                size(px(40.0), px(40.0)),
562            ))
563            .build();
564        document
565            .apply_transaction(CanvasTransaction::single(
566                DocumentCommand::SetRecordParent {
567                    child: CanvasRecordId::Node(NodeId::from("child")),
568                    parent: CanvasRecordId::Shape(ShapeId::from("frame")),
569                },
570            ))
571            .unwrap();
572        let mut selection = CanvasSelection::default();
573        selection.insert_shape(ShapeId::from("frame"));
574
575        let result = snap_delta_for_resize_selection(
576            &document,
577            &selection,
578            CanvasResizeHandle::TopLeft,
579            point(px(-46.0), px(0.0)),
580            DEFAULT_SNAP_THRESHOLD,
581            None,
582        );
583
584        assert_eq!(result.delta, point(px(-46.0), px(0.0)));
585        assert!(result.guides.is_empty());
586    }
587
588    #[test]
589    fn kind_registry_bounds_are_used_for_selection_and_candidates() {
590        let mut active =
591            CanvasNode::new("active", point(px(0.0), px(0.0)), size(px(40.0), px(40.0)));
592        active.kind = "wide".to_string();
593        let target = CanvasNode::new(
594            "target",
595            point(px(100.0), px(80.0)),
596            size(px(40.0), px(40.0)),
597        );
598        let document = document_fixture().node(active).node(target).build();
599        let mut selection = CanvasSelection::default();
600        selection.insert_node(NodeId::from("active"));
601        let mut registry = CanvasKindRegistry::open();
602        registry.register_node_kind(
603            "wide",
604            CanvasNodeKind::new().with_geometry_policy(WideNodeKind),
605        );
606
607        let without_registry = snap_delta_for_selection(
608            &document,
609            &selection,
610            point(px(9.0), px(0.0)),
611            DEFAULT_SNAP_THRESHOLD,
612            None,
613        );
614        let with_registry = snap_delta_for_selection(
615            &document,
616            &selection,
617            point(px(9.0), px(0.0)),
618            DEFAULT_SNAP_THRESHOLD,
619            Some(&registry),
620        );
621
622        assert_eq!(without_registry.delta, point(px(9.0), px(0.0)));
623        assert!(without_registry.guides.is_empty());
624        assert_eq!(with_registry.delta, point(px(10.0), px(0.0)));
625        assert!(!with_registry.guides.is_empty());
626    }
627
628    #[test]
629    fn resize_snaps_only_the_dragged_edges() {
630        let document = document_fixture()
631            .node(CanvasNode::new(
632                "active",
633                point(px(100.0), px(100.0)),
634                size(px(40.0), px(40.0)),
635            ))
636            .shape(CanvasShape::new(
637                "target",
638                Bounds::new(point(px(50.0), px(60.0)), size(px(32.0), px(24.0))),
639            ))
640            .build();
641        let mut selection = CanvasSelection::default();
642        selection.insert_node(NodeId::from("active"));
643        selection.insert_shape(ShapeId::from("target"));
644
645        let result = snap_delta_for_resize_selection(
646            &document,
647            &selection,
648            CanvasResizeHandle::TopLeft,
649            point(px(-46.0), px(-35.0)),
650            DEFAULT_SNAP_THRESHOLD,
651            None,
652        );
653
654        assert_eq!(result.delta, point(px(-46.0), px(-35.0)));
655        assert!(result.guides.is_empty());
656
657        selection.clear_shapes();
658        let result = snap_delta_for_resize_selection(
659            &document,
660            &selection,
661            CanvasResizeHandle::TopLeft,
662            point(px(-46.0), px(-35.0)),
663            DEFAULT_SNAP_THRESHOLD,
664            None,
665        );
666
667        assert_eq!(result.delta, point(px(-50.0), px(-40.0)));
668        assert!(
669            result
670                .guides
671                .iter()
672                .any(|guide| guide.axis == CanvasSnapAxis::Horizontal)
673        );
674        assert!(
675            result
676                .guides
677                .iter()
678                .any(|guide| guide.axis == CanvasSnapAxis::Vertical)
679        );
680    }
681
682    struct WideNodeKind;
683
684    impl CanvasNodeGeometryPolicy for WideNodeKind {
685        fn node_bounds(&self, node: &CanvasNode) -> Option<Bounds<Pixels>> {
686            Some(Bounds::new(
687                node.position,
688                size(node.size.width + px(50.0), node.size.height),
689            ))
690        }
691    }
692}