Skip to main content

nifty_runtime/
dialog.rs

1//! File dialogs, shared by the interpreter and compiled apps. Code asks for one during an
2//! event; it is shown once the event has finished and the UI caught up, and the user's
3//! answer comes back through the app's waker, like a download's reports.
4
5use std::cell::RefCell;
6use std::collections::VecDeque;
7use std::fmt;
8use std::mem;
9use std::path::PathBuf;
10use std::rc::Rc;
11use std::sync::Arc;
12
13use crate::backend::{Backend, Waker};
14use crate::paths::{NoHomeFolder, expand_home};
15
16/// What a dialog asks the user to pick. Compiled code passes it to the runtime as a `u32`,
17/// which every platform's C calling convention passes without widening.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19#[repr(u32)]
20pub enum DialogKind {
21    /// One existing file.
22    File,
23    /// Any number of existing files, at least one.
24    Files,
25    /// One existing folder.
26    Folder,
27    /// Where to save a new file, which may already exist.
28    SavePath,
29}
30
31impl DialogKind {
32    /// Whether the answer is a list of paths rather than one.
33    #[must_use]
34    pub fn picks_many(self) -> bool {
35        self == Self::Files
36    }
37}
38
39/// A dialog as the platform shows it.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct FileDialog {
42    pub kind: DialogKind,
43    /// Shown above the files; empty for the platform's own wording.
44    pub title: String,
45    /// The file extensions allowed, without the dot, such as `png`; empty allows any.
46    pub types: Vec<String>,
47    /// The folder it opens in, which exists; `None` lets the platform choose.
48    pub folder: Option<PathBuf>,
49    /// The file name a save dialog suggests; empty for none.
50    pub name: String,
51}
52
53impl FileDialog {
54    /// The dialog an app's call asks for. `types` may be written with or without the dot.
55    /// `folder` is written as in the app, `~` included; it is left to the platform when
56    /// empty or not an existing folder.
57    ///
58    /// # Errors
59    /// When `folder` starts with `~` and the home folder can't be found.
60    pub fn new(
61        kind: DialogKind,
62        title: String,
63        types: &[&str],
64        folder: &str,
65        name: String,
66    ) -> Result<Self, NoHomeFolder> {
67        let folder = if folder.is_empty() {
68            None
69        } else {
70            Some(expand_home(folder)?).filter(|folder| folder.is_dir())
71        };
72        Ok(Self {
73            kind,
74            title,
75            types: types
76                .iter()
77                .map(|extension| extension.trim_start_matches('.'))
78                .filter(|extension| !extension.is_empty())
79                .map(str::to_owned)
80                .collect(),
81            folder,
82            name,
83        })
84    }
85}
86
87/// How a dialog closed.
88#[derive(Clone, Debug, PartialEq, Eq)]
89pub enum Picked {
90    /// The paths the user chose: exactly one, except for [`DialogKind::Files`].
91    Chosen(Vec<String>),
92    Cancelled,
93    /// The platform couldn't show the dialog or read its answer.
94    Failed(String),
95}
96
97pub type DialogHandler = Box<dyn FnOnce(Picked)>;
98
99/// Shows file dialogs; every [`Backend`] is one, so code that isn't generic over the
100/// backend can hold it as `Rc<dyn ShowDialog>`.
101pub trait ShowDialog {
102    fn show_dialog(&self, dialog: &FileDialog, on_close: DialogHandler);
103}
104
105impl<B: Backend> ShowDialog for B {
106    fn show_dialog(&self, dialog: &FileDialog, on_close: DialogHandler) {
107        self.show_file_dialog(dialog, on_close);
108    }
109}
110
111/// The dialogs an app has asked for, and the answers that have come back. `T` says where
112/// each answer goes, in whatever terms the app uses.
113pub struct Dialogs<T> {
114    shower: Rc<dyn ShowDialog>,
115    waker: Arc<dyn Waker>,
116    asked: RefCell<Vec<(FileDialog, T)>>,
117    answers: Rc<RefCell<VecDeque<(T, Picked)>>>,
118}
119
120impl<T: 'static> Dialogs<T> {
121    /// `waker` asks the UI thread to call [`Dialogs::show_asked`] and then, inside an event,
122    /// [`Dialogs::receive`].
123    pub fn new(shower: Rc<dyn ShowDialog>, waker: Arc<dyn Waker>) -> Self {
124        Self {
125            shower,
126            waker,
127            asked: RefCell::new(Vec::new()),
128            answers: Rc::new(RefCell::new(VecDeque::new())),
129        }
130    }
131
132    /// Asks for `dialog`, to be shown once the current event has finished.
133    pub fn ask(&self, dialog: FileDialog, target: T) {
134        self.asked.borrow_mut().push((dialog, target));
135        self.waker.wake();
136    }
137
138    /// Shows every dialog asked for since the last call. Call it from the waker's handler
139    /// outside any event: a platform may run a modal loop here, which handles other events.
140    pub fn show_asked(&self) {
141        let asked = mem::take(&mut *self.asked.borrow_mut());
142        for (dialog, target) in asked {
143            let answers = Rc::clone(&self.answers);
144            let waker = Arc::clone(&self.waker);
145            self.shower.show_dialog(
146                &dialog,
147                Box::new(move |picked| {
148                    answers.borrow_mut().push_back((target, picked));
149                    waker.wake();
150                }),
151            );
152        }
153    }
154
155    /// Hands each answer that has come back to `apply` with where it goes. Stops at the
156    /// first error, leaving later answers queued.
157    ///
158    /// # Errors
159    /// The first error `apply` returns.
160    pub fn receive<E>(&self, mut apply: impl FnMut(&T, Picked) -> Result<(), E>) -> Result<(), E> {
161        loop {
162            let next = self.answers.borrow_mut().pop_front();
163            let Some((target, picked)) = next else {
164                return Ok(());
165            };
166            apply(&target, picked)?;
167        }
168    }
169}
170
171impl<T> fmt::Debug for Dialogs<T> {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.debug_struct("Dialogs")
174            .field("asked", &self.asked.borrow().len())
175            .field("answers", &self.answers.borrow().len())
176            .finish_non_exhaustive()
177    }
178}
179
180#[cfg(test)]
181mod tests;