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]
176 pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
177 self.starting_dir = Some(path.into());
178 self
179 }
180
181 pub fn browse_label(mut self, label: impl Into<LocalizedString>) -> Self {
182 self.browse_label = label.into();
183 self
184 }
185
186 pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
189 self.icon = Some(Box::new(icon));
190 self
191 }
192
193 pub fn style(mut self, style: impl DropZoneStyle) -> Self {
195 self.style_override = Some(Rc::new(style));
196 self
197 }
198
199 pub fn on_files_dropped(
202 mut self,
203 f: impl FnMut(Vec<PathBuf>, &mut EventContext) + 'static,
204 ) -> Self {
205 self.on_files = Some(Box::new(f));
206 self
207 }
208
209 pub fn on_text_dropped(mut self, f: impl FnMut(String, &mut EventContext) + 'static) -> Self {
211 self.on_text = Some(Box::new(f));
212 self
213 }
214
215 pub fn on_urls_dropped(
217 mut self,
218 f: impl FnMut(Vec<String>, &mut EventContext) + 'static,
219 ) -> Self {
220 self.on_urls = Some(Box::new(f));
221 self
222 }
223}
224
225impl std::fmt::Debug for DropZone {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.debug_struct("DropZone")
228 .field("label", &self.label)
229 .field("extensions", &self.extensions)
230 .field("allow_multiple", &self.allow_multiple)
231 .finish_non_exhaustive()
232 }
233}
234
235fn payload_accepted(
238 payload: &DragPayload,
239 extensions: &[String],
240 allow_multiple: bool,
241 has_files_cb: bool,
242 has_text_cb: bool,
243 has_urls_cb: bool,
244) -> bool {
245 let files = payload.files();
246 if !files.is_empty() {
247 if !has_files_cb {
248 return false;
249 }
250 if !allow_multiple && files.len() > 1 {
251 return false;
252 }
253 if extensions.is_empty() {
254 return true;
255 }
256 return files.iter().all(|p| {
257 p.extension()
258 .and_then(|e| e.to_str())
259 .map(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e)))
260 .unwrap_or(false)
261 });
262 }
263 if payload.text().is_some() {
264 return has_text_cb;
265 }
266 if !payload.uris().is_empty() {
267 return has_urls_cb;
268 }
269 if payload.is_external() {
274 let formats = payload.formats();
275 let offers = |needles: &[&str]| {
276 formats
277 .iter()
278 .any(|f| needles.iter().any(|n| f == n || f.starts_with(n)))
279 };
280 if has_files_cb && offers(&["text/uri-list"]) {
281 return true;
282 }
283 if has_text_cb && offers(&["text/plain", "UTF8_STRING", "STRING", "TEXT"]) {
284 return true;
285 }
286 if has_urls_cb && offers(&["text/x-moz-url", "text/uri-list", "_NETSCAPE_URL"]) {
287 return true;
288 }
289 }
290 false
291}
292
293fn hover_announcement(payload: &DragPayload) -> String {
300 let files = payload.files().len();
301 if files == 1 {
302 return tr_widget!(drop_zone_hover_file_one()).resolve_now();
303 }
304 if files > 1 {
305 return tr_widget!(drop_zone_hover_file_many(count = files as i64)).resolve_now();
306 }
307 if payload.text().is_some() {
308 return tr_widget!(drop_zone_hover_text()).resolve_now();
309 }
310 let links = payload.uris().len();
311 if links == 1 {
312 return tr_widget!(drop_zone_hover_link_one()).resolve_now();
313 }
314 if links > 1 {
315 return tr_widget!(drop_zone_hover_link_many(count = links as i64)).resolve_now();
316 }
317 tr_widget!(drop_zone_hover_generic()).resolve_now()
319}
320
321fn added_announcement(payload: &DragPayload) -> String {
323 let files = payload.files().len();
324 if files >= 1 {
325 return added_files_announcement(files);
326 }
327 if payload.text().is_some() {
328 return tr_widget!(drop_zone_added_text()).resolve_now();
329 }
330 let links = payload.uris().len();
331 if links == 1 {
332 return tr_widget!(drop_zone_added_link_one()).resolve_now();
333 }
334 if links > 1 {
335 return tr_widget!(drop_zone_added_link_many(count = links as i64)).resolve_now();
336 }
337 added_files_announcement(files)
338}
339
340fn added_files_announcement(count: usize) -> String {
342 if count == 1 {
343 tr_widget!(drop_zone_added_file_one()).resolve_now()
344 } else {
345 tr_widget!(drop_zone_added_file_many(count = count as i64)).resolve_now()
346 }
347}
348
349impl Widget for DropZone {
350 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
351 let state = ctx.signal(DropZoneVisualState::Idle);
352 let announce = ctx.signal(String::new());
353
354 let extensions = self.extensions.clone();
356 let allow_multiple = self.allow_multiple;
357 let has_files_cb = self.on_files.is_some();
358 let has_text_cb = self.on_text.is_some();
359 let has_urls_cb = self.on_urls.is_some();
360
361 let on_files = self.on_files.take().map(|f| Rc::new(RefCell::new(f)));
362 let on_text = self.on_text.take().map(|f| Rc::new(RefCell::new(f)));
363 let on_urls = self.on_urls.take().map(|f| Rc::new(RefCell::new(f)));
364
365 let mut content = VStack::new().spacing(8.0).alignment(HAlignment::Center);
367
368 if let Some(icon) = self.icon.take() {
369 let icon_id = ctx.add_boxed(icon);
370 content = content.child(icon_id);
371 }
372
373 let label_id = ctx.add(TextWidget::new(self.label.clone()));
377 self.label_node = Some(label_id);
378 content = content.child(label_id);
379
380 if let Some(subtitle) = &self.subtitle {
381 content = content.child(TextWidget::new(subtitle.clone()).color(TextRole::Secondary));
382 }
383
384 content = content.child(
386 TextWidget::new(lit!(String::new()))
387 .text(announce.clone())
388 .color(TextRole::Secondary)
389 .access_live(Live::Polite),
390 );
391
392 if self.show_browse_button {
393 let browse_extensions = self.extensions.clone();
394 let allow_multiple_browse = self.allow_multiple;
395 let on_files_browse = on_files.clone();
396 let announce_browse = announce.clone();
397 let browse_starting_dir = self.starting_dir.clone();
398 let browse = Button::new(self.browse_label.clone()).on_activate_fn(
399 move |ctx: &mut EventContext| {
400 let mut request = FileDialogRequest::pick_file();
401 if let Some(dir) = &browse_starting_dir {
402 request = request.starting_dir(dir.clone());
403 }
404 if !browse_extensions.is_empty() {
405 let exts: Vec<&str> =
406 browse_extensions.iter().map(String::as_str).collect();
407 request = request.add_filter("Allowed", &exts);
408 }
409 let on_files_cb = on_files_browse.clone();
410 let announce_cb = announce_browse.clone();
411 let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
412 let paths = match result {
413 FileDialogResult::File(Some(p)) => vec![p],
414 FileDialogResult::Files(v) => v,
415 _ => Vec::new(),
416 };
417 if paths.is_empty() {
418 return;
419 }
420 let count = paths.len();
421 if let Some(cb) = &on_files_cb {
422 (cb.borrow_mut())(paths, ctx);
423 }
424 announce_cb.set(added_files_announcement(count));
425 };
426 let _ = if allow_multiple_browse {
429 ctx.pick_files(request, result_cb)
430 } else {
431 ctx.pick_file(request, result_cb)
432 };
433 },
434 );
435 content = content.child(browse);
436 }
437
438 let content_id = ctx.add(content);
439
440 let style = self
442 .style_override
443 .clone()
444 .or_else(|| ctx.theme().style_slots.drop_zone.clone())
445 .unwrap_or_else(|| {
446 Rc::new(crate::styles::RecipeDropZoneStyle::for_tokens(
447 &ctx.theme().input,
448 ))
449 });
450 let body = style.make_body(
451 &DropZoneStyleConfig {
452 state: state.clone(),
453 content: content_id,
454 },
455 ctx,
456 );
457
458 let hover_state = state.clone();
460 let hover_announce = announce.clone();
461 let hover_exts = extensions.clone();
462 let leave_state = state.clone();
463 let leave_announce = announce.clone();
464 let drop_exts = extensions;
465
466 let handlers = HandlerSet::new()
467 .on_drag_hover(move |payload, _pos, _ctx| {
468 let ok = payload_accepted(
469 payload,
470 &hover_exts,
471 allow_multiple,
472 has_files_cb,
473 has_text_cb,
474 has_urls_cb,
475 );
476 if ok {
477 hover_state.set(DropZoneVisualState::HoverAccept);
478 hover_announce.set(hover_announcement(payload));
479 } else {
480 hover_state.set(DropZoneVisualState::HoverReject);
481 hover_announce.set(tr_widget!(drop_zone_hover_reject()).resolve_now());
482 }
483 if ok {
487 DropFeedback::Accept
488 } else {
489 DropFeedback::NoFeedback
490 }
491 })
492 .on_drag_leave(move |_ctx| {
493 leave_state.set(DropZoneVisualState::Idle);
494 leave_announce.set(String::new());
495 })
496 .on_drop(move |payload, _pos, ctx| {
497 let ok = payload_accepted(
498 &payload,
499 &drop_exts,
500 allow_multiple,
501 has_files_cb,
502 has_text_cb,
503 has_urls_cb,
504 );
505 state.set(DropZoneVisualState::Idle);
506 if !ok {
507 announce.set(tr_widget!(drop_zone_rejected()).resolve_now());
508 return false;
509 }
510 if !payload.files().is_empty() {
511 if let Some(cb) = &on_files {
512 (cb.borrow_mut())(payload.files().to_vec(), ctx);
513 }
514 } else if let Some(text) = payload.text() {
515 if let Some(cb) = &on_text {
516 (cb.borrow_mut())(text.to_string(), ctx);
517 }
518 } else if !payload.uris().is_empty() {
519 if let Some(cb) = &on_urls {
520 (cb.borrow_mut())(payload.uris().to_vec(), ctx);
521 }
522 }
523 announce.set(added_announcement(&payload));
524 true
525 });
526 ctx.apply_self_handlers(handlers);
527
528 self.root_child_id = Some(body);
529 if let Some(label_id) = self.label_node {
530 let self_id = ctx.self_id();
531 ctx.access_labelled_by(self_id, label_id);
532 }
533 self.children()
534 }
535
536 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
537 self.root_child_id
538 .and_then(|id| ctx.child_size(id, proposal))
539 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
540 .into()
541 }
542
543 fn place_children(
544 &self,
545 bounds: Rect,
546 _proposal: SizeProposal,
547 children: &mut [WidgetPlacement],
548 _ctx: &LayoutContext,
549 ) {
550 for child in children.iter_mut() {
551 child.origin = bounds.origin();
552 child.size = bounds.size();
553 }
554 }
555
556 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
557 builder.set_role(Role::Group);
560 if self.label_node.is_none() {
564 builder.set_name(self.label.clone());
565 }
566 }
567
568 fn children(&self) -> Vec<WidgetId> {
569 self.root_child_id.into_iter().collect()
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576 use std::cell::RefCell;
577 use std::rc::Rc;
578 use teksilo_canvas::Point;
579 use teksilo_core::ExternalDropData;
580 use teksilo_core::widget_tree::WidgetTree;
581
582 fn tree() -> WidgetTree {
583 WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
584 }
585
586 #[test]
590 fn a_starting_directory_reaches_the_built_zone() {
591 let zone = DropZone::new(lit!("Drop files here")).starting_dir("/tmp/somewhere");
592 assert_eq!(
593 zone.starting_dir.as_deref(),
594 Some(std::path::Path::new("/tmp/somewhere"))
595 );
596
597 let mut tree = tree();
598 let id = tree.add(zone);
599 tree.layout(SizeProposal::exact(400.0, 300.0));
600 let b = tree.bounds(id);
601 assert!(
602 b.width > 0.0 && b.height > 0.0,
603 "a zone carrying a starting directory still builds"
604 );
605 }
606
607 #[test]
608 fn builds_with_nonzero_size() {
609 let mut tree = tree();
610 let id = tree.add(DropZone::new(lit!("Drop files here")));
611 tree.layout(SizeProposal::exact(400.0, 300.0));
612 let b = tree.bounds(id);
613 assert!(b.width > 0.0 && b.height > 0.0);
614 }
615
616 #[test]
617 fn matching_file_drop_fires_callback() {
618 let mut tree = tree();
619 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
620 let g = got.clone();
621 tree.add(
622 DropZone::new(lit!("Images"))
623 .accept_extensions(["png", "jpg"])
624 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
625 );
626 tree.layout(SizeProposal::exact(400.0, 300.0));
627
628 let mut noop = teksilo_core::NoopWindowOps;
629 let data = ExternalDropData {
630 files: vec![PathBuf::from("/tmp/photo.png")],
631 ..Default::default()
632 };
633 let p = Point::new(200.0, 150.0);
634 tree.begin_external_drag(p, data.clone(), &mut noop);
635 tree.end_external_drag(p, data, &mut noop);
636
637 assert_eq!(*got.borrow(), vec![PathBuf::from("/tmp/photo.png")]);
638 }
639
640 #[test]
641 fn wrong_extension_is_rejected() {
642 let mut tree = tree();
643 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
644 let g = got.clone();
645 tree.add(
646 DropZone::new(lit!("Images"))
647 .accept_extensions(["png"])
648 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
649 );
650 tree.layout(SizeProposal::exact(400.0, 300.0));
651
652 let mut noop = teksilo_core::NoopWindowOps;
653 let data = ExternalDropData {
654 files: vec![PathBuf::from("/tmp/notes.txt")],
655 ..Default::default()
656 };
657 let p = Point::new(200.0, 150.0);
658 tree.begin_external_drag(p, data.clone(), &mut noop);
659 tree.end_external_drag(p, data, &mut noop);
660
661 assert!(got.borrow().is_empty(), "non-png drop must be rejected");
662 }
663
664 #[test]
665 fn multi_file_rejected_when_single_only() {
666 let mut tree = tree();
667 let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
668 let g = got.clone();
669 tree.add(
670 DropZone::new(lit!("One file"))
671 .allow_multiple(false)
672 .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
673 );
674 tree.layout(SizeProposal::exact(400.0, 300.0));
675
676 let mut noop = teksilo_core::NoopWindowOps;
677 let data = ExternalDropData {
678 files: vec![PathBuf::from("/a"), PathBuf::from("/b")],
679 ..Default::default()
680 };
681 let p = Point::new(200.0, 150.0);
682 tree.begin_external_drag(p, data.clone(), &mut noop);
683 tree.end_external_drag(p, data, &mut noop);
684
685 assert!(got.borrow().is_empty(), "multi-file drop must be rejected");
686 }
687
688 #[test]
689 fn text_drop_fires_when_handler_set() {
690 let mut tree = tree();
691 let got: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
692 let g = got.clone();
693 tree.add(
694 DropZone::new(lit!("Notes")).on_text_dropped(move |t, _ctx| *g.borrow_mut() = Some(t)),
695 );
696 tree.layout(SizeProposal::exact(400.0, 300.0));
697
698 let mut noop = teksilo_core::NoopWindowOps;
699 let data = ExternalDropData {
700 text: Some("hello".to_string()),
701 ..Default::default()
702 };
703 let p = Point::new(200.0, 150.0);
704 tree.begin_external_drag(p, data.clone(), &mut noop);
705 tree.end_external_drag(p, data, &mut noop);
706
707 assert_eq!(got.borrow().as_deref(), Some("hello"));
708 }
709
710 #[test]
715 fn formats_only_hover_accepts_matching_kind() {
716 let file_drag = DragPayload::external(ExternalDropData {
718 formats: vec!["text/uri-list".into(), "text/plain".into()],
719 ..Default::default()
720 });
721 assert!(payload_accepted(
724 &file_drag,
725 &["png".into()],
726 true,
727 true,
728 false,
729 false
730 ));
731
732 let text_drag = DragPayload::external(ExternalDropData {
734 formats: vec!["text/plain".into()],
735 ..Default::default()
736 });
737 assert!(!payload_accepted(&text_drag, &[], true, true, false, false));
738 assert!(payload_accepted(&text_drag, &[], true, false, true, false));
740 }
741
742 #[test]
743 fn formats_only_internal_drag_is_not_accepted() {
744 let internal = DragPayload::typed(7_u32);
747 assert!(!payload_accepted(&internal, &[], true, true, true, true));
748 }
749}