Skip to main content

teksilo_platform/
file_dialog.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Native file-dialog service.
5//!
6//! This module provides an async, parent-aware, testable file-dialog
7//! API. Three concerns are separated:
8//!
9//! - **Trait surface** — [`FileDialogBackend`] is the swappable
10//!   abstraction (rfd, mock, custom). Mirrors the `ClipboardBackend`
11//!   pattern.
12//! - **Handle** — [`FileDialogHandle`] is the per-app service
13//!   registered in app-state. Holds an `Rc<RefCell<dyn FileDialogBackend>>`
14//!   plus a pending-callbacks map keyed by [`RequestId`]. Cloneable.
15//! - **Result delivery** — backend posts a
16//!   [`FileDialogEventPayload`] via [`teksilo_core::AppEventPoster::post_external`];
17//!   `teksilo-app` picks the payload up in its `AppEvent::External` arm,
18//!   routes it to the originating window's `WidgetTree`, and invokes
19//!   [`FileDialogHandle::deliver`] which pops the callback and calls
20//!   it with a fully built `EventContext`.
21//!
22//! # Threading
23//!
24//! The OS dialog runs on its native UI thread (e.g. macOS dispatches
25//! to the AppKit main run loop internally; Linux uses an XDG portal
26//! D-Bus call; Windows uses COM). The `rfd::AsyncFileDialog` future
27//! is `Send` across all rfd-supported platforms, so it is polled by
28//! an `async-std` worker thread spawned in [`RfdAsyncBackend`]. The
29//! result is sent back to the UI thread as an
30//! [`AppEvent::External`](teksilo_core::AppEvent::External) and the
31//! callback runs on the main thread inside an `EventContext` —
32//! handlers can `ctx.send_intent(...)`, mutate signals, open windows,
33//! exactly as they would from a normal pointer event.
34//!
35//! # Window safety
36//!
37//! Each pending callback is tagged with the originating window's
38//! `TeksiloWindowId`. When that window closes, [`FileDialogHandle::purge_window`]
39//! (called by `teksilo-app`'s window-close hook) drops the callback box
40//! before the widget tree is torn down. A worker-thread future that
41//! resolves after window close still arrives at the dispatcher, but
42//! `deliver` finds no pending entry and silently drops the result —
43//! no panic, no use-after-free.
44
45use std::any::Any;
46use std::cell::{Cell, RefCell};
47use std::collections::{HashMap, VecDeque};
48use std::path::PathBuf;
49use std::rc::Rc;
50use std::sync::Arc;
51
52use teksilo_core::raw_handle::ParentHandle;
53use teksilo_core::widget::EventContext;
54use teksilo_core::window::TeksiloWindowId;
55
56// ============================================================
57// RequestId
58// ============================================================
59
60/// Unique id for one in-flight file-dialog request, allocated by
61/// [`FileDialogHandle`] at submit time.
62#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
63pub struct RequestId(u64);
64
65// ============================================================
66// FileDialogResult
67// ============================================================
68
69/// Outcome of a file-dialog request, delivered to the result callback.
70#[derive(Debug, Clone)]
71pub enum FileDialogResult {
72    /// Open-single-file: `Some(path)` if the user picked a file,
73    /// `None` if they cancelled.
74    File(Option<PathBuf>),
75
76    /// Open-multiple-files: empty `Vec` if cancelled or no selection.
77    Files(Vec<PathBuf>),
78
79    /// Pick-folder: `Some(path)` on selection, `None` on cancel.
80    Folder(Option<PathBuf>),
81
82    /// Save-file: `Some(path)` on confirm, `None` on cancel.
83    Saved(Option<PathBuf>),
84
85    /// Backend or OS error. Rare; expected paths return Cancelled
86    /// rather than `Error`.
87    Error(String),
88}
89
90// ============================================================
91// FileDialogRequest
92// ============================================================
93
94/// Kind of dialog to open. Picked by the constructor used:
95/// [`FileDialogRequest::pick_file`], [`FileDialogRequest::pick_files`],
96/// [`FileDialogRequest::pick_folder`], or [`FileDialogRequest::save_file`].
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum DialogKind {
99    PickFile,
100    PickFiles,
101    PickFolder,
102    SaveFile,
103}
104
105/// One file-extension filter row in the dialog's filter dropdown.
106#[derive(Debug, Clone)]
107pub struct FileFilter {
108    /// Human-readable label shown in the dropdown (e.g. `"Images"`).
109    pub label: String,
110    /// Extension list without leading dots (e.g. `["png", "jpg"]`).
111    pub extensions: Vec<String>,
112}
113
114/// Builder describing one file-dialog request.
115///
116/// Construct via [`Self::pick_file`] / [`Self::pick_files`] /
117/// [`Self::pick_folder`] / [`Self::save_file`]; chain options;
118/// hand to [`FileDialogHandle::submit`] (or call one of the
119/// `EventContext::pick_*` convenience methods).
120#[derive(Debug, Clone)]
121pub struct FileDialogRequest {
122    kind: DialogKind,
123    title: Option<String>,
124    starting_dir: Option<PathBuf>,
125    default_file_name: Option<String>,
126    filters: Vec<FileFilter>,
127    parent: Option<ParentHandle>,
128}
129
130impl FileDialogRequest {
131    fn new(kind: DialogKind) -> Self {
132        Self {
133            kind,
134            title: None,
135            starting_dir: None,
136            default_file_name: None,
137            filters: Vec::new(),
138            parent: None,
139        }
140    }
141
142    /// Build an open-single-file dialog request.
143    pub fn pick_file() -> Self {
144        Self::new(DialogKind::PickFile)
145    }
146
147    /// Build an open-multiple-files dialog request.
148    pub fn pick_files() -> Self {
149        Self::new(DialogKind::PickFiles)
150    }
151
152    /// Build a pick-folder dialog request.
153    pub fn pick_folder() -> Self {
154        Self::new(DialogKind::PickFolder)
155    }
156
157    /// Build a save-file dialog request.
158    pub fn save_file() -> Self {
159        Self::new(DialogKind::SaveFile)
160    }
161
162    /// Set the dialog's title (window caption on most platforms).
163    #[must_use]
164    pub fn title(mut self, t: impl Into<String>) -> Self {
165        self.title = Some(t.into());
166        self
167    }
168
169    /// Set the directory the dialog opens in.
170    #[must_use]
171    pub fn starting_dir(mut self, p: impl Into<PathBuf>) -> Self {
172        self.starting_dir = Some(p.into());
173        self
174    }
175
176    /// Set the default file name pre-filled in the save dialog.
177    /// No-op for open / pick-folder kinds (kept on the request so
178    /// callers can prepare a single builder regardless of kind).
179    #[must_use]
180    pub fn default_file_name(mut self, n: impl Into<String>) -> Self {
181        self.default_file_name = Some(n.into());
182        self
183    }
184
185    /// Add an extension filter row (e.g. `"Images"`, `&["png", "jpg"]`).
186    /// Extensions are case-insensitive on platforms that natively
187    /// support filtering; do not include leading dots.
188    #[must_use]
189    pub fn add_filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self {
190        self.filters.push(FileFilter {
191            label: label.into(),
192            extensions: extensions.iter().map(|e| (*e).to_string()).collect(),
193        });
194        self
195    }
196
197    /// Stamp the parent window handle. Called by the
198    /// `EventContext::pick_*` convenience methods — apps that submit
199    /// a request directly via [`FileDialogHandle::submit`] are
200    /// responsible for providing the parent handle themselves.
201    #[must_use]
202    pub fn with_parent(mut self, p: ParentHandle) -> Self {
203        self.parent = Some(p);
204        self
205    }
206
207    /// Validate filter extensions. Called by [`FileDialogHandle::submit`]
208    /// before dispatch. Returns the first problem found:
209    ///
210    /// - empty extensions list on a filter,
211    /// - extension containing a leading dot, slash, or whitespace.
212    pub fn validate(&self) -> Result<(), String> {
213        for f in &self.filters {
214            if f.extensions.is_empty() {
215                return Err(format!("filter {:?} has no extensions", f.label));
216            }
217            for ext in &f.extensions {
218                if ext.is_empty() {
219                    return Err(format!("filter {:?} has an empty extension", f.label));
220                }
221                if ext.starts_with('.') {
222                    return Err(format!(
223                        "filter {:?} extension {ext:?} must not start with a dot",
224                        f.label
225                    ));
226                }
227                if ext
228                    .chars()
229                    .any(|c| c.is_whitespace() || c == '/' || c == '\\')
230                {
231                    return Err(format!(
232                        "filter {:?} extension {ext:?} contains whitespace or path separator",
233                        f.label
234                    ));
235                }
236            }
237        }
238        Ok(())
239    }
240
241    /// Only consumed by feature-gated real backends (e.g. `RfdAsyncBackend`
242    /// under `rfd-backend`); dead in a default build that compiles none.
243    #[allow(dead_code)]
244    fn kind(&self) -> DialogKind {
245        self.kind
246    }
247}
248
249// ============================================================
250// FileDialogEventPayload
251// ============================================================
252
253/// Boxed inside `AppEvent::External` when a backend completes a
254/// dialog. `teksilo-app`'s app-event handler downcasts to this type and
255/// routes to [`FileDialogHandle::deliver`].
256pub struct FileDialogEventPayload {
257    /// Identifies which pending callback to invoke.
258    pub request_id: RequestId,
259    /// The window the request was submitted from. The dispatcher
260    /// uses this to route delivery to the correct widget tree.
261    pub window_id_owner: TeksiloWindowId,
262    /// The OS dialog's outcome.
263    pub result: FileDialogResult,
264}
265
266// ============================================================
267// FileDialogBackend trait
268// ============================================================
269
270/// Swappable file-dialog backend. Mirrors the `ClipboardBackend`
271/// pattern.
272///
273/// The real backend ([`RfdAsyncBackend`] behind the `rfd-backend`
274/// feature) drives an `rfd::AsyncFileDialog` future on a worker
275/// thread; the test backend ([`MemoryFileDialog`]) returns scripted
276/// results synchronously.
277pub trait FileDialogBackend {
278    /// Spawn an async pick/save/folder request. The backend MUST
279    /// eventually deliver the result by calling
280    /// [`AppEventPoster::post_external`](teksilo_core::AppEventPoster::post_external)
281    /// on the supplied poster, with a boxed [`FileDialogEventPayload`]
282    /// whose `request_id` matches the argument and whose
283    /// `window_id_owner` is set to `window_id`.
284    fn dispatch(
285        &mut self,
286        request_id: RequestId,
287        window_id: TeksiloWindowId,
288        request: FileDialogRequest,
289        poster: Arc<dyn teksilo_core::AppEventPoster>,
290    );
291}
292
293// ============================================================
294// FileDialogHandle
295// ============================================================
296
297/// Boxed callback waiting for an in-flight dialog to resolve.
298type ResultCallback = Box<dyn FnOnce(FileDialogResult, &mut EventContext)>;
299
300struct PendingCallback {
301    window_id: TeksiloWindowId,
302    callback: ResultCallback,
303}
304
305struct FileDialogState {
306    backend: RefCell<Box<dyn FileDialogBackend>>,
307    pending: RefCell<HashMap<RequestId, PendingCallback>>,
308    next_id: Cell<u64>,
309}
310
311/// Per-app file-dialog service. Registered in app-state by
312/// [`TeksiloAppBuilder::install_file_dialog`](https://docs.rs/teksilo-app);
313/// reachable from any handler via
314/// `ctx.app_state::<FileDialogHandle>()`. Cloneable; clones share the
315/// same backend and pending-callbacks map.
316#[derive(Clone)]
317pub struct FileDialogHandle {
318    inner: Rc<FileDialogState>,
319}
320
321impl FileDialogHandle {
322    /// Build a handle wrapping the given backend.
323    pub fn new<B: FileDialogBackend + 'static>(backend: B) -> Self {
324        Self {
325            inner: Rc::new(FileDialogState {
326                backend: RefCell::new(Box::new(backend)),
327                pending: RefCell::new(HashMap::new()),
328                next_id: Cell::new(1),
329            }),
330        }
331    }
332
333    /// Submit a request. Validates the request, registers the
334    /// callback, and asks the backend to dispatch.
335    ///
336    /// `on_result` runs on the main thread when the OS dialog
337    /// completes, or is dropped if `window_id`'s window closes
338    /// first ([`Self::purge_window`]).
339    ///
340    /// Returns the [`RequestId`] for diagnostics; the caller does
341    /// not need to track it for the result to be delivered.
342    pub fn submit(
343        &self,
344        window_id: TeksiloWindowId,
345        request: FileDialogRequest,
346        poster: Arc<dyn teksilo_core::AppEventPoster>,
347        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
348    ) -> Result<RequestId, String> {
349        request.validate()?;
350        let id = self.alloc_id();
351        self.inner.pending.borrow_mut().insert(
352            id,
353            PendingCallback {
354                window_id,
355                callback: Box::new(on_result),
356            },
357        );
358        self.inner
359            .backend
360            .borrow_mut()
361            .dispatch(id, window_id, request, poster);
362        Ok(id)
363    }
364
365    /// Deliver a backend-completed payload to its pending callback.
366    /// Called by `teksilo-app` from the `AppEvent::External` arm. If
367    /// the callback was already purged (window closed), the payload
368    /// is silently dropped.
369    pub fn deliver(&self, payload: FileDialogEventPayload, ctx: &mut EventContext) {
370        let entry = self.inner.pending.borrow_mut().remove(&payload.request_id);
371        let Some(pending) = entry else {
372            return;
373        };
374        if pending.window_id != payload.window_id_owner {
375            // Window changed since submit (re-use of an id slot is
376            // impossible because ids are monotonic Cell<u64> bumps,
377            // but this is a defensive guard).
378            return;
379        }
380        (pending.callback)(payload.result, ctx);
381    }
382
383    /// Drop every pending callback whose owning window matches
384    /// `window_id`. Called by `teksilo-app`'s window-close path so
385    /// callbacks capturing widget state cannot fire into a
386    /// torn-down tree.
387    pub fn purge_window(&self, window_id: TeksiloWindowId) {
388        self.inner
389            .pending
390            .borrow_mut()
391            .retain(|_, p| p.window_id != window_id);
392    }
393
394    /// Number of pending callbacks. Test helper.
395    pub fn pending_count(&self) -> usize {
396        self.inner.pending.borrow().len()
397    }
398
399    fn alloc_id(&self) -> RequestId {
400        let n = self.inner.next_id.get();
401        self.inner.next_id.set(n.wrapping_add(1));
402        RequestId(n)
403    }
404}
405
406impl std::fmt::Debug for FileDialogHandle {
407    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408        f.debug_struct("FileDialogHandle")
409            .field("pending", &self.inner.pending.borrow().len())
410            .finish_non_exhaustive()
411    }
412}
413
414// ============================================================
415// EventContext extension trait
416// ============================================================
417
418/// Convenience methods on [`EventContext`] for opening native file
419/// dialogs. Brings the four shapes (open file, open files, pick
420/// folder, save file) into scope as `ctx.pick_file(req, |result| ...)`
421/// without forcing every caller to look up the handle and poster
422/// from app-state by hand.
423///
424/// Apps `use teksilo_platform::file_dialog::EventContextFileDialogExt`
425/// (or `use teksilo::prelude::*` once the umbrella re-exports it).
426///
427/// All four methods perform the same internal sequence:
428///
429///   1. (macOS only) focus the current window so the panel comes to
430///      front for non-bundled binaries.
431///   2. Stamp the parent handle into the request.
432///   3. Look up [`FileDialogHandle`] and the
433///      [`teksilo_core::AppEventPoster`] via app-state.
434///   4. Forward to [`FileDialogHandle::submit`].
435///
436/// Returns `Err` only when the request fails validation
437/// ([`FileDialogRequest::validate`]) or when the framework was not
438/// initialised with a [`FileDialogHandle`] (i.e. the application
439/// did not call `TeksiloAppBuilder::install_file_dialog`).
440///
441/// **Convenience method overrides the request kind.** The method you
442/// call dictates the operation: `ctx.pick_file(req, …)` always opens
443/// a single-file picker even if `req` was built with
444/// [`FileDialogRequest::save_file`]. Each method overwrites
445/// `request.kind` so the [`FileDialogResult`] variant returned to the
446/// callback always matches the method name. To control the kind
447/// explicitly, call [`FileDialogHandle::submit`] directly with a
448/// pre-built request.
449pub trait EventContextFileDialogExt {
450    /// Open a single-file dialog parented to the current window.
451    /// `on_result` runs on the main thread on dialog completion or is
452    /// dropped if the originating window closes first.
453    ///
454    /// The request's kind is forced to `PickFile` regardless of how
455    /// it was constructed — see the trait-level docs.
456    fn pick_file(
457        &mut self,
458        request: FileDialogRequest,
459        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
460    ) -> Result<RequestId, String>;
461
462    /// Open a multi-file selection dialog. The request's kind is
463    /// forced to `PickFiles`. See [`Self::pick_file`].
464    fn pick_files(
465        &mut self,
466        request: FileDialogRequest,
467        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
468    ) -> Result<RequestId, String>;
469
470    /// Open a folder picker. The request's kind is forced to
471    /// `PickFolder`. See [`Self::pick_file`].
472    fn pick_folder(
473        &mut self,
474        request: FileDialogRequest,
475        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
476    ) -> Result<RequestId, String>;
477
478    /// Open a save dialog. The request's kind is forced to
479    /// `SaveFile`. See [`Self::pick_file`].
480    fn save_file(
481        &mut self,
482        request: FileDialogRequest,
483        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
484    ) -> Result<RequestId, String>;
485}
486
487impl EventContextFileDialogExt for EventContext<'_> {
488    fn pick_file(
489        &mut self,
490        mut request: FileDialogRequest,
491        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
492    ) -> Result<RequestId, String> {
493        request.kind = DialogKind::PickFile;
494        submit_via_ctx(self, request, on_result)
495    }
496
497    fn pick_files(
498        &mut self,
499        mut request: FileDialogRequest,
500        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
501    ) -> Result<RequestId, String> {
502        request.kind = DialogKind::PickFiles;
503        submit_via_ctx(self, request, on_result)
504    }
505
506    fn pick_folder(
507        &mut self,
508        mut request: FileDialogRequest,
509        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
510    ) -> Result<RequestId, String> {
511        request.kind = DialogKind::PickFolder;
512        submit_via_ctx(self, request, on_result)
513    }
514
515    fn save_file(
516        &mut self,
517        mut request: FileDialogRequest,
518        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
519    ) -> Result<RequestId, String> {
520        request.kind = DialogKind::SaveFile;
521        submit_via_ctx(self, request, on_result)
522    }
523}
524
525fn submit_via_ctx(
526    ctx: &mut EventContext,
527    mut request: FileDialogRequest,
528    on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
529) -> Result<RequestId, String> {
530    let window_id = ctx.window().map(|w| w.id()).ok_or_else(|| {
531        "EventContext has no window — file dialog needs a parent window".to_string()
532    })?;
533
534    // macOS focus-to-front: a non-bundled binary may launch the
535    // panel behind another app. Focusing the parent first reliably
536    // brings it forward via NSApp.activateIgnoringOtherApps.
537    #[cfg(target_os = "macos")]
538    ctx.focus_window(window_id);
539
540    if request.parent.is_none()
541        && let Some(parent) = ctx.parent_window_handle()
542    {
543        request = request.with_parent(parent);
544    }
545
546    let handle = ctx
547        .app_state::<FileDialogHandle>()
548        .ok_or_else(|| {
549            "FileDialogHandle not installed in app-state — call \
550             TeksiloAppBuilder::install_file_dialog (or app_state(...)) at startup"
551                .to_string()
552        })?
553        .clone();
554    let poster = ctx
555        .poster()
556        .ok_or_else(|| {
557            "AppEventPoster not installed — file dialog needs a way to post \
558             results back to the UI loop"
559                .to_string()
560        })?
561        .clone();
562
563    handle.submit(window_id, request, poster, on_result)
564}
565
566// ============================================================
567// MemoryFileDialog (test backend)
568// ============================================================
569
570/// In-memory deterministic backend for headless tests. Holds a
571/// scripted queue of pre-canned [`FileDialogResult`]s that pop in
572/// submission order. Each `dispatch` call pops one result and
573/// immediately posts it through the supplied poster — handy for
574/// tests that drive the event loop one tick at a time.
575pub struct MemoryFileDialog {
576    scripted: VecDeque<FileDialogResult>,
577}
578
579impl MemoryFileDialog {
580    /// Build a new empty mock backend. Use [`Self::enqueue`] to
581    /// script per-call results.
582    pub fn new() -> Self {
583        Self {
584            scripted: VecDeque::new(),
585        }
586    }
587
588    /// Push a result onto the FIFO queue. Each `dispatch` call pops
589    /// the front of the queue.
590    pub fn enqueue(&mut self, r: FileDialogResult) {
591        self.scripted.push_back(r);
592    }
593}
594
595impl Default for MemoryFileDialog {
596    fn default() -> Self {
597        Self::new()
598    }
599}
600
601impl FileDialogBackend for MemoryFileDialog {
602    fn dispatch(
603        &mut self,
604        request_id: RequestId,
605        window_id: TeksiloWindowId,
606        _request: FileDialogRequest,
607        poster: Arc<dyn teksilo_core::AppEventPoster>,
608    ) {
609        let result = self.scripted.pop_front().unwrap_or_else(|| {
610            FileDialogResult::Error("MemoryFileDialog: no scripted result enqueued".into())
611        });
612        let payload = FileDialogEventPayload {
613            request_id,
614            window_id_owner: window_id,
615            result,
616        };
617        poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
618    }
619}
620
621// ============================================================
622// RfdAsyncBackend (real backend, gated behind rfd-backend feature)
623// ============================================================
624
625#[cfg(feature = "rfd-backend")]
626mod rfd_backend {
627    use super::*;
628
629    /// Native file-dialog backend backed by the `rfd` crate.
630    ///
631    /// Each [`Self::dispatch`] call builds an `rfd::AsyncFileDialog`,
632    /// attaches the parent window handle, then spawns the future on
633    /// `async-std`'s global thread pool. The future's resolution
634    /// posts a [`FileDialogEventPayload`] back through the supplied
635    /// [`teksilo_core::AppEventPoster`].
636    ///
637    /// On macOS, rfd dispatches the actual `NSOpenPanel` /
638    /// `NSSavePanel` to the AppKit main run loop internally — the
639    /// future drives the wakeup machinery, but the panel UI runs on
640    /// the main thread that winit is already pumping.
641    pub struct RfdAsyncBackend;
642
643    impl RfdAsyncBackend {
644        pub fn new() -> Self {
645            Self
646        }
647    }
648
649    impl Default for RfdAsyncBackend {
650        fn default() -> Self {
651            Self::new()
652        }
653    }
654
655    impl FileDialogBackend for RfdAsyncBackend {
656        fn dispatch(
657            &mut self,
658            request_id: RequestId,
659            window_id: TeksiloWindowId,
660            request: FileDialogRequest,
661            poster: Arc<dyn teksilo_core::AppEventPoster>,
662        ) {
663            let mut dialog = rfd::AsyncFileDialog::new();
664            if let Some(t) = request.title.as_ref() {
665                dialog = dialog.set_title(t);
666            }
667            if let Some(d) = request.starting_dir.as_ref() {
668                dialog = dialog.set_directory(d);
669            }
670            if let Some(n) = request.default_file_name.as_ref() {
671                dialog = dialog.set_file_name(n);
672            }
673            for f in &request.filters {
674                let exts: Vec<&str> = f.extensions.iter().map(String::as_str).collect();
675                dialog = dialog.add_filter(&f.label, &exts);
676            }
677            if let Some(parent) = request.parent.as_ref() {
678                // `ParentHandle` itself implements `HasWindowHandle +
679                // HasDisplayHandle`, so rfd can extract the raw bytes
680                // eagerly into its own storage.
681                dialog = dialog.set_parent(parent);
682            }
683
684            let kind = request.kind();
685            spawn_dialog_task(async move {
686                let result = match kind {
687                    DialogKind::PickFile => FileDialogResult::File(
688                        dialog.pick_file().await.map(|h| h.path().to_path_buf()),
689                    ),
690                    DialogKind::PickFiles => FileDialogResult::Files(
691                        dialog
692                            .pick_files()
693                            .await
694                            .unwrap_or_default()
695                            .into_iter()
696                            .map(|h| h.path().to_path_buf())
697                            .collect(),
698                    ),
699                    DialogKind::PickFolder => FileDialogResult::Folder(
700                        dialog.pick_folder().await.map(|h| h.path().to_path_buf()),
701                    ),
702                    DialogKind::SaveFile => FileDialogResult::Saved(
703                        dialog.save_file().await.map(|h| h.path().to_path_buf()),
704                    ),
705                };
706                let payload = FileDialogEventPayload {
707                    request_id,
708                    window_id_owner: window_id,
709                    result,
710                };
711                poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
712            });
713        }
714    }
715
716    fn spawn_dialog_task<F>(f: F)
717    where
718        F: std::future::Future<Output = ()> + Send + 'static,
719    {
720        // Wrapped in a private function so swapping executors (tokio,
721        // smol, ...) is a one-line change without touching the public
722        // backend.
723        async_std::task::spawn(f);
724    }
725}
726
727#[cfg(feature = "rfd-backend")]
728pub use rfd_backend::RfdAsyncBackend;
729
730// ============================================================
731// Tests
732// ============================================================
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use std::any::Any;
738    use std::sync::Mutex;
739    use teksilo_core::AppEventPoster;
740
741    /// Test poster that captures every posted External payload into
742    /// a shared queue so tests can pull them out and feed them back
743    /// to `deliver`.
744    struct CapturingPoster {
745        captured: Mutex<Vec<Box<dyn Any + Send>>>,
746    }
747
748    impl CapturingPoster {
749        fn new() -> Arc<Self> {
750            Arc::new(Self {
751                captured: Mutex::new(Vec::new()),
752            })
753        }
754
755        fn drain(&self) -> Vec<Box<dyn Any + Send>> {
756            std::mem::take(&mut *self.captured.lock().unwrap())
757        }
758    }
759
760    impl AppEventPoster for CapturingPoster {
761        fn post_subscription_event(
762            &self,
763            _sub_id: teksilo_core::SubscriptionId,
764            _event: Box<dyn Any + Send>,
765        ) {
766        }
767
768        fn post_external(&self, payload: Box<dyn Any + Send>) {
769            self.captured.lock().unwrap().push(payload);
770        }
771    }
772
773    fn teksilo_id(n: u64) -> TeksiloWindowId {
774        TeksiloWindowId::new(n)
775    }
776
777    #[test]
778    fn validate_rejects_empty_extension_list() {
779        let req = FileDialogRequest::pick_file().add_filter("Images", &[]);
780        assert!(req.validate().is_err());
781    }
782
783    #[test]
784    fn validate_rejects_leading_dot() {
785        let req = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
786        assert!(req.validate().is_err());
787    }
788
789    #[test]
790    fn validate_rejects_whitespace_extension() {
791        let req = FileDialogRequest::pick_file().add_filter("Images", &["png ", "jpg"]);
792        assert!(req.validate().is_err());
793    }
794
795    #[test]
796    fn validate_accepts_clean_filters() {
797        let req = FileDialogRequest::pick_file()
798            .title("Open")
799            .add_filter("Images", &["png", "jpg", "JPG"]);
800        assert!(req.validate().is_ok());
801    }
802
803    #[test]
804    fn memory_backend_pops_scripted_in_order() {
805        let mut mock = MemoryFileDialog::new();
806        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/a.txt"))));
807        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/b.txt"))));
808        let handle = FileDialogHandle::new(mock);
809        let cap = CapturingPoster::new();
810        let poster: Arc<dyn AppEventPoster> = cap.clone();
811
812        let _ = handle
813            .submit(
814                teksilo_id(1),
815                FileDialogRequest::pick_file(),
816                poster.clone(),
817                |_, _| {},
818            )
819            .unwrap();
820        let _ = handle
821            .submit(
822                teksilo_id(1),
823                FileDialogRequest::pick_file(),
824                poster.clone(),
825                |_, _| {},
826            )
827            .unwrap();
828
829        // Two callbacks pending; two payloads posted.
830        assert_eq!(handle.pending_count(), 2);
831        let posted = cap.drain();
832        assert_eq!(posted.len(), 2);
833        for p in posted {
834            let typed = p.downcast::<FileDialogEventPayload>().unwrap();
835            match typed.result {
836                FileDialogResult::File(Some(_)) => {}
837                _ => panic!("expected File(Some)"),
838            }
839        }
840    }
841
842    #[test]
843    fn purge_drops_callbacks_for_matching_window() {
844        let mut mock = MemoryFileDialog::new();
845        mock.enqueue(FileDialogResult::File(None));
846        mock.enqueue(FileDialogResult::File(None));
847        let handle = FileDialogHandle::new(mock);
848        let cap = CapturingPoster::new();
849        let poster: Arc<dyn AppEventPoster> = cap.clone();
850
851        let _ = handle
852            .submit(
853                teksilo_id(7),
854                FileDialogRequest::pick_file(),
855                poster.clone(),
856                |_, _| {},
857            )
858            .unwrap();
859        let _ = handle
860            .submit(
861                teksilo_id(8),
862                FileDialogRequest::pick_file(),
863                poster.clone(),
864                |_, _| {},
865            )
866            .unwrap();
867        assert_eq!(handle.pending_count(), 2);
868
869        handle.purge_window(teksilo_id(7));
870        assert_eq!(handle.pending_count(), 1);
871        handle.purge_window(teksilo_id(8));
872        assert_eq!(handle.pending_count(), 0);
873    }
874
875    #[test]
876    fn submit_validates_before_dispatch() {
877        let mock = MemoryFileDialog::new();
878        let handle = FileDialogHandle::new(mock);
879        let cap = CapturingPoster::new();
880        let poster: Arc<dyn AppEventPoster> = cap.clone();
881        // Bad filter — should never reach the backend.
882        let bad = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
883        assert!(
884            handle
885                .submit(teksilo_id(1), bad, poster, |_, _| {})
886                .is_err()
887        );
888        assert_eq!(handle.pending_count(), 0);
889        // Backend was not asked to dispatch — nothing was posted.
890        assert_eq!(cap.drain().len(), 0);
891    }
892
893    #[test]
894    fn payload_round_trips_through_capturing_poster() {
895        let mut mock = MemoryFileDialog::new();
896        mock.enqueue(FileDialogResult::Folder(Some(PathBuf::from("/home/u"))));
897        let handle = FileDialogHandle::new(mock);
898        let cap = CapturingPoster::new();
899        let poster: Arc<dyn AppEventPoster> = cap.clone();
900
901        let req_id = handle
902            .submit(
903                teksilo_id(42),
904                FileDialogRequest::pick_folder(),
905                poster,
906                |_, _| {},
907            )
908            .unwrap();
909
910        let mut posted = cap.drain();
911        assert_eq!(posted.len(), 1);
912        let payload = posted
913            .pop()
914            .unwrap()
915            .downcast::<FileDialogEventPayload>()
916            .expect("payload type matches");
917        assert_eq!(payload.request_id, req_id);
918        assert_eq!(payload.window_id_owner, teksilo_id(42));
919        match &payload.result {
920            FileDialogResult::Folder(Some(p)) => assert_eq!(p, &PathBuf::from("/home/u")),
921            other => panic!("unexpected result: {other:?}"),
922        }
923    }
924
925    #[test]
926    fn deliver_after_purge_is_silent() {
927        // Build a tiny tree so we can synthesize an EventContext to
928        // hand into deliver. The callback should NOT fire after the
929        // window's pending entries were purged.
930        use std::cell::Cell as StdCell;
931        use teksilo_core::WidgetTree;
932
933        let mut mock = MemoryFileDialog::new();
934        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/x"))));
935        let handle = FileDialogHandle::new(mock);
936        let cap = CapturingPoster::new();
937        let poster: Arc<dyn AppEventPoster> = cap.clone();
938
939        let fired = Rc::new(StdCell::new(false));
940        let fired_clone = fired.clone();
941
942        let req_id = handle
943            .submit(
944                teksilo_id(5),
945                FileDialogRequest::pick_file(),
946                poster,
947                move |_, _| fired_clone.set(true),
948            )
949            .unwrap();
950
951        // Window closes before delivery.
952        handle.purge_window(teksilo_id(5));
953
954        // Pull the posted payload (still queued in `cap`) and
955        // attempt delivery.
956        let mut posted = cap.drain();
957        let payload = *posted
958            .pop()
959            .unwrap()
960            .downcast::<FileDialogEventPayload>()
961            .unwrap();
962        assert_eq!(payload.request_id, req_id);
963
964        let mut tree = WidgetTree::new();
965        let mut noop = teksilo_core::NoopWindowOps;
966        tree.run_with_event_context(&mut noop, |ctx| {
967            handle.deliver(payload, ctx);
968        });
969
970        assert!(!fired.get(), "callback must not fire after purge");
971    }
972
973    #[test]
974    fn deliver_invokes_callback_with_result() {
975        use std::cell::Cell as StdCell;
976        use teksilo_core::WidgetTree;
977
978        let mut mock = MemoryFileDialog::new();
979        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/y.txt"))));
980        let handle = FileDialogHandle::new(mock);
981        let cap = CapturingPoster::new();
982        let poster: Arc<dyn AppEventPoster> = cap.clone();
983
984        let captured: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
985        let captured_clone = captured.clone();
986        // Discard the unused Cell import warning by referencing it.
987        let _ = StdCell::new(0);
988
989        let _ = handle
990            .submit(
991                teksilo_id(11),
992                FileDialogRequest::pick_file(),
993                poster,
994                move |result, _| {
995                    if let FileDialogResult::File(Some(p)) = result {
996                        *captured_clone.borrow_mut() = Some(p);
997                    }
998                },
999            )
1000            .unwrap();
1001
1002        let payload = *cap
1003            .drain()
1004            .pop()
1005            .unwrap()
1006            .downcast::<FileDialogEventPayload>()
1007            .unwrap();
1008        let mut tree = WidgetTree::new();
1009        let mut noop = teksilo_core::NoopWindowOps;
1010        tree.run_with_event_context(&mut noop, |ctx| handle.deliver(payload, ctx));
1011
1012        assert_eq!(*captured.borrow(), Some(PathBuf::from("/tmp/y.txt")));
1013        // After delivery, no callback remains.
1014        assert_eq!(handle.pending_count(), 0);
1015    }
1016}