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//!
44//! ## Touch and pen
45//!
46//! The zone is one target and the whole surface of it, so nothing here needs a
47//! floor or an outset, and an external drop carries no press to move to a release.
48//! The keyboard Browse fallback is what makes the action reachable at all where
49//! there is no OS drag-and-drop backend; it is not a touch affordance and is
50//! documented at its own builder.
51
52use 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
81/// A drop target for external (OS) drag-and-drop. See the module docs.
82pub 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    /// The label that paints the prompt, pointed at by the group's own
97    /// `labelled_by` relation so the prompt is not announced twice.
98    label_node: Option<WidgetId>,
99}
100
101impl DropZone {
102    /// Build a drop zone with the given prompt (e.g. `tr!("drop_files_here")`).
103    /// The label may come from `tr!(...)` (translated) or
104    /// `lit!(...)`; it is stored as a `LocalizedString` and handed to the
105    /// prompt's `TextWidget`, so a `tr!(...)` label re-resolves on a locale
106    /// switch without rebuilding the zone — the same model as
107    /// [`Button::new`](crate::button::Button::new).
108    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    /// Secondary line under the prompt (e.g. `tr!("png_or_jpeg")`).
128    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
129        self.subtitle = Some(text.into());
130        self
131    }
132
133    /// Restrict accepted files to these extensions (without leading dots,
134    /// case-insensitive). Empty (the default) accepts any file. Text and URL
135    /// drops are unaffected.
136    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    /// Whether more than one file may be dropped at once. Default `true`.
149    /// When `false`, a multi-file drop is rejected.
150    pub fn allow_multiple(mut self, allow: bool) -> Self {
151        self.allow_multiple = allow;
152        self
153    }
154
155    /// Show or hide the keyboard-operable Browse button. Default `true`.
156    ///
157    /// It is the zone's **only** route that is not a drag. Turning it off leaves
158    /// the drop as the sole way in, which fails WCAG 2.2 SC 2.5.7 (Dragging
159    /// Movements) as well as SC 2.1.1 — so an application that hides it owes the
160    /// same action another affordance of its own, reachable by keyboard and by a
161    /// single pointer. See
162    /// [the non-drag alternatives page](https://github.com/ferntech-eu/teksilo/blob/main/docs/a11y/non-drag-alternatives.md).
163    pub fn show_browse_button(mut self, show: bool) -> Self {
164        self.show_browse_button = show;
165        self
166    }
167
168    /// Directory the Browse button's dialog opens in. If unset, the OS default is
169    /// used.
170    ///
171    /// The same builder [`FilePickerField::starting_dir`](crate::file_picker_field::FilePickerField::starting_dir)
172    /// offers, and for the same reason: an app that remembers where its writer last
173    /// picked files has no way to say so otherwise, because this widget builds its own
174    /// `FileDialogRequest` internally rather than taking one.
175    #[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    /// An icon widget shown above the prompt (any widget — typically an
187    /// [`IconWidget`](crate::primitives::IconWidget)).
188    pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
189        self.icon = Some(Box::new(icon));
190        self
191    }
192
193    /// Override the Tier-3 [`DropZoneStyle`] for this instance only.
194    pub fn style(mut self, style: impl DropZoneStyle) -> Self {
195        self.style_override = Some(Rc::new(style));
196        self
197    }
198
199    /// Called with the dropped (or browsed) file paths. Files are only
200    /// accepted when this is set.
201    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    /// Called with dropped plain text. Text drops are only accepted when set.
210    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    /// Called with dropped non-file URLs. URL drops are only accepted when set.
216    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
235/// Decide whether `payload` is acceptable given the zone's policy. Free
236/// function so the drag closures don't need to borrow `self`.
237fn 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    // No concrete data yet — on Wayland the bytes only arrive at drop, so the
270    // hover decision is made from the advertised formats. Optimistic: accept if
271    // the zone handles a kind the source offers; the real extension check runs
272    // at drop once `files()` is populated.
273    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
293/// Localized live-region announcement for a drag hovering over the zone.
294/// Singular vs plural is chosen here (in Rust) rather than via a Fluent
295/// select expression so the `tr_widget!` compile-time English fallback
296/// works for apps that don't register the framework bundle. Drop counts
297/// are always >= 1, so the `== 1` / `> 1` split is correct for both
298/// English and French.
299fn 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    // Wayland hover before the bytes arrive (formats-only) — generic prompt.
318    tr_widget!(drop_zone_hover_generic()).resolve_now()
319}
320
321/// Localized live-region announcement for a completed drop.
322fn 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
340/// Localized "N file(s) added" — shared by drop success and Browse success.
341fn 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        // Snapshots for the closures.
355        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        // --- Content column: [icon?] prompt [subtitle?] [status] [Browse?] ---
366        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        // Kept by id: the zone names itself by pointing at the prompt it
374        // already paints, so the prompt stays a label a reader can review
375        // rather than a string announced only as the group's name.
376        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        // Live-region status line: empty at rest, narrates hover / drop.
385        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                    // Multi vs single picker per policy. Errors (no dialog
427                    // installed) are ignored — the zone stays usable.
428                    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        // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
441        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        // --- Drag behaviour on the composite node (the drop target) ---
459        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                // Visuals are state-driven; engage with `Accept` (no framework
484                // feedback) when accepting so the drop lands here, else
485                // `NoFeedback` so the drag bubbles past to the next drop target.
486                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        // The composite node is the drop target and the labelled group; the
558        // Live status line lives inside the content column.
559        builder.set_role(Role::Group);
560        // Named through the relation wired in `build`. Setting a name here
561        // as well would win over it in the consumer and announce a copy
562        // that no longer tracks the prompt.
563        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    /// The builder must survive to the widget: a starting directory that is accepted
587    /// and then dropped would look identical to one that works, right up until a writer
588    /// noticed the dialog still opening in their home folder.
589    #[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    // --- Hover-time acceptance from advertised formats (Wayland) -------
711    // On Wayland the dropped bytes only arrive at drop, so hover accept/reject
712    // is decided from the advertised MIME formats alone.
713
714    #[test]
715    fn formats_only_hover_accepts_matching_kind() {
716        // A file drag advertises text/uri-list (+ text/plain for the path).
717        let file_drag = DragPayload::external(ExternalDropData {
718            formats: vec!["text/uri-list".into(), "text/plain".into()],
719            ..Default::default()
720        });
721        // Image-style zone: files handler, png filter — accept on hover even
722        // though the extension can't be checked until drop.
723        assert!(payload_accepted(
724            &file_drag,
725            &["png".into()],
726            true,
727            true,
728            false,
729            false
730        ));
731
732        // A pure text drag (no uri-list) onto a files-only zone → reject.
733        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        // …but a text-handling zone accepts it.
739        assert!(payload_accepted(&text_drag, &[], true, false, true, false));
740    }
741
742    #[test]
743    fn formats_only_internal_drag_is_not_accepted() {
744        // A non-external payload with no concrete data must not be accepted via
745        // the formats path.
746        let internal = DragPayload::typed(7_u32);
747        assert!(!payload_accepted(&internal, &[], true, true, true, true));
748    }
749}