Skip to main content

elegance/
file_drop_zone.rs

1//! File drop zone — a dashed-bordered surface that accepts dropped files.
2//!
3//! A [`FileDropZone`] renders a click-and-drop target with a cloud icon,
4//! prompt text, and a hint line. The zone responds visually when files are
5//! dragged over it and reports any files dropped on its rect via the
6//! returned [`FileDropResponse`]. The widget itself is stateless: the caller
7//! either consumes the dropped files immediately or stores them in their
8//! own app state.
9
10use egui::{
11    pos2, vec2, Color32, CornerRadius, DroppedFile, FontSelection, Pos2, Rect, Response, Sense,
12    Stroke, StrokeKind, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
13};
14
15use crate::glyphs::UPLOAD as UPLOAD_GLYPH;
16use crate::theme::{with_alpha, Theme};
17
18/// A click-and-drop file target.
19///
20/// ```no_run
21/// # use elegance::FileDropZone;
22/// # egui::__run_test_ui(|ui| {
23/// let drop = FileDropZone::new().show(ui);
24/// if drop.response.clicked() {
25///     // Open a native file picker, e.g. via the `rfd` crate.
26/// }
27/// for file in &drop.dropped_files {
28///     // Handle file.path / file.bytes.
29///     let _ = file;
30/// }
31/// # });
32/// ```
33#[must_use = "Call `.show(ui)` to render the drop zone."]
34pub struct FileDropZone {
35    prompt: Option<WidgetText>,
36    action_word: Option<String>,
37    hint: Option<WidgetText>,
38    min_height: f32,
39    enabled: bool,
40}
41
42impl std::fmt::Debug for FileDropZone {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        f.debug_struct("FileDropZone")
45            .field("prompt", &self.prompt.as_ref().map(|p| p.text()))
46            .field("action_word", &self.action_word)
47            .field("hint", &self.hint.as_ref().map(|h| h.text()))
48            .field("min_height", &self.min_height)
49            .field("enabled", &self.enabled)
50            .finish()
51    }
52}
53
54impl Default for FileDropZone {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl FileDropZone {
61    /// Create a drop zone with the default prompt, action word, and minimum
62    /// height.
63    pub fn new() -> Self {
64        Self {
65            prompt: None,
66            action_word: None,
67            hint: None,
68            min_height: 120.0,
69            enabled: true,
70        }
71    }
72
73    /// Override the prompt text. Defaults to "Drop files here, or browse",
74    /// where the action word is rendered in the sky accent.
75    #[inline]
76    pub fn prompt(mut self, prompt: impl Into<WidgetText>) -> Self {
77        self.prompt = Some(prompt.into());
78        self
79    }
80
81    /// Override the action word rendered in the sky accent inside the
82    /// prompt. Defaults to `"browse"`. Ignored if [`Self::prompt`] is set.
83    #[inline]
84    pub fn action_word(mut self, word: impl Into<String>) -> Self {
85        self.action_word = Some(word.into());
86        self
87    }
88
89    /// Set the hint line under the prompt (e.g. accepted formats and size
90    /// limits). Defaults to no hint.
91    #[inline]
92    pub fn hint(mut self, hint: impl Into<WidgetText>) -> Self {
93        self.hint = Some(hint.into());
94        self
95    }
96
97    /// Override the minimum height of the zone in points. Defaults to 120.
98    #[inline]
99    pub fn min_height(mut self, h: f32) -> Self {
100        self.min_height = h;
101        self
102    }
103
104    /// Disable the zone. Defaults to enabled.
105    #[inline]
106    pub fn enabled(mut self, enabled: bool) -> Self {
107        self.enabled = enabled;
108        self
109    }
110
111    /// Render the zone and return the response plus any files dropped on
112    /// its rect this frame.
113    pub fn show(self, ui: &mut Ui) -> FileDropResponse {
114        let theme = Theme::current(ui.ctx());
115
116        let action_word = self.action_word.as_deref().unwrap_or("browse");
117        let prompt_text = self
118            .prompt
119            .as_ref()
120            .map(|w| w.text().to_string())
121            .unwrap_or_else(|| format!("Drop files here, or {action_word}"));
122        let hint_text = self.hint.as_ref().map(|w| w.text().to_string());
123        let a11y_label = prompt_text.clone();
124
125        let desired = vec2(ui.available_width(), self.min_height);
126        let sense = if self.enabled {
127            Sense::click()
128        } else {
129            Sense::hover()
130        };
131        let (rect, mut response) = ui.allocate_exact_size(desired, sense);
132
133        let (files_dragging, pointer) = ui
134            .ctx()
135            .input(|i| (!i.raw.hovered_files.is_empty(), i.pointer.interact_pos()));
136        let pointer_in_rect = pointer.is_some_and(|pos| rect.contains(pos));
137        let dragover = self.enabled && files_dragging && pointer_in_rect;
138
139        let dropped_files = if self.enabled {
140            ui.ctx().input(|i| {
141                if i.raw.dropped_files.is_empty() {
142                    return Vec::new();
143                }
144                if pointer_in_rect {
145                    i.raw.dropped_files.clone()
146                } else {
147                    Vec::new()
148                }
149            })
150        } else {
151            Vec::new()
152        };
153        if !dropped_files.is_empty() {
154            response.mark_changed();
155        }
156
157        if ui.is_rect_visible(rect) {
158            paint_zone(
159                ui,
160                &theme,
161                rect,
162                &response,
163                dragover,
164                self.enabled,
165                &prompt_text,
166                action_word,
167                hint_text.as_deref(),
168            );
169        }
170
171        response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, self.enabled, &a11y_label));
172
173        FileDropResponse {
174            response,
175            dropped_files,
176        }
177    }
178}
179
180/// The result of rendering a [`FileDropZone`].
181#[derive(Debug)]
182pub struct FileDropResponse {
183    /// The underlying egui [`Response`]: use `.clicked()` to open a picker
184    /// and `.has_focus()` for keyboard handling.
185    pub response: Response,
186    /// Files dropped on the zone this frame. Empty when nothing was
187    /// dropped or the drop landed outside the zone's rect.
188    pub dropped_files: Vec<DroppedFile>,
189}
190
191#[allow(clippy::too_many_arguments)]
192fn paint_zone(
193    ui: &Ui,
194    theme: &Theme,
195    rect: Rect,
196    response: &Response,
197    dragover: bool,
198    enabled: bool,
199    prompt: &str,
200    action_word: &str,
201    hint: Option<&str>,
202) {
203    let p = &theme.palette;
204    let t = &theme.typography;
205
206    let radius = CornerRadius::same(theme.card_radius as u8);
207    let painter = ui.painter();
208
209    let hovered = enabled && response.hovered();
210    let focused = enabled && response.has_focus();
211
212    // Background fill. Subtle by default; sky-tinted while a file is dragged
213    // over the zone.
214    let fill = if !enabled {
215        Color32::TRANSPARENT
216    } else if dragover {
217        with_alpha(p.sky, 26)
218    } else {
219        p.depth_tint(p.card, 0.015)
220    };
221    painter.rect(rect, radius, fill, Stroke::NONE, StrokeKind::Inside);
222
223    // Dashed border. Sharp corners on the dashed polyline read fine against
224    // the rounded fill underneath.
225    let border_color = if !enabled {
226        with_alpha(p.border, 160)
227    } else if dragover {
228        p.sky
229    } else if hovered || focused {
230        p.text_muted
231    } else {
232        p.border
233    };
234    let border_stroke = Stroke::new(1.5, border_color);
235    let pts = [
236        rect.left_top(),
237        rect.right_top(),
238        rect.right_bottom(),
239        rect.left_bottom(),
240        rect.left_top(),
241    ];
242    painter.extend(egui::Shape::dashed_line(&pts, border_stroke, 6.0, 4.0));
243
244    if focused {
245        // Outer focus ring, mirroring the rest of the elegance widgets.
246        painter.rect_stroke(
247            rect.expand(2.0),
248            radius,
249            Stroke::new(2.0, with_alpha(p.sky, 180)),
250            StrokeKind::Outside,
251        );
252    }
253
254    // Lay out the icon, prompt, and hint as a centered stack.
255    let icon_diameter = 44.0;
256    let icon_gap = 12.0;
257    let prompt_gap = 4.0;
258
259    let prompt_color = if !enabled {
260        p.text_muted
261    } else if dragover {
262        p.sky
263    } else {
264        p.text
265    };
266    let prompt_galley =
267        egui::WidgetText::from(egui::RichText::new(prompt).color(prompt_color).size(t.body))
268            .into_galley(
269                ui,
270                Some(egui::TextWrapMode::Extend),
271                rect.width() - 24.0,
272                FontSelection::FontId(egui::FontId::proportional(t.body)),
273            );
274
275    let hint_galley = hint.map(|h| {
276        egui::WidgetText::from(egui::RichText::new(h).color(p.text_faint).size(t.small))
277            .into_galley(
278                ui,
279                Some(egui::TextWrapMode::Extend),
280                rect.width() - 24.0,
281                FontSelection::FontId(egui::FontId::proportional(t.small)),
282            )
283    });
284
285    let total_h = icon_diameter
286        + icon_gap
287        + prompt_galley.size().y
288        + hint_galley
289            .as_ref()
290            .map(|g| prompt_gap + g.size().y)
291            .unwrap_or(0.0);
292    let mut cursor_y = rect.center().y - total_h * 0.5;
293
294    // Icon circle.
295    let icon_center = pos2(rect.center().x, cursor_y + icon_diameter * 0.5);
296    let icon_color = if !enabled {
297        p.text_faint
298    } else if dragover {
299        p.sky
300    } else {
301        p.text_muted
302    };
303    let icon_bg = if dragover {
304        with_alpha(p.sky, 30)
305    } else {
306        p.input_bg
307    };
308    let icon_stroke_color = if dragover {
309        with_alpha(p.sky, 115)
310    } else {
311        p.border
312    };
313    painter.circle(
314        icon_center,
315        icon_diameter * 0.5,
316        icon_bg,
317        Stroke::new(1.0, icon_stroke_color),
318    );
319    let glyph_size = icon_diameter * 0.7;
320    let font_id = egui::FontId::proportional(glyph_size);
321    let galley = painter.layout_no_wrap(UPLOAD_GLYPH.to_string(), font_id, icon_color);
322    // Center the actual ink bounding box (`mesh_bounds`) on `icon_center`
323    // rather than the line-box (`galley.size()`) — the line-box includes
324    // empty descender space that throws cap-height-aligned icon glyphs
325    // off visual center.
326    let ink_center = galley.mesh_bounds.center();
327    let pos = pos2(icon_center.x - ink_center.x, icon_center.y - ink_center.y);
328    painter.galley(pos, galley, icon_color);
329    cursor_y += icon_diameter + icon_gap;
330
331    // Prompt text. If we generated the default prompt, draw the action
332    // word in the sky accent instead of the body colour.
333    let prompt_size = prompt_galley.size();
334    let prompt_pos = pos2(rect.center().x - prompt_size.x * 0.5, cursor_y);
335    if enabled && !dragover {
336        if let Some((before, after)) = split_around(prompt, action_word) {
337            paint_split_prompt(
338                ui,
339                theme,
340                prompt_pos,
341                prompt_size,
342                before,
343                action_word,
344                after,
345            );
346        } else {
347            painter.galley(prompt_pos, prompt_galley, p.text);
348        }
349    } else {
350        painter.galley(prompt_pos, prompt_galley, prompt_color);
351    }
352    cursor_y += prompt_size.y + prompt_gap;
353
354    if let Some(hint_g) = hint_galley {
355        let hint_size = hint_g.size();
356        painter.galley(
357            pos2(rect.center().x - hint_size.x * 0.5, cursor_y),
358            hint_g,
359            p.text_faint,
360        );
361    }
362}
363
364fn split_around<'a>(prompt: &'a str, word: &str) -> Option<(&'a str, &'a str)> {
365    let idx = prompt.find(word)?;
366    Some((&prompt[..idx], &prompt[idx + word.len()..]))
367}
368
369fn paint_split_prompt(
370    ui: &Ui,
371    theme: &Theme,
372    base: Pos2,
373    full_size: Vec2,
374    before: &str,
375    accent_word: &str,
376    after: &str,
377) {
378    let p = &theme.palette;
379    let size = theme.typography.body;
380    let font = egui::FontId::proportional(size);
381
382    let layout = |s: &str, color: Color32| {
383        egui::WidgetText::from(egui::RichText::new(s).color(color).size(size)).into_galley(
384            ui,
385            Some(egui::TextWrapMode::Extend),
386            f32::INFINITY,
387            FontSelection::FontId(font.clone()),
388        )
389    };
390
391    let before_g = layout(before, p.text);
392    let word_g = layout(accent_word, p.sky);
393    let after_g = layout(after, p.text);
394
395    let baseline_y = base.y + (full_size.y - before_g.size().y) * 0.5;
396    let mut x = base.x;
397    let painter = ui.painter();
398    painter.galley(pos2(x, baseline_y), before_g.clone(), p.text);
399    x += before_g.size().x;
400    painter.galley(pos2(x, baseline_y), word_g.clone(), p.sky);
401    x += word_g.size().x;
402    painter.galley(pos2(x, baseline_y), after_g, p.text);
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn split_around_works() {
411        assert_eq!(
412            split_around("Drop files here, or browse", "browse"),
413            Some(("Drop files here, or ", ""))
414        );
415        assert_eq!(
416            split_around("Click to browse files", "browse"),
417            Some(("Click to ", " files"))
418        );
419        assert_eq!(split_around("nothing here", "missing"), None);
420    }
421}