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. All four desktop backends are real (OLE on Windows,
25//! `NSDraggingDestination` on macOS, `wl_data_device` on Wayland, XDND on X11
26//! — see `teksilo-platform/src/external_dnd.rs`), so the Browse button is the
27//! keyboard route rather than a fallback for a platform that cannot drop.
28//!
29//! # Styling
30//!
31//! The bordered, tinted chrome is a Tier-3 [`DropZoneStyle`]; the default
32//! [`RecipeDropZoneStyle`](crate::styles::RecipeDropZoneStyle) tracks the
33//! interaction state. Override per-call with [`DropZone::style`] or theme-wide
34//! via `theme.style_slots.drop_zone`.
35//!
36//! # Accessibility
37//!
38//! The zone is a `Role::Group` labelled by its prompt, with a `Live::Polite`
39//! status line that announces hover ("Drop to add 3 files"), success
40//! ("3 files added"), and rejection. AccessKit models no drag/drop action and
41//! ARIA's `aria-grabbed` / `aria-dropeffect` are deprecated, so live-region
42//! announcements plus the Browse fallback are the supported pattern.
43
44use 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
73/// A drop target for external (OS) drag-and-drop. See the module docs.
74pub 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    /// Build a drop zone with the given prompt (e.g. `tr!("drop_files_here")`).
92    /// The label may come from `tr!(...)` (translated) or
93    /// `lit!(...)`; it is resolved eagerly at construction
94    /// and stored as a `String`. Locale changes rebuild the composite parent,
95    /// which re-creates the `DropZone` with a fresh translation — the same
96    /// model as [`Button::new`](crate::button::Button::new).
97    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    /// Secondary line under the prompt (e.g. `tr!("png_or_jpeg")`).
116    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
117        self.subtitle = Some(text.into());
118        self
119    }
120
121    /// Restrict accepted files to these extensions (without leading dots,
122    /// case-insensitive). Empty (the default) accepts any file. Text and URL
123    /// drops are unaffected.
124    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    /// Whether more than one file may be dropped at once. Default `true`.
137    /// When `false`, a multi-file drop is rejected.
138    pub fn allow_multiple(mut self, allow: bool) -> Self {
139        self.allow_multiple = allow;
140        self
141    }
142
143    /// Show or hide the keyboard-operable Browse button. Default `true`.
144    /// Keeping it visible is strongly recommended — it is the only
145    /// keyboard-accessible path to the zone's action.
146    pub fn show_browse_button(mut self, show: bool) -> Self {
147        self.show_browse_button = show;
148        self
149    }
150
151    /// Override the Browse button's label (e.g. `tr!("browse")`).
152    /// Directory the Browse button's dialog opens in. If unset, the OS default is
153    /// used.
154    ///
155    /// The same builder [`FilePickerField::starting_dir`](crate::file_picker_field::FilePickerField::starting_dir)
156    /// offers, and for the same reason: an app that remembers where its writer last
157    /// picked files has no way to say so otherwise, because this widget builds its own
158    /// `FileDialogRequest` internally rather than taking one.
159    #[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    /// An icon widget shown above the prompt (any widget — typically an
171    /// [`IconWidget`](crate::primitives::IconWidget)).
172    pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
173        self.icon = Some(Box::new(icon));
174        self
175    }
176
177    /// Override the Tier-3 [`DropZoneStyle`] for this instance only.
178    pub fn style(mut self, style: impl DropZoneStyle) -> Self {
179        self.style_override = Some(Rc::new(style));
180        self
181    }
182
183    /// Called with the dropped (or browsed) file paths. Files are only
184    /// accepted when this is set.
185    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    /// Called with dropped plain text. Text drops are only accepted when set.
194    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    /// Called with dropped non-file URLs. URL drops are only accepted when set.
200    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
219/// Decide whether `payload` is acceptable given the zone's policy. Free
220/// function so the drag closures don't need to borrow `self`.
221fn 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    // No concrete data yet — on Wayland the bytes only arrive at drop, so the
254    // hover decision is made from the advertised formats. Optimistic: accept if
255    // the zone handles a kind the source offers; the real extension check runs
256    // at drop once `files()` is populated.
257    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
277/// Localized live-region announcement for a drag hovering over the zone.
278/// Singular vs plural is chosen here (in Rust) rather than via a Fluent
279/// select expression so the `tr_widget!` compile-time English fallback
280/// works for apps that don't register the framework bundle. Drop counts
281/// are always >= 1, so the `== 1` / `> 1` split is correct for both
282/// English and French.
283fn 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    // Wayland hover before the bytes arrive (formats-only) — generic prompt.
302    tr_widget!(drop_zone_hover_generic()).resolve_now()
303}
304
305/// Localized live-region announcement for a completed drop.
306fn 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
324/// Localized "N file(s) added" — shared by drop success and Browse success.
325fn 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        // Snapshots for the closures.
339        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        // --- Content column: [icon?] prompt [subtitle?] [status] [Browse?] ---
350        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        // Live-region status line: empty at rest, narrates hover / drop.
364        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                    // Multi vs single picker per policy. Errors (no dialog
406                    // installed) are ignored — the zone stays usable.
407                    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        // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
420        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        // --- Drag behaviour on the composite node (the drop target) ---
434        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                // Visuals are state-driven; engage with `Accept` (no framework
459                // feedback) when accepting so the drop lands here, else
460                // `NoFeedback` so the drag bubbles past to the next drop target.
461                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        // The composite node is the drop target and the labelled group; the
529        // Live status line lives inside the content column.
530        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    /// The builder must survive to the widget: a starting directory that is accepted
553    /// and then dropped would look identical to one that works, right up until a writer
554    /// noticed the dialog still opening in their home folder.
555    #[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    // --- Hover-time acceptance from advertised formats (Wayland) -------
677    // On Wayland the dropped bytes only arrive at drop, so hover accept/reject
678    // is decided from the advertised MIME formats alone.
679
680    #[test]
681    fn formats_only_hover_accepts_matching_kind() {
682        // A file drag advertises text/uri-list (+ text/plain for the path).
683        let file_drag = DragPayload::external(ExternalDropData {
684            formats: vec!["text/uri-list".into(), "text/plain".into()],
685            ..Default::default()
686        });
687        // Image-style zone: files handler, png filter — accept on hover even
688        // though the extension can't be checked until drop.
689        assert!(payload_accepted(
690            &file_drag,
691            &["png".into()],
692            true,
693            true,
694            false,
695            false
696        ));
697
698        // A pure text drag (no uri-list) onto a files-only zone → reject.
699        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        // …but a text-handling zone accepts it.
705        assert!(payload_accepted(&text_drag, &[], true, false, true, false));
706    }
707
708    #[test]
709    fn formats_only_internal_drag_is_not_accepted() {
710        // A non-external payload with no concrete data must not be accepted via
711        // the formats path.
712        let internal = DragPayload::typed(7_u32);
713        assert!(!payload_accepted(&internal, &[], true, true, true, true));
714    }
715}