Skip to main content

compose_rust/
file_dialog.rs

1//! File dialogs: asking the user to pick or name a file from the platform's own panel.
2//!
3//! The Host does not call the platform. The Renderer owns the windows and the platform's
4//! panel, so a request crosses as a command record in an ordinary batch, and the answer
5//! comes back as an ordinary event. A dialog is not a node: it has no place in the tree and
6//! it is gone once it has answered.
7//!
8//! Every request gets exactly one answer, whether the user chose, cancelled, or this run has
9//! no way to show a panel. The Host numbers each request, remembers the callback under that
10//! number, calls it once when the matching event arrives, and drops it. An answer for a
11//! number nobody is waiting on is ignored.
12//!
13//! ```ignore
14//! FileDialog::open_files()
15//!     .title("Choose files to send")
16//!     .filter("Images", &["png", "jpg"])
17//!     .show(|result| {
18//!         if let FileDialogResult::Chosen { paths, .. } = result {
19//!             send(paths);
20//!         }
21//!     });
22//! ```
23//!
24//! [`FileDialog::show`] belongs on the Renderer's UI thread, from a handler or a component,
25//! because its callback runs there. A worker thread asks through [`FileDialogSender`], whose
26//! callback has to be `Send` for the same reason: it is stored by the Host and called on the
27//! UI thread. Nothing here is a queue on the boundary. The queue is the Host's own and it is
28//! written into the next batch inside a call.
29
30use crate::schema::{FileDialogKind, FileDialogStatus};
31use std::cell::RefCell;
32use std::collections::HashMap;
33use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
34use std::sync::{Mutex, MutexGuard};
35
36/// How a dialog ended.
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum FileDialogResult {
39    /// The user chose. `paths` holds at least one absolute path, exactly as the platform
40    /// gave it. In a browser, which gives a page no paths, each entry is a reference to a
41    /// file the Renderer holds instead, written `webfile:<handle>/<name>`; read and write
42    /// what was chosen through [`FileDialogResult::files`] on every platform rather than
43    /// taking the entries apart. `filter` is the position of the filter that was in use,
44    /// when the request carried filters and the platform says which one it was.
45    Chosen {
46        paths: Vec<String>,
47        filter: Option<usize>,
48    },
49    /// The user closed the dialog, or the window it belonged to went away while it was open.
50    Cancelled,
51    /// This run cannot show a dialog: the platform has none yet, the window does not exist,
52    /// or the answer could not be delivered whole. Not an error, and not a cancel.
53    Unavailable,
54}
55
56/// One file dialog, built and then shown.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct FileDialog {
59    kind: FileDialogKind,
60    window: u32,
61    title: String,
62    directory: String,
63    name: String,
64    filters: Vec<(String, Vec<String>)>,
65    default_filter: usize,
66}
67
68impl FileDialog {
69    fn new(kind: FileDialogKind) -> Self {
70        Self {
71            kind,
72            window: 0,
73            title: String::new(),
74            directory: String::new(),
75            name: String::new(),
76            filters: Vec::new(),
77            default_filter: 0,
78        }
79    }
80
81    /// Choose one file.
82    pub fn open_file() -> Self {
83        Self::new(FileDialogKind::OpenFile)
84    }
85
86    /// Choose one or more files.
87    pub fn open_files() -> Self {
88        Self::new(FileDialogKind::OpenFiles)
89    }
90
91    /// Choose one folder.
92    pub fn open_folder() -> Self {
93        Self::new(FileDialogKind::OpenFolder)
94    }
95
96    /// Choose one or more folders.
97    pub fn open_folders() -> Self {
98        Self::new(FileDialogKind::OpenFolders)
99    }
100
101    /// Choose a path to save to. The platform asks before overwriting; the Renderer only
102    /// hands back the path and never writes the file.
103    pub fn save() -> Self {
104        Self::new(FileDialogKind::Save)
105    }
106
107    /// The dialog's title. Empty is the platform's default.
108    pub fn title(mut self, title: impl Into<String>) -> Self {
109        self.title = title.into();
110        self
111    }
112
113    /// The folder the dialog opens in. It should be absolute. One that is empty, missing
114    /// or unreadable falls back to the platform's default, and is not an error.
115    pub fn directory(mut self, directory: impl Into<String>) -> Self {
116        self.directory = directory.into();
117        self
118    }
119
120    /// The file name a save dialog starts with. Ignored by the open kinds. A path
121    /// separator in it keeps only what follows the last one.
122    pub fn name(mut self, name: impl Into<String>) -> Self {
123        self.name = name.into();
124        self
125    }
126
127    /// Adds a filter: a label and the extensions it allows, without dots and compared
128    /// without regard to case. Ignored when choosing folders. Without any filter every file
129    /// can be chosen.
130    pub fn filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self {
131        self.filters.push((
132            label.into(),
133            extensions
134                .iter()
135                .map(|extension| (*extension).to_owned())
136                .collect(),
137        ));
138        self
139    }
140
141    /// The position of the filter selected first. A position past the last filter selects
142    /// the first.
143    pub fn default_filter(mut self, position: usize) -> Self {
144        self.default_filter = position;
145        self
146    }
147
148    /// The `Window` node the dialog belongs to and is modal for. 0 is the implicit root
149    /// window of an application with one window.
150    pub fn window(mut self, node_id: u32) -> Self {
151        self.window = node_id;
152        self
153    }
154
155    /// The window the dialog belongs to, by handle.
156    pub fn in_window(self, window: crate::windows::WindowHandle) -> Self {
157        self.window(window.id())
158    }
159
160    /// Asks for the dialog. `on_result` is called once, on the UI thread, when it ends, and
161    /// is then dropped.
162    ///
163    /// Call this on the UI thread, from a component or an event handler. From a worker use
164    /// [`FileDialogSender`].
165    pub fn show(self, on_result: impl FnOnce(FileDialogResult) + 'static) {
166        let request = next_request();
167        let callback: LocalCallback = Box::new(on_result);
168        LOCAL.with_borrow_mut(|callbacks| {
169            callbacks.insert(request, callback);
170        });
171        submit(self.into_request(request));
172    }
173
174    fn into_request(self, id: u32) -> Request {
175        Request {
176            id,
177            window: self.window,
178            kind: self.kind,
179            default_filter: u16::try_from(self.default_filter).unwrap_or(u16::MAX),
180            title: self.title,
181            directory: self.directory,
182            name: self.name,
183            filters: encode_filters(&self.filters),
184        }
185    }
186}
187
188/// A handle a worker thread asks for dialogs through.
189///
190/// `Send`, `Copy` and free to make: the queue it feeds belongs to the Host. The callback is
191/// stored by the Host and run on the UI thread, so it has to be `Send`.
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
193pub struct FileDialogSender {
194    _private: (),
195}
196
197impl FileDialogSender {
198    pub const fn new() -> Self {
199        Self { _private: () }
200    }
201
202    /// Asks for `dialog` and calls `on_result` once on the UI thread when it ends.
203    pub fn show(
204        &self,
205        dialog: FileDialog,
206        on_result: impl FnOnce(FileDialogResult) + Send + 'static,
207    ) {
208        let request = next_request();
209        let callback: RemoteCallback = Box::new(on_result);
210        remote().push((request, callback));
211        submit(dialog.into_request(request));
212    }
213}
214
215/// One request waiting for the next batch.
216#[derive(Clone, Debug, PartialEq, Eq)]
217pub(crate) struct Request {
218    pub(crate) id: u32,
219    pub(crate) window: u32,
220    pub(crate) kind: FileDialogKind,
221    pub(crate) default_filter: u16,
222    pub(crate) title: String,
223    pub(crate) directory: String,
224    pub(crate) name: String,
225    pub(crate) filters: String,
226}
227
228/// The filters as the wire carries them: each label, then its extensions joined by commas,
229/// all separated by NUL. A NUL or a comma inside a part would shift every later part, so
230/// they are dropped from the parts rather than sent.
231fn encode_filters(filters: &[(String, Vec<String>)]) -> String {
232    let mut out = String::new();
233    for (label, extensions) in filters {
234        if !out.is_empty() {
235            out.push('\0');
236        }
237        out.extend(label.chars().filter(|c| *c != '\0'));
238        out.push('\0');
239        let mut first = true;
240        for extension in extensions {
241            if !first {
242                out.push(',');
243            }
244            first = false;
245            out.extend(extension.chars().filter(|c| *c != '\0' && *c != ','));
246        }
247    }
248    out
249}
250
251type LocalCallback = Box<dyn FnOnce(FileDialogResult)>;
252type RemoteCallback = Box<dyn FnOnce(FileDialogResult) + Send>;
253
254thread_local! {
255    /// Callbacks asked for on the UI thread, which need not be `Send`.
256    static LOCAL: RefCell<HashMap<u32, LocalCallback>> = RefCell::new(HashMap::new());
257}
258
259/// Callbacks a worker asked for, kept until the UI thread delivers the answer.
260static REMOTE: Mutex<Vec<(u32, RemoteCallback)>> = Mutex::new(Vec::new());
261
262/// Requests waiting for the next batch. Shared across threads, because a worker asks.
263static QUEUE: Mutex<Vec<Request>> = Mutex::new(Vec::new());
264
265/// Whether the queue has anything in it, read without taking the lock, so a batch that has
266/// nothing to write pays one atomic load.
267static PENDING: AtomicBool = AtomicBool::new(false);
268
269/// The next request number. 0 is skipped so a zeroed record never looks like a request.
270static NEXT_REQUEST: AtomicU32 = AtomicU32::new(1);
271
272fn next_request() -> u32 {
273    loop {
274        let id = NEXT_REQUEST.fetch_add(1, Ordering::Relaxed);
275        if id != 0 {
276            return id;
277        }
278    }
279}
280
281fn queue() -> MutexGuard<'static, Vec<Request>> {
282    // A worker that panicked while holding the lock does not make the queue wrong.
283    QUEUE.lock().unwrap_or_else(|poison| poison.into_inner())
284}
285
286fn remote() -> MutexGuard<'static, Vec<(u32, RemoteCallback)>> {
287    REMOTE.lock().unwrap_or_else(|poison| poison.into_inner())
288}
289
290fn submit(request: Request) {
291    queue().push(request);
292    PENDING.store(true, Ordering::Release);
293    // Inside a Host call on the UI thread the batch that call produces carries it, so a
294    // frame request would only buy an empty frame.
295    if !crate::boundary::in_host_call() {
296        crate::boundary::request_frame_from_worker();
297    }
298}
299
300/// Hands every queued request to `emit` and empties the queue.
301pub(crate) fn drain(mut emit: impl FnMut(&Request)) {
302    if !PENDING.swap(false, Ordering::AcqRel) {
303        return;
304    }
305    let mut taken = std::mem::take(&mut *queue());
306    for request in &taken {
307        emit(request);
308    }
309    taken.clear();
310    let mut guard = queue();
311    if guard.is_empty() {
312        *guard = taken;
313    } else {
314        // Something was asked for while these were being written, and is still waiting.
315        PENDING.store(true, Ordering::Release);
316    }
317}
318
319/// Hands an answer to the callback that asked for it. Returns whether there was one.
320///
321/// A request that is not waiting, because it was already answered or never asked for, finds
322/// no callback and is ignored. `paths` is the wire string: paths separated by NUL.
323pub(crate) fn deliver(request: u32, status: FileDialogStatus, filter: u32, paths: &str) -> bool {
324    let result = match status {
325        FileDialogStatus::Chosen => {
326            let paths: Vec<String> = paths
327                .split('\0')
328                .filter(|path| !path.is_empty())
329                .map(str::to_owned)
330                .collect();
331            // A choice with nothing chosen is not one the Renderer sends. Reporting it as a
332            // pick would hand the application an empty list to index into.
333            if paths.is_empty() {
334                FileDialogResult::Unavailable
335            } else {
336                FileDialogResult::Chosen {
337                    paths,
338                    filter: (filter != u32::MAX).then_some(filter as usize),
339                }
340            }
341        }
342        FileDialogStatus::Cancelled => FileDialogResult::Cancelled,
343        FileDialogStatus::Unavailable => FileDialogResult::Unavailable,
344    };
345    // Taken out before it runs: the callback may ask for another dialog, which edits the
346    // table being read here.
347    if let Some(callback) = LOCAL.with_borrow_mut(|callbacks| callbacks.remove(&request)) {
348        callback(result);
349        return true;
350    }
351    let callback = {
352        let mut waiting = remote();
353        waiting
354            .iter()
355            .position(|(id, _)| *id == request)
356            .map(|at| waiting.swap_remove(at).1)
357    };
358    match callback {
359        Some(callback) => {
360            callback(result);
361            true
362        }
363        None => false,
364    }
365}
366
367/// Forgets every callback and anything still queued. Used between tests.
368#[doc(hidden)]
369pub fn reset_file_dialogs() {
370    queue().clear();
371    PENDING.store(false, Ordering::Release);
372    remote().clear();
373    LOCAL.with_borrow_mut(HashMap::clear);
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::cell::Cell;
380    use std::rc::Rc;
381
382    #[test]
383    fn fr72_filters_are_one_nul_separated_string() {
384        assert_eq!(encode_filters(&[]), "");
385        let filters = vec![
386            (
387                "Images".to_owned(),
388                vec!["png".to_owned(), "jpg".to_owned()],
389            ),
390            ("Docs".to_owned(), vec!["pdf".to_owned(), "txt".to_owned()]),
391        ];
392        assert_eq!(encode_filters(&filters), "Images\0png,jpg\0Docs\0pdf,txt");
393        let hostile = vec![("A\0B".to_owned(), vec!["p,n\0g".to_owned()])];
394        assert_eq!(encode_filters(&hostile), "AB\0png");
395    }
396
397    #[test]
398    fn fr72_paths_are_split_on_nul_and_a_stray_answer_is_ignored() {
399        let seen = Rc::new(RefCell::new(Vec::new()));
400        let sink = Rc::clone(&seen);
401        let request = next_request();
402        let callback: LocalCallback =
403            Box::new(move |result: FileDialogResult| sink.borrow_mut().push(result));
404        LOCAL.with_borrow_mut(|callbacks| {
405            callbacks.insert(request, callback);
406        });
407        assert!(deliver(
408            request,
409            FileDialogStatus::Chosen,
410            1,
411            "/home/한글/문서.txt\0/tmp/a b.png"
412        ));
413        assert_eq!(
414            *seen.borrow(),
415            vec![FileDialogResult::Chosen {
416                paths: vec!["/home/한글/문서.txt".to_owned(), "/tmp/a b.png".to_owned()],
417                filter: Some(1),
418            }]
419        );
420        // Answered once; the callback is gone.
421        assert!(!deliver(request, FileDialogStatus::Cancelled, u32::MAX, ""));
422        assert!(!deliver(
423            u32::MAX,
424            FileDialogStatus::Cancelled,
425            u32::MAX,
426            ""
427        ));
428        assert_eq!(seen.borrow().len(), 1);
429    }
430
431    #[test]
432    fn fr72_a_pick_with_no_paths_is_unavailable_not_chosen() {
433        let seen = Rc::new(Cell::new(None));
434        let sink = Rc::clone(&seen);
435        let request = next_request();
436        let callback: LocalCallback =
437            Box::new(move |result: FileDialogResult| sink.set(Some(result)));
438        LOCAL.with_borrow_mut(|callbacks| {
439            callbacks.insert(request, callback);
440        });
441        deliver(request, FileDialogStatus::Chosen, u32::MAX, "");
442        assert_eq!(seen.take(), Some(FileDialogResult::Unavailable));
443    }
444
445    #[test]
446    fn fr72_a_worker_callback_is_called_once_with_its_own_answer() {
447        let seen = std::sync::Arc::new(Mutex::new(Vec::new()));
448        let sink = std::sync::Arc::clone(&seen);
449        let request = next_request();
450        let callback: RemoteCallback =
451            Box::new(move |result: FileDialogResult| sink.lock().unwrap().push(result));
452        remote().push((request, callback));
453        assert!(deliver(
454            request,
455            FileDialogStatus::Unavailable,
456            u32::MAX,
457            ""
458        ));
459        assert!(!deliver(
460            request,
461            FileDialogStatus::Unavailable,
462            u32::MAX,
463            ""
464        ));
465        assert_eq!(*seen.lock().unwrap(), vec![FileDialogResult::Unavailable]);
466    }
467}