Skip to main content

gpui_kit/interaction/
dnd.rs

1//! Carrying an item from where it is to where it should go.
2//!
3//! # The library never moves anything
4//!
5//! A drop reports an intent — "this item goes before that one", "into that
6//! folder" — and stops. The host applies it to the data it owns and hands back
7//! a new order; the list shows the reorder on the frame that new order
8//! arrives, and not before. A host that refuses the move keeps the order that
9//! still holds, which is the same rule every other component in this library
10//! follows for values, sorts and selections.
11//!
12//! # What GPUI provides, and what is added here
13//!
14//! GPUI owns the gesture: [`gpui::StatefulInteractiveElement::on_drag`] turns
15//! a press plus two pixels of travel into a drag, keeps the payload in the
16//! application, paints one drag view at the pointer, routes
17//! [`gpui::InteractiveElement::on_drag_move`] to every registered element and
18//! [`gpui::InteractiveElement::on_drop`] to whatever the pointer is over on
19//! release, and lets a target refuse a payload through
20//! [`gpui::InteractiveElement::can_drop`].
21//!
22//! What GPUI does not have, and this module adds: a vocabulary for where a
23//! drop lands ([`DropPosition`]), one payload type every surface in the
24//! library agrees on ([`DragItem`]), the speed the gesture was moving at as
25//! well as where it was ([`DropIntent::velocity`]), a published record of the
26//! drag so a test can read what is being carried and where it would land,
27//! escape as a cancel,
28//! a ghost that follows the pointer on a spring instead of snapping to it, and
29//! the make-way slide that opens the slot the drop would land in.
30//!
31//! # What a drag publishes
32//!
33//! While a drag is in flight the semantic tree carries one extra node, with
34//! the id [`DRAG_NODE_ID`] and the role [`Role::Drag`]:
35//!
36//! - `text` — the label of the item being carried;
37//! - `value` — the item's id and where it would land, as
38//!   `"<item id> before:<anchor>"`, `"after:<anchor>"`, `"into:<anchor>"`, or
39//!   `"<item id> none"` when the pointer is over nothing that offers a slot;
40//! - `invalid` — set when the target under the pointer refuses the payload.
41//!
42//! The node exists only while the drag does, so a test reads it from an
43//! ordinary snapshot and never has to sleep.
44//!
45//! # Reduced motion
46//!
47//! The ghost is direct manipulation, not decoration: it is the thing the hand
48//! is holding, so it keeps following the pointer under reduced motion. What it
49//! loses is the spring — it tracks the pointer exactly instead of trailing it.
50//! The make-way slides are decoration, so they settle instantly and the slot
51//! is simply open from the first frame.
52
53use std::any::Any;
54use std::cell::RefCell;
55use std::panic::Location;
56use std::rc::Rc;
57use std::time::Duration;
58
59use gpui::{
60    App, AppContext, Bounds, Context, Element, ElementId, GlobalElementId, InspectorElementId,
61    InteractiveElement, IntoElement, LayoutId, ParentElement, Pixels, Point, Render, SharedString,
62    StatefulInteractiveElement, Styled, Window, div, px,
63};
64use gpui_kit_assets::Icon;
65use gpui_kit_semantics::{NodeSpec, Role, Semantic};
66use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, SpringPreset};
67use web_time::Instant;
68
69use crate::display::icon::Icon as IconView;
70use crate::foundation::Sizable;
71use crate::foundation::StyledExt;
72use crate::motion::{Interpolate, Spring, Velocity, VelocityTracker, keyed};
73use crate::strings::{ActiveStrings, StringKey};
74
75/// The semantic id of the node a drag publishes while it is in flight.
76pub const DRAG_NODE_ID: &str = "dnd.drag";
77
78/// The payload kind a row, node, or tab carries.
79pub const ROW_KIND: &str = "row";
80
81/// The payload kind an external file drop carries.
82pub const FILE_KIND: &str = "file";
83
84/// What a drag is carrying.
85///
86/// The identity is the item's business identity, never its position, because a
87/// reorder changes every position and nothing else.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct DragItem {
90    /// The surface the drag started in, so a target can tell its own rows from
91    /// somebody else's.
92    pub source: SharedString,
93    pub id: SharedString,
94    /// The name shown on the ghost and published in the tree.
95    pub label: SharedString,
96    /// What sort of thing this is, so a target can refuse a payload it does
97    /// not handle.
98    pub kind: SharedString,
99    pub icon: Option<Icon>,
100}
101
102impl DragItem {
103    pub fn new(
104        source: impl Into<SharedString>,
105        id: impl Into<SharedString>,
106        label: impl Into<SharedString>,
107    ) -> Self {
108        Self {
109            source: source.into(),
110            id: id.into(),
111            label: label.into(),
112            kind: SharedString::new_static(ROW_KIND),
113            icon: None,
114        }
115    }
116
117    pub fn kind(mut self, kind: impl Into<SharedString>) -> Self {
118        self.kind = kind.into();
119        self
120    }
121
122    pub fn icon(mut self, icon: Icon) -> Self {
123        self.icon = Some(icon);
124        self
125    }
126}
127
128/// Where a dropped item goes, expressed against something already there.
129///
130/// "At index N" is deliberately absent: an index is a position, and a position
131/// stops meaning anything the moment the host applies the move. A drop at the
132/// top of a list is `Before` the first item; a drop into an empty container is
133/// `Into` the container.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub enum DropPosition {
136    Before(SharedString),
137    After(SharedString),
138    /// Inside the named container — a folder in a tree, or a zone that holds
139    /// items without ordering them.
140    Into(SharedString),
141}
142
143impl DropPosition {
144    /// The item the position is expressed against.
145    pub fn anchor(&self) -> &SharedString {
146        match self {
147            Self::Before(id) | Self::After(id) | Self::Into(id) => id,
148        }
149    }
150
151    pub fn verb(&self) -> &'static str {
152        match self {
153            Self::Before(_) => "before",
154            Self::After(_) => "after",
155            Self::Into(_) => "into",
156        }
157    }
158}
159
160impl std::fmt::Display for DropPosition {
161    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        write!(formatter, "{}:{}", self.verb(), self.anchor())
163    }
164}
165
166/// One drop, as it is reported to the host.
167#[derive(Clone, Debug, PartialEq)]
168pub struct DropIntent {
169    pub item: DragItem,
170    pub position: DropPosition,
171    /// How fast the pointer was moving when it let go, in pixels a second.
172    ///
173    /// A gesture that stopped before it was released reports
174    /// [`Velocity::ZERO`], which is the difference between a flick and a
175    /// deliberate placement. See [`crate::motion::flick`].
176    pub velocity: Velocity,
177}
178
179/// Which way a target's slots are laid out.
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub enum DropAxis {
182    Vertical,
183    Horizontal,
184}
185
186/// The drag as anything outside the module can see it.
187#[derive(Clone, Debug, PartialEq)]
188pub struct ActiveDrag {
189    pub item: DragItem,
190    /// How fast the pointer is moving right now, in pixels a second. A drag
191    /// the user has paused reports [`Velocity::ZERO`].
192    pub velocity: Velocity,
193    /// The surface the pointer is currently over, when it offers a slot.
194    pub surface: Option<SharedString>,
195    /// Where the drop would land right now.
196    pub position: Option<DropPosition>,
197    /// Whether the target under the pointer accepts the payload. A drag over
198    /// nothing is neither accepted nor refused.
199    pub accepted: bool,
200}
201
202/// The state a target published on the last pointer move.
203#[derive(Clone, Debug)]
204struct Landing {
205    surface: SharedString,
206    position: DropPosition,
207    /// How many rows precede the insertion point, for deciding which rows
208    /// slide out of the way. `None` when the drop goes inside something and
209    /// therefore opens no slot.
210    slot: Option<usize>,
211    accepted: bool,
212    /// The pointer position that produced this landing.
213    ///
214    /// Every target sees the same position for one move, so a landing whose
215    /// position is not the current one was written by an earlier move and the
216    /// pointer has since left the target that wrote it. `None` marks a staged
217    /// landing, which has no pointer behind it.
218    at: Option<Point<Pixels>>,
219}
220
221#[derive(Default)]
222struct Session {
223    item: Option<DragItem>,
224    landing: Option<Landing>,
225    /// Set by [`stage`]. A staged drag has no pointer and no gesture, so it
226    /// neither expires with the pointer nor settles over time.
227    staged: bool,
228    /// How fast the pointer is travelling, so a drop reports the speed it was
229    /// let go at and not only where.
230    speed: VelocityTracker,
231    /// The last position and instant handed to the tracker. Every registered
232    /// target hears the same move, so without this one gesture would be
233    /// sampled once per target.
234    sampled: Option<(Instant, Point<Pixels>)>,
235}
236
237#[derive(Default)]
238struct SessionGlobal(RefCell<Session>);
239
240impl gpui::Global for SessionGlobal {}
241
242/// Installs the drag session and the key that cancels a drag.
243///
244/// Escape is observed at the application rather than bound to an element,
245/// because the pointer can be anywhere by the time a drag is abandoned and the
246/// element the drag started on may no longer be under it.
247pub fn install(cx: &mut App) {
248    if cx.has_global::<SessionGlobal>() {
249        return;
250    }
251    cx.set_global(SessionGlobal::default());
252    cx.observe_keystrokes(|event, window, cx| {
253        if event.keystroke.key == "escape" && cx.has_active_drag() {
254            cancel(window, cx);
255        }
256    })
257    .detach();
258}
259
260fn session<R>(cx: &mut App, act: impl FnOnce(&mut Session) -> R) -> R {
261    if !cx.has_global::<SessionGlobal>() {
262        cx.set_global(SessionGlobal::default());
263    }
264    let mut state = cx.global::<SessionGlobal>().0.borrow_mut();
265    act(&mut state)
266}
267
268fn read<R>(cx: &App, act: impl FnOnce(&Session) -> R) -> Option<R> {
269    cx.try_global::<SessionGlobal>()
270        .map(|global| act(&global.0.borrow()))
271}
272
273/// Records the item a gesture just picked up.
274fn begin(item: DragItem, cx: &mut App) {
275    session(cx, |state| {
276        state.item = Some(item);
277        state.landing = None;
278        state.staged = false;
279        // A new gesture starts from rest: the speed of the one before it is
280        // not this one's.
281        state.speed.clear();
282        state.sampled = None;
283    });
284}
285
286/// Drops everything the session remembers.
287fn clear(cx: &mut App) {
288    session(cx, |state| *state = Session::default());
289}
290
291/// Forgets the session once a drop has been reported.
292pub(crate) fn finish(cx: &mut App) {
293    clear(cx);
294}
295
296/// Records a platform file drag, which never passes through [`draggable`].
297///
298/// The label counts the files rather than naming them: a path is
299/// user-generated content and the label is published in the semantic tree.
300pub(crate) fn adopt_external(count: usize, cx: &mut App) {
301    let label = if count == 1 {
302        cx.strings().text(StringKey::DragFileOne)
303    } else {
304        cx.strings()
305            .format(StringKey::DragFileMany, &[&count.to_string()])
306    };
307    let carried = read(cx, |state| state.item.clone()).flatten();
308    if carried.is_some_and(|item| item.kind.as_ref() == FILE_KIND && item.label == label) {
309        return;
310    }
311    begin(
312        DragItem::new(SharedString::new_static("platform"), "files", label).kind(FILE_KIND),
313        cx,
314    );
315}
316
317/// Abandons a drag in flight. A cancelled drag reports nothing.
318pub fn cancel(window: &mut Window, cx: &mut App) {
319    cx.stop_active_drag(window);
320    clear(cx);
321}
322
323/// Forgets a session whose gesture has ended.
324///
325/// A component calls this before it reads the session, so a drag that finished
326/// between two frames cannot leave an indicator behind.
327pub(crate) fn sync(cx: &mut App) {
328    let stale = session(cx, |state| state.item.is_some() && !state.staged);
329    if stale && !cx.has_active_drag() {
330        clear(cx);
331    }
332}
333
334/// The drag in flight, if there is one.
335pub fn active(window: &Window, cx: &App) -> Option<ActiveDrag> {
336    let pointer = window.mouse_position();
337    read(cx, |state| {
338        let item = state.item.clone()?;
339        let landing = state
340            .landing
341            .as_ref()
342            .filter(|landing| landing.at.is_none_or(|at| at == pointer));
343        Some(ActiveDrag {
344            item,
345            velocity: state.speed.velocity_at(cx.background_executor().now()),
346            surface: landing.map(|landing| landing.surface.clone()),
347            position: landing.map(|landing| landing.position.clone()),
348            accepted: landing.is_some_and(|landing| landing.accepted),
349        })
350    })
351    .flatten()
352}
353
354/// The landing this surface published for the current pointer position.
355fn landing_for(surface: &SharedString, pointer: Point<Pixels>, cx: &App) -> Option<Landing> {
356    read(cx, |state| {
357        state
358            .landing
359            .as_ref()
360            .filter(|landing| &landing.surface == surface)
361            .filter(|landing| landing.at.is_none_or(|at| at == pointer))
362            .cloned()
363    })
364    .flatten()
365}
366
367fn fresh_landing(pointer: Point<Pixels>, cx: &App) -> Option<Landing> {
368    read(cx, |state| {
369        state
370            .landing
371            .as_ref()
372            .filter(|landing| landing.at.is_none_or(|at| at == pointer))
373            .cloned()
374    })
375    .flatten()
376}
377
378fn set_landing(landing: Landing, cx: &mut App) {
379    session(cx, |state| state.landing = Some(landing));
380}
381
382fn clear_landing(cx: &mut App) {
383    session(cx, |state| state.landing = None);
384}
385
386fn is_staged(cx: &App) -> bool {
387    read(cx, |state| state.staged).unwrap_or(false)
388}
389
390/// Feeds one pointer move to the gesture's velocity tracker.
391fn record_pointer(pointer: Point<Pixels>, at: Instant, cx: &mut App) {
392    session(cx, |state| {
393        if state.sampled == Some((at, pointer)) {
394            return;
395        }
396        state.sampled = Some((at, pointer));
397        state.speed.sample(pointer, at);
398    });
399}
400
401/// How fast the drag in flight is moving.
402///
403/// The clock is passed in rather than read from the samples, because a pointer
404/// that has stopped sends nothing at all and a pause is only visible against a
405/// clock.
406fn velocity(cx: &App) -> Velocity {
407    read(cx, |state| {
408        state.speed.velocity_at(cx.background_executor().now())
409    })
410    .unwrap_or(Velocity::ZERO)
411}
412
413/// What a surface needs to know to draw a drag it is taking part in.
414#[derive(Clone, Debug)]
415pub(crate) struct SurfaceDrag {
416    pub item: DragItem,
417    pub position: Option<DropPosition>,
418    pub slot: Option<usize>,
419    pub accepted: bool,
420}
421
422impl SurfaceDrag {
423    /// Whether the row named `id` is the one being carried.
424    pub fn carries(&self, id: &SharedString) -> bool {
425        &self.item.id == id
426    }
427
428    /// The indicator this row should draw, if any.
429    pub fn indicator_for(&self, id: &SharedString) -> Option<(DropPosition, bool)> {
430        let position = self.position.clone()?;
431        (position.anchor() == id).then_some((position, self.accepted))
432    }
433
434    /// Whether a row sitting at `index` has to slide to open the slot.
435    pub fn makes_way(&self, index: usize) -> bool {
436        self.slot.is_some_and(|slot| index >= slot)
437    }
438}
439
440/// The drag `surface` is currently taking part in.
441pub(crate) fn surface_drag(
442    surface: &SharedString,
443    window: &Window,
444    cx: &mut App,
445) -> Option<SurfaceDrag> {
446    sync(cx);
447    let pointer = window.mouse_position();
448    let item = read(cx, |state| state.item.clone()).flatten()?;
449    let landing = landing_for(surface, pointer, cx);
450    Some(SurfaceDrag {
451        item,
452        position: landing.as_ref().map(|landing| landing.position.clone()),
453        slot: landing.as_ref().and_then(|landing| landing.slot),
454        accepted: landing.is_some_and(|landing| landing.accepted),
455    })
456}
457
458// -- staging -----------------------------------------------------------------
459
460/// A drag placed by hand rather than by a pointer.
461///
462/// A still image cannot photograph a gesture, and a capture that waited for a
463/// real drag would race the pointer and the spring. Staging puts the system
464/// into one fixed state so a scene renders the same pixels every run.
465#[derive(Clone, Debug)]
466pub struct StagedDrag {
467    item: DragItem,
468    landing: Option<Landing>,
469}
470
471impl StagedDrag {
472    pub fn new(item: DragItem) -> Self {
473        Self {
474            item,
475            landing: None,
476        }
477    }
478
479    /// Where the staged drag would land. `slot` is how many rows precede the
480    /// insertion point, which is what decides the rows that slide aside.
481    pub fn landing(
482        mut self,
483        surface: impl Into<SharedString>,
484        position: DropPosition,
485        slot: Option<usize>,
486        accepted: bool,
487    ) -> Self {
488        self.landing = Some(Landing {
489            surface: surface.into(),
490            position,
491            slot,
492            accepted,
493            at: None,
494        });
495        self
496    }
497}
498
499/// Places the drag system in a fixed state, for a capture or a review that has
500/// to show a drag in flight.
501pub fn stage(drag: StagedDrag, cx: &mut App) {
502    session(cx, |state| {
503        state.item = Some(drag.item.clone());
504        state.landing = drag.landing.clone();
505        state.staged = true;
506    });
507}
508
509/// The ghost of a staged drag, for a scene that places it itself.
510pub fn staged_ghost(cx: &mut App) -> Option<gpui::Div> {
511    if !is_staged(cx) {
512        return None;
513    }
514    let item = read(cx, |state| state.item.clone()).flatten()?;
515    let landing = read(cx, |state| state.landing.clone()).flatten();
516    Some(ghost_element(&item, landing.as_ref(), cx))
517}
518
519// -- picking a payload up -----------------------------------------------------
520
521/// Makes `element` the handle of a drag carrying `item`.
522///
523/// GPUI decides when the press becomes a drag; this only says what is being
524/// carried and what the ghost looks like.
525pub fn draggable<E>(element: E, item: DragItem) -> E
526where
527    E: StatefulInteractiveElement + Sized,
528{
529    element.on_drag(item, |item, _offset, _window, cx| {
530        begin(item.clone(), cx);
531        let carried = item.clone();
532        cx.new(|_| DragGhost::new(carried))
533    })
534}
535
536// -- putting a payload down ---------------------------------------------------
537
538/// Whether a payload may land, and where.
539type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
540type Dropped = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
541
542/// One row, node, or tab offering the slots around itself.
543pub(crate) struct RowTarget {
544    pub surface: SharedString,
545    pub id: SharedString,
546    /// How many rows precede this one, for deciding the make-way slot.
547    pub index: usize,
548    /// Whether the middle of the row offers [`DropPosition::Into`].
549    pub allow_into: bool,
550    pub axis: DropAxis,
551    pub accepts: Accepts,
552    pub on_drop: Dropped,
553}
554
555/// Which slot a pointer standing `fraction` of the way across a target asks
556/// for.
557///
558/// A target that can be dropped into keeps its middle half for that, leaving a
559/// quarter at each end for the slots beside it. A target that cannot splits in
560/// two, so every pixel of it asks for something.
561pub(crate) fn zone(fraction: f32, allow_into: bool) -> DropZone {
562    if allow_into {
563        if fraction < 0.25 {
564            DropZone::Before
565        } else if fraction > 0.75 {
566            DropZone::After
567        } else {
568            DropZone::Into
569        }
570    } else if fraction < 0.5 {
571        DropZone::Before
572    } else {
573        DropZone::After
574    }
575}
576
577#[derive(Clone, Copy, Debug, PartialEq, Eq)]
578pub(crate) enum DropZone {
579    Before,
580    After,
581    Into,
582}
583
584impl DropZone {
585    fn resolve(self, id: &SharedString, index: usize) -> (DropPosition, Option<usize>) {
586        match self {
587            Self::Before => (DropPosition::Before(id.clone()), Some(index)),
588            Self::After => (DropPosition::After(id.clone()), Some(index + 1)),
589            Self::Into => (DropPosition::Into(id.clone()), None),
590        }
591    }
592}
593
594fn fraction_of(bounds: Bounds<Pixels>, pointer: Point<Pixels>, axis: DropAxis) -> f32 {
595    let (offset, extent) = match axis {
596        DropAxis::Vertical => (
597            f32::from(pointer.y - bounds.origin.y),
598            f32::from(bounds.size.height),
599        ),
600        DropAxis::Horizontal => (
601            f32::from(pointer.x - bounds.origin.x),
602            f32::from(bounds.size.width),
603        ),
604    };
605    if extent <= 0.0 {
606        return 0.0;
607    }
608    (offset / extent).clamp(0.0, 1.0)
609}
610
611/// Wires `element` up as a target for the slots around itself.
612pub(crate) fn drop_target<E>(element: E, target: RowTarget) -> E
613where
614    E: InteractiveElement + Sized,
615{
616    let RowTarget {
617        surface,
618        id,
619        index,
620        allow_into,
621        axis,
622        accepts,
623        on_drop,
624    } = target;
625
626    let element = element.on_drag_move::<DragItem>({
627        let surface = surface.clone();
628        let id = id.clone();
629        let accepts = Rc::clone(&accepts);
630        move |event, _window, cx| {
631            let pointer = event.event.position;
632            // Sampled before the bounds check: the speed of the gesture is a
633            // property of the hand, not of whatever it happens to be over.
634            record_pointer(pointer, cx.background_executor().now(), cx);
635            if !event.bounds.contains(&pointer) {
636                return;
637            }
638            let item = event.drag(cx).clone();
639            // An item cannot be moved relative to itself, so its own row
640            // offers no slot at all rather than a refusal.
641            if item.id == id && item.source == surface {
642                clear_landing(cx);
643                return;
644            }
645            let (position, slot) =
646                zone(fraction_of(event.bounds, pointer, axis), allow_into).resolve(&id, index);
647            let accepted = accepts(&item, &position);
648            set_landing(
649                Landing {
650                    surface: surface.clone(),
651                    position,
652                    slot,
653                    accepted,
654                    at: Some(pointer),
655                },
656                cx,
657            );
658        }
659    });
660
661    let element = element.can_drop({
662        let surface = surface.clone();
663        move |payload: &dyn Any, window: &mut Window, cx: &mut App| {
664            payload.downcast_ref::<DragItem>().is_some()
665                && landing_for(&surface, window.mouse_position(), cx)
666                    .is_some_and(|landing| landing.accepted)
667        }
668    });
669
670    element.on_drop::<DragItem>(move |item, window, cx| {
671        let Some(landing) = landing_for(&surface, window.mouse_position(), cx) else {
672            return;
673        };
674        if !landing.accepted {
675            return;
676        }
677        let intent = DropIntent {
678            item: item.clone(),
679            position: landing.position.clone(),
680            velocity: velocity(cx),
681        };
682        clear(cx);
683        on_drop(&intent, window, cx);
684    })
685}
686
687// -- the ghost ----------------------------------------------------------------
688
689/// The rendering GPUI paints at the pointer while a drag is in flight.
690///
691/// It is a view rather than a builder because the spring that trails the
692/// pointer has to remember where it was on the previous frame.
693struct DragGhost {
694    item: DragItem,
695    /// The pointer position the current trail started from.
696    from: Point<Pixels>,
697    current: Point<Pixels>,
698    anchor: Option<Point<Pixels>>,
699    elapsed: Duration,
700    last_frame: Option<Instant>,
701}
702
703impl DragGhost {
704    fn new(item: DragItem) -> Self {
705        Self {
706            item,
707            from: Point::default(),
708            current: Point::default(),
709            anchor: None,
710            elapsed: Duration::ZERO,
711            last_frame: None,
712        }
713    }
714
715    /// The offset the ghost is painted at, relative to the pointer.
716    fn trail(&mut self, pointer: Point<Pixels>, spring: Spring, settle: Duration, now: Instant) {
717        if let Some(last) = self.last_frame {
718            self.elapsed += now.saturating_duration_since(last);
719        }
720        self.last_frame = Some(now);
721        let residual = self.sample(spring, settle);
722        match self.anchor {
723            Some(anchor) if anchor != pointer => {
724                self.from = residual + anchor - pointer;
725                self.elapsed = Duration::ZERO;
726            }
727            None => self.from = Point::default(),
728            _ => {}
729        }
730        self.anchor = Some(pointer);
731        self.current = self.sample(spring, settle);
732        if self.elapsed >= settle {
733            self.last_frame = None;
734        }
735    }
736
737    fn sample(&self, spring: Spring, settle: Duration) -> Point<Pixels> {
738        if self.elapsed >= settle {
739            return Point::default();
740        }
741        self.from.lerp(Point::default(), spring.value(self.elapsed))
742    }
743
744    fn snap(&mut self, pointer: Point<Pixels>) {
745        self.anchor = Some(pointer);
746        self.from = Point::default();
747        self.current = Point::default();
748        self.elapsed = Duration::ZERO;
749        self.last_frame = None;
750    }
751}
752
753impl Render for DragGhost {
754    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
755        let pointer = window.mouse_position();
756        if cx.reduce_motion() {
757            self.snap(pointer);
758        } else {
759            let spring = Spring::preset(cx.theme(), SpringPreset::Grab);
760            let settle = spring.settle_time();
761            let now = cx.background_executor().now();
762            self.trail(pointer, spring, settle, now);
763        }
764        let offset = self.current;
765        if offset != Point::default() {
766            window.request_animation_frame();
767        }
768
769        let landing = fresh_landing(pointer, cx);
770        let card = ghost_element(&self.item, landing.as_ref(), cx);
771        div().child(card.ml(offset.x).mt(offset.y))
772    }
773}
774
775/// The ghost itself: a rendering of the item, not a grey box.
776fn ghost_element(item: &DragItem, landing: Option<&Landing>, cx: &mut App) -> gpui::Div {
777    let theme = cx.theme().clone();
778    let refused = landing.is_some_and(|landing| !landing.accepted);
779    let border = if refused {
780        theme.colors.danger
781    } else {
782        theme.colors.accent
783    };
784    let where_to = match landing {
785        Some(landing) => format!("{} {}", item.id, landing.position),
786        None => format!("{} none", item.id),
787    };
788
789    let ghost = div()
790        .row()
791        .flex_none()
792        .gap_token(&theme, Space::Xs)
793        .px_token(&theme, Space::Sm)
794        .py_token(&theme, Space::Xs)
795        .bg(theme.colors.raised)
796        .border(px(theme.borders.hairline))
797        .border_color(border)
798        .radius(&theme, Radius::Control)
799        .elevation(&theme, Elevation::Overlay)
800        .text_color(theme.colors.text)
801        .type_scale(&theme, gpui_kit_theme::TypeScale::Body)
802        .opacity(theme.opacity.muted)
803        // The same glyph, the same token step, the same role: this is what
804        // hand-drawing an icon was already spelling out.
805        .children(item.icon.map(|glyph| IconView::new(glyph).medium().muted()))
806        .child(item.label.clone())
807        .semantic_in(
808            cx,
809            NodeSpec::new(DRAG_NODE_ID, Role::Drag)
810                .text(item.label.clone())
811                .value(where_to)
812                .invalid(refused),
813        );
814    div().child(ghost)
815}
816
817// -- the drop indicator -------------------------------------------------------
818
819/// The line or highlight that says where the drop would land.
820///
821/// It is drawn over the row rather than between rows, because a real gap would
822/// move the layout and a virtualized list has no room between its slots.
823pub(crate) fn indicator(
824    position: &DropPosition,
825    accepted: bool,
826    axis: DropAxis,
827    cx: &App,
828) -> gpui::Div {
829    let theme = cx.theme();
830    let color = if accepted {
831        theme.colors.accent
832    } else {
833        theme.colors.danger
834    };
835    let thickness = px(theme.borders.thick);
836    let line = div().absolute().bg(color);
837    match (position, axis) {
838        (DropPosition::Into(_), _) => div()
839            .absolute()
840            .inset_0()
841            .border(px(theme.borders.thick))
842            .border_color(color)
843            .rounded(px(theme.radii.small))
844            .bg(color.opacity(theme.effects.selected_ring_alpha)),
845        (DropPosition::Before(_), DropAxis::Vertical) => {
846            line.left_0().right_0().top_0().h(thickness)
847        }
848        (DropPosition::After(_), DropAxis::Vertical) => {
849            line.left_0().right_0().bottom_0().h(thickness)
850        }
851        (DropPosition::Before(_), DropAxis::Horizontal) => {
852            line.top_0().bottom_0().left_0().w(thickness)
853        }
854        (DropPosition::After(_), DropAxis::Horizontal) => {
855            line.top_0().bottom_0().right_0().w(thickness)
856        }
857    }
858}
859
860// -- making way ---------------------------------------------------------------
861
862/// How far a row travels to open the slot a drop would land in.
863pub(crate) fn make_way_gap(cx: &App, axis: DropAxis) -> Pixels {
864    let theme = cx.theme();
865    match axis {
866        DropAxis::Vertical => px(theme.space(Space::Md)),
867        DropAxis::Horizontal => px(theme.space(Space::Lg)),
868    }
869}
870
871#[derive(Default)]
872struct SlideState {
873    target: Point<Pixels>,
874    from: Point<Pixels>,
875    current: Point<Pixels>,
876    elapsed: Duration,
877    last_frame: Option<Instant>,
878}
879
880impl SlideState {
881    fn advance(&mut self, target: Point<Pixels>, spring: Spring, settle: Duration, now: Instant) {
882        if target != self.target {
883            self.from = self.current;
884            self.target = target;
885            self.elapsed = Duration::ZERO;
886            self.last_frame = Some(now);
887        } else if let Some(last) = self.last_frame {
888            self.elapsed += now.saturating_duration_since(last);
889            self.last_frame = Some(now);
890        }
891        self.current = if self.elapsed >= settle {
892            self.last_frame = None;
893            self.target
894        } else {
895            self.from.lerp(self.target, spring.value(self.elapsed))
896        };
897    }
898
899    fn settle_at(&mut self, target: Point<Pixels>) {
900        self.target = target;
901        self.from = target;
902        self.current = target;
903        self.elapsed = Duration::ZERO;
904        self.last_frame = None;
905    }
906}
907
908/// Slides an element away from where layout put it, to open a slot.
909///
910/// This is the invert-and-play half of [`crate::motion::Flipping::flip`]
911/// without the measurement half: a row inside a virtualized list keeps the
912/// layout slot its index gives it, so the gap can only ever be painted.
913pub(crate) trait MakingWay: IntoElement + Sized {
914    fn make_way(
915        self,
916        id: impl Into<SharedString>,
917        offset: Point<Pixels>,
918        window: &mut Window,
919        cx: &mut App,
920    ) -> MakeWay {
921        let id = id.into();
922        let state = keyed::slot::<SlideState>(&id, cx);
923        let spring = Spring::preset(cx.theme(), SpringPreset::Grab);
924        let instant = cx.reduce_motion() || is_staged(cx);
925        if state.borrow().current != offset {
926            window.request_animation_frame();
927        }
928        MakeWay {
929            element: self.into_any_element(),
930            state,
931            offset,
932            spring,
933            settle: spring.settle_time(),
934            instant,
935        }
936    }
937}
938
939impl<E: IntoElement> MakingWay for E {}
940
941/// An element painted beside where layout put it.
942pub struct MakeWay {
943    element: gpui::AnyElement,
944    state: Rc<RefCell<SlideState>>,
945    offset: Point<Pixels>,
946    spring: Spring,
947    settle: Duration,
948    instant: bool,
949}
950
951impl std::fmt::Debug for MakeWay {
952    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        formatter
954            .debug_struct("MakeWay")
955            .field("offset", &self.offset)
956            .field("instant", &self.instant)
957            .finish()
958    }
959}
960
961impl IntoElement for MakeWay {
962    type Element = Self;
963
964    fn into_element(self) -> Self::Element {
965        self
966    }
967}
968
969impl Element for MakeWay {
970    type RequestLayoutState = ();
971    type PrepaintState = ();
972
973    fn id(&self) -> Option<ElementId> {
974        None
975    }
976
977    fn source_location(&self) -> Option<&'static Location<'static>> {
978        None
979    }
980
981    fn request_layout(
982        &mut self,
983        _id: Option<&GlobalElementId>,
984        _inspector_id: Option<&InspectorElementId>,
985        window: &mut Window,
986        cx: &mut App,
987    ) -> (LayoutId, ()) {
988        (self.element.request_layout(window, cx), ())
989    }
990
991    fn prepaint(
992        &mut self,
993        _id: Option<&GlobalElementId>,
994        _inspector_id: Option<&InspectorElementId>,
995        _bounds: Bounds<Pixels>,
996        _request_layout: &mut (),
997        window: &mut Window,
998        cx: &mut App,
999    ) {
1000        let painted = {
1001            let mut state = self.state.borrow_mut();
1002            if self.instant {
1003                state.settle_at(self.offset);
1004            } else {
1005                state.advance(
1006                    self.offset,
1007                    self.spring,
1008                    self.settle,
1009                    cx.background_executor().now(),
1010                );
1011            }
1012            state.current
1013        };
1014
1015        window.with_element_offset(painted, |window| {
1016            self.element.prepaint(window, cx);
1017        });
1018
1019        if painted != self.offset {
1020            window.request_animation_frame();
1021        }
1022    }
1023
1024    fn paint(
1025        &mut self,
1026        _id: Option<&GlobalElementId>,
1027        _inspector_id: Option<&InspectorElementId>,
1028        _bounds: Bounds<Pixels>,
1029        _request_layout: &mut (),
1030        _prepaint: &mut (),
1031        window: &mut Window,
1032        cx: &mut App,
1033    ) {
1034        self.element.paint(window, cx);
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041    use gpui::{point, size};
1042    use gpui_kit_theme::Theme;
1043
1044    fn grab() -> Spring {
1045        Spring::preset(&Theme::studio_dark(), SpringPreset::Grab)
1046    }
1047
1048    #[test]
1049    fn a_target_that_cannot_be_entered_splits_in_two() {
1050        assert_eq!(zone(0.0, false), DropZone::Before);
1051        assert_eq!(zone(0.49, false), DropZone::Before);
1052        assert_eq!(zone(0.5, false), DropZone::After);
1053        assert_eq!(zone(1.0, false), DropZone::After);
1054    }
1055
1056    #[test]
1057    fn a_target_that_can_be_entered_keeps_its_middle() {
1058        assert_eq!(zone(0.1, true), DropZone::Before);
1059        assert_eq!(zone(0.5, true), DropZone::Into);
1060        assert_eq!(zone(0.9, true), DropZone::After);
1061    }
1062
1063    #[test]
1064    fn a_slot_counts_the_rows_that_precede_the_insertion_point() {
1065        let id = SharedString::new_static("beta");
1066        assert_eq!(DropZone::Before.resolve(&id, 3).1, Some(3));
1067        assert_eq!(DropZone::After.resolve(&id, 3).1, Some(4));
1068        // Entering something opens no slot, so nothing slides.
1069        assert_eq!(DropZone::Into.resolve(&id, 3).1, None);
1070    }
1071
1072    #[test]
1073    fn a_position_reads_as_a_verb_and_an_anchor() {
1074        let position = DropPosition::Before(SharedString::new_static("beta"));
1075        assert_eq!(position.to_string(), "before:beta");
1076        assert_eq!(position.anchor().as_ref(), "beta");
1077        assert_eq!(
1078            DropPosition::Into(SharedString::new_static("docs")).to_string(),
1079            "into:docs"
1080        );
1081    }
1082
1083    #[test]
1084    fn a_pointer_is_placed_by_the_axis_it_is_measured_on() {
1085        let bounds = Bounds::new(point(px(10.0), px(20.0)), size(px(100.0), px(40.0)));
1086        let pointer = point(px(60.0), px(50.0));
1087        assert_eq!(fraction_of(bounds, pointer, DropAxis::Horizontal), 0.5);
1088        assert_eq!(fraction_of(bounds, pointer, DropAxis::Vertical), 0.75);
1089    }
1090
1091    #[test]
1092    fn an_unmeasured_target_offers_its_first_slot() {
1093        let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(0.0), px(0.0)));
1094        assert_eq!(
1095            fraction_of(bounds, point(px(0.0), px(0.0)), DropAxis::Vertical),
1096            0.0
1097        );
1098    }
1099
1100    #[test]
1101    fn a_row_slides_only_once_the_insertion_point_reaches_it() {
1102        let drag = SurfaceDrag {
1103            item: DragItem::new("list", "gamma", "Gamma"),
1104            position: Some(DropPosition::Before(SharedString::new_static("beta"))),
1105            slot: Some(2),
1106            accepted: true,
1107        };
1108        assert!(!drag.makes_way(1));
1109        assert!(drag.makes_way(2));
1110        assert!(drag.makes_way(9));
1111    }
1112
1113    #[test]
1114    fn entering_a_folder_moves_no_row_aside() {
1115        let drag = SurfaceDrag {
1116            item: DragItem::new("tree", "lib", "lib.rs"),
1117            position: Some(DropPosition::Into(SharedString::new_static("docs"))),
1118            slot: None,
1119            accepted: true,
1120        };
1121        assert!(!drag.makes_way(0));
1122        assert!(
1123            drag.indicator_for(&SharedString::new_static("docs"))
1124                .is_some()
1125        );
1126        assert!(
1127            drag.indicator_for(&SharedString::new_static("src"))
1128                .is_none()
1129        );
1130    }
1131
1132    #[test]
1133    fn the_ghost_trails_the_pointer_and_catches_up() {
1134        let spring = grab();
1135        let settle = spring.settle_time();
1136        let mut ghost = DragGhost::new(DragItem::new("list", "gamma", "Gamma"));
1137        let start = Instant::now();
1138        ghost.trail(point(px(0.0), px(0.0)), spring, settle, start);
1139        assert_eq!(ghost.current, Point::default());
1140
1141        ghost.trail(point(px(0.0), px(40.0)), spring, settle, start);
1142        assert_eq!(ghost.current, point(px(0.0), px(-40.0)));
1143
1144        ghost.trail(point(px(0.0), px(40.0)), spring, settle, start + settle);
1145        assert_eq!(ghost.current, Point::default());
1146    }
1147
1148    #[test]
1149    fn reduced_motion_pins_the_ghost_to_the_pointer() {
1150        let mut ghost = DragGhost::new(DragItem::new("list", "gamma", "Gamma"));
1151        ghost.snap(point(px(0.0), px(0.0)));
1152        ghost.snap(point(px(0.0), px(80.0)));
1153        assert_eq!(ghost.current, Point::default());
1154    }
1155
1156    #[test]
1157    fn a_slide_starts_from_what_is_on_screen() {
1158        let spring = grab();
1159        let settle = spring.settle_time();
1160        let mut slide = SlideState::default();
1161        let start = Instant::now();
1162        slide.advance(point(px(0.0), px(12.0)), spring, settle, start);
1163        assert_eq!(slide.current, Point::default());
1164        slide.advance(point(px(0.0), px(12.0)), spring, settle, start + settle);
1165        assert_eq!(slide.current, point(px(0.0), px(12.0)));
1166    }
1167
1168    #[test]
1169    fn an_instant_slide_is_already_where_it_belongs() {
1170        let mut slide = SlideState::default();
1171        slide.settle_at(point(px(0.0), px(12.0)));
1172        assert_eq!(slide.current, point(px(0.0), px(12.0)));
1173    }
1174}