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}