Skip to main content

guise/input/
dropzone.rs

1//! `Dropzone` — a drag-and-drop file target (stateless builder).
2//!
3//! Accepts OS file drags (gpui `ExternalPaths`) and, by default, opens the
4//! platform file dialog on click. The parent owns whatever happens with the
5//! paths; wire `.on_files(...)`.
6
7use std::path::PathBuf;
8use std::rc::Rc;
9
10use gpui::prelude::*;
11use gpui::{
12    div, px, App, ElementId, ExternalPaths, IntoElement, PathPromptOptions, SharedString, Window,
13};
14
15use super::accept::{filter_paths, normalize_ext};
16use crate::devtools::Probed;
17use crate::icon::{Icon, IconName};
18use crate::theme::{theme, Size};
19
20type FilesHandler = Rc<dyn Fn(Vec<PathBuf>, &mut App) + 'static>;
21
22/// A drop target for OS file drags. `Dropzone::new("dz").on_files(...)`.
23#[derive(IntoElement)]
24pub struct Dropzone {
25    id: ElementId,
26    label: SharedString,
27    hint: Option<SharedString>,
28    icon: IconName,
29    accept: Vec<String>,
30    multiple: bool,
31    clickable: bool,
32    height: f32,
33    on_files: Option<FilesHandler>,
34}
35
36impl Dropzone {
37    pub fn new(id: impl Into<ElementId>) -> Self {
38        Dropzone {
39            id: id.into(),
40            label: SharedString::new_static("Drop files here"),
41            hint: None,
42            icon: IconName::CloudUpload,
43            accept: Vec::new(),
44            multiple: true,
45            clickable: true,
46            height: 140.0,
47            on_files: None,
48        }
49    }
50
51    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
52        self.label = label.into();
53        self
54    }
55
56    /// Secondary line under the label ("PNG or JPG, up to 10 MB").
57    pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
58        self.hint = Some(hint.into());
59        self
60    }
61
62    pub fn icon(mut self, icon: IconName) -> Self {
63        self.icon = icon;
64        self
65    }
66
67    /// Allowed extensions ("png", ".jpg", case-insensitive). Empty = any.
68    /// Dropped paths that don't pass are silently ignored.
69    pub fn accept<I, S>(mut self, entries: I) -> Self
70    where
71        I: IntoIterator<Item = S>,
72        S: AsRef<str>,
73    {
74        self.accept = entries
75            .into_iter()
76            .map(|e| normalize_ext(e.as_ref()))
77            .collect();
78        self
79    }
80
81    /// Only take the first dropped/browsed file.
82    pub fn single(mut self) -> Self {
83        self.multiple = false;
84        self
85    }
86
87    /// Disable the click-to-browse dialog (drop only).
88    pub fn no_click(mut self) -> Self {
89        self.clickable = false;
90        self
91    }
92
93    pub fn height(mut self, height: f32) -> Self {
94        self.height = height;
95        self
96    }
97
98    /// Receives the accepted paths of every drop or dialog pick.
99    pub fn on_files(mut self, handler: impl Fn(Vec<PathBuf>, &mut App) + 'static) -> Self {
100        self.on_files = Some(Rc::new(handler));
101        self
102    }
103}
104
105impl RenderOnce for Dropzone {
106    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
107        let t = theme(cx);
108        let radius = t.radius(t.default_radius);
109        let surface = t.surface().hsla();
110        let border = t.border().hsla();
111        let text_color = t.text().hsla();
112        let dimmed = t.dimmed().hsla();
113        let accent = t.primary();
114        let accent_border = accent.hsla();
115        let accent_tint = accent.alpha(0.08);
116        let font = t.font_size(Size::Sm);
117
118        let accept = self.accept;
119        let multiple = self.multiple;
120        let handler = self.on_files;
121
122        let deliver = {
123            let accept = accept.clone();
124            let handler = handler.clone();
125            move |mut paths: Vec<PathBuf>, cx: &mut App| {
126                paths = filter_paths(paths, &accept);
127                if !multiple {
128                    paths.truncate(1);
129                }
130                if let (Some(handler), false) = (&handler, paths.is_empty()) {
131                    handler(paths, cx);
132                }
133            }
134        };
135
136        let mut zone = div()
137            .id(self.id)
138            .flex()
139            .flex_col()
140            .items_center()
141            .justify_center()
142            .gap(px(6.0))
143            .w_full()
144            .h(px(self.height))
145            .rounded(px(radius))
146            .border_1()
147            .border_dashed()
148            .border_color(border)
149            .bg(surface)
150            .drag_over::<ExternalPaths>(move |style, _paths, _window, _cx| {
151                style.border_color(accent_border).bg(accent_tint)
152            })
153            .on_drop({
154                let deliver = deliver.clone();
155                move |dropped: &ExternalPaths, _window, cx| {
156                    deliver(dropped.paths().to_vec(), cx);
157                }
158            })
159            .child(
160                div()
161                    .text_color(dimmed)
162                    .child(Icon::new(self.icon).size(Size::Lg)),
163            )
164            .child(
165                div()
166                    .text_size(px(font))
167                    .text_color(text_color)
168                    .child(self.label),
169            );
170
171        if let Some(hint) = self.hint {
172            zone = zone.child(
173                div()
174                    .text_size(px(font - 2.0))
175                    .text_color(dimmed)
176                    .child(hint),
177            );
178        }
179
180        if self.clickable {
181            zone = zone.cursor_pointer().on_click(move |_ev, _window, cx| {
182                let receiver = cx.prompt_for_paths(PathPromptOptions {
183                    files: true,
184                    directories: false,
185                    multiple,
186                    prompt: None,
187                });
188                let deliver = deliver.clone();
189                cx.spawn(async move |cx| {
190                    if let Ok(Ok(Some(paths))) = receiver.await {
191                        cx.update(|cx| deliver(paths, cx)).ok();
192                    }
193                })
194                .detach();
195            });
196        }
197
198        zone.probe("Dropzone")
199    }
200}