Skip to main content

kui_core/
dialog.rs

1//! File dialogs as an ask: the app describes an Open, Save or folder
2//! dialog, the host shows the platform's own, and the answer comes back as
3//! one `{kind:"files", paths, tag}` event (`paths` empty when cancelled).
4//!
5//! Build a [`FileDialog`] and hand it to `Ui::request_files` from a view or
6//! `Core::request_files` between frames. The core cannot show a dialog, so
7//! it queues the ask; the windowed runner drains it and answers for you,
8//! while a host driving its own window drains `Core::take_file_requests`
9//! and answers with `InputEvent::Files`. One ask is out at a time: a
10//! second while one is unanswered is dropped, so a view that asks every
11//! frame until the answer lands asks once.
12//!
13//! ```rust
14//! use kui_core::{Core, FileDialog, FileDialogMode, InputEvent};
15//!
16//! let dialog = FileDialog::open()
17//!     .multiple()
18//!     .title("Add images")
19//!     .filter("Images", &["png", "jpg"])
20//!     .tag("import");
21//!
22//! let mut core = Core::new();
23//! assert!(core.request_files(dialog));
24//!
25//! // A host without the runner: drain the ask, show the dialog, answer.
26//! let asked = core.take_file_requests();
27//! assert_eq!(asked[0].mode, FileDialogMode::Open);
28//! let events = core.handle_input(InputEvent::Files(vec!["/tmp/a.png".into()]));
29//! assert_eq!(events[0].kind(), Some("files"));
30//! ```
31
32use crate::key::Key;
33use crate::tree::OriginId;
34use crate::value::Value;
35
36/// What a dialog picks.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum FileDialogMode {
39    /// An existing file (or several, with `multiple`).
40    #[default]
41    Open,
42    /// A path to write: the file need not exist, and the platform asks
43    /// before one that does is replaced.
44    Save,
45    /// A folder (or several, with `multiple`).
46    Folder,
47}
48
49impl FileDialogMode {
50    pub const ALL: [FileDialogMode; 3] = [Self::Open, Self::Save, Self::Folder];
51
52    /// The spelling the bindings use: `"open"`, `"save"`, `"folder"`.
53    pub fn name(self) -> &'static str {
54        match self {
55            Self::Open => "open",
56            Self::Save => "save",
57            Self::Folder => "folder",
58        }
59    }
60
61    pub fn from_name(s: &str) -> Option<Self> {
62        Self::ALL.into_iter().find(|m| m.name() == s)
63    }
64}
65
66/// One entry of a dialog's file-type menu: `Images` over `png`, `jpg`.
67/// Extensions are without the dot.
68#[derive(Clone, Debug, Default, PartialEq, Eq)]
69pub struct FileFilter {
70    pub name: String,
71    pub extensions: Vec<String>,
72}
73
74/// An Open, Save or folder dialog, as an app asks for one.
75#[derive(Clone, Debug, Default, PartialEq)]
76pub struct FileDialog {
77    pub mode: FileDialogMode,
78    /// More than one file or folder may be picked (Open and Folder).
79    pub multiple: bool,
80    /// The dialog's title, where the platform shows one.
81    pub title: Option<String>,
82    /// The file types offered, the first one chosen; none is any file.
83    pub filters: Vec<FileFilter>,
84    /// The folder it opens in; the platform's choice when absent.
85    pub directory: Option<String>,
86    /// A Save dialog's suggested name.
87    pub file_name: Option<String>,
88    /// Handed back on the `files` event, as a node's tag is.
89    pub tag: Value,
90}
91
92impl FileDialog {
93    /// An Open dialog for one existing file.
94    pub fn open() -> Self {
95        Self::default()
96    }
97
98    /// A Save dialog.
99    pub fn save() -> Self {
100        Self {
101            mode: FileDialogMode::Save,
102            ..Self::default()
103        }
104    }
105
106    /// A folder picker.
107    pub fn folder() -> Self {
108        Self {
109            mode: FileDialogMode::Folder,
110            ..Self::default()
111        }
112    }
113
114    pub fn multiple(mut self) -> Self {
115        self.multiple = true;
116        self
117    }
118
119    pub fn title(mut self, title: impl Into<String>) -> Self {
120        self.title = Some(title.into());
121        self
122    }
123
124    /// Offers the file type `name`, matching `extensions` (without the
125    /// dot; one written with it is taken without). The first filter added
126    /// is the one chosen when it opens.
127    pub fn filter(mut self, name: impl Into<String>, extensions: &[&str]) -> Self {
128        self.filters.push(FileFilter {
129            name: name.into(),
130            extensions: extensions
131                .iter()
132                .map(|e| e.trim_start_matches('.').to_string())
133                .collect(),
134        });
135        self
136    }
137
138    pub fn directory(mut self, dir: impl Into<String>) -> Self {
139        self.directory = Some(dir.into());
140        self
141    }
142
143    pub fn file_name(mut self, name: impl Into<String>) -> Self {
144        self.file_name = Some(name.into());
145        self
146    }
147
148    pub fn tag(mut self, tag: impl Into<Value>) -> Self {
149        self.tag = tag.into();
150        self
151    }
152
153    /// The ask as plain data — `{mode, multiple, title?, filters: [{name,
154    /// extensions}], directory?, fileName?, tag}` — what a Node host's
155    /// `takeFileRequests` hands out and a script's `request_files` takes.
156    pub fn to_value(&self) -> Value {
157        let mut entries = vec![
158            ("mode", Value::str(self.mode.name())),
159            ("multiple", Value::Bool(self.multiple)),
160            (
161                "filters",
162                Value::list(self.filters.iter().map(|f| {
163                    Value::map([
164                        ("name", Value::str(&f.name)),
165                        (
166                            "extensions",
167                            Value::list(f.extensions.iter().map(Value::str)),
168                        ),
169                    ])
170                })),
171            ),
172            ("tag", self.tag.clone()),
173        ];
174        if let Some(t) = &self.title {
175            entries.push(("title", Value::str(t)));
176        }
177        if let Some(d) = &self.directory {
178            entries.push(("directory", Value::str(d)));
179        }
180        if let Some(n) = &self.file_name {
181            entries.push(("file_name", Value::str(n)));
182        }
183        Value::map(entries)
184    }
185
186    /// Reads the plain-data shape back, `file_name` or `fileName` alike.
187    /// `Err` names what is wrong: an unknown mode, a filter without a name
188    /// or with extensions that are not strings.
189    pub fn from_value(v: &Value) -> Result<Self, String> {
190        let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
191        let mode = match s("mode") {
192            None => FileDialogMode::Open,
193            Some(m) => FileDialogMode::from_name(&m)
194                .ok_or_else(|| format!("unknown dialog mode {m:?} (open, save or folder)"))?,
195        };
196        let mut filters = Vec::new();
197        if let Some(list) = v.get("filters").and_then(Value::as_list) {
198            for f in list {
199                let name = f
200                    .get_str("name")
201                    .ok_or("a filter needs a `name`")?
202                    .to_string();
203                let extensions = f
204                    .get("extensions")
205                    .and_then(Value::as_list)
206                    .unwrap_or(&[])
207                    .iter()
208                    .map(|e| {
209                        e.as_str()
210                            .map(|e| e.trim_start_matches('.').to_string())
211                            .ok_or("a filter's `extensions` are strings")
212                    })
213                    .collect::<Result<Vec<_>, _>>()?;
214                filters.push(FileFilter { name, extensions });
215            }
216        }
217        Ok(FileDialog {
218            mode,
219            multiple: v.get_bool("multiple").unwrap_or(false),
220            title: s("title"),
221            filters,
222            directory: s("directory"),
223            file_name: s("file_name").or_else(|| s("fileName")),
224            tag: v.get("tag").cloned().unwrap_or(Value::Null),
225        })
226    }
227}
228
229/// Where the one ask stands.
230#[derive(Clone, Debug, Default)]
231pub(crate) enum FileAsk {
232    #[default]
233    None,
234    /// Asked, not yet taken by a host.
235    Queued(FileDialog, OriginId),
236    /// Taken, not yet answered: what the answer carries back and to whom.
237    Taken(Value, OriginId),
238}
239
240impl FileAsk {
241    pub(crate) fn pending(&self) -> bool {
242        !matches!(self, FileAsk::None)
243    }
244
245    /// The `files` event for the answer `paths`, and the ask spent; `None`
246    /// when nothing was asked — a late or stray answer is dropped.
247    pub(crate) fn answer(&mut self, paths: &[String]) -> Option<crate::input::UiEvent> {
248        let (tag, origin) = match std::mem::take(self) {
249            FileAsk::None => return None,
250            FileAsk::Queued(d, origin) => (d.tag, origin),
251            FileAsk::Taken(tag, origin) => (tag, origin),
252        };
253        Some(
254            crate::input::UiEvent::on(
255                origin,
256                Key::ROOT,
257                Value::map([
258                    ("kind", Value::str("files")),
259                    ("paths", Value::list(paths.iter().map(Value::str))),
260                ]),
261            )
262            .tagged(Some(&tag)),
263        )
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    /// A dot written before an extension is dropped by the builder as it
272    /// is on the way in from Node, Lua and C (backlog RG44).
273    #[test]
274    fn an_extension_is_taken_without_its_dot_by_every_door() {
275        let dotted = FileDialog::open().filter("Text", &[".txt", "md"]);
276        assert_eq!(dotted.filters[0].extensions, ["txt", "md"]);
277        assert_eq!(
278            FileDialog::from_value(&dotted.to_value()).unwrap().filters,
279            FileDialog::open().filter("Text", &["txt", "md"]).filters
280        );
281    }
282}