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