1use 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#[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 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 #[inline]
76 pub fn prompt(mut self, prompt: impl Into<WidgetText>) -> Self {
77 self.prompt = Some(prompt.into());
78 self
79 }
80
81 #[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 #[inline]
92 pub fn hint(mut self, hint: impl Into<WidgetText>) -> Self {
93 self.hint = Some(hint.into());
94 self
95 }
96
97 #[inline]
99 pub fn min_height(mut self, h: f32) -> Self {
100 self.min_height = h;
101 self
102 }
103
104 #[inline]
106 pub fn enabled(mut self, enabled: bool) -> Self {
107 self.enabled = enabled;
108 self
109 }
110
111 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#[derive(Debug)]
182pub struct FileDropResponse {
183 pub response: Response,
186 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 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 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 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 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 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 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 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}