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