1use std::cell::RefCell;
43use std::path::PathBuf;
44use std::rc::Rc;
45use teksilo_i18n::{lit, tr_widget};
46
47use teksilo_canvas::{Rect, SizeProposal};
48use teksilo_core::accessibility::AccessNodeBuilder;
49use teksilo_core::accesskit::{Live, Role};
50use teksilo_core::build_context::BuildContext;
51use teksilo_core::styles::{
52 DropZoneStyle, DropZoneStyleConfig, DropZoneVisualState, SharedDropZoneStyle,
53};
54use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
55use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
56use teksilo_core::widget_id::WidgetId;
57use teksilo_core::{DragPayload, DropFeedback};
58use teksilo_platform::file_dialog::{
59 EventContextFileDialogExt, FileDialogRequest, FileDialogResult,
60};
61use teksilo_tokens::{HAlignment, TextRole};
62
63use crate::button::Button;
64use crate::primitives::{TextWidget, VStack};
65use teksilo_i18n::LocalizedString;
66
67type FilesCallback = Box<dyn FnMut(Vec<PathBuf>, &mut EventContext)>;
68type TextCallback = Box<dyn FnMut(String, &mut EventContext)>;
69type UrlsCallback = Box<dyn FnMut(Vec<String>, &mut EventContext)>;
70
71pub struct DropZone {
73 label: LocalizedString,
74 subtitle: Option<LocalizedString>,
75 browse_label: LocalizedString,
76 starting_dir: Option<PathBuf>,
77 extensions: Vec<String>,
78 allow_multiple: bool,
79 show_browse_button: bool,
80 icon: Option<Box<dyn Widget>>,
81 on_files: Option<FilesCallback>,
82 on_text: Option<TextCallback>,
83 on_urls: Option<UrlsCallback>,
84 style_override: Option<SharedDropZoneStyle>,
85 root_child_id: Option<WidgetId>,
86}
87
88impl DropZone {
89 pub fn new(label: impl Into<LocalizedString>) -> Self {
96 Self {
97 label: label.into(),
98 subtitle: None,
99 browse_label: lit!("Browse…"),
100 starting_dir: None,
101 extensions: Vec::new(),
102 allow_multiple: true,
103 show_browse_button: true,
104 icon: None,
105 on_files: None,
106 on_text: None,
107 on_urls: None,
108 style_override: None,
109 root_child_id: None,
110 }
111 }
112
113 pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
115 self.subtitle = Some(text.into());
116 self
117 }
118
119 pub fn accept_extensions<I, S>(mut self, extensions: I) -> Self
123 where
124 I: IntoIterator<Item = S>,
125 S: Into<String>,
126 {
127 self.extensions = extensions
128 .into_iter()
129 .map(|e| e.into().trim_start_matches('.').to_ascii_lowercase())
130 .collect();
131 self
132 }
133
134 pub fn allow_multiple(mut self, allow: bool) -> Self {
137 self.allow_multiple = allow;
138 self
139 }
140
141 pub fn show_browse_button(mut self, show: bool) -> Self {
145 self.show_browse_button = show;
146 self
147 }
148
149 #[must_use]
158 pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
159 self.starting_dir = Some(path.into());
160 self
161 }
162
163 pub fn browse_label(mut self, label: impl Into<LocalizedString>) -> Self {
164 self.browse_label = label.into();
165 self
166 }
167
168 pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
171 self.icon = Some(Box::new(icon));
172 self
173 }
174
175 pub fn style(mut self, style: impl DropZoneStyle) -> Self {
177 self.style_override = Some(Rc::new(style));
178 self
179 }
180
181 pub fn on_files_dropped(
184 mut self,
185 f: impl FnMut(Vec<PathBuf>, &mut EventContext) + 'static,
186 ) -> Self {
187 self.on_files = Some(Box::new(f));
188 self
189 }
190
191 pub fn on_text_dropped(mut self, f: impl FnMut(String, &mut EventContext) + 'static) -> Self {
193 self.on_text = Some(Box::new(f));
194 self
195 }
196
197 pub fn on_urls_dropped(
199 mut self,
200 f: impl FnMut(Vec<String>, &mut EventContext) + 'static,
201 ) -> Self {
202 self.on_urls = Some(Box::new(f));
203 self
204 }
205}
206
207impl std::fmt::Debug for DropZone {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 f.debug_struct("DropZone")
210 .field("label", &self.label)
211 .field("extensions", &self.extensions)
212 .field("allow_multiple", &self.allow_multiple)
213 .finish_non_exhaustive()
214 }
215}
216
217fn payload_accepted(
220 payload: &DragPayload,
221 extensions: &[String],
222 allow_multiple: bool,
223 has_files_cb: bool,
224 has_text_cb: bool,
225 has_urls_cb: bool,
226) -> bool {
227 let files = payload.files();
228 if !files.is_empty() {
229 if !has_files_cb {
230 return false;
231 }
232 if !allow_multiple && files.len() > 1 {
233 return false;
234 }
235 if extensions.is_empty() {
236 return true;
237 }
238 return files.iter().all(|p| {
239 p.extension()
240 .and_then(|e| e.to_str())
241 .map(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e)))
242 .unwrap_or(false)
243 });
244 }
245 if payload.text().is_some() {
246 return has_text_cb;
247 }
248 if !payload.uris().is_empty() {
249 return has_urls_cb;
250 }
251 if payload.is_external() {
256 let formats = payload.formats();
257 let offers = |needles: &[&str]| {
258 formats
259 .iter()
260 .any(|f| needles.iter().any(|n| f == n || f.starts_with(n)))
261 };
262 if has_files_cb && offers(&["text/uri-list"]) {
263 return true;
264 }
265 if has_text_cb && offers(&["text/plain", "UTF8_STRING", "STRING", "TEXT"]) {
266 return true;
267 }
268 if has_urls_cb && offers(&["text/x-moz-url", "text/uri-list", "_NETSCAPE_URL"]) {
269 return true;
270 }
271 }
272 false
273}
274
275fn hover_announcement(payload: &DragPayload) -> String {
282 let files = payload.files().len();
283 if files == 1 {
284 return tr_widget!(drop_zone_hover_file_one()).resolve_now();
285 }
286 if files > 1 {
287 return tr_widget!(drop_zone_hover_file_many(count = files as i64)).resolve_now();
288 }
289 if payload.text().is_some() {
290 return tr_widget!(drop_zone_hover_text()).resolve_now();
291 }
292 let links = payload.uris().len();
293 if links == 1 {
294 return tr_widget!(drop_zone_hover_link_one()).resolve_now();
295 }
296 if links > 1 {
297 return tr_widget!(drop_zone_hover_link_many(count = links as i64)).resolve_now();
298 }
299 tr_widget!(drop_zone_hover_generic()).resolve_now()
301}
302
303fn added_announcement(payload: &DragPayload) -> String {
305 let files = payload.files().len();
306 if files >= 1 {
307 return added_files_announcement(files);
308 }
309 if payload.text().is_some() {
310 return tr_widget!(drop_zone_added_text()).resolve_now();
311 }
312 let links = payload.uris().len();
313 if links == 1 {
314 return tr_widget!(drop_zone_added_link_one()).resolve_now();
315 }
316 if links > 1 {
317 return tr_widget!(drop_zone_added_link_many(count = links as i64)).resolve_now();
318 }
319 added_files_announcement(files)
320}
321
322fn added_files_announcement(count: usize) -> String {
324 if count == 1 {
325 tr_widget!(drop_zone_added_file_one()).resolve_now()
326 } else {
327 tr_widget!(drop_zone_added_file_many(count = count as i64)).resolve_now()
328 }
329}
330
331impl Widget for DropZone {
332 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
333 let state = ctx.signal(DropZoneVisualState::Idle);
334 let announce = ctx.signal(String::new());
335
336 let extensions = self.extensions.clone();
338 let allow_multiple = self.allow_multiple;
339 let has_files_cb = self.on_files.is_some();
340 let has_text_cb = self.on_text.is_some();
341 let has_urls_cb = self.on_urls.is_some();
342
343 let on_files = self.on_files.take().map(|f| Rc::new(RefCell::new(f)));
344 let on_text = self.on_text.take().map(|f| Rc::new(RefCell::new(f)));
345 let on_urls = self.on_urls.take().map(|f| Rc::new(RefCell::new(f)));
346
347 let mut content = VStack::new().spacing(8.0).alignment(HAlignment::Center);
349
350 if let Some(icon) = self.icon.take() {
351 let icon_id = ctx.add_boxed(icon);
352 content = content.add_child(icon_id);
353 }
354
355 content = content.child(TextWidget::new(self.label.clone()));
356
357 if let Some(subtitle) = &self.subtitle {
358 content = content.child(TextWidget::new(subtitle.clone()).color(TextRole::Secondary));
359 }
360
361 content = content.child(
363 TextWidget::new(lit!(String::new()))
364 .text(announce.clone())
365 .color(TextRole::Secondary)
366 .access_live(Live::Polite),
367 );
368
369 if self.show_browse_button {
370 let browse_extensions = self.extensions.clone();
371 let allow_multiple_browse = self.allow_multiple;
372 let on_files_browse = on_files.clone();
373 let announce_browse = announce.clone();
374 let browse_starting_dir = self.starting_dir.clone();
375 let browse = Button::new(self.browse_label.clone()).on_activate_fn(
376 move |ctx: &mut EventContext| {
377 let mut request = FileDialogRequest::pick_file();
378 if let Some(dir) = &browse_starting_dir {
379 request = request.starting_dir(dir.clone());
380 }
381 if !browse_extensions.is_empty() {
382 let exts: Vec<&str> =
383 browse_extensions.iter().map(String::as_str).collect();
384 request = request.add_filter("Allowed", &exts);
385 }
386 let on_files_cb = on_files_browse.clone();
387 let announce_cb = announce_browse.clone();
388 let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
389 let paths = match result {
390 FileDialogResult::File(Some(p)) => vec![p],
391 FileDialogResult::Files(v) => v,
392 _ => Vec::new(),
393 };
394 if paths.is_empty() {
395 return;
396 }
397 let count = paths.len();
398 if let Some(cb) = &on_files_cb {
399 (cb.borrow_mut())(paths, ctx);
400 }
401 announce_cb.set(added_files_announcement(count));
402 };
403 let _ = if allow_multiple_browse {
406 ctx.pick_files(request, result_cb)
407 } else {
408 ctx.pick_file(request, result_cb)
409 };
410 },
411 );
412 content = content.child(browse);
413 }
414
415 let content_id = ctx.add(content);
416
417 let style = self
419 .style_override
420 .clone()
421 .or_else(|| ctx.theme().style_slots.drop_zone.clone())
422 .unwrap_or_else(|| Rc::new(crate::styles::RecipeDropZoneStyle::default()));
423 let body = style.make_body(
424 &DropZoneStyleConfig {
425 state: state.clone(),
426 content: content_id,
427 },
428 ctx,
429 );
430
431 let hover_state = state.clone();
433 let hover_announce = announce.clone();
434 let hover_exts = extensions.clone();
435 let leave_state = state.clone();
436 let leave_announce = announce.clone();
437 let drop_exts = extensions;
438
439 let handlers = HandlerSet::new()
440 .on_drag_hover(move |payload, _pos, _ctx| {
441 let ok = payload_accepted(
442 payload,
443 &hover_exts,
444 allow_multiple,
445 has_files_cb,
446 has_text_cb,
447 has_urls_cb,
448 );
449 if ok {
450 hover_state.set(DropZoneVisualState::HoverAccept);
451 hover_announce.set(hover_announcement(payload));
452 } else {
453 hover_state.set(DropZoneVisualState::HoverReject);
454 hover_announce.set(tr_widget!(drop_zone_hover_reject()).resolve_now());
455 }
456 if ok {
460 DropFeedback::Accept
461 } else {
462 DropFeedback::NoFeedback
463 }
464 })
465 .on_drag_leave(move |_ctx| {
466 leave_state.set(DropZoneVisualState::Idle);
467 leave_announce.set(String::new());
468 })
469 .on_drop(move |payload, _pos, ctx| {
470 let ok = payload_accepted(
471 &payload,
472 &drop_exts,
473 allow_multiple,
474 has_files_cb,
475 has_text_cb,
476 has_urls_cb,
477 );
478 state.set(DropZoneVisualState::Idle);
479 if !ok {
480 announce.set(tr_widget!(drop_zone_rejected()).resolve_now());
481 return false;
482 }
483 if !payload.files().is_empty() {
484 if let Some(cb) = &on_files {
485 (cb.borrow_mut())(payload.files().to_vec(), ctx);
486 }
487 } else if let Some(text) = payload.text() {
488 if let Some(cb) = &on_text {
489 (cb.borrow_mut())(text.to_string(), ctx);
490 }
491 } else if !payload.uris().is_empty() {
492 if let Some(cb) = &on_urls {
493 (cb.borrow_mut())(payload.uris().to_vec(), ctx);
494 }
495 }
496 announce.set(added_announcement(&payload));
497 true
498 });
499 ctx.apply_self_handlers(handlers);
500
501 self.root_child_id = Some(body);
502 self.children()
503 }
504
505 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
506 self.root_child_id
507 .and_then(|id| ctx.child_size(id, proposal))
508 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
509 .into()
510 }
511
512 fn place_children(
513 &self,
514 bounds: Rect,
515 _proposal: SizeProposal,
516 children: &mut [WidgetPlacement],
517 _ctx: &LayoutContext,
518 ) {
519 for child in children.iter_mut() {
520 child.origin = bounds.origin();
521 child.size = bounds.size();
522 }
523 }
524
525 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
526 builder.set_role(Role::Group);
529 builder.set_name(self.label.clone());
530 }
531
532 fn children(&self) -> Vec<WidgetId> {
533 self.root_child_id.into_iter().collect()
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use std::cell::RefCell;
541 use std::rc::Rc;
542 use teksilo_canvas::Point;
543 use teksilo_core::ExternalDropData;
544 use teksilo_core::widget_tree::WidgetTree;
545
546 fn tree() -> WidgetTree {
547 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
548 }
549
550 #[test]
554 fn a_starting_directory_reaches_the_built_zone() {
555 let zone = DropZone::new(lit!("Drop files here")).starting_dir("/tmp/somewhere");
556 assert_eq!(
557 zone.starting_dir.as_deref(),
558 Some(std::path::Path::new("/tmp/somewhere"))
559 );
560
561 let mut tree = tree();
562 let id = tree.add(zone);
563 tree.layout(SizeProposal::exact(400.0, 300.0));
564 let b = tree.bounds(id);
565 assert!(
566 b.width > 0.0 && b.height > 0.0,
567 "a zone carrying a starting directory still builds"
568 );
569 }
570
571 #[test]
572 fn builds_with_nonzero_size() {
573 let mut tree = tree();
574 let id = tree.add(DropZone::new(lit!("Drop files here")));
575 tree.layout(SizeProposal::exact(400.0, 300.0));
576 let b = tree.bounds(id);
577 assert!(b.width > 0.0 && b.height > 0.0);
578 }
579
580 #[test]
581 fn matching_file_drop_fires_callback() {
582 let mut tree = tree();
583 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
584 let g = got.clone();
585 tree.add(
586 DropZone::new(lit!("Images"))
587 .accept_extensions(["png", "jpg"])
588 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
589 );
590 tree.layout(SizeProposal::exact(400.0, 300.0));
591
592 let mut noop = teksilo_core::NoopWindowOps;
593 let data = ExternalDropData {
594 files: vec![PathBuf::from("/tmp/photo.png")],
595 ..Default::default()
596 };
597 let p = Point::new(200.0, 150.0);
598 tree.begin_external_drag(p, data.clone(), &mut noop);
599 tree.end_external_drag(p, data, &mut noop);
600
601 assert_eq!(*got.borrow(), vec![PathBuf::from("/tmp/photo.png")]);
602 }
603
604 #[test]
605 fn wrong_extension_is_rejected() {
606 let mut tree = tree();
607 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
608 let g = got.clone();
609 tree.add(
610 DropZone::new(lit!("Images"))
611 .accept_extensions(["png"])
612 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
613 );
614 tree.layout(SizeProposal::exact(400.0, 300.0));
615
616 let mut noop = teksilo_core::NoopWindowOps;
617 let data = ExternalDropData {
618 files: vec![PathBuf::from("/tmp/notes.txt")],
619 ..Default::default()
620 };
621 let p = Point::new(200.0, 150.0);
622 tree.begin_external_drag(p, data.clone(), &mut noop);
623 tree.end_external_drag(p, data, &mut noop);
624
625 assert!(got.borrow().is_empty(), "non-png drop must be rejected");
626 }
627
628 #[test]
629 fn multi_file_rejected_when_single_only() {
630 let mut tree = tree();
631 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
632 let g = got.clone();
633 tree.add(
634 DropZone::new(lit!("One file"))
635 .allow_multiple(false)
636 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
637 );
638 tree.layout(SizeProposal::exact(400.0, 300.0));
639
640 let mut noop = teksilo_core::NoopWindowOps;
641 let data = ExternalDropData {
642 files: vec![PathBuf::from("/a"), PathBuf::from("/b")],
643 ..Default::default()
644 };
645 let p = Point::new(200.0, 150.0);
646 tree.begin_external_drag(p, data.clone(), &mut noop);
647 tree.end_external_drag(p, data, &mut noop);
648
649 assert!(got.borrow().is_empty(), "multi-file drop must be rejected");
650 }
651
652 #[test]
653 fn text_drop_fires_when_handler_set() {
654 let mut tree = tree();
655 let got: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
656 let g = got.clone();
657 tree.add(
658 DropZone::new(lit!("Notes")).on_text_dropped(move |t, _ctx| *g.borrow_mut() = Some(t)),
659 );
660 tree.layout(SizeProposal::exact(400.0, 300.0));
661
662 let mut noop = teksilo_core::NoopWindowOps;
663 let data = ExternalDropData {
664 text: Some("hello".to_string()),
665 ..Default::default()
666 };
667 let p = Point::new(200.0, 150.0);
668 tree.begin_external_drag(p, data.clone(), &mut noop);
669 tree.end_external_drag(p, data, &mut noop);
670
671 assert_eq!(got.borrow().as_deref(), Some("hello"));
672 }
673
674 #[test]
679 fn formats_only_hover_accepts_matching_kind() {
680 let file_drag = DragPayload::external(ExternalDropData {
682 formats: vec!["text/uri-list".into(), "text/plain".into()],
683 ..Default::default()
684 });
685 assert!(payload_accepted(
688 &file_drag,
689 &["png".into()],
690 true,
691 true,
692 false,
693 false
694 ));
695
696 let text_drag = DragPayload::external(ExternalDropData {
698 formats: vec!["text/plain".into()],
699 ..Default::default()
700 });
701 assert!(!payload_accepted(&text_drag, &[], true, true, false, false));
702 assert!(payload_accepted(&text_drag, &[], true, false, true, false));
704 }
705
706 #[test]
707 fn formats_only_internal_drag_is_not_accepted() {
708 let internal = DragPayload::typed(7_u32);
711 assert!(!payload_accepted(&internal, &[], true, true, true, true));
712 }
713}