druid_shell/
dialog.rs

1// Copyright 2019 The Druid Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! File open/save dialogs.
16
17use std::path::{Path, PathBuf};
18
19/// Information about the path to be opened or saved.
20///
21/// This path might point to a file or a directory.
22#[derive(Debug, Clone)]
23pub struct FileInfo {
24    /// The path to the selected file.
25    ///
26    /// On macOS, this is already rewritten to use the extension that the user selected
27    /// with the `file format` property.
28    pub path: PathBuf,
29    /// The selected file format.
30    ///
31    /// If there're multiple different formats available
32    /// this allows understanding the kind of format that the user expects the file
33    /// to be written in. Examples could be Blender 2.4 vs Blender 2.6 vs Blender 2.8.
34    /// The `path` above will already contain the appropriate extension chosen in the
35    /// `format` property, so it is not necessary to mutate `path` any further.
36    pub format: Option<FileSpec>,
37}
38
39/// Type of file dialog.
40#[cfg(not(any(
41    all(
42        feature = "x11",
43        any(target_os = "freebsd", target_os = "linux", target_os = "openbsd")
44    ),
45    feature = "wayland"
46)))]
47#[derive(Clone, Copy, PartialEq, Eq)]
48pub enum FileDialogType {
49    /// File open dialog.
50    Open,
51    /// File save dialog.
52    Save,
53}
54
55/// Options for file dialogs.
56///
57/// File dialogs let the user choose a specific path to open or save.
58///
59/// By default the file dialogs operate in *files mode* where the user can only choose files.
60/// Importantly these are files from the user's perspective, but technically the returned path
61/// will be a directory when the user chooses a package. You can read more about [packages] below.
62/// It's also possible for users to manually specify a path which they might otherwise not be able
63/// to choose. Thus it is important to verify that all the returned paths match your expectations.
64///
65/// The open dialog can also be switched to *directories mode* via [`select_directories`].
66///
67/// # Cross-platform compatibility
68///
69/// You could write platform specific code that really makes the best use of each platform.
70/// However if you want to write universal code that will work on all platforms then
71/// you have to keep some restrictions in mind.
72///
73/// ## Don't depend on directories with extensions
74///
75/// Your application should avoid having to deal with directories that have extensions
76/// in their name, e.g. `my_stuff.pkg`. This clashes with [packages] on macOS and you
77/// will either need platform specific code or a degraded user experience on macOS
78/// via [`packages_as_directories`].
79///
80/// ## Use the save dialog only for new paths
81///
82/// Don't direct the user to choose an existing file with the save dialog.
83/// Selecting existing files for overwriting is possible but extremely cumbersome on macOS.
84/// The much more optimized flow is to have the user select a file with the open dialog
85/// and then keep saving to that file without showing a save dialog.
86/// Use the save dialog only for selecting a new location.
87///
88/// # macOS
89///
90/// The file dialog works a bit differently on macOS. For a lot of applications this doesn't matter
91/// and you don't need to know the details. However if your application makes extensive use
92/// of file dialogs and you target macOS then you should understand the macOS specifics.
93///
94/// ## Packages
95///
96/// On macOS directories with known extensions are considered to be packages, e.g. `app_files.pkg`.
97/// Furthermore the packages are divided into two groups based on their extension.
98/// First there are packages that have been defined at the OS level, and secondly there are
99/// packages that are defined at the file dialog level based on [`allowed_types`].
100/// These two types have slightly different behavior in the file dialogs. Generally packages
101/// behave similarly to regular files in many contexts, including the file dialogs.
102/// This package concept can be turned off in the file dialog via [`packages_as_directories`].
103///
104/// &#xFEFF; | Packages as files. File filters apply to packages. | Packages as directories.
105/// -------- | -------------------------------------------------- | ------------------------
106/// Open directory | Not selectable. Not traversable. | Selectable. Traversable.
107/// Open file | Selectable. Not traversable. | Not selectable. Traversable.
108/// Save file | OS packages [clickable] but not traversable.<br/>Dialog packages traversable but not selectable. | Not selectable. Traversable.
109///
110/// Keep in mind that the file dialog may start inside any package if the user has traversed
111/// into one just recently. The user might also manually specify a path inside a package.
112///
113/// Generally this behavior should be kept, because it's least surprising to macOS users.
114/// However if your application requires selecting directories with extensions as directories
115/// or the user needs to be able to traverse into them to select a specific file,
116/// then you can change the default behavior via [`packages_as_directories`]
117/// to force macOS to behave like other platforms and not give special treatment to packages.
118///
119/// ## Selecting files for overwriting in the save dialog is cumbersome
120///
121/// Existing files can be clicked on in the save dialog, but that only copies their base file name.
122/// If the clicked file's extension is different than the first extension of the default type
123/// then the returned path does not actually match the path of the file that was clicked on.
124/// Clicking on a file doesn't change the base path either. Keep in mind that the macOS file dialog
125/// can have several directories open at once. So if a user has traversed into `/Users/Joe/foo/`
126/// and then clicks on an existing file `/Users/Joe/old.txt` in another directory then the returned
127/// path will actually be `/Users/Joe/foo/old.rtf` if the default type's first extension is `rtf`.
128///
129/// [clickable]: #selecting-files-for-overwriting-in-the-save-dialog-is-cumbersome
130/// [packages]: #packages
131/// [`select_directories`]: #method.select_directories
132/// [`allowed_types`]: #method.allowed_types
133/// [`packages_as_directories`]: #method.packages_as_directories
134#[derive(Debug, Clone, Default)]
135pub struct FileDialogOptions {
136    pub(crate) show_hidden: bool,
137    pub(crate) allowed_types: Option<Vec<FileSpec>>,
138    pub(crate) default_type: Option<FileSpec>,
139    pub(crate) select_directories: bool,
140    pub(crate) packages_as_directories: bool,
141    pub(crate) multi_selection: bool,
142    pub(crate) default_name: Option<String>,
143    pub(crate) name_label: Option<String>,
144    pub(crate) title: Option<String>,
145    pub(crate) button_text: Option<String>,
146    pub(crate) starting_directory: Option<PathBuf>,
147}
148
149/// A description of a filetype, for specifying allowed types in a file dialog.
150///
151/// # Windows
152///
153/// Each instance of this type is converted to a [`COMDLG_FILTERSPEC`] struct.
154///
155/// # macOS
156///
157/// These file types also apply to directories to define them as [packages].
158///
159/// [`COMDLG_FILTERSPEC`]: https://docs.microsoft.com/en-ca/windows/win32/api/shtypes/ns-shtypes-comdlg_filterspec
160/// [packages]: struct.FileDialogOptions.html#packages
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct FileSpec {
163    /// A human readable name, describing this filetype.
164    ///
165    /// This is used in the Windows file dialog, where the user can select
166    /// from a dropdown the type of file they would like to choose.
167    ///
168    /// This should not include the file extensions; they will be added automatically.
169    /// For instance, if we are describing Word documents, the name would be "Word Document",
170    /// and the displayed string would be "Word Document (*.doc)".
171    pub name: &'static str,
172    /// The file extensions used by this file type.
173    ///
174    /// This should not include the leading '.'.
175    pub extensions: &'static [&'static str],
176}
177
178impl FileInfo {
179    /// Returns the underlying path.
180    pub fn path(&self) -> &Path {
181        &self.path
182    }
183}
184
185impl FileDialogOptions {
186    /// Create a new set of options.
187    pub fn new() -> FileDialogOptions {
188        FileDialogOptions::default()
189    }
190
191    /// Set hidden files and directories to be visible.
192    pub fn show_hidden(mut self) -> Self {
193        self.show_hidden = true;
194        self
195    }
196
197    /// Set directories to be selectable instead of files.
198    ///
199    /// This is only relevant for open dialogs.
200    pub fn select_directories(mut self) -> Self {
201        self.select_directories = true;
202        self
203    }
204
205    /// Set [packages] to be treated as directories instead of files.
206    ///
207    /// This allows for writing more universal cross-platform code at the cost of user experience.
208    ///
209    /// This is only relevant on macOS.
210    ///
211    /// [packages]: #packages
212    pub fn packages_as_directories(mut self) -> Self {
213        self.packages_as_directories = true;
214        self
215    }
216
217    /// Set multiple items to be selectable.
218    ///
219    /// This is only relevant for open dialogs.
220    pub fn multi_selection(mut self) -> Self {
221        self.multi_selection = true;
222        self
223    }
224
225    /// Set the file types the user is allowed to select.
226    ///
227    /// This filter is only applied to files and [packages], but not to directories.
228    ///
229    /// An empty collection is treated as no filter.
230    ///
231    /// # macOS
232    ///
233    /// These file types also apply to directories to define [packages].
234    /// Which means the directories that match the filter are no longer considered directories.
235    /// The packages are defined by this collection even in *directories mode*.
236    ///
237    /// [packages]: #packages
238    pub fn allowed_types(mut self, types: Vec<FileSpec>) -> Self {
239        // An empty vector can cause platform issues, so treat it as no filter
240        if types.is_empty() {
241            self.allowed_types = None;
242        } else {
243            self.allowed_types = Some(types);
244        }
245        self
246    }
247
248    /// Set the default file type.
249    ///
250    /// The provided `default_type` must also be present in [`allowed_types`].
251    ///
252    /// If it's `None` then the first entry in [`allowed_types`] will be used as the default.
253    ///
254    /// This is only relevant in *files mode*.
255    ///
256    /// [`allowed_types`]: #method.allowed_types
257    pub fn default_type(mut self, default_type: FileSpec) -> Self {
258        self.default_type = Some(default_type);
259        self
260    }
261
262    /// Set the default filename that appears in the dialog.
263    pub fn default_name(mut self, default_name: impl Into<String>) -> Self {
264        self.default_name = Some(default_name.into());
265        self
266    }
267
268    /// Set the text in the label next to the filename editbox.
269    pub fn name_label(mut self, name_label: impl Into<String>) -> Self {
270        self.name_label = Some(name_label.into());
271        self
272    }
273
274    /// Set the title text of the dialog.
275    pub fn title(mut self, title: impl Into<String>) -> Self {
276        self.title = Some(title.into());
277        self
278    }
279
280    /// Set the text of the Open/Save button.
281    pub fn button_text(mut self, text: impl Into<String>) -> Self {
282        self.button_text = Some(text.into());
283        self
284    }
285
286    /// Force the starting directory to the specified `path`.
287    ///
288    /// # User experience
289    ///
290    /// This should almost never be used because it overrides the OS choice,
291    /// which will usually be a directory that the user recently visited.
292    pub fn force_starting_directory(mut self, path: impl Into<PathBuf>) -> Self {
293        self.starting_directory = Some(path.into());
294        self
295    }
296}
297
298impl FileSpec {
299    pub const TEXT: FileSpec = FileSpec::new("Text", &["txt"]);
300    pub const JPG: FileSpec = FileSpec::new("Jpeg", &["jpg", "jpeg"]);
301    pub const GIF: FileSpec = FileSpec::new("Gif", &["gif"]);
302    pub const PNG: FileSpec = FileSpec::new("Portable network graphics (png)", &["png"]);
303    pub const PDF: FileSpec = FileSpec::new("PDF", &["pdf"]);
304    pub const HTML: FileSpec = FileSpec::new("Web Page", &["htm", "html"]);
305
306    /// Create a new `FileSpec`.
307    pub const fn new(name: &'static str, extensions: &'static [&'static str]) -> Self {
308        FileSpec { name, extensions }
309    }
310}