Skip to main content

ui/
docking.rs

1//! Pane docking previews. Hosts own the layout and apply one accepted drop.
2
3use std::{cell::RefCell, rc::Rc};
4
5use gpui::{
6    AnyElement, App, Bounds, ElementId, IntoElement, Pixels, Point, Window, point, px, size,
7};
8use motion::{Painter, TAB_SLIDE};
9use web_time::Instant;
10
11mod element;
12pub use element::{Pane, Surface};
13
14/// The destination within a pane. The bar and centre both join its tab strip.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum Zone {
17    Join,
18    Left,
19    Right,
20    Top,
21    Bottom,
22}
23
24/// Hit-test a pane in window coordinates. Corners choose the nearest normalized edge.
25pub fn zone(bounds: Bounds<Pixels>, bar_height: Pixels, pointer: Point<Pixels>) -> Option<Zone> {
26    if bounds.size.width <= px(0.) || bounds.size.height <= px(0.) || !bounds.contains(&pointer) {
27        return None;
28    }
29    if pointer.y < bounds.top() + bar_height.max(px(0.)) {
30        return Some(Zone::Join);
31    }
32    let x = (pointer.x - bounds.left()) / bounds.size.width;
33    let y = (pointer.y - bounds.top()) / bounds.size.height;
34    let (distance, edge) = [
35        (x, Zone::Left),
36        (1. - x, Zone::Right),
37        (y, Zone::Top),
38        (1. - y, Zone::Bottom),
39    ]
40    .into_iter()
41    .min_by(|a, b| a.0.total_cmp(&b.0))
42    .unwrap();
43    Some(if distance < 0.25 { edge } else { Zone::Join })
44}
45
46/// The inset preview rectangle for a destination.
47pub fn preview_bounds(mut bounds: Bounds<Pixels>, zone: Zone) -> Bounds<Pixels> {
48    match zone {
49        Zone::Join => {}
50        Zone::Left => bounds.size.width /= 2.,
51        Zone::Right => {
52            bounds.size.width /= 2.;
53            bounds.origin.x += bounds.size.width;
54        }
55        Zone::Top => bounds.size.height /= 2.,
56        Zone::Bottom => {
57            bounds.size.height /= 2.;
58            bounds.origin.y += bounds.size.height;
59        }
60    }
61    let inset = px(4.)
62        .min(bounds.size.width / 2.)
63        .min(bounds.size.height / 2.);
64    bounds.origin += point(inset, inset);
65    bounds.size.width -= 2. * inset;
66    bounds.size.height -= 2. * inset;
67    bounds
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct Drop<PaneId, ItemId> {
72    pub item: ItemId,
73    pub pane: PaneId,
74    pub zone: Zone,
75}
76
77/// Keep one controller on the view that renders the whole docking surface.
78pub struct Dock<P, I>(Rc<RefCell<State<P, I>>>);
79
80impl<P, I> Clone for Dock<P, I> {
81    fn clone(&self) -> Self {
82        Self(self.0.clone())
83    }
84}
85
86type Ghost<I> = dyn Fn(&I, &mut Window, &mut App) -> AnyElement;
87type OnDrop<P, I> = dyn Fn(&Drop<P, I>, &mut Window, &mut App) -> Option<P>;
88
89struct Target<P> {
90    id: P,
91    bounds: Bounds<Pixels>,
92    visible: Bounds<Pixels>,
93    bar_height: Pixels,
94}
95
96struct Tween {
97    from: Bounds<Pixels>,
98    to: Bounds<Pixels>,
99    since: Instant,
100}
101
102impl Tween {
103    fn progress(&self, cx: &App) -> f32 {
104        if cx.reduce_motion() {
105            return 1.;
106        }
107        let elapsed = cx
108            .background_executor()
109            .now()
110            .saturating_duration_since(self.since);
111        TAB_SLIDE.progress(
112            elapsed.as_secs_f32()
113                / TAB_SLIDE
114                    .total()
115                    .mul_f32(motion::speed_scale())
116                    .as_secs_f32(),
117        )
118    }
119    fn bounds(&self, cx: &App) -> Bounds<Pixels> {
120        let t = self.progress(cx);
121        Bounds::new(
122            self.from.origin + (self.to.origin - self.from.origin) * t,
123            size(
124                self.from.size.width + (self.to.size.width - self.from.size.width) * t,
125                self.from.size.height + (self.to.size.height - self.from.size.height) * t,
126            ),
127        )
128    }
129}
130
131struct Settling<P, I> {
132    pane: P,
133    item: I,
134    preview: Bounds<Pixels>,
135    /// Where the docking ghost settles from; `None` when the source domain
136    /// drew its own.
137    ghost: Option<Bounds<Pixels>>,
138    since: Instant,
139}
140
141struct Carried<I> {
142    item: I,
143    gesture: Rc<crate::drag::Gesture>,
144    pointer: Point<Pixels>,
145}
146
147impl<I> Carried<I> {
148    /// Off every region of its domain: this surface's to take.
149    fn detached(&self) -> bool {
150        !self.gesture.claimed.get()
151    }
152
153    /// Detached, and drawn by this surface rather than by its source domain.
154    fn hosted(&self) -> bool {
155        self.detached() && !self.gesture.ghosted.get()
156    }
157
158    fn ghost_bounds(&self) -> Bounds<Pixels> {
159        self.gesture.ghost_bounds(self.pointer)
160    }
161}
162
163struct State<P, I> {
164    painter: Painter,
165    ghost: Rc<Ghost<I>>,
166    dropped: Option<Rc<OnDrop<P, I>>>,
167    targets: Vec<Target<P>>,
168    carried: Option<Carried<I>>,
169    landing: Option<(P, Zone)>,
170    preview: Option<Tween>,
171    settling: Option<Settling<P, I>>,
172}
173
174impl<P: Clone + PartialEq + 'static, I: Clone + PartialEq + 'static> Dock<P, I> {
175    /// `ghost` renders the detached item and its brief settle after a drop.
176    pub fn new(
177        painter: Painter,
178        ghost: impl Fn(&I, &mut Window, &mut App) -> AnyElement + 'static,
179    ) -> Self {
180        Self(Rc::new(RefCell::new(State {
181            painter,
182            ghost: Rc::new(ghost),
183            dropped: None,
184            targets: Vec::new(),
185            carried: None,
186            landing: None,
187            preview: None,
188            settling: None,
189        })))
190    }
191
192    /// Mount every target inside this surface, in the controller's owning view.
193    pub fn surface(&self, id: impl Into<ElementId>, child: impl IntoElement) -> Surface<P, I> {
194        element::Surface {
195            dock: self.clone(),
196            id: id.into(),
197            child: child.into_any_element(),
198            dropped: None,
199        }
200    }
201
202    /// Measure a pane and animate its entry after an accepted drop.
203    pub fn pane(&self, id: P, bar_height: Pixels, child: impl IntoElement) -> Pane<P, I> {
204        element::Pane {
205            dock: self.clone(),
206            id,
207            bar_height,
208            child: child.into_any_element(),
209        }
210    }
211}
212
213impl<P: Clone + PartialEq, I: Clone + PartialEq> State<P, I> {
214    fn aim(&mut self, cx: &mut App) {
215        let target = self
216            .carried
217            .as_ref()
218            .filter(|carried| carried.detached())
219            .and_then(|carried| {
220                self.targets.iter().rev().find_map(|target| {
221                    if !target.visible.contains(&carried.pointer) {
222                        return None;
223                    }
224                    zone(target.bounds, target.bar_height, carried.pointer)
225                        .map(|zone| (target.id.clone(), zone, preview_bounds(target.bounds, zone)))
226                })
227            });
228        let Some((pane, zone, bounds)) = target else {
229            self.landing = None;
230            self.preview = None;
231            return;
232        };
233        if self
234            .preview
235            .as_ref()
236            .is_none_or(|preview| preview.to != bounds)
237        {
238            let from = self
239                .preview
240                .as_ref()
241                .map(|preview| preview.bounds(cx))
242                .unwrap_or(bounds);
243            self.preview = Some(Tween {
244                from,
245                to: bounds,
246                since: cx.background_executor().now(),
247            });
248        }
249        self.landing = Some((pane, zone));
250    }
251}
252
253impl<P: Clone + PartialEq + 'static, I: Clone + PartialEq + 'static> Dock<P, I> {
254    fn update(
255        &self,
256        item: I,
257        gesture: Rc<crate::drag::Gesture>,
258        pointer: Point<Pixels>,
259        cx: &mut App,
260    ) {
261        let mut state = self.0.borrow_mut();
262        state.carried = Some(Carried {
263            item,
264            gesture,
265            pointer,
266        });
267        state.settling = None;
268        state.aim(cx);
269        state.painter.notify(cx);
270    }
271
272    fn clear(&self, cx: &mut App) {
273        let mut state = self.0.borrow_mut();
274        if state.carried.take().is_some() {
275            state.landing = None;
276            state.preview = None;
277            state.painter.notify(cx);
278        }
279    }
280
281    fn release(
282        &self,
283        carry: &crate::drag::Carry<I>,
284        pointer: Point<Pixels>,
285        window: &mut Window,
286        cx: &mut App,
287    ) {
288        self.update(carry.item().clone(), carry.gesture.clone(), pointer, cx);
289        let proposal = {
290            let state = self.0.borrow();
291            let carried = state.carried.as_ref().filter(|carried| carried.detached());
292            carried
293                .zip(state.landing.as_ref())
294                .zip(state.dropped.as_ref())
295                .map(|((carried, (pane, zone)), callback)| {
296                    (
297                        Drop {
298                            item: carried.item.clone(),
299                            pane: pane.clone(),
300                            zone: *zone,
301                        },
302                        callback.clone(),
303                        state.preview.as_ref().unwrap().bounds(cx),
304                        carried.hosted().then(|| carried.ghost_bounds()),
305                    )
306                })
307        };
308        self.clear(cx);
309        if let Some((event, callback, preview, ghost)) = proposal
310            && let Some(pane) = callback(&event, window, cx)
311        {
312            let mut state = self.0.borrow_mut();
313            state.settling = Some(Settling {
314                pane,
315                item: event.item,
316                preview,
317                ghost,
318                since: cx.background_executor().now(),
319            });
320            state.painter.notify(cx);
321        }
322    }
323}