Skip to main content

azul_layout/desktop/
dialogs.rs

1//! Native OS dialog wrappers (message boxes, file open/save, color picker).
2//!
3//! Desktop targets back this with the `tfd` (tiny-file-dialogs) crate; on
4//! Android / iOS every method is a no-op that returns the "cancelled / safe
5//! default" answer (there is no equivalent of `tfd` on those platforms from
6//! a pure-Rust crate, and `tfd 0.1.0` does not cross-compile for them
7//! anyway). The public type surface is identical on every target so
8//! consumer code keeps compiling.
9
10use azul_css::{
11    corety::OptionString,
12    impl_option, impl_option_inner,
13    props::basic::color::{ColorU, OptionColorU},
14    AzString, OptionStringVec, StringVec,
15};
16
17#[cfg(not(any(target_os = "android", target_os = "ios")))]
18use tfd::{DefaultColorValue, MessageBoxIcon};
19
20/// Static-method namespace for `tfd`-backed message-box dialogs.
21#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
22#[repr(C)]
23pub struct MsgBox {
24    pub _reserved: u8,
25}
26
27/// Static-method namespace for `tfd`-backed file dialogs.
28#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
29#[repr(C)]
30pub struct FileDialog {
31    pub _reserved: u8,
32}
33
34/// Static-method namespace for the `tfd`-backed color picker.
35#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
36#[repr(C)]
37pub struct ColorPickerDialog {
38    pub _reserved: u8,
39}
40
41#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
42#[repr(C)]
43pub enum OkCancel {
44    Ok,
45    Cancel,
46}
47
48#[cfg(not(any(target_os = "android", target_os = "ios")))]
49impl From<tfd::OkCancel> for OkCancel {
50    #[inline]
51    fn from(e: tfd::OkCancel) -> Self {
52        match e {
53            tfd::OkCancel::Ok => OkCancel::Ok,
54            tfd::OkCancel::Cancel => OkCancel::Cancel,
55        }
56    }
57}
58
59#[cfg(not(any(target_os = "android", target_os = "ios")))]
60impl From<OkCancel> for tfd::OkCancel {
61    #[inline]
62    fn from(e: OkCancel) -> Self {
63        match e {
64            OkCancel::Ok => tfd::OkCancel::Ok,
65            OkCancel::Cancel => tfd::OkCancel::Cancel,
66        }
67    }
68}
69
70#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
71#[repr(C)]
72pub enum YesNo {
73    Yes,
74    No,
75}
76
77#[cfg(not(any(target_os = "android", target_os = "ios")))]
78impl From<YesNo> for tfd::YesNo {
79    #[inline]
80    fn from(e: YesNo) -> Self {
81        match e {
82            YesNo::Yes => tfd::YesNo::Yes,
83            YesNo::No => tfd::YesNo::No,
84        }
85    }
86}
87
88#[cfg(not(any(target_os = "android", target_os = "ios")))]
89impl From<tfd::YesNo> for YesNo {
90    #[inline]
91    fn from(e: tfd::YesNo) -> Self {
92        match e {
93            tfd::YesNo::Yes => YesNo::Yes,
94            tfd::YesNo::No => YesNo::No,
95        }
96    }
97}
98
99#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
100#[repr(C)]
101pub enum MsgBoxIcon {
102    Info,
103    Warning,
104    Error,
105    Question,
106}
107
108#[cfg(not(any(target_os = "android", target_os = "ios")))]
109impl From<MsgBoxIcon> for MessageBoxIcon {
110    #[inline]
111    fn from(e: MsgBoxIcon) -> Self {
112        match e {
113            MsgBoxIcon::Info => MessageBoxIcon::Info,
114            MsgBoxIcon::Warning => MessageBoxIcon::Warning,
115            MsgBoxIcon::Error => MessageBoxIcon::Error,
116            MsgBoxIcon::Question => MessageBoxIcon::Question,
117        }
118    }
119}
120
121impl MsgBox {
122    /// Returns a zero-initialised namespace handle. The struct itself carries
123    /// no state — instances exist only so the FFI layer can hang static
124    /// methods off the type.
125    pub const fn new() -> Self {
126        Self { _reserved: 0 }
127    }
128
129    /// "Ok" message box — title, message, icon. Quotes are stripped from the
130    /// message to work around `tfd` misinterpreting them as shell metacharacters
131    /// on some platforms.
132    pub fn ok(title: AzString, message: AzString, icon: MsgBoxIcon) {
133        #[cfg(not(any(target_os = "android", target_os = "ios")))]
134        {
135            let mut msg = message.as_str().to_string();
136            msg = msg.replace('\"', "");
137            msg = msg.replace('\'', "");
138            tfd::MessageBox::new(title.as_str(), &msg)
139                .with_icon(icon.into())
140                .run_modal();
141        }
142        #[cfg(any(target_os = "android", target_os = "ios"))]
143        {
144            let _ = (title, message, icon);
145        }
146    }
147
148    /// "Ok / Cancel" message box — title, message, icon, default button.
149    pub fn ok_cancel(
150        title: AzString,
151        message: AzString,
152        icon: MsgBoxIcon,
153        default: OkCancel,
154    ) -> OkCancel {
155        #[cfg(not(any(target_os = "android", target_os = "ios")))]
156        {
157            tfd::MessageBox::new(title.as_str(), message.as_str())
158                .with_icon(icon.into())
159                .run_modal_ok_cancel(default.into())
160                .into()
161        }
162        #[cfg(any(target_os = "android", target_os = "ios"))]
163        {
164            let _ = (title, message, icon);
165            default
166        }
167    }
168
169    /// "Yes / No" message box — title, message, icon, default button.
170    pub fn yes_no(
171        title: AzString,
172        message: AzString,
173        icon: MsgBoxIcon,
174        default: YesNo,
175    ) -> YesNo {
176        #[cfg(not(any(target_os = "android", target_os = "ios")))]
177        {
178            tfd::MessageBox::new(title.as_str(), message.as_str())
179                .with_icon(icon.into())
180                .run_modal_yes_no(default.into())
181                .into()
182        }
183        #[cfg(any(target_os = "android", target_os = "ios"))]
184        {
185            let _ = (title, message, icon);
186            default
187        }
188    }
189
190    /// Convenience: "Ok" message box with the title "Info" and an info icon.
191    pub fn info(content: AzString) {
192        Self::ok(AzString::from("Info"), content, MsgBoxIcon::Info);
193    }
194}
195
196impl ColorPickerDialog {
197    /// Returns a zero-initialised namespace handle. Static-only — the struct
198    /// is just a hook for the FFI layer.
199    pub const fn new() -> Self {
200        Self { _reserved: 0 }
201    }
202
203    /// Opens the default color picker dialog. Returns `None` if cancelled.
204    pub fn open(title: AzString, default_value: OptionColorU) -> OptionColorU {
205        #[cfg(not(any(target_os = "android", target_os = "ios")))]
206        {
207            let rgb = default_value
208                .into_option()
209                .map_or([0, 0, 0], |c| [c.r, c.g, c.b]);
210            let default_color = DefaultColorValue::RGB(rgb);
211            let result = tfd::ColorChooser::new(title.as_str())
212                .with_default_color(default_color)
213                .run_modal();
214            match result {
215                Some(r) => OptionColorU::Some(ColorU {
216                    r: r.1[0],
217                    g: r.1[1],
218                    b: r.1[2],
219                    a: ColorU::ALPHA_OPAQUE,
220                }),
221                None => OptionColorU::None,
222            }
223        }
224        #[cfg(any(target_os = "android", target_os = "ios"))]
225        {
226            let _ = title;
227            default_value
228        }
229    }
230}
231
232#[derive(Debug, Clone, PartialEq, PartialOrd)]
233#[repr(C)]
234pub struct FileTypeList {
235    pub document_types: StringVec,
236    pub document_descriptor: AzString,
237}
238
239impl_option!(
240    FileTypeList,
241    OptionFileTypeList,
242    copy = false,
243    [Debug, Clone, PartialEq, PartialOrd]
244);
245
246/// Apply a [`FileTypeList`] filter to a `tfd::FileDialog`.
247#[cfg(not(any(target_os = "android", target_os = "ios")))]
248fn apply_filter(mut dialog: tfd::FileDialog, filter: FileTypeList) -> tfd::FileDialog {
249    let v = filter.document_types.clone().into_library_owned_vec();
250    let patterns: Vec<&str> = v.iter().map(|s| s.as_str()).collect();
251    dialog = dialog.with_filter(&patterns, filter.document_descriptor.as_str());
252    dialog
253}
254
255impl FileDialog {
256    /// Returns a zero-initialised namespace handle. Static-only — the struct
257    /// is just a hook for the FFI layer.
258    pub const fn new() -> Self {
259        Self { _reserved: 0 }
260    }
261
262    /// Open a single file. Returns `None` if the user cancelled.
263    pub fn open_file(
264        title: AzString,
265        default_path: OptionString,
266        filter_list: OptionFileTypeList,
267    ) -> OptionString {
268        #[cfg(not(any(target_os = "android", target_os = "ios")))]
269        {
270            let mut dialog = tfd::FileDialog::new(title.as_str());
271            if let Some(path) = default_path.as_option() {
272                dialog = dialog.with_path(path.as_str());
273            }
274            if let Some(filter) = filter_list.into_option() {
275                dialog = apply_filter(dialog, filter);
276            }
277            dialog.open_file().map(AzString::from).into()
278        }
279        #[cfg(any(target_os = "android", target_os = "ios"))]
280        {
281            let _ = (title, default_path, filter_list);
282            OptionString::None
283        }
284    }
285
286    /// Open a directory. Returns `None` if the user cancelled.
287    pub fn open_directory(title: AzString, default_path: OptionString) -> OptionString {
288        #[cfg(not(any(target_os = "android", target_os = "ios")))]
289        {
290            let mut dialog = tfd::FileDialog::new(title.as_str());
291            if let Some(path) = default_path.as_option() {
292                dialog = dialog.with_path(path.as_str());
293            }
294            dialog.select_folder().map(AzString::from).into()
295        }
296        #[cfg(any(target_os = "android", target_os = "ios"))]
297        {
298            let _ = (title, default_path);
299            OptionString::None
300        }
301    }
302
303    /// Open multiple files. Returns `None` if the user cancelled.
304    pub fn open_multiple_files(
305        title: AzString,
306        default_path: OptionString,
307        filter_list: OptionFileTypeList,
308    ) -> OptionStringVec {
309        #[cfg(not(any(target_os = "android", target_os = "ios")))]
310        {
311            let mut dialog =
312                tfd::FileDialog::new(title.as_str()).with_multiple_selection(true);
313            if let Some(path) = default_path.as_option() {
314                dialog = dialog.with_path(path.as_str());
315            }
316            if let Some(filter) = filter_list.into_option() {
317                dialog = apply_filter(dialog, filter);
318            }
319            dialog.open_files().map(StringVec::from).into()
320        }
321        #[cfg(any(target_os = "android", target_os = "ios"))]
322        {
323            let _ = (title, default_path, filter_list);
324            OptionStringVec::None
325        }
326    }
327
328    /// Save file dialog. Returns `None` if the user cancelled.
329    pub fn save_file(title: AzString, default_path: OptionString) -> OptionString {
330        #[cfg(not(any(target_os = "android", target_os = "ios")))]
331        {
332            let mut dialog = tfd::FileDialog::new(title.as_str());
333            if let Some(path) = default_path.as_option() {
334                dialog = dialog.with_path(path.as_str());
335            }
336            dialog.save_file().map(AzString::from).into()
337        }
338        #[cfg(any(target_os = "android", target_os = "ios"))]
339        {
340            let _ = (title, default_path);
341            OptionString::None
342        }
343    }
344}
345
346/// Convenience shim: show a default "Info" message box.
347pub fn msg_box(content: &str) {
348    MsgBox::info(AzString::from(content));
349}