1use 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#[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#[derive(IntoElement)]
51pub struct Dropzone {
52 ident: Ident,
53 label: SharedString,
54 hint: Option<SharedString>,
55 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 pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
99 self.hint = Some(hint.into());
100 self
101 }
102
103 pub fn refusal(mut self, refusal: impl Into<SharedString>) -> Self {
105 self.refusal = Some(refusal.into());
106 self
107 }
108
109 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 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 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 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 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 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}