1use 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
75pub const DRAG_NODE_ID: &str = "dnd.drag";
77
78pub const ROW_KIND: &str = "row";
80
81pub const FILE_KIND: &str = "file";
83
84#[derive(Clone, Debug, PartialEq, Eq)]
89pub struct DragItem {
90 pub source: SharedString,
93 pub id: SharedString,
94 pub label: SharedString,
96 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#[derive(Clone, Debug, PartialEq, Eq)]
135pub enum DropPosition {
136 Before(SharedString),
137 After(SharedString),
138 Into(SharedString),
141}
142
143impl DropPosition {
144 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#[derive(Clone, Debug, PartialEq)]
168pub struct DropIntent {
169 pub item: DragItem,
170 pub position: DropPosition,
171 pub velocity: Velocity,
177}
178
179#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub enum DropAxis {
182 Vertical,
183 Horizontal,
184}
185
186#[derive(Clone, Debug, PartialEq)]
188pub struct ActiveDrag {
189 pub item: DragItem,
190 pub velocity: Velocity,
193 pub surface: Option<SharedString>,
195 pub position: Option<DropPosition>,
197 pub accepted: bool,
200}
201
202#[derive(Clone, Debug)]
204struct Landing {
205 surface: SharedString,
206 position: DropPosition,
207 slot: Option<usize>,
211 accepted: bool,
212 at: Option<Point<Pixels>>,
219}
220
221#[derive(Default)]
222struct Session {
223 item: Option<DragItem>,
224 landing: Option<Landing>,
225 staged: bool,
228 speed: VelocityTracker,
231 sampled: Option<(Instant, Point<Pixels>)>,
235}
236
237#[derive(Default)]
238struct SessionGlobal(RefCell<Session>);
239
240impl gpui::Global for SessionGlobal {}
241
242pub 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
273fn begin(item: DragItem, cx: &mut App) {
275 session(cx, |state| {
276 state.item = Some(item);
277 state.landing = None;
278 state.staged = false;
279 state.speed.clear();
282 state.sampled = None;
283 });
284}
285
286fn clear(cx: &mut App) {
288 session(cx, |state| *state = Session::default());
289}
290
291pub(crate) fn finish(cx: &mut App) {
293 clear(cx);
294}
295
296pub(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
317pub fn cancel(window: &mut Window, cx: &mut App) {
319 cx.stop_active_drag(window);
320 clear(cx);
321}
322
323pub(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
334pub 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
354fn 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
390fn 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
401fn 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#[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 pub fn carries(&self, id: &SharedString) -> bool {
425 &self.item.id == id
426 }
427
428 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 pub fn makes_way(&self, index: usize) -> bool {
436 self.slot.is_some_and(|slot| index >= slot)
437 }
438}
439
440pub(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#[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 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
499pub 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
509pub 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
519pub 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
536type Accepts = Rc<dyn Fn(&DragItem, &DropPosition) -> bool>;
540type Dropped = Rc<dyn Fn(&DropIntent, &mut Window, &mut App)>;
541
542pub(crate) struct RowTarget {
544 pub surface: SharedString,
545 pub id: SharedString,
546 pub index: usize,
548 pub allow_into: bool,
550 pub axis: DropAxis,
551 pub accepts: Accepts,
552 pub on_drop: Dropped,
553}
554
555pub(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
611pub(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 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 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
687struct DragGhost {
694 item: DragItem,
695 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 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
775fn 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 .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
817pub(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
860pub(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
908pub(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
941pub 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 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}