Skip to main content

gpui_base/dock/
drag.rs

1//! Drag hit-testing and drop geometry for the dock.
2//!
3//! This module decides *where* a drop would land and what shape a hovering
4//! drag session occupies. It draws nothing: the styled drag preview and the
5//! rendered drop indicator are appearance and live in `crates/component`.
6
7use std::{
8    any::Any,
9    cell::Cell,
10    rc::Rc,
11    sync::{
12        Arc,
13        atomic::{AtomicU64, Ordering},
14    },
15};
16
17use gpui::{
18    Bounds, Context, Empty, IntoElement, Pixels, Point, Render, Size, Window, point, px, size,
19};
20
21use crate::Placement;
22
23use super::layout::{NodeId, PanelId};
24
25/// A panel being dragged out of a tab group.
26///
27/// The panel is carried as a [`PanelId`] rather than a view handle: the base
28/// layer has no `PanelView` trait or `TabPanel` entity of its own (those are
29/// layered above), and the layout algebra already addresses panels this way
30/// (see `insert_panel`/`remove_panel`/`move_panel` in `layout::edit`). A
31/// consumer resolves the id back to a view through the dock area's panel
32/// map.
33#[derive(Clone)]
34pub struct DragPanel {
35    panel: PanelId,
36    source: NodeId,
37    drag_offset: Rc<Cell<Point<Pixels>>>,
38    preview_size: Rc<Cell<Size<Pixels>>>,
39    drag_session_id: u64,
40}
41
42static NEXT_DRAG_SESSION_ID: AtomicU64 = AtomicU64::new(1);
43
44/// Stands in for [`DragPanel::drag_session_id`] on host-owned drag items, which
45/// carry no session of their own. `NEXT_DRAG_SESSION_ID` starts at 1, so 0 never
46/// collides.
47pub(crate) const ITEM_DRAG_SESSION_ID: u64 = 0;
48
49impl DragPanel {
50    pub fn new(panel: PanelId, source: NodeId) -> Self {
51        Self {
52            panel,
53            source,
54            drag_offset: Rc::new(Cell::new(Point::default())),
55            preview_size: Rc::new(Cell::new(Size::default())),
56            drag_session_id: NEXT_DRAG_SESSION_ID.fetch_add(1, Ordering::Relaxed),
57        }
58    }
59
60    pub fn panel(&self) -> PanelId {
61        self.panel
62    }
63
64    /// The tab group this panel was dragged out of.
65    pub fn source(&self) -> NodeId {
66        self.source
67    }
68
69    pub fn drag_offset(&self) -> Point<Pixels> {
70        self.drag_offset.get()
71    }
72
73    /// Records where inside the panel the drag started, so a preview can be
74    /// positioned relative to the cursor.
75    pub fn set_drag_offset(&self, offset: Point<Pixels>) {
76        self.drag_offset.set(offset);
77    }
78
79    /// How large the drag preview is on screen.
80    ///
81    /// The dock reads it to decide where a drop placeholder flies in from,
82    /// which is hit geometry rather than styling; the preview's own size is a
83    /// visual decision, so the skin that draws the preview reports it here.
84    /// Defaults to zero, which degrades to a placeholder that grows out of the
85    /// cursor rather than out of the preview.
86    pub fn preview_size(&self) -> Size<Pixels> {
87        self.preview_size.get()
88    }
89
90    pub fn set_preview_size(&self, size: Size<Pixels>) {
91        self.preview_size.set(size);
92    }
93
94    pub fn drag_session_id(&self) -> u64 {
95        self.drag_session_id
96    }
97}
98
99impl Render for DragPanel {
100    /// Base draws nothing: the styled drag preview is appearance and belongs
101    /// to `crates/component`, which reintroduces it as a separate render type.
102    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
103        Empty
104    }
105}
106
107/// A host-owned value being dragged over the dock, opaque to the dock itself.
108#[derive(Clone, Debug)]
109pub struct AnyDrag {
110    value: Arc<dyn Any>,
111}
112
113impl AnyDrag {
114    pub fn new(value: impl Any) -> Self {
115        Self {
116            value: Arc::new(value),
117        }
118    }
119
120    pub fn value(&self) -> &Arc<dyn Any> {
121        &self.value
122    }
123}
124
125/// Where a host-owned drag landed.
126#[derive(Clone, Debug)]
127pub enum DropTarget {
128    /// A tiles canvas, where the cursor position is the landing position and
129    /// the host can read it directly.
130    Canvas,
131    /// A tab group in a split layout. A split layout has no free coordinates,
132    /// so the container reports the group and the edge it resolved instead.
133    ///
134    /// `placement` is `None` for the centre zone, meaning merge into the group
135    /// rather than split.
136    Group {
137        node: NodeId,
138        placement: Option<Placement>,
139    },
140}
141
142/// What the skin should draw while a drag hovers a group.
143///
144/// It carries geometry over time — where the placeholder comes from, where it
145/// settles, and which run of the animation this is — but not the tween. Easing
146/// and duration are styling and belong to the skin that draws it.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub struct DropIndicator {
149    bounds: Bounds<Pixels>,
150    placement: Option<Placement>,
151    from: DropPlaceholderBounds,
152    to: DropPlaceholderBounds,
153    drag_session_id: u64,
154    epoch: u64,
155}
156
157impl DropIndicator {
158    pub(crate) fn new(
159        bounds: Bounds<Pixels>,
160        placement: Option<Placement>,
161        from: DropPlaceholderBounds,
162        to: DropPlaceholderBounds,
163        drag_session_id: u64,
164        epoch: u64,
165    ) -> Self {
166        Self {
167            bounds,
168            placement,
169            from,
170            to,
171            drag_session_id,
172            epoch,
173        }
174    }
175
176    /// The hovered group's content bounds, in window coordinates.
177    pub fn bounds(&self) -> Bounds<Pixels> {
178        self.bounds
179    }
180
181    /// `None` means the drop merges into the tab group.
182    pub fn placement(&self) -> Option<Placement> {
183        self.placement
184    }
185
186    /// Where the placeholder starts, relative to [`Self::bounds`].
187    pub fn from(&self) -> DropPlaceholderBounds {
188        self.from
189    }
190
191    /// Where the placeholder settles, relative to [`Self::bounds`].
192    pub fn to(&self) -> DropPlaceholderBounds {
193        self.to
194    }
195
196    /// Which drag session this indicator belongs to. Host-owned drag items
197    /// share [`ITEM_DRAG_SESSION_ID`].
198    pub fn drag_session_id(&self) -> u64 {
199        self.drag_session_id
200    }
201
202    /// Bumped on every restart, so an animation keyed on it replays instead of
203    /// resuming when the target placement changes.
204    pub fn epoch(&self) -> u64 {
205        self.epoch
206    }
207}
208
209/// The bounds a drop placeholder should occupy within a tab group, given
210/// where the drop would land.
211#[derive(Clone, Copy, Debug, PartialEq)]
212pub struct DropPlaceholderBounds {
213    origin: Point<Pixels>,
214    size: Size<Pixels>,
215}
216
217impl DropPlaceholderBounds {
218    pub(crate) fn new(origin: Point<Pixels>, size: Size<Pixels>) -> Self {
219        Self { origin, size }
220    }
221
222    pub fn for_placement(bounds: Bounds<Pixels>, placement: Option<Placement>) -> Self {
223        let half_width = bounds.size.width * 0.5;
224        let half_height = bounds.size.height * 0.5;
225
226        match placement {
227            Some(Placement::Left) => Self {
228                origin: Point::default(),
229                size: size(half_width, bounds.size.height),
230            },
231            Some(Placement::Right) => Self {
232                origin: point(half_width, px(0.)),
233                size: size(half_width, bounds.size.height),
234            },
235            Some(Placement::Top) => Self {
236                origin: Point::default(),
237                size: size(bounds.size.width, half_height),
238            },
239            Some(Placement::Bottom) => Self {
240                origin: point(px(0.), half_height),
241                size: size(bounds.size.width, half_height),
242            },
243            None => Self {
244                origin: Point::default(),
245                size: bounds.size,
246            },
247        }
248    }
249
250    pub fn origin(&self) -> Point<Pixels> {
251        self.origin
252    }
253
254    pub fn size(&self) -> Size<Pixels> {
255        self.size
256    }
257}
258
259/// Which split zone `position` falls into within `bounds`, or `None` for the
260/// centre zone (merge into the tab group rather than split).
261pub fn split_placement_at(bounds: Bounds<Pixels>, position: Point<Pixels>) -> Option<Placement> {
262    if position.x < bounds.left() + bounds.size.width * 0.35 {
263        Some(Placement::Left)
264    } else if position.x > bounds.left() + bounds.size.width * 0.65 {
265        Some(Placement::Right)
266    } else if position.y < bounds.top() + bounds.size.height * 0.35 {
267        Some(Placement::Top)
268    } else if position.y > bounds.top() + bounds.size.height * 0.65 {
269        Some(Placement::Bottom)
270    } else {
271        None
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use gpui::point;
278
279    use super::*;
280
281    #[test]
282    fn drop_placeholder_bounds_cover_each_target_placement() {
283        let bounds = gpui::Bounds {
284            origin: point(px(120.), px(80.)),
285            size: gpui::size(px(400.), px(300.)),
286        };
287
288        assert_eq!(
289            DropPlaceholderBounds::for_placement(bounds, Some(Placement::Left)),
290            DropPlaceholderBounds {
291                origin: point(px(0.), px(0.)),
292                size: gpui::size(px(200.), px(300.)),
293            }
294        );
295        assert_eq!(
296            DropPlaceholderBounds::for_placement(bounds, Some(Placement::Right)),
297            DropPlaceholderBounds {
298                origin: point(px(200.), px(0.)),
299                size: gpui::size(px(200.), px(300.)),
300            }
301        );
302        assert_eq!(
303            DropPlaceholderBounds::for_placement(bounds, Some(Placement::Top)),
304            DropPlaceholderBounds {
305                origin: point(px(0.), px(0.)),
306                size: gpui::size(px(400.), px(150.)),
307            }
308        );
309        assert_eq!(
310            DropPlaceholderBounds::for_placement(bounds, Some(Placement::Bottom)),
311            DropPlaceholderBounds {
312                origin: point(px(0.), px(150.)),
313                size: gpui::size(px(400.), px(150.)),
314            }
315        );
316        assert_eq!(
317            DropPlaceholderBounds::for_placement(bounds, None),
318            DropPlaceholderBounds {
319                origin: point(px(0.), px(0.)),
320                size: gpui::size(px(400.), px(300.)),
321            }
322        );
323    }
324
325    #[test]
326    fn split_placement_follows_the_cursor_zone() {
327        let bounds = gpui::Bounds {
328            origin: point(px(120.), px(80.)),
329            size: gpui::size(px(400.), px(300.)),
330        };
331        // 35% / 65% of 400x300 from origin (120, 80).
332        let at = |x: f32, y: f32| split_placement_at(bounds, point(px(x), px(y)));
333
334        assert_eq!(at(130., 230.), Some(Placement::Left));
335        assert_eq!(at(510., 230.), Some(Placement::Right));
336        assert_eq!(at(320., 90.), Some(Placement::Top));
337        assert_eq!(at(320., 370.), Some(Placement::Bottom));
338        assert_eq!(at(320., 230.), None, "centre merges into the tab group");
339    }
340
341    #[test]
342    fn split_placement_prefers_horizontal_in_the_corners() {
343        let bounds = gpui::Bounds {
344            origin: Point::default(),
345            size: gpui::size(px(400.), px(300.)),
346        };
347
348        // Top-left corner satisfies both Left and Top; x is tested first.
349        assert_eq!(
350            split_placement_at(bounds, point(px(10.), px(10.))),
351            Some(Placement::Left)
352        );
353        assert_eq!(
354            split_placement_at(bounds, point(px(390.), px(290.))),
355            Some(Placement::Right)
356        );
357    }
358
359    #[test]
360    fn split_placement_boundaries_fall_into_the_centre() {
361        let bounds = gpui::Bounds {
362            origin: Point::default(),
363            size: gpui::size(px(400.), px(300.)),
364        };
365
366        // Comparisons are strict, so the threshold itself is the centre zone.
367        assert_eq!(split_placement_at(bounds, point(px(140.), px(150.))), None);
368        assert_eq!(split_placement_at(bounds, point(px(260.), px(150.))), None);
369        assert_eq!(split_placement_at(bounds, point(px(200.), px(105.))), None);
370        assert_eq!(split_placement_at(bounds, point(px(200.), px(195.))), None);
371    }
372}