Skip to main content

nifty_macos/
dialog.rs

1//! File dialogs: `NSOpenPanel` and `NSSavePanel`, as a sheet on the key window, or a
2//! window of their own when none of the app's windows is key.
3
4use std::cell::Cell;
5
6use block2::RcBlock;
7use objc2::rc::Retained;
8use objc2::{MainThreadMarker, Message};
9use objc2_app_kit::{NSApplication, NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel};
10use objc2_foundation::{NSArray, NSString, NSURL};
11use objc2_uniform_type_identifiers::UTType;
12
13use nifty_runtime::{DialogHandler, DialogKind, FileDialog, Picked};
14
15/// The panel a dialog shows; open panels are save panels with more settings.
16enum Panel {
17    Open(Retained<NSOpenPanel>),
18    Save(Retained<NSSavePanel>),
19}
20
21impl Panel {
22    fn new(mtm: MainThreadMarker, kind: DialogKind) -> Self {
23        if kind == DialogKind::SavePath {
24            return Self::Save(NSSavePanel::savePanel(mtm));
25        }
26        let panel = NSOpenPanel::openPanel(mtm);
27        panel.setCanChooseFiles(kind != DialogKind::Folder);
28        panel.setCanChooseDirectories(kind == DialogKind::Folder);
29        panel.setAllowsMultipleSelection(kind.picks_many());
30        Self::Open(panel)
31    }
32
33    fn save_panel(&self) -> &NSSavePanel {
34        match self {
35            Self::Open(panel) => panel,
36            Self::Save(panel) => panel,
37        }
38    }
39
40    /// The paths the user chose, once they pressed the panel's default button.
41    fn chosen(&self) -> Result<Vec<String>, String> {
42        let urls = match self {
43            Self::Open(panel) => panel.URLs().to_vec(),
44            Self::Save(panel) => panel.URL().into_iter().collect(),
45        };
46        if urls.is_empty() {
47            return Err("macOS said a file was chosen but didn't say which".to_owned());
48        }
49        urls.iter().map(|url| path(url)).collect()
50    }
51}
52
53/// Shows `dialog`, passing how it closed to `on_close` once the user closes it.
54pub(crate) fn show(
55    mtm: MainThreadMarker,
56    app: &NSApplication,
57    dialog: &FileDialog,
58    on_close: DialogHandler,
59) {
60    let panel = Panel::new(mtm, dialog.kind);
61    if let Err(reason) = configure(panel.save_panel(), dialog) {
62        on_close(Picked::Failed(reason));
63        return;
64    }
65    let pending = Cell::new(Some(on_close));
66    let shown = panel.save_panel().retain();
67    let handler = RcBlock::new(move |response: NSModalResponse| {
68        let picked = if response == NSModalResponseOK {
69            panel.chosen().map_or_else(Picked::Failed, Picked::Chosen)
70        } else {
71            Picked::Cancelled
72        };
73        if let Some(on_close) = pending.take() {
74            on_close(picked);
75        }
76    });
77    match app.keyWindow() {
78        Some(window) => shown.beginSheetModalForWindow_completionHandler(&window, &handler),
79        None => shown.beginWithCompletionHandler(&handler),
80    }
81}
82
83fn configure(panel: &NSSavePanel, dialog: &FileDialog) -> Result<(), String> {
84    panel.setCanCreateDirectories(true);
85    if !dialog.title.is_empty() {
86        // Panels show their message inside them; their title bar is hidden as a sheet.
87        panel.setMessage(Some(&NSString::from_str(&dialog.title)));
88    }
89    if !dialog.types.is_empty() {
90        let types = dialog
91            .types
92            .iter()
93            .map(|extension| {
94                UTType::typeWithFilenameExtension(&NSString::from_str(extension))
95                    .ok_or_else(|| format!("macOS doesn't know the file type `{extension}`"))
96            })
97            .collect::<Result<Vec<_>, _>>()?;
98        panel.setAllowedContentTypes(&NSArray::from_retained_slice(&types));
99    }
100    if let Some(folder) = &dialog.folder {
101        let folder = NSString::from_str(&folder.to_string_lossy());
102        panel.setDirectoryURL(Some(&NSURL::fileURLWithPath_isDirectory(&folder, true)));
103    }
104    if !dialog.name.is_empty() {
105        panel.setNameFieldStringValue(&NSString::from_str(&dialog.name));
106    }
107    Ok(())
108}
109
110fn path(url: &NSURL) -> Result<String, String> {
111    url.path()
112        .map(|path| path.to_string())
113        .ok_or_else(|| "macOS chose a location that isn't a file path".to_owned())
114}