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    /// Directory the Browse button's dialog opens in. If unset, the OS default is
171    /// used.
172    ///
173    /// The same builder [`FilePickerField::starting_dir`](crate::file_picker_field::FilePickerField::starting_dir)
174    /// offers, and for the same reason: an app that remembers where its writer last
175    /// picked files has no way to say so otherwise, because this widget builds its own
176    /// `FileDialogRequest` internally rather than taking one.
177    #[must_use]
178    pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
179        self.starting_dir = Some(path.into());
180        self
181    }
182
183    pub fn browse_label(mut self, label: impl Into<LocalizedString>) -> Self {
184        self.browse_label = label.into();
185        self
186    }
187
188    /// An icon widget shown above the prompt (any widget — typically an
189    /// [`IconWidget`](crate::primitives::IconWidget)).
190    pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
191        self.icon = Some(Box::new(icon));
192        self
193    }
194
195    /// Override the Tier-3 [`DropZoneStyle`] for this instance only.
196    pub fn style(mut self, style: impl DropZoneStyle) -> Self {
197        self.style_override = Some(Rc::new(style));
198        self
199    }
200
201    /// Called with the dropped (or browsed) file paths. Files are only
202    /// accepted when this is set.
203    pub fn on_files_dropped(
204        mut self,
205        f: impl FnMut(Vec<PathBuf>, &mut EventContext) + 'static,
206    ) -> Self {
207        self.on_files = Some(Box::new(f));
208        self
209    }
210
211    /// Called with dropped plain text. Text drops are only accepted when set.
212    pub fn on_text_dropped(mut self, f: impl FnMut(String, &mut EventContext) + 'static) -> Self {
213        self.on_text = Some(Box::new(f));
214        self
215    }
216
217    /// Called with dropped non-file URLs. URL drops are only accepted when set.
218    pub fn on_urls_dropped(
219        mut self,
220        f: impl FnMut(Vec<String>, &mut EventContext) + 'static,
221    ) -> Self {
222        self.on_urls = Some(Box::new(f));
223        self
224    }
225}
226
227impl std::fmt::Debug for DropZone {
228    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229        f.debug_struct("DropZone")
230            .field("label", &self.label)
231            .field("extensions", &self.extensions)
232            .field("allow_multiple", &self.allow_multiple)
233            .finish_non_exhaustive()
234    }
235}
236
237/// Decide whether `payload` is acceptable given the zone's policy. Free
238/// function so the drag closures don't need to borrow `self`.
239fn payload_accepted(
240    payload: &DragPayload,
241    extensions: &[String],
242    allow_multiple: bool,
243    has_files_cb: bool,
244    has_text_cb: bool,
245    has_urls_cb: bool,
246) -> bool {
247    let files = payload.files();
248    if !files.is_empty() {
249        if !has_files_cb {
250            return false;
251        }
252        if !allow_multiple && files.len() > 1 {
253            return false;
254        }
255        if extensions.is_empty() {
256            return true;
257        }
258        return files.iter().all(|p| {
259            p.extension()
260                .and_then(|e| e.to_str())
261                .map(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e)))
262                .unwrap_or(false)
263        });
264    }
265    if payload.text().is_some() {
266        return has_text_cb;
267    }
268    if !payload.uris().is_empty() {
269        return has_urls_cb;
270    }
271    // No concrete data yet — on Wayland the bytes only arrive at drop, so the
272    // hover decision is made from the advertised formats. Optimistic: accept if
273    // the zone handles a kind the source offers; the real extension check runs
274    // at drop once `files()` is populated.
275    if payload.is_external() {
276        let formats = payload.formats();
277        let offers = |needles: &[&str]| {
278            formats
279                .iter()
280                .any(|f| needles.iter().any(|n| f == n || f.starts_with(n)))
281        };
282        if has_files_cb && offers(&["text/uri-list"]) {
283            return true;
284        }
285        if has_text_cb && offers(&["text/plain", "UTF8_STRING", "STRING", "TEXT"]) {
286            return true;
287        }
288        if has_urls_cb && offers(&["text/x-moz-url", "text/uri-list", "_NETSCAPE_URL"]) {
289            return true;
290        }
291    }
292    false
293}
294
295/// Localized live-region announcement for a drag hovering over the zone.
296/// Singular vs plural is chosen here (in Rust) rather than via a Fluent
297/// select expression so the `tr_widget!` compile-time English fallback
298/// works for apps that don't register the framework bundle. Drop counts
299/// are always >= 1, so the `== 1` / `> 1` split is correct for both
300/// English and French.
301fn hover_announcement(payload: &DragPayload) -> String {
302    let files = payload.files().len();
303    if files == 1 {
304        return tr_widget!(drop_zone_hover_file_one()).resolve_now();
305    }
306    if files > 1 {
307        return tr_widget!(drop_zone_hover_file_many(count = files as i64)).resolve_now();
308    }
309    if payload.text().is_some() {
310        return tr_widget!(drop_zone_hover_text()).resolve_now();
311    }
312    let links = payload.uris().len();
313    if links == 1 {
314        return tr_widget!(drop_zone_hover_link_one()).resolve_now();
315    }
316    if links > 1 {
317        return tr_widget!(drop_zone_hover_link_many(count = links as i64)).resolve_now();
318    }
319    // Wayland hover before the bytes arrive (formats-only) — generic prompt.
320    tr_widget!(drop_zone_hover_generic()).resolve_now()
321}
322
323/// Localized live-region announcement for a completed drop.
324fn added_announcement(payload: &DragPayload) -> String {
325    let files = payload.files().len();
326    if files >= 1 {
327        return added_files_announcement(files);
328    }
329    if payload.text().is_some() {
330        return tr_widget!(drop_zone_added_text()).resolve_now();
331    }
332    let links = payload.uris().len();
333    if links == 1 {
334        return tr_widget!(drop_zone_added_link_one()).resolve_now();
335    }
336    if links > 1 {
337        return tr_widget!(drop_zone_added_link_many(count = links as i64)).resolve_now();
338    }
339    added_files_announcement(files)
340}
341
342/// Localized "N file(s) added" — shared by drop success and Browse success.
343fn added_files_announcement(count: usize) -> String {
344    if count == 1 {
345        tr_widget!(drop_zone_added_file_one()).resolve_now()
346    } else {
347        tr_widget!(drop_zone_added_file_many(count = count as i64)).resolve_now()
348    }
349}
350
351impl Widget for DropZone {
352    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
353        let state = ctx.signal(DropZoneVisualState::Idle);
354        let announce = ctx.signal(String::new());
355
356        // Snapshots for the closures.
357        let extensions = self.extensions.clone();
358        let allow_multiple = self.allow_multiple;
359        let has_files_cb = self.on_files.is_some();
360        let has_text_cb = self.on_text.is_some();
361        let has_urls_cb = self.on_urls.is_some();
362
363        let on_files = self.on_files.take().map(|f| Rc::new(RefCell::new(f)));
364        let on_text = self.on_text.take().map(|f| Rc::new(RefCell::new(f)));
365        let on_urls = self.on_urls.take().map(|f| Rc::new(RefCell::new(f)));
366
367        // --- Content column: [icon?] prompt [subtitle?] [status] [Browse?] ---
368        let mut content = VStack::new().spacing(8.0).alignment(HAlignment::Center);
369
370        if let Some(icon) = self.icon.take() {
371            let icon_id = ctx.add_boxed(icon);
372            content = content.child(icon_id);
373        }
374
375        // Kept by id: the zone names itself by pointing at the prompt it
376        // already paints, so the prompt stays a label a reader can review
377        // rather than a string announced only as the group's name.
378        let label_id = ctx.add(TextWidget::new(self.label.clone()));
379        self.label_node = Some(label_id);
380        content = content.child(label_id);
381
382        if let Some(subtitle) = &self.subtitle {
383            content = content.child(TextWidget::new(subtitle.clone()).color(TextRole::Secondary));
384        }
385
386        // Live-region status line: empty at rest, narrates hover / drop.
387        content = content.child(
388            TextWidget::new(lit!(String::new()))
389                .text(announce.clone())
390                .color(TextRole::Secondary)
391                .access_live(Live::Polite),
392        );
393
394        if self.show_browse_button {
395            let browse_extensions = self.extensions.clone();
396            let allow_multiple_browse = self.allow_multiple;
397            let on_files_browse = on_files.clone();
398            let announce_browse = announce.clone();
399            let browse_starting_dir = self.starting_dir.clone();
400            let browse = Button::new(self.browse_label.clone()).on_activate_fn(
401                move |ctx: &mut EventContext| {
402                    let mut request = FileDialogRequest::pick_file();
403                    if let Some(dir) = &browse_starting_dir {
404                        request = request.starting_dir(dir.clone());
405                    }
406                    if !browse_extensions.is_empty() {
407                        let exts: Vec<&str> =
408                            browse_extensions.iter().map(String::as_str).collect();
409                        request = request.add_filter("Allowed", &exts);
410                    }
411                    let on_files_cb = on_files_browse.clone();
412                    let announce_cb = announce_browse.clone();
413                    let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
414                        let paths = match result {
415                            FileDialogResult::File(Some(p)) => vec![p],
416                            FileDialogResult::Files(v) => v,
417                            _ => Vec::new(),
418                        };
419                        if paths.is_empty() {
420                            return;
421                        }
422                        let count = paths.len();
423                        if let Some(cb) = &on_files_cb {
424                            (cb.borrow_mut())(paths, ctx);
425                        }
426                        announce_cb.set(added_files_announcement(count));
427                    };
428                    // Multi vs single picker per policy. Errors (no dialog
429                    // installed) are ignored — the zone stays usable.
430                    let _ = if allow_multiple_browse {
431                        ctx.pick_files(request, result_cb)
432                    } else {
433                        ctx.pick_file(request, result_cb)
434                    };
435                },
436            );
437            content = content.child(browse);
438        }
439
440        let content_id = ctx.add(content);
441
442        // --- Tier-3 chrome: resolve style (per-call > theme slot > default) ---
443        let style = self
444            .style_override
445            .clone()
446            .or_else(|| ctx.theme().style_slots.drop_zone.clone())
447            .unwrap_or_else(|| {
448                Rc::new(crate::styles::RecipeDropZoneStyle::for_tokens(
449                    &ctx.theme().input,
450                ))
451            });
452        let body = style.make_body(
453            &DropZoneStyleConfig {
454                state: state.clone(),
455                content: content_id,
456            },
457            ctx,
458        );
459
460        // --- Drag behaviour on the composite node (the drop target) ---
461        let hover_state = state.clone();
462        let hover_announce = announce.clone();
463        let hover_exts = extensions.clone();
464        let leave_state = state.clone();
465        let leave_announce = announce.clone();
466        let drop_exts = extensions;
467
468        let handlers = HandlerSet::new()
469            .on_drag_hover(move |payload, _pos, _ctx| {
470                let ok = payload_accepted(
471                    payload,
472                    &hover_exts,
473                    allow_multiple,
474                    has_files_cb,
475                    has_text_cb,
476                    has_urls_cb,
477                );
478                if ok {
479                    hover_state.set(DropZoneVisualState::HoverAccept);
480                    hover_announce.set(hover_announcement(payload));
481                } else {
482                    hover_state.set(DropZoneVisualState::HoverReject);
483                    hover_announce.set(tr_widget!(drop_zone_hover_reject()).resolve_now());
484                }
485                // Visuals are state-driven; engage with `Accept` (no framework
486                // feedback) when accepting so the drop lands here, else
487                // `NoFeedback` so the drag bubbles past to the next drop target.
488                if ok {
489                    DropFeedback::Accept
490                } else {
491                    DropFeedback::NoFeedback
492                }
493            })
494            .on_drag_leave(move |_ctx| {
495                leave_state.set(DropZoneVisualState::Idle);
496                leave_announce.set(String::new());
497            })
498            .on_drop(move |payload, _pos, ctx| {
499                let ok = payload_accepted(
500                    &payload,
501                    &drop_exts,
502                    allow_multiple,
503                    has_files_cb,
504                    has_text_cb,
505                    has_urls_cb,
506                );
507                state.set(DropZoneVisualState::Idle);
508                if !ok {
509                    announce.set(tr_widget!(drop_zone_rejected()).resolve_now());
510                    return false;
511                }
512                if !payload.files().is_empty() {
513                    if let Some(cb) = &on_files {
514                        (cb.borrow_mut())(payload.files().to_vec(), ctx);
515                    }
516                } else if let Some(text) = payload.text() {
517                    if let Some(cb) = &on_text {
518                        (cb.borrow_mut())(text.to_string(), ctx);
519                    }
520                } else if !payload.uris().is_empty() {
521                    if let Some(cb) = &on_urls {
522                        (cb.borrow_mut())(payload.uris().to_vec(), ctx);
523                    }
524                }
525                announce.set(added_announcement(&payload));
526                true
527            });
528        ctx.apply_self_handlers(handlers);
529
530        self.root_child_id = Some(body);
531        if let Some(label_id) = self.label_node {
532            let self_id = ctx.self_id();
533            ctx.access_labelled_by(self_id, label_id);
534        }
535        self.children()
536    }
537
538    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
539        self.root_child_id
540            .and_then(|id| ctx.child_size(id, proposal))
541            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
542            .into()
543    }
544
545    fn place_children(
546        &self,
547        bounds: Rect,
548        _proposal: SizeProposal,
549        children: &mut [WidgetPlacement],
550        _ctx: &LayoutContext,
551    ) {
552        for child in children.iter_mut() {
553            child.origin = bounds.origin();
554            child.size = bounds.size();
555        }
556    }
557
558    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
559        // The composite node is the drop target and the labelled group; the
560        // Live status line lives inside the content column.
561        builder.set_role(Role::Group);
562        // Named through the relation wired in `build`. Setting a name here
563        // as well would win over it in the consumer and announce a copy
564        // that no longer tracks the prompt.
565        if self.label_node.is_none() {
566            builder.set_name(self.label.clone());
567        }
568    }
569
570    fn children(&self) -> Vec<WidgetId> {
571        self.root_child_id.into_iter().collect()
572    }
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578    use std::cell::RefCell;
579    use std::rc::Rc;
580    use teksilo_canvas::Point;
581    use teksilo_core::ExternalDropData;
582    use teksilo_core::widget_tree::WidgetTree;
583
584    fn tree() -> WidgetTree {
585        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
586    }
587
588    /// The builder must survive to the widget: a starting directory that is accepted
589    /// and then dropped would look identical to one that works, right up until a writer
590    /// noticed the dialog still opening in their home folder.
591    #[test]
592    fn a_starting_directory_reaches_the_built_zone() {
593        let zone = DropZone::new(lit!("Drop files here")).starting_dir("/tmp/somewhere");
594        assert_eq!(
595            zone.starting_dir.as_deref(),
596            Some(std::path::Path::new("/tmp/somewhere"))
597        );
598
599        let mut tree = tree();
600        let id = tree.add(zone);
601        tree.layout(SizeProposal::exact(400.0, 300.0));
602        let b = tree.bounds(id);
603        assert!(
604            b.width > 0.0 && b.height > 0.0,
605            "a zone carrying a starting directory still builds"
606        );
607    }
608
609    #[test]
610    fn builds_with_nonzero_size() {
611        let mut tree = tree();
612        let id = tree.add(DropZone::new(lit!("Drop files here")));
613        tree.layout(SizeProposal::exact(400.0, 300.0));
614        let b = tree.bounds(id);
615        assert!(b.width > 0.0 && b.height > 0.0);
616    }
617
618    #[test]
619    fn matching_file_drop_fires_callback() {
620        let mut tree = tree();
621        let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
622        let g = got.clone();
623        tree.add(
624            DropZone::new(lit!("Images"))
625                .accept_extensions(["png", "jpg"])
626                .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
627        );
628        tree.layout(SizeProposal::exact(400.0, 300.0));
629
630        let mut noop = teksilo_core::NoopWindowOps;
631        let data = ExternalDropData {
632            files: vec![PathBuf::from("/tmp/photo.png")],
633            ..Default::default()
634        };
635        let p = Point::new(200.0, 150.0);
636        tree.begin_external_drag(p, data.clone(), &mut noop);
637        tree.end_external_drag(p, data, &mut noop);
638
639        assert_eq!(*got.borrow(), vec![PathBuf::from("/tmp/photo.png")]);
640    }
641
642    #[test]
643    fn wrong_extension_is_rejected() {
644        let mut tree = tree();
645        let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
646        let g = got.clone();
647        tree.add(
648            DropZone::new(lit!("Images"))
649                .accept_extensions(["png"])
650                .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
651        );
652        tree.layout(SizeProposal::exact(400.0, 300.0));
653
654        let mut noop = teksilo_core::NoopWindowOps;
655        let data = ExternalDropData {
656            files: vec![PathBuf::from("/tmp/notes.txt")],
657            ..Default::default()
658        };
659        let p = Point::new(200.0, 150.0);
660        tree.begin_external_drag(p, data.clone(), &mut noop);
661        tree.end_external_drag(p, data, &mut noop);
662
663        assert!(got.borrow().is_empty(), "non-png drop must be rejected");
664    }
665
666    #[test]
667    fn multi_file_rejected_when_single_only() {
668        let mut tree = tree();
669        let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
670        let g = got.clone();
671        tree.add(
672            DropZone::new(lit!("One file"))
673                .allow_multiple(false)
674                .on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
675        );
676        tree.layout(SizeProposal::exact(400.0, 300.0));
677
678        let mut noop = teksilo_core::NoopWindowOps;
679        let data = ExternalDropData {
680            files: vec![PathBuf::from("/a"), PathBuf::from("/b")],
681            ..Default::default()
682        };
683        let p = Point::new(200.0, 150.0);
684        tree.begin_external_drag(p, data.clone(), &mut noop);
685        tree.end_external_drag(p, data, &mut noop);
686
687        assert!(got.borrow().is_empty(), "multi-file drop must be rejected");
688    }
689
690    #[test]
691    fn text_drop_fires_when_handler_set() {
692        let mut tree = tree();
693        let got: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
694        let g = got.clone();
695        tree.add(
696            DropZone::new(lit!("Notes")).on_text_dropped(move |t, _ctx| *g.borrow_mut() = Some(t)),
697        );
698        tree.layout(SizeProposal::exact(400.0, 300.0));
699
700        let mut noop = teksilo_core::NoopWindowOps;
701        let data = ExternalDropData {
702            text: Some("hello".to_string()),
703            ..Default::default()
704        };
705        let p = Point::new(200.0, 150.0);
706        tree.begin_external_drag(p, data.clone(), &mut noop);
707        tree.end_external_drag(p, data, &mut noop);
708
709        assert_eq!(got.borrow().as_deref(), Some("hello"));
710    }
711
712    // --- Hover-time acceptance from advertised formats (Wayland) -------
713    // On Wayland the dropped bytes only arrive at drop, so hover accept/reject
714    // is decided from the advertised MIME formats alone.
715
716    #[test]
717    fn formats_only_hover_accepts_matching_kind() {
718        // A file drag advertises text/uri-list (+ text/plain for the path).
719        let file_drag = DragPayload::external(ExternalDropData {
720            formats: vec!["text/uri-list".into(), "text/plain".into()],
721            ..Default::default()
722        });
723        // Image-style zone: files handler, png filter — accept on hover even
724        // though the extension can't be checked until drop.
725        assert!(payload_accepted(
726            &file_drag,
727            &["png".into()],
728            true,
729            true,
730            false,
731            false
732        ));
733
734        // A pure text drag (no uri-list) onto a files-only zone → reject.
735        let text_drag = DragPayload::external(ExternalDropData {
736            formats: vec!["text/plain".into()],
737            ..Default::default()
738        });
739        assert!(!payload_accepted(&text_drag, &[], true, true, false, false));
740        // …but a text-handling zone accepts it.
741        assert!(payload_accepted(&text_drag, &[], true, false, true, false));
742    }
743
744    #[test]
745    fn formats_only_internal_drag_is_not_accepted() {
746        // A non-external payload with no concrete data must not be accepted via
747        // the formats path.
748        let internal = DragPayload::typed(7_u32);
749        assert!(!payload_accepted(&internal, &[], true, true, true, true));
750    }
751}