Skip to main content

gpui_kit/controls/
dropzone.rs

1//! An area that accepts something dropped on it from outside itself.
2//!
3//! A dropzone distinguishes three states and never conflates two of them:
4//!
5//! - **Idle** — nothing is being dragged over it;
6//! - **Accepting** — a payload it handles is over it;
7//! - **Refusing** — a payload it does not handle is over it.
8//!
9//! Refusing is drawn as a refusal, with the reason the caller gave, and never
10//! as idle. A zone that looked idle while refusing would tell a typist that
11//! letting go was going to work.
12
13use std::rc::Rc;
14
15use gpui::{
16    App, ExternalPaths, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
17    Styled, Window, div, prelude::FluentBuilder, px,
18};
19use gpui_kit_assets::{Icon, icon};
20use gpui_kit_semantics::{NodeSpec, Role, Semantic};
21use gpui_kit_theme::{ActiveTheme, Radius, Space, TypeScale};
22
23use crate::foundation::{Disableable, Ident, StyledExt, text as foundation_text};
24use crate::interaction::dnd::{self, DragItem, FILE_KIND};
25use crate::layout::measure;
26use crate::strings::{ActiveStrings, StringKey};
27
28type DropHandler = Rc<dyn Fn(&DragItem, &mut Window, &mut App)>;
29type FilesHandler = Rc<dyn Fn(&ExternalPaths, &mut Window, &mut App)>;
30
31/// What a dropzone is currently saying.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum DropzoneState {
34    Idle,
35    Accepting,
36    Refusing,
37}
38
39impl DropzoneState {
40    pub fn name(self) -> &'static str {
41        match self {
42            Self::Idle => "idle",
43            Self::Accepting => "accepting",
44            Self::Refusing => "refusing",
45        }
46    }
47}
48
49/// An area that accepts a drop.
50#[derive(IntoElement)]
51pub struct Dropzone {
52    ident: Ident,
53    label: SharedString,
54    hint: Option<SharedString>,
55    /// Why a payload this zone does not handle is refused.
56    refusal: Option<SharedString>,
57    kinds: Vec<SharedString>,
58    pinned: Option<DropzoneState>,
59    disabled: bool,
60    icon: Option<Icon>,
61    on_drop: Option<DropHandler>,
62    on_files: Option<FilesHandler>,
63}
64
65impl std::fmt::Debug for Dropzone {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        formatter
68            .debug_struct("Dropzone")
69            .field("ident", &self.ident)
70            .field("kinds", &self.kinds)
71            .field("pinned", &self.pinned)
72            .field("disabled", &self.disabled)
73            .field(
74                "has_handler",
75                &(self.on_drop.is_some() || self.on_files.is_some()),
76            )
77            .finish()
78    }
79}
80
81impl Dropzone {
82    pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
83        Self {
84            ident: ident.into(),
85            label: label.into(),
86            hint: None,
87            refusal: None,
88            kinds: vec![SharedString::new_static(FILE_KIND)],
89            pinned: None,
90            disabled: false,
91            icon: Some(Icon::Paperclip),
92            on_drop: None,
93            on_files: None,
94        }
95    }
96
97    /// A second line, shown while the zone is idle.
98    pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
99        self.hint = Some(hint.into());
100        self
101    }
102
103    /// What the zone says when it refuses a payload.
104    pub fn refusal(mut self, refusal: impl Into<SharedString>) -> Self {
105        self.refusal = Some(refusal.into());
106        self
107    }
108
109    /// Which payload kinds this zone handles. Anything else is refused.
110    pub fn accepts<S: Into<SharedString>>(mut self, kinds: impl IntoIterator<Item = S>) -> Self {
111        self.kinds = kinds.into_iter().map(Into::into).collect();
112        self
113    }
114
115    pub fn icon(mut self, icon: Icon) -> Self {
116        self.icon = Some(icon);
117        self
118    }
119
120    /// Pins the zone to one state.
121    ///
122    /// A live drag decides the state otherwise. Pinning exists so all three
123    /// can be reviewed side by side in a capture, which no single pointer
124    /// position could produce.
125    pub fn state(mut self, state: DropzoneState) -> Self {
126        self.pinned = Some(state);
127        self
128    }
129
130    pub fn on_drop(mut self, handler: impl Fn(&DragItem, &mut Window, &mut App) + 'static) -> Self {
131        self.on_drop = Some(Rc::new(handler));
132        self
133    }
134
135    /// Reports paths the platform handed over when files were dropped.
136    ///
137    /// The paths are user-generated content, so they reach the handler and
138    /// never the semantic tree.
139    pub fn on_files(
140        mut self,
141        handler: impl Fn(&ExternalPaths, &mut Window, &mut App) + 'static,
142    ) -> Self {
143        self.on_files = Some(Rc::new(handler));
144        self
145    }
146}
147
148impl Disableable for Dropzone {
149    fn disabled(mut self, disabled: bool) -> Self {
150        self.disabled = disabled;
151        self
152    }
153}
154
155impl RenderOnce for Dropzone {
156    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
157        let theme = cx.theme().clone();
158        dnd::sync(cx);
159
160        // The zone has to know whether the pointer is inside it before any
161        // drop event arrives, and only prepaint knows where it ended up.
162        let measured = measure::cell(&self.ident.semantic_id(), cx);
163        let over = measured.get().contains(&window.mouse_position());
164        let carried = dnd::active(window, cx).map(|drag| drag.item);
165        let handles = |item: &DragItem| self.kinds.contains(&item.kind);
166
167        let state = self.pinned.unwrap_or(match &carried {
168            Some(item) if over && !self.disabled => {
169                if handles(item) {
170                    DropzoneState::Accepting
171                } else {
172                    DropzoneState::Refusing
173                }
174            }
175            _ => DropzoneState::Idle,
176        });
177
178        let (border, text, message) = match state {
179            DropzoneState::Idle => (
180                theme.colors.hairline_strong,
181                theme.colors.text_muted,
182                self.label.clone(),
183            ),
184            DropzoneState::Accepting => {
185                (theme.colors.accent, theme.colors.text, self.label.clone())
186            }
187            DropzoneState::Refusing => (
188                theme.colors.danger,
189                theme.colors.danger,
190                self.refusal
191                    .clone()
192                    .unwrap_or_else(|| cx.strings().text(StringKey::DropzoneRefusal)),
193            ),
194        };
195
196        let mut zone = div()
197            .id(self.ident.element_id())
198            .column()
199            .items_center()
200            .justify_center()
201            .gap_token(&theme, Space::Xs)
202            .p_token(&theme, Space::Lg)
203            .border(px(theme.borders.thick))
204            .border_color(border)
205            .radius(&theme, Radius::Card)
206            .when(state == DropzoneState::Accepting, |element| {
207                element.bg(theme
208                    .colors
209                    .accent
210                    .opacity(theme.effects.selected_ring_alpha))
211            })
212            .when(self.disabled, |element| {
213                element.opacity(theme.opacity.disabled)
214            })
215            .children(self.icon.map(|glyph| {
216                icon(glyph)
217                    .size(px(theme.control.md.icon_size))
218                    .text_color(text)
219            }))
220            .child(foundation_text(&theme, TypeScale::Label, message.clone()).text_color(text))
221            .children(
222                self.hint
223                    .clone()
224                    .filter(|_| state == DropzoneState::Idle)
225                    .map(|hint| {
226                        foundation_text(&theme, TypeScale::Caption, hint)
227                            .text_tone(&theme, gpui_kit_theme::TextTone::Faint)
228                    }),
229            );
230
231        let live = !self.disabled;
232
233        // A platform file drag never passes through `on_drag`, so the zone
234        // records the payload itself and everything downstream sees the same
235        // session an in-application drag produces.
236        if live {
237            zone = zone.on_drag_move::<ExternalPaths>(|event, _window, cx| {
238                let count = event.drag(cx).paths().len();
239                dnd::adopt_external(count, cx);
240            });
241        }
242
243        if let (true, Some(handler)) = (live, self.on_drop.clone()) {
244            let kinds = self.kinds.clone();
245            zone = zone
246                .can_drop(move |payload, _, _| {
247                    payload
248                        .downcast_ref::<DragItem>()
249                        .is_some_and(|item| kinds.contains(&item.kind))
250                })
251                .on_drop::<DragItem>(move |item, window, cx| {
252                    dnd::finish(cx);
253                    handler(item, window, cx);
254                });
255        }
256
257        if let (true, Some(handler)) = (live, self.on_files.clone()) {
258            zone = zone.on_drop::<ExternalPaths>(move |paths, window, cx| {
259                dnd::finish(cx);
260                handler(paths, window, cx);
261            });
262        }
263
264        let zone = zone.semantic_in(
265            cx,
266            NodeSpec::new(self.ident.semantic_id(), Role::Region)
267                .text(message)
268                .value(state.name())
269                .disabled(self.disabled)
270                .selected(state == DropzoneState::Accepting)
271                .invalid(state == DropzoneState::Refusing),
272        );
273
274        // The measurement has to be of the zone itself, not of what it
275        // contains, so it is taken one level up where the zone is the only
276        // child.
277        div()
278            .column()
279            .on_children_prepainted(move |bounds, window, _| {
280                if let Some(first) = bounds.first() {
281                    measure::record(&measured, *first, window);
282                }
283            })
284            .child(zone)
285    }
286}