Skip to main content

kui_core/
dialog.rs

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