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::*`, which re-exports it under the
426/// umbrella's `file-dialog` / `file-dialog-trait` features).
427///
428/// All four methods perform the same internal sequence:
429///
430///   1. (macOS only) focus the current window so the panel comes to
431///      front for non-bundled binaries.
432///   2. Stamp the parent handle into the request.
433///   3. Look up [`FileDialogHandle`] and the
434///      [`teksilo_core::AppEventPoster`] via app-state.
435///   4. Forward to [`FileDialogHandle::submit`].
436///
437/// Returns `Err` only when the request fails validation
438/// ([`FileDialogRequest::validate`]) or when the framework was not
439/// initialised with a [`FileDialogHandle`] (i.e. the application
440/// did not call `TeksiloAppBuilder::install_file_dialog`).
441///
442/// **Convenience method overrides the request kind.** The method you
443/// call dictates the operation: `ctx.pick_file(req, …)` always opens
444/// a single-file picker even if `req` was built with
445/// [`FileDialogRequest::save_file`]. Each method overwrites
446/// `request.kind` so the [`FileDialogResult`] variant returned to the
447/// callback always matches the method name. To control the kind
448/// explicitly, call [`FileDialogHandle::submit`] directly with a
449/// pre-built request.
450pub trait EventContextFileDialogExt {
451    /// Open a single-file dialog parented to the current window.
452    /// `on_result` runs on the main thread on dialog completion or is
453    /// dropped if the originating window closes first.
454    ///
455    /// The request's kind is forced to `PickFile` regardless of how
456    /// it was constructed — see the trait-level docs.
457    fn pick_file(
458        &mut self,
459        request: FileDialogRequest,
460        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
461    ) -> Result<RequestId, String>;
462
463    /// Open a multi-file selection dialog. The request's kind is
464    /// forced to `PickFiles`. See [`Self::pick_file`].
465    fn pick_files(
466        &mut self,
467        request: FileDialogRequest,
468        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
469    ) -> Result<RequestId, String>;
470
471    /// Open a folder picker. The request's kind is forced to
472    /// `PickFolder`. See [`Self::pick_file`].
473    fn pick_folder(
474        &mut self,
475        request: FileDialogRequest,
476        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
477    ) -> Result<RequestId, String>;
478
479    /// Open a save dialog. The request's kind is forced to
480    /// `SaveFile`. See [`Self::pick_file`].
481    fn save_file(
482        &mut self,
483        request: FileDialogRequest,
484        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
485    ) -> Result<RequestId, String>;
486}
487
488impl EventContextFileDialogExt for EventContext<'_> {
489    fn pick_file(
490        &mut self,
491        mut request: FileDialogRequest,
492        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
493    ) -> Result<RequestId, String> {
494        request.kind = DialogKind::PickFile;
495        submit_via_ctx(self, request, on_result)
496    }
497
498    fn pick_files(
499        &mut self,
500        mut request: FileDialogRequest,
501        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
502    ) -> Result<RequestId, String> {
503        request.kind = DialogKind::PickFiles;
504        submit_via_ctx(self, request, on_result)
505    }
506
507    fn pick_folder(
508        &mut self,
509        mut request: FileDialogRequest,
510        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
511    ) -> Result<RequestId, String> {
512        request.kind = DialogKind::PickFolder;
513        submit_via_ctx(self, request, on_result)
514    }
515
516    fn save_file(
517        &mut self,
518        mut request: FileDialogRequest,
519        on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
520    ) -> Result<RequestId, String> {
521        request.kind = DialogKind::SaveFile;
522        submit_via_ctx(self, request, on_result)
523    }
524}
525
526fn submit_via_ctx(
527    ctx: &mut EventContext,
528    mut request: FileDialogRequest,
529    on_result: impl FnOnce(FileDialogResult, &mut EventContext) + 'static,
530) -> Result<RequestId, String> {
531    let window_id = ctx.window().map(|w| w.id()).ok_or_else(|| {
532        "EventContext has no window — file dialog needs a parent window".to_string()
533    })?;
534
535    // macOS focus-to-front: a non-bundled binary may launch the
536    // panel behind another app. Focusing the parent first reliably
537    // brings it forward via NSApp.activateIgnoringOtherApps.
538    #[cfg(target_os = "macos")]
539    ctx.focus_window(window_id);
540
541    if request.parent.is_none()
542        && let Some(parent) = ctx.parent_window_handle()
543    {
544        request = request.with_parent(parent);
545    }
546
547    let handle = ctx
548        .app_state::<FileDialogHandle>()
549        .ok_or_else(|| {
550            "FileDialogHandle not installed in app-state — call \
551             TeksiloAppBuilder::install_file_dialog (or app_state(...)) at startup"
552                .to_string()
553        })?
554        .clone();
555    let poster = ctx
556        .poster()
557        .ok_or_else(|| {
558            "AppEventPoster not installed — file dialog needs a way to post \
559             results back to the UI loop"
560                .to_string()
561        })?
562        .clone();
563
564    handle.submit(window_id, request, poster, on_result)
565}
566
567// ============================================================
568// MemoryFileDialog (test backend)
569// ============================================================
570
571/// In-memory deterministic backend for headless tests. Holds a
572/// scripted queue of pre-canned [`FileDialogResult`]s that pop in
573/// submission order. Each `dispatch` call pops one result and
574/// immediately posts it through the supplied poster — handy for
575/// tests that drive the event loop one tick at a time.
576pub struct MemoryFileDialog {
577    scripted: VecDeque<FileDialogResult>,
578}
579
580impl MemoryFileDialog {
581    /// Build a new empty mock backend. Use [`Self::enqueue`] to
582    /// script per-call results.
583    pub fn new() -> Self {
584        Self {
585            scripted: VecDeque::new(),
586        }
587    }
588
589    /// Push a result onto the FIFO queue. Each `dispatch` call pops
590    /// the front of the queue.
591    pub fn enqueue(&mut self, r: FileDialogResult) {
592        self.scripted.push_back(r);
593    }
594}
595
596impl Default for MemoryFileDialog {
597    fn default() -> Self {
598        Self::new()
599    }
600}
601
602impl FileDialogBackend for MemoryFileDialog {
603    fn dispatch(
604        &mut self,
605        request_id: RequestId,
606        window_id: TeksiloWindowId,
607        _request: FileDialogRequest,
608        poster: Arc<dyn teksilo_core::AppEventPoster>,
609    ) {
610        let result = self.scripted.pop_front().unwrap_or_else(|| {
611            FileDialogResult::Error("MemoryFileDialog: no scripted result enqueued".into())
612        });
613        let payload = FileDialogEventPayload {
614            request_id,
615            window_id_owner: window_id,
616            result,
617        };
618        poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
619    }
620}
621
622// ============================================================
623// RfdAsyncBackend (real backend, gated behind rfd-backend feature)
624// ============================================================
625
626#[cfg(feature = "rfd-backend")]
627mod rfd_backend {
628    use super::*;
629
630    /// Native file-dialog backend backed by the `rfd` crate.
631    ///
632    /// Each [`Self::dispatch`] call builds an `rfd::AsyncFileDialog`,
633    /// attaches the parent window handle, then spawns the future on
634    /// `async-std`'s global thread pool. The future's resolution
635    /// posts a [`FileDialogEventPayload`] back through the supplied
636    /// [`teksilo_core::AppEventPoster`].
637    ///
638    /// On macOS, rfd dispatches the actual `NSOpenPanel` /
639    /// `NSSavePanel` to the AppKit main run loop internally — the
640    /// future drives the wakeup machinery, but the panel UI runs on
641    /// the main thread that winit is already pumping.
642    pub struct RfdAsyncBackend;
643
644    impl RfdAsyncBackend {
645        pub fn new() -> Self {
646            Self
647        }
648    }
649
650    impl Default for RfdAsyncBackend {
651        fn default() -> Self {
652            Self::new()
653        }
654    }
655
656    impl FileDialogBackend for RfdAsyncBackend {
657        fn dispatch(
658            &mut self,
659            request_id: RequestId,
660            window_id: TeksiloWindowId,
661            request: FileDialogRequest,
662            poster: Arc<dyn teksilo_core::AppEventPoster>,
663        ) {
664            let mut dialog = rfd::AsyncFileDialog::new();
665            if let Some(t) = request.title.as_ref() {
666                dialog = dialog.set_title(t);
667            }
668            if let Some(d) = request.starting_dir.as_ref() {
669                dialog = dialog.set_directory(d);
670            }
671            if let Some(n) = request.default_file_name.as_ref() {
672                dialog = dialog.set_file_name(n);
673            }
674            for f in &request.filters {
675                let exts: Vec<&str> = f.extensions.iter().map(String::as_str).collect();
676                dialog = dialog.add_filter(&f.label, &exts);
677            }
678            if let Some(parent) = request.parent.as_ref() {
679                // `ParentHandle` itself implements `HasWindowHandle +
680                // HasDisplayHandle`, so rfd can extract the raw bytes
681                // eagerly into its own storage.
682                dialog = dialog.set_parent(parent);
683            }
684
685            let kind = request.kind();
686            spawn_dialog_task(async move {
687                let result = match kind {
688                    DialogKind::PickFile => FileDialogResult::File(
689                        dialog.pick_file().await.map(|h| h.path().to_path_buf()),
690                    ),
691                    DialogKind::PickFiles => FileDialogResult::Files(
692                        dialog
693                            .pick_files()
694                            .await
695                            .unwrap_or_default()
696                            .into_iter()
697                            .map(|h| h.path().to_path_buf())
698                            .collect(),
699                    ),
700                    DialogKind::PickFolder => FileDialogResult::Folder(
701                        dialog.pick_folder().await.map(|h| h.path().to_path_buf()),
702                    ),
703                    DialogKind::SaveFile => FileDialogResult::Saved(
704                        dialog.save_file().await.map(|h| h.path().to_path_buf()),
705                    ),
706                };
707                let payload = FileDialogEventPayload {
708                    request_id,
709                    window_id_owner: window_id,
710                    result,
711                };
712                poster.post_external(Box::new(payload) as Box<dyn Any + Send>);
713            });
714        }
715    }
716
717    fn spawn_dialog_task<F>(f: F)
718    where
719        F: std::future::Future<Output = ()> + Send + 'static,
720    {
721        // Wrapped in a private function so swapping executors (tokio,
722        // smol, ...) is a one-line change without touching the public
723        // backend.
724        async_std::task::spawn(f);
725    }
726}
727
728#[cfg(feature = "rfd-backend")]
729pub use rfd_backend::RfdAsyncBackend;
730
731// ============================================================
732// Tests
733// ============================================================
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use std::any::Any;
739    use std::sync::Mutex;
740    use teksilo_core::AppEventPoster;
741
742    /// Test poster that captures every posted External payload into
743    /// a shared queue so tests can pull them out and feed them back
744    /// to `deliver`.
745    struct CapturingPoster {
746        captured: Mutex<Vec<Box<dyn Any + Send>>>,
747    }
748
749    impl CapturingPoster {
750        fn new() -> Arc<Self> {
751            Arc::new(Self {
752                captured: Mutex::new(Vec::new()),
753            })
754        }
755
756        fn drain(&self) -> Vec<Box<dyn Any + Send>> {
757            std::mem::take(&mut *self.captured.lock().unwrap())
758        }
759    }
760
761    impl AppEventPoster for CapturingPoster {
762        fn post_subscription_event(
763            &self,
764            _sub_id: teksilo_core::SubscriptionId,
765            _event: Box<dyn Any + Send>,
766        ) {
767        }
768
769        fn post_external(&self, payload: Box<dyn Any + Send>) {
770            self.captured.lock().unwrap().push(payload);
771        }
772    }
773
774    fn teksilo_id(n: u64) -> TeksiloWindowId {
775        TeksiloWindowId::new(n)
776    }
777
778    #[test]
779    fn validate_rejects_empty_extension_list() {
780        let req = FileDialogRequest::pick_file().add_filter("Images", &[]);
781        assert!(req.validate().is_err());
782    }
783
784    #[test]
785    fn validate_rejects_leading_dot() {
786        let req = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
787        assert!(req.validate().is_err());
788    }
789
790    #[test]
791    fn validate_rejects_whitespace_extension() {
792        let req = FileDialogRequest::pick_file().add_filter("Images", &["png ", "jpg"]);
793        assert!(req.validate().is_err());
794    }
795
796    #[test]
797    fn validate_accepts_clean_filters() {
798        let req = FileDialogRequest::pick_file()
799            .title("Open")
800            .add_filter("Images", &["png", "jpg", "JPG"]);
801        assert!(req.validate().is_ok());
802    }
803
804    #[test]
805    fn memory_backend_pops_scripted_in_order() {
806        let mut mock = MemoryFileDialog::new();
807        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/a.txt"))));
808        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/b.txt"))));
809        let handle = FileDialogHandle::new(mock);
810        let cap = CapturingPoster::new();
811        let poster: Arc<dyn AppEventPoster> = cap.clone();
812
813        let _ = handle
814            .submit(
815                teksilo_id(1),
816                FileDialogRequest::pick_file(),
817                poster.clone(),
818                |_, _| {},
819            )
820            .unwrap();
821        let _ = handle
822            .submit(
823                teksilo_id(1),
824                FileDialogRequest::pick_file(),
825                poster.clone(),
826                |_, _| {},
827            )
828            .unwrap();
829
830        // Two callbacks pending; two payloads posted.
831        assert_eq!(handle.pending_count(), 2);
832        let posted = cap.drain();
833        assert_eq!(posted.len(), 2);
834        for p in posted {
835            let typed = p.downcast::<FileDialogEventPayload>().unwrap();
836            match typed.result {
837                FileDialogResult::File(Some(_)) => {}
838                _ => panic!("expected File(Some)"),
839            }
840        }
841    }
842
843    #[test]
844    fn purge_drops_callbacks_for_matching_window() {
845        let mut mock = MemoryFileDialog::new();
846        mock.enqueue(FileDialogResult::File(None));
847        mock.enqueue(FileDialogResult::File(None));
848        let handle = FileDialogHandle::new(mock);
849        let cap = CapturingPoster::new();
850        let poster: Arc<dyn AppEventPoster> = cap.clone();
851
852        let _ = handle
853            .submit(
854                teksilo_id(7),
855                FileDialogRequest::pick_file(),
856                poster.clone(),
857                |_, _| {},
858            )
859            .unwrap();
860        let _ = handle
861            .submit(
862                teksilo_id(8),
863                FileDialogRequest::pick_file(),
864                poster.clone(),
865                |_, _| {},
866            )
867            .unwrap();
868        assert_eq!(handle.pending_count(), 2);
869
870        handle.purge_window(teksilo_id(7));
871        assert_eq!(handle.pending_count(), 1);
872        handle.purge_window(teksilo_id(8));
873        assert_eq!(handle.pending_count(), 0);
874    }
875
876    #[test]
877    fn submit_validates_before_dispatch() {
878        let mock = MemoryFileDialog::new();
879        let handle = FileDialogHandle::new(mock);
880        let cap = CapturingPoster::new();
881        let poster: Arc<dyn AppEventPoster> = cap.clone();
882        // Bad filter — should never reach the backend.
883        let bad = FileDialogRequest::pick_file().add_filter("Images", &[".png"]);
884        assert!(
885            handle
886                .submit(teksilo_id(1), bad, poster, |_, _| {})
887                .is_err()
888        );
889        assert_eq!(handle.pending_count(), 0);
890        // Backend was not asked to dispatch — nothing was posted.
891        assert_eq!(cap.drain().len(), 0);
892    }
893
894    #[test]
895    fn payload_round_trips_through_capturing_poster() {
896        let mut mock = MemoryFileDialog::new();
897        mock.enqueue(FileDialogResult::Folder(Some(PathBuf::from("/home/u"))));
898        let handle = FileDialogHandle::new(mock);
899        let cap = CapturingPoster::new();
900        let poster: Arc<dyn AppEventPoster> = cap.clone();
901
902        let req_id = handle
903            .submit(
904                teksilo_id(42),
905                FileDialogRequest::pick_folder(),
906                poster,
907                |_, _| {},
908            )
909            .unwrap();
910
911        let mut posted = cap.drain();
912        assert_eq!(posted.len(), 1);
913        let payload = posted
914            .pop()
915            .unwrap()
916            .downcast::<FileDialogEventPayload>()
917            .expect("payload type matches");
918        assert_eq!(payload.request_id, req_id);
919        assert_eq!(payload.window_id_owner, teksilo_id(42));
920        match &payload.result {
921            FileDialogResult::Folder(Some(p)) => assert_eq!(p, &PathBuf::from("/home/u")),
922            other => panic!("unexpected result: {other:?}"),
923        }
924    }
925
926    #[test]
927    fn deliver_after_purge_is_silent() {
928        // Build a tiny tree so we can synthesize an EventContext to
929        // hand into deliver. The callback should NOT fire after the
930        // window's pending entries were purged.
931        use std::cell::Cell as StdCell;
932        use teksilo_core::WidgetTree;
933
934        let mut mock = MemoryFileDialog::new();
935        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/x"))));
936        let handle = FileDialogHandle::new(mock);
937        let cap = CapturingPoster::new();
938        let poster: Arc<dyn AppEventPoster> = cap.clone();
939
940        let fired = Rc::new(StdCell::new(false));
941        let fired_clone = fired.clone();
942
943        let req_id = handle
944            .submit(
945                teksilo_id(5),
946                FileDialogRequest::pick_file(),
947                poster,
948                move |_, _| fired_clone.set(true),
949            )
950            .unwrap();
951
952        // Window closes before delivery.
953        handle.purge_window(teksilo_id(5));
954
955        // Pull the posted payload (still queued in `cap`) and
956        // attempt delivery.
957        let mut posted = cap.drain();
958        let payload = *posted
959            .pop()
960            .unwrap()
961            .downcast::<FileDialogEventPayload>()
962            .unwrap();
963        assert_eq!(payload.request_id, req_id);
964
965        let mut tree = WidgetTree::new();
966        let mut noop = teksilo_core::NoopWindowOps;
967        tree.run_with_event_context(&mut noop, |ctx| {
968            handle.deliver(payload, ctx);
969        });
970
971        assert!(!fired.get(), "callback must not fire after purge");
972    }
973
974    #[test]
975    fn deliver_invokes_callback_with_result() {
976        use std::cell::Cell as StdCell;
977        use teksilo_core::WidgetTree;
978
979        let mut mock = MemoryFileDialog::new();
980        mock.enqueue(FileDialogResult::File(Some(PathBuf::from("/tmp/y.txt"))));
981        let handle = FileDialogHandle::new(mock);
982        let cap = CapturingPoster::new();
983        let poster: Arc<dyn AppEventPoster> = cap.clone();
984
985        let captured: Rc<RefCell<Option<PathBuf>>> = Rc::new(RefCell::new(None));
986        let captured_clone = captured.clone();
987        // Discard the unused Cell import warning by referencing it.
988        let _ = StdCell::new(0);
989
990        let _ = handle
991            .submit(
992                teksilo_id(11),
993                FileDialogRequest::pick_file(),
994                poster,
995                move |result, _| {
996                    if let FileDialogResult::File(Some(p)) = result {
997                        *captured_clone.borrow_mut() = Some(p);
998                    }
999                },
1000            )
1001            .unwrap();
1002
1003        let payload = *cap
1004            .drain()
1005            .pop()
1006            .unwrap()
1007            .downcast::<FileDialogEventPayload>()
1008            .unwrap();
1009        let mut tree = WidgetTree::new();
1010        let mut noop = teksilo_core::NoopWindowOps;
1011        tree.run_with_event_context(&mut noop, |ctx| handle.deliver(payload, ctx));
1012
1013        assert_eq!(*captured.borrow(), Some(PathBuf::from("/tmp/y.txt")));
1014        // After delivery, no callback remains.
1015        assert_eq!(handle.pending_count(), 0);
1016    }
1017}