Skip to main content

gpui_base/dock/
tiles_geometry.rs

1//! Pure arithmetic for the tiles canvas: magnetic snapping, boundary
2//! constraints, resize math, and grid rounding, plus the undo/redo history
3//! record for a tile change.
4//!
5//! This module decides *where* a tile lands. It draws nothing: the tile
6//! frame, the drag-bar chrome, and the resize-handle visuals are appearance
7//! and live in `crates/component`.
8
9use gpui::{Bounds, EntityId, Pixels, Point, Size, px, size};
10
11/// A tile smaller than this on either axis cannot be usefully manipulated.
12/// This is behavior, not presentation: it bounds what resize/drag arithmetic
13/// will produce.
14pub const MINIMUM_SIZE: Size<Pixels> = size(px(100.), px(100.));
15
16/// Height of the tile's drag bar. This is hit-target geometry the skin must
17/// agree with when it paints the drag bar, not a visual constant, so it
18/// lives here rather than in `crates/component`.
19pub const DRAG_BAR_HEIGHT: Pixels = px(30.);
20
21/// Size of the resize-handle hit target at a tile's corner/edge. Same
22/// reasoning as [`DRAG_BAR_HEIGHT`].
23pub const HANDLE_SIZE: Pixels = px(5.0);
24
25/// A recorded change to one tile's bounds or z-order, for undo/redo.
26///
27/// Exactly one of the bounds pair or the order pair is populated per change,
28/// mirroring the two ways a tile can be edited (move/resize vs. reorder).
29#[derive(Clone, PartialEq, Debug)]
30pub struct TileChange {
31    tile_id: EntityId,
32    old_bounds: Option<Bounds<Pixels>>,
33    new_bounds: Option<Bounds<Pixels>>,
34}
35
36impl TileChange {
37    /// A change record for a tile whose bounds moved or resized.
38    pub fn bounds_change(
39        tile_id: EntityId,
40        old_bounds: Bounds<Pixels>,
41        new_bounds: Bounds<Pixels>,
42    ) -> Self {
43        Self {
44            tile_id,
45            old_bounds: Some(old_bounds),
46            new_bounds: Some(new_bounds),
47        }
48    }
49
50    pub fn tile_id(&self) -> EntityId {
51        self.tile_id
52    }
53
54    pub fn old_bounds(&self) -> Option<Bounds<Pixels>> {
55        self.old_bounds
56    }
57
58    pub fn new_bounds(&self) -> Option<Bounds<Pixels>> {
59        self.new_bounds
60    }
61}
62
63/// Which edge (or corner) of a tile a resize drag is manipulating.
64#[derive(Clone, Copy, PartialEq, Eq, Debug)]
65pub enum ResizeSide {
66    Left,
67    Right,
68    Top,
69    Bottom,
70    BottomRight,
71}
72
73/// In-flight state for a resize drag: which side is moving, where the pointer
74/// began the drag, and the tile bounds recorded at the last processed move
75/// event.
76///
77/// Pointer positions arrive in window coordinates while tile bounds live in
78/// canvas coordinates, so the start position is kept for the only measure
79/// meaningful across the two: how far the pointer has travelled since the
80/// drag began.
81#[derive(Clone, Copy, Debug)]
82pub struct ResizeDrag {
83    side: ResizeSide,
84    start_position: Point<Pixels>,
85    last_bounds: Bounds<Pixels>,
86}
87
88impl ResizeDrag {
89    pub fn new(
90        side: ResizeSide,
91        start_position: Point<Pixels>,
92        last_bounds: Bounds<Pixels>,
93    ) -> Self {
94        Self {
95            side,
96            start_position,
97            last_bounds,
98        }
99    }
100
101    pub fn side(&self) -> ResizeSide {
102        self.side
103    }
104
105    pub fn start_position(&self) -> Point<Pixels> {
106        self.start_position
107    }
108
109    pub fn last_bounds(&self) -> Bounds<Pixels> {
110        self.last_bounds
111    }
112
113    pub fn with_last_bounds(mut self, last_bounds: Bounds<Pixels>) -> Self {
114        self.last_bounds = last_bounds;
115        self
116    }
117}
118
119/// Snap `edge` to the nearest value in `candidates` whose distance is strictly
120/// below `threshold`. Returns `None` when nothing is close enough.
121pub fn snap_edge(edge: Pixels, candidates: &[Pixels], threshold: Pixels) -> Option<Pixels> {
122    let mut best: Option<Pixels> = None;
123    let mut best_dist = threshold;
124    for &candidate in candidates {
125        let dist = (edge - candidate).abs();
126        if dist < best_dist {
127            best_dist = dist;
128            best = Some(candidate);
129        }
130    }
131    best
132}
133
134/// Compute the final bounds for a resize, applying magnetic edge snapping to
135/// neighboring panels and falling back to grid rounding when no neighbor edge
136/// is within `grid_size`.
137///
138/// Which edges move is inferred from the provided `Option`s, mirroring the
139/// original `Tiles::resize`:
140/// - `new_x` set                  => left edge moves (right edge pinned)
141/// - `new_width` set, `new_x` not => right edge moves (left edge pinned)
142/// - `new_y` set                  => top edge moves (bottom edge pinned)
143/// - `new_height` set, `new_y` not => bottom edge moves (top edge pinned)
144pub fn compute_resized_bounds(
145    previous: Bounds<Pixels>,
146    new_x: Option<Pixels>,
147    new_y: Option<Pixels>,
148    new_width: Option<Pixels>,
149    new_height: Option<Pixels>,
150    other_bounds: &[Bounds<Pixels>],
151    grid_size: Pixels,
152) -> Bounds<Pixels> {
153    // Candidate snap edges from neighbouring panels.
154    let mut x_edges = Vec::with_capacity(other_bounds.len() * 2);
155    let mut y_edges = Vec::with_capacity(other_bounds.len() * 2);
156    for bounds in other_bounds {
157        x_edges.push(bounds.left());
158        x_edges.push(bounds.right());
159        y_edges.push(bounds.top());
160        y_edges.push(bounds.bottom());
161    }
162
163    let prev_right = previous.origin.x + previous.size.width;
164    let prev_bottom = previous.origin.y + previous.size.height;
165
166    // --- X axis ---
167    let (final_x, final_width) = if let Some(x) = new_x {
168        // Left edge moving; right edge pinned. Canvas-left (0) is also a target.
169        let raw_left = x.max(px(0.));
170        let mut candidates = x_edges.clone();
171        candidates.push(px(0.));
172        let snapped_left = snap_edge(raw_left, &candidates, grid_size)
173            .unwrap_or_else(|| round_to_grid(raw_left, grid_size));
174        let width = (prev_right - snapped_left).max(MINIMUM_SIZE.width);
175        (snapped_left, width)
176    } else if let Some(width) = new_width {
177        // Right edge moving; left edge pinned.
178        let raw_right = previous.origin.x + width;
179        let snapped_right = snap_edge(raw_right, &x_edges, grid_size)
180            .unwrap_or_else(|| round_to_grid(raw_right, grid_size));
181        let width = (snapped_right - previous.origin.x).max(MINIMUM_SIZE.width);
182        (previous.origin.x, width)
183    } else {
184        (previous.origin.x, previous.size.width)
185    };
186
187    // --- Y axis ---
188    let (final_y, final_height) = if let Some(y) = new_y {
189        // Top edge moving; bottom edge pinned. Canvas-top (0) is also a target.
190        let raw_top = y.max(px(0.));
191        let mut candidates = y_edges.clone();
192        candidates.push(px(0.));
193        let snapped_top = snap_edge(raw_top, &candidates, grid_size)
194            .unwrap_or_else(|| round_to_grid(raw_top, grid_size));
195        let height = (prev_bottom - snapped_top).max(MINIMUM_SIZE.height);
196        (snapped_top, height)
197    } else if let Some(height) = new_height {
198        // Bottom edge moving; top edge pinned.
199        let raw_bottom = previous.origin.y + height;
200        let snapped_bottom = snap_edge(raw_bottom, &y_edges, grid_size)
201            .unwrap_or_else(|| round_to_grid(raw_bottom, grid_size));
202        let height = (snapped_bottom - previous.origin.y).max(MINIMUM_SIZE.height);
203        (previous.origin.y, height)
204    } else {
205        (previous.origin.y, previous.size.height)
206    };
207
208    Bounds {
209        origin: Point {
210            x: final_x,
211            y: final_y,
212        },
213        size: Size {
214            width: final_width,
215            height: final_height,
216        },
217    }
218}
219
220/// Round `value` to the nearest multiple of `grid_size`.
221///
222/// This is the original `round_to_nearest_ten_with` (already grid-size
223/// parameterized, not `cx`-dependent), renamed to match the split described
224/// below and exposed directly rather than duplicated under two names.
225///
226/// The original `round_to_nearest_ten` and `round_point_to_nearest_ten` read
227/// the grid size off the theme via `cx`; base cannot see a theme, so the
228/// skin reads the grid size and passes it in here instead.
229pub fn round_to_grid(value: Pixels, grid_size: Pixels) -> Pixels {
230    (value / grid_size).round() * grid_size
231}
232
233/// Calculate the magnetic snap position for a tile being dragged.
234///
235/// `moving` is the tile's candidate bounds (already translated by the drag
236/// delta, before snapping). `others` are the bounds of every other tile in
237/// the same canvas. The returned point keeps `moving.origin`'s coordinate on
238/// any axis that did not snap, so the result can be assigned directly as the
239/// tile's new origin.
240pub fn magnetic_snap(
241    moving: Bounds<Pixels>,
242    others: &[Bounds<Pixels>],
243    threshold: Pixels,
244) -> Point<Pixels> {
245    // Only check nearby panels
246    let search_bounds = Bounds {
247        origin: Point {
248            x: moving.left() - threshold,
249            y: moving.top() - threshold,
250        },
251        size: Size {
252            width: moving.size.width + threshold * 2.0,
253            height: moving.size.height + threshold * 2.0,
254        },
255    };
256
257    let mut snap_x: Option<Pixels> = None;
258    let mut snap_y: Option<Pixels> = None;
259    let mut min_x_dist = threshold;
260    let mut min_y_dist = threshold;
261
262    // Pre-calculate dragging bounds edges to avoid repeated method calls
263    let drag_left = moving.left();
264    let drag_right = moving.right();
265    let drag_top = moving.top();
266    let drag_bottom = moving.bottom();
267    let drag_width = moving.size.width;
268    let drag_height = moving.size.height;
269
270    // Check for edge snapping first (top and left boundaries)
271    let edge_snap_pos = px(0.);
272
273    // Snap to top edge
274    let top_dist = drag_top.abs();
275    if top_dist < threshold {
276        snap_y = Some(edge_snap_pos);
277        min_y_dist = top_dist;
278    }
279
280    // Snap to left edge
281    let left_dist = drag_left.abs();
282    if left_dist < threshold {
283        snap_x = Some(edge_snap_pos);
284        min_x_dist = left_dist;
285    }
286
287    // If both edges are snapped, skip the neighbor search entirely.
288    if snap_x.is_none() || snap_y.is_none() {
289        for other in others {
290            if snap_x.is_some() && snap_y.is_some() {
291                break;
292            }
293
294            // Pre-calculate other bounds edges
295            let other_left = other.left();
296            let other_right = other.right();
297            let other_top = other.top();
298            let other_bottom = other.bottom();
299
300            // Skip panels that are far away
301            if other_right < search_bounds.left()
302                || other_left > search_bounds.right()
303                || other_bottom < search_bounds.top()
304                || other_top > search_bounds.bottom()
305            {
306                continue;
307            }
308
309            // Horizontal snapping (X axis) - find closest snap point
310            if snap_x.is_none() {
311                let candidates = [
312                    ((drag_left - other_left).abs(), other_left),
313                    ((drag_left - other_right).abs(), other_right),
314                    ((drag_right - other_left).abs(), other_left - drag_width),
315                    ((drag_right - other_right).abs(), other_right - drag_width),
316                ];
317
318                for (dist, snap_pos) in candidates {
319                    if dist < min_x_dist {
320                        min_x_dist = dist;
321                        snap_x = Some(snap_pos);
322                    }
323                }
324            }
325
326            // Vertical snapping (Y axis) - find closest snap point
327            if snap_y.is_none() {
328                let candidates = [
329                    ((drag_top - other_top).abs(), other_top),
330                    ((drag_top - other_bottom).abs(), other_bottom),
331                    ((drag_bottom - other_top).abs(), other_top - drag_height),
332                    (
333                        (drag_bottom - other_bottom).abs(),
334                        other_bottom - drag_height,
335                    ),
336                ];
337
338                for (dist, snap_pos) in candidates {
339                    if dist < min_y_dist {
340                        min_y_dist = dist;
341                        snap_y = Some(snap_pos);
342                    }
343                }
344            }
345        }
346    }
347
348    Point {
349        x: snap_x.unwrap_or(moving.origin.x),
350        y: snap_y.unwrap_or(moving.origin.y),
351    }
352}
353
354/// Clamp a dragged tile's origin to the canvas boundary.
355///
356/// The top is a hard boundary (a tile's top can never go negative), and at
357/// most `dragging_width - 64px` of the tile may hang off the left edge,
358/// keeping 64px of it visible. There is no boundary on the right or bottom:
359/// the canvas scrolls.
360///
361/// `dragging_width` is the width of the tile being dragged, not a canvas
362/// size — the original `Tiles::apply_boundary_constraints` reads it from
363/// `self.dragging_initial_bounds.size.width`, the entity's own drag-tracking
364/// state, not a container/tile-list argument.
365pub fn apply_boundary_constraints(origin: Point<Pixels>, dragging_width: Pixels) -> Point<Pixels> {
366    let mut origin = origin;
367
368    // Top boundary
369    if origin.y < px(0.) {
370        origin.y = px(0.);
371    }
372
373    // Left boundary (allow partial off-screen but keep 64px visible)
374    let min_left = -dragging_width + px(64.);
375    if origin.x < min_left {
376        origin.x = min_left;
377    }
378
379    origin
380}
381
382/// The scrollable extent a set of tiles occupies, measured from the canvas
383/// origin.
384///
385/// Reproduces the fold the old `Tiles::render` did before handing the result
386/// to its scrollbar: the union runs from `min(0, left)` to `max(0, right)` on
387/// each axis, so a canvas whose tiles all sit at positive coordinates reports
388/// the far edge, and one with a tile dragged past the origin reports the
389/// distance across both.
390pub fn content_size(tiles: &[Bounds<Pixels>]) -> Size<Pixels> {
391    let mut left = px(0.);
392    let mut top = px(0.);
393    let mut right = px(0.);
394    let mut bottom = px(0.);
395    for bounds in tiles {
396        left = left.min(bounds.left());
397        top = top.min(bounds.top());
398        right = right.max(bounds.right());
399        bottom = bottom.max(bounds.bottom());
400    }
401    size(right - left, bottom - top)
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    fn b(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
409        Bounds {
410            origin: Point { x: px(x), y: px(y) },
411            size: Size {
412                width: px(w),
413                height: px(h),
414            },
415        }
416    }
417
418    #[test]
419    fn test_snap_edge_within_threshold() {
420        // 102 is 2px from 100 (< 8) -> snaps to 100.
421        assert_eq!(
422            snap_edge(px(102.), &[px(100.), px(300.)], px(8.)),
423            Some(px(100.))
424        );
425    }
426
427    #[test]
428    fn test_snap_edge_outside_threshold() {
429        // 120 is 20px from nearest candidate (>= 8) -> no snap.
430        assert_eq!(snap_edge(px(120.), &[px(100.), px(300.)], px(8.)), None);
431    }
432
433    #[test]
434    fn test_snap_edge_picks_nearest() {
435        // 303 is 3px from 300 and 5px from 308 -> picks 300.
436        assert_eq!(
437            snap_edge(px(303.), &[px(308.), px(300.)], px(8.)),
438            Some(px(300.))
439        );
440    }
441
442    #[test]
443    fn test_snap_edge_empty_candidates() {
444        assert_eq!(snap_edge(px(50.), &[], px(8.)), None);
445    }
446
447    #[test]
448    fn test_resize_right_edge_snaps_to_neighbor_left() {
449        // Panel A: x=0 w=196 (right edge 196). Neighbour B starts at x=200.
450        // Dragging right edge to 197 should snap right edge to 200 -> width 200.
451        let prev = b(0., 0., 196., 100.);
452        let neighbor = b(200., 0., 100., 100.);
453        let out =
454            compute_resized_bounds(prev, None, None, Some(px(197.)), None, &[neighbor], px(8.));
455        assert_eq!(out.origin.x, px(0.));
456        assert_eq!(out.size.width, px(200.));
457    }
458
459    #[test]
460    fn test_resize_bottom_edge_snaps_to_neighbor_top() {
461        let prev = b(0., 0., 100., 196.);
462        let neighbor = b(0., 200., 100., 100.);
463        let out =
464            compute_resized_bounds(prev, None, None, None, Some(px(197.)), &[neighbor], px(8.));
465        assert_eq!(out.origin.y, px(0.));
466        assert_eq!(out.size.height, px(200.));
467    }
468
469    #[test]
470    fn test_resize_left_edge_snaps_and_pins_right() {
471        // Panel: x=200 w=100 (right edge 300). Neighbour right edge at 100.
472        // Drag left edge to 103 -> snaps to 100 -> width = 300 - 100 = 200.
473        let prev = b(200., 0., 100., 100.);
474        let neighbor = b(0., 0., 100., 100.);
475        let out = compute_resized_bounds(
476            prev,
477            Some(px(103.)),
478            None,
479            Some(px(197.)),
480            None,
481            &[neighbor],
482            px(8.),
483        );
484        assert_eq!(out.origin.x, px(100.));
485        assert_eq!(out.size.width, px(200.));
486    }
487
488    #[test]
489    fn test_resize_corner_snaps_both_edges() {
490        // Right edge -> neighbour-right at 300; bottom edge -> neighbour-bottom at 250.
491        let prev = b(0., 0., 196., 196.);
492        let right_neighbor = b(100., 0., 200., 100.); // right edge = 300
493        let bottom_neighbor = b(0., 100., 100., 150.); // bottom edge = 250
494        let out = compute_resized_bounds(
495            prev,
496            None,
497            None,
498            Some(px(298.)),
499            Some(px(248.)),
500            &[right_neighbor, bottom_neighbor],
501            px(8.),
502        );
503        assert_eq!(out.size.width, px(300.));
504        assert_eq!(out.size.height, px(250.));
505    }
506
507    #[test]
508    fn test_resize_grid_rounds_when_no_neighbor_close() {
509        // No neighbours; raw right edge 153 -> grid round to 152 (nearest multiple of 8).
510        let prev = b(0., 0., 100., 100.);
511        let out = compute_resized_bounds(prev, None, None, Some(px(153.)), None, &[], px(8.));
512        assert_eq!(out.size.width, px(152.));
513    }
514
515    #[test]
516    fn test_resize_respects_minimum_size() {
517        let prev = b(0., 0., 100., 100.);
518        let out = compute_resized_bounds(prev, None, None, Some(px(10.)), None, &[], px(8.));
519        assert_eq!(out.size.width, MINIMUM_SIZE.width);
520    }
521
522    #[test]
523    fn content_size_spans_from_the_origin_to_the_far_edge() {
524        assert_eq!(
525            content_size(&[b(20., 20., 380., 280.), b(420., 20., 380., 280.)]),
526            size(px(800.), px(300.)),
527            "the extent runs from the canvas origin, not from the first tile"
528        );
529        assert_eq!(
530            content_size(&[]),
531            size(px(0.), px(0.)),
532            "an empty canvas scrolls nowhere"
533        );
534        assert_eq!(
535            content_size(&[b(-40., -10., 100., 100.)]),
536            size(px(100.), px(100.)),
537            "a tile dragged past the origin still reports the distance across it"
538        );
539    }
540
541    #[test]
542    fn test_resize_no_change_returns_previous_geometry() {
543        let prev = b(0., 0., 100., 100.);
544        let out = compute_resized_bounds(prev, None, None, None, None, &[], px(8.));
545        assert_eq!(out.origin.x, px(0.));
546        assert_eq!(out.origin.y, px(0.));
547        assert_eq!(out.size.width, px(100.));
548        assert_eq!(out.size.height, px(100.));
549    }
550}