Skip to main content

teksilo_widgets/
drop_zone.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DropZone` — a "drop files here" target for external (OS) drag-and-drop.
5//!
6//! A bordered, tinted region that accepts files / text / URLs dragged in from
7//! the operating system (Finder, Explorer, Nautilus) or another application.
8//! It reacts to hover (accept / reject highlight) and fires typed callbacks on
9//! drop. Because an OS drag cannot be initiated from the keyboard, the zone
10//! also offers a keyboard-operable **Browse…** button (opening the native file
11//! dialog) as the WCAG 2.1.1 equivalent.
12//!
13//! ```ignore
14//! DropZone::new(tr!("drop_images_here"))
15//!     .subtitle(tr!("png_or_jpeg"))
16//!     .accept_extensions(["png", "jpg", "jpeg"])
17//!     .allow_multiple(true)
18//!     .on_files_dropped(|paths, _ctx| { /* import paths */ });
19//! ```
20//!
21//! External drops are delivered through the framework's normal drag pipeline
22//! (`on_drag_hover` / `on_drag_leave` / `on_drop`) once
23//! [`install_external_dnd`](https://docs.rs/teksilo-app) is wired and a backend
24//! is available; on platforms with no backend (e.g. X11) the Browse button
25//! keeps the zone fully usable.
26//!
27//! # Styling
28//!
29//! The bordered, tinted chrome is a Tier-3 [`DropZoneStyle`]; the default
30//! [`RecipeDropZoneStyle`](crate::styles::RecipeDropZoneStyle) tracks the
31//! interaction state. Override per-call with [`DropZone::style`] or theme-wide
32//! via `theme.style_slots.drop_zone`.
33//!
34//! # Accessibility
35//!
36//! The zone is a `Role::Group` labelled by its prompt, with a `Live::Polite`
37//! status line that announces hover ("Drop to add 3 files"), success
38//! ("3 files added"), and rejection. AccessKit models no drag/drop action and
39//! ARIA's `aria-grabbed` / `aria-dropeffect` are deprecated, so live-region
40//! announcements plus the Browse fallback are the supported pattern.
41
42use 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
71/// A drop target for external (OS) drag-and-drop. See the module docs.
72pub 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    /// Build a drop zone with the given prompt (e.g. `tr!("drop_files_here")`).
90    /// The label may come from `tr!(...)` (translated) or
91    /// `lit!(...)`; it is resolved eagerly at construction
92    /// and stored as a `String`. Locale changes rebuild the composite parent,
93    /// which re-creates the `DropZone` with a fresh translation — the same
94    /// model as [`Button::new`](crate::button::Button::new).
95    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    /// Secondary line under the prompt (e.g. `tr!("png_or_jpeg")`).
114    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
115        self.subtitle = Some(text.into());
116        self
117    }
118
119    /// Restrict accepted files to these extensions (without leading dots,
120    /// case-insensitive). Empty (the default) accepts any file. Text and URL
121    /// drops are unaffected.
122    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    /// Whether more than one file may be dropped at once. Default `true`.
135    /// When `false`, a multi-file drop is rejected.
136    pub fn allow_multiple(mut self, allow: bool) -> Self {
137        self.allow_multiple = allow;
138        self
139    }
140
141    /// Show or hide the keyboard-operable Browse button. Default `true`.
142    /// Keeping it visible is strongly recommended — it is the only
143    /// keyboard-accessible path to the zone's action.
144    pub fn show_browse_button(mut self, show: bool) -> Self {
145        self.show_browse_button = show;
146        self
147    }
148
149    /// Override the Browse button's label (e.g. `tr!("browse")`).
150    /// Directory the Browse button's dialog opens in. If unset, the OS default is
151    /// used.
152    ///
153    /// The same builder [`FilePickerField::starting_dir`](crate::file_picker_field::FilePickerField::starting_dir)
154    /// offers, and for the same reason: an app that remembers where its writer last
155    /// picked files has no way to say so otherwise, because this widget builds its own
156    /// `FileDialogRequest` internally rather than taking one.
157    #[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    /// An icon widget shown above the prompt (any widget — typically an
169    /// [`IconWidget`](crate::primitives::IconWidget)).
170    pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
171        self.icon = Some(Box::new(icon));
172        self
173    }
174
175    /// Override the Tier-3 [`DropZoneStyle`] for this instance only.
176    pub fn style(mut self, style: impl DropZoneStyle) -> Self {
177        self.style_override = Some(Rc::new(style));
178        self
179    }
180
181    /// Called with the dropped (or browsed) file paths. Files are only
182    /// accepted when this is set.
183    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    /// Called with dropped plain text. Text drops are only accepted when set.
192    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    /// Called with dropped non-file URLs. URL drops are only accepted when set.
198    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
217/// Decide whether `payload` is acceptable given the zone's policy. Free
218/// function so the drag closures don't need to borrow `self`.
219fn 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    // No concrete data yet — on Wayland the bytes only arrive at drop, so the
252    // hover decision is made from the advertised formats. Optimistic: accept if
253    // the zone handles a kind the source offers; the real extension check runs
254    // at drop once `files()` is populated.
255    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
275/// Localized live-region announcement for a drag hovering over the zone.
276/// Singular vs plural is chosen here (in Rust) rather than via a Fluent
277/// select expression so the `tr_widget!` compile-time English fallback
278/// works for apps that don't register the framework bundle. Drop counts
279/// are always >= 1, so the `== 1` / `> 1` split is correct for both
280/// English and French.
281fn 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    // Wayland hover before the bytes arrive (formats-only) — generic prompt.
300    tr_widget!(drop_zone_hover_generic()).resolve_now()
301}
302
303/// Localized live-region announcement for a completed drop.
304fn 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
322/// Localized "N file(s) added" — shared by drop success and Browse success.
323fn 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        // Snapshots for the closures.
337        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        // --- Content column: [icon?] prompt [subtitle?] [status] [Browse?] ---
348        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        // Live-region status line: empty at rest, narrates hover / drop.
362        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                    // Multi vs single picker per policy. Errors (no dialog
404                    // installed) are ignored — the zone stays usable.
405                    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        // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
418        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        // --- Drag behaviour on the composite node (the drop target) ---
432        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                // Visuals are state-driven; engage with `Accept` (no framework
457                // feedback) when accepting so the drop lands here, else
458                // `NoFeedback` so the drag bubbles past to the next drop target.
459                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        // The composite node is the drop target and the labelled group; the
527        // Live status line lives inside the content column.
528        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    /// The builder must survive to the widget: a starting directory that is accepted
551    /// and then dropped would look identical to one that works, right up until a writer
552    /// noticed the dialog still opening in their home folder.
553    #[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    // --- Hover-time acceptance from advertised formats (Wayland) -------
675    // On Wayland the dropped bytes only arrive at drop, so hover accept/reject
676    // is decided from the advertised MIME formats alone.
677
678    #[test]
679    fn formats_only_hover_accepts_matching_kind() {
680        // A file drag advertises text/uri-list (+ text/plain for the path).
681        let file_drag = DragPayload::external(ExternalDropData {
682            formats: vec!["text/uri-list".into(), "text/plain".into()],
683            ..Default::default()
684        });
685        // Image-style zone: files handler, png filter — accept on hover even
686        // though the extension can't be checked until drop.
687        assert!(payload_accepted(
688            &file_drag,
689            &["png".into()],
690            true,
691            true,
692            false,
693            false
694        ));
695
696        // A pure text drag (no uri-list) onto a files-only zone → reject.
697        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        // …but a text-handling zone accepts it.
703        assert!(payload_accepted(&text_drag, &[], true, false, true, false));
704    }
705
706    #[test]
707    fn formats_only_internal_drag_is_not_accepted() {
708        // A non-external payload with no concrete data must not be accepted via
709        // the formats path.
710        let internal = DragPayload::typed(7_u32);
711        assert!(!payload_accepted(&internal, &[], true, true, true, true));
712    }
713}