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(®istry),
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}