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