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}
390
391#[cfg(test)]
392mod autotest_generated {
393    use super::*;
394
395    // Every dialog entry point in this file (`MsgBox::ok`, `FileDialog::open_file`,
396    // `ColorPickerDialog::open`, `msg_box`, ...) ends in a `run_modal()` /
397    // `open_file()` call that blocks on a native modal window. Calling one from a
398    // test would hang the test binary forever (or shell out to zenity/kdialog on
399    // a headless box), so they are NEVER invoked here. Instead they are covered by
400    // a signature guard (below) that type-checks the FFI surface without running
401    // it, and by the android/iOS no-op contract tests, which exercise the branch
402    // that genuinely returns without showing a dialog.
403    //
404    // What IS exercised for real: the three const namespace constructors, the
405    // `tfd` enum conversions, and `apply_filter` — a pure builder that never
406    // opens anything.
407
408    fn s(value: &str) -> AzString {
409        AzString::from(value.to_string())
410    }
411
412    fn file_type_list(patterns: &[&str], descriptor: &str) -> FileTypeList {
413        FileTypeList {
414            document_types: StringVec::from_vec(patterns.iter().map(|p| s(p)).collect()),
415            document_descriptor: s(descriptor),
416        }
417    }
418
419    // ---------------------------------------------------------------------
420    // Constructors: MsgBox::new / FileDialog::new / ColorPickerDialog::new
421    // ---------------------------------------------------------------------
422
423    #[test]
424    fn namespace_handles_are_zero_initialised() {
425        assert_eq!(MsgBox::new()._reserved, 0);
426        assert_eq!(FileDialog::new()._reserved, 0);
427        assert_eq!(ColorPickerDialog::new()._reserved, 0);
428    }
429
430    #[test]
431    fn namespace_handles_are_const_evaluable() {
432        // `new()` is `const fn`; if it ever stops being usable in a const context
433        // the FFI/api.json static-namespace contract breaks. This fails to compile
434        // rather than fails at runtime, which is the point.
435        const MSG_BOX: MsgBox = MsgBox::new();
436        const FILE_DIALOG: FileDialog = FileDialog::new();
437        const COLOR_PICKER: ColorPickerDialog = ColorPickerDialog::new();
438
439        assert_eq!(MSG_BOX._reserved, 0);
440        assert_eq!(FILE_DIALOG._reserved, 0);
441        assert_eq!(COLOR_PICKER._reserved, 0);
442    }
443
444    #[test]
445    fn namespace_handles_default_matches_new() {
446        assert_eq!(MsgBox::default(), MsgBox::new());
447        assert_eq!(FileDialog::default(), FileDialog::new());
448        assert_eq!(ColorPickerDialog::default(), ColorPickerDialog::new());
449    }
450
451    #[test]
452    fn namespace_handles_are_stateless_single_byte_shims() {
453        // These types are `#[repr(C)]` placeholders that the FFI layer hangs static
454        // methods off. A field creeping in would silently change the C ABI.
455        assert_eq!(core::mem::size_of::<MsgBox>(), 1);
456        assert_eq!(core::mem::size_of::<FileDialog>(), 1);
457        assert_eq!(core::mem::size_of::<ColorPickerDialog>(), 1);
458        assert_eq!(core::mem::align_of::<MsgBox>(), 1);
459        assert_eq!(core::mem::align_of::<FileDialog>(), 1);
460        assert_eq!(core::mem::align_of::<ColorPickerDialog>(), 1);
461    }
462
463    #[test]
464    fn namespace_handles_are_copy_and_hash_consistently() {
465        use std::{
466            collections::hash_map::DefaultHasher,
467            hash::{Hash, Hasher},
468        };
469
470        fn hash_of<T: Hash>(value: &T) -> u64 {
471            let mut hasher = DefaultHasher::new();
472            value.hash(&mut hasher);
473            hasher.finish()
474        }
475
476        let original = MsgBox::new();
477        let copied = original; // Copy, not a move
478        assert_eq!(original, copied);
479        assert_eq!(hash_of(&original), hash_of(&copied));
480        assert_eq!(hash_of(&MsgBox::new()), hash_of(&MsgBox::new()));
481        assert_eq!(hash_of(&FileDialog::new()), hash_of(&FileDialog::new()));
482        assert_eq!(
483            hash_of(&ColorPickerDialog::new()),
484            hash_of(&ColorPickerDialog::new())
485        );
486    }
487
488    // ---------------------------------------------------------------------
489    // Enum conversions to/from `tfd` (round-trip: encode == decode)
490    // ---------------------------------------------------------------------
491
492    #[cfg(not(any(target_os = "android", target_os = "ios")))]
493    #[test]
494    fn ok_cancel_round_trips_through_tfd() {
495        for variant in [OkCancel::Ok, OkCancel::Cancel] {
496            let encoded: tfd::OkCancel = variant.into();
497            let decoded: OkCancel = encoded.into();
498            assert_eq!(decoded, variant, "round-trip lost {variant:?}");
499        }
500
501        // ... and the other direction, exhaustively.
502        assert_eq!(OkCancel::from(tfd::OkCancel::Ok), OkCancel::Ok);
503        assert_eq!(OkCancel::from(tfd::OkCancel::Cancel), OkCancel::Cancel);
504    }
505
506    #[cfg(not(any(target_os = "android", target_os = "ios")))]
507    #[test]
508    fn yes_no_round_trips_through_tfd() {
509        for variant in [YesNo::Yes, YesNo::No] {
510            let encoded: tfd::YesNo = variant.into();
511            let decoded: YesNo = encoded.into();
512            assert_eq!(decoded, variant, "round-trip lost {variant:?}");
513        }
514
515        assert_eq!(YesNo::from(tfd::YesNo::Yes), YesNo::Yes);
516        assert_eq!(YesNo::from(tfd::YesNo::No), YesNo::No);
517    }
518
519    #[cfg(not(any(target_os = "android", target_os = "ios")))]
520    #[test]
521    fn answer_enums_must_be_converted_by_variant_never_by_discriminant() {
522        // azul declares `OkCancel { Ok, Cancel }` (Ok = 0) but tfd declares
523        // `OkCancel { Cancel = 0, Ok = 1 }` — the discriminants are INVERTED.
524        // Same story for YesNo. So a `transmute` or an `as`-cast in place of the
525        // `From` impls would silently turn "Ok" into "Cancel", i.e. hand the caller
526        // the exact opposite of what the user clicked. This test pins the mismatch
527        // so nobody "optimises" the match arms into a cast.
528        assert_eq!(OkCancel::Ok as u8, 0);
529        assert_eq!(OkCancel::Cancel as u8, 1);
530        assert_eq!(tfd::OkCancel::Ok as u8, 1);
531        assert_eq!(tfd::OkCancel::Cancel as u8, 0);
532
533        assert_eq!(YesNo::Yes as u8, 0);
534        assert_eq!(YesNo::No as u8, 1);
535        assert_eq!(tfd::YesNo::Yes as u8, 1);
536        assert_eq!(tfd::YesNo::No as u8, 0);
537
538        // The conversions must follow the variant, not the number.
539        assert_eq!(tfd::OkCancel::from(OkCancel::Ok), tfd::OkCancel::Ok);
540        assert_eq!(tfd::YesNo::from(YesNo::Yes), tfd::YesNo::Yes);
541    }
542
543    #[cfg(not(any(target_os = "android", target_os = "ios")))]
544    #[test]
545    fn msg_box_icon_maps_to_the_matching_tfd_icon() {
546        let mapping = [
547            (MsgBoxIcon::Info, MessageBoxIcon::Info),
548            (MsgBoxIcon::Warning, MessageBoxIcon::Warning),
549            (MsgBoxIcon::Error, MessageBoxIcon::Error),
550            (MsgBoxIcon::Question, MessageBoxIcon::Question),
551        ];
552        for (ours, theirs) in mapping {
553            assert_eq!(MessageBoxIcon::from(ours), theirs, "wrong icon for {ours:?}");
554        }
555
556        // Injective: four distinct inputs must not collapse onto three icons.
557        let encoded: Vec<MessageBoxIcon> = mapping
558            .iter()
559            .map(|(ours, _)| MessageBoxIcon::from(*ours))
560            .collect();
561        for (i, a) in encoded.iter().enumerate() {
562            for b in encoded.iter().skip(i + 1) {
563                assert_ne!(a, b, "two MsgBoxIcon variants map to the same tfd icon");
564            }
565        }
566    }
567
568    // ---------------------------------------------------------------------
569    // apply_filter — the only non-modal logic in this file
570    // ---------------------------------------------------------------------
571
572    #[cfg(not(any(target_os = "android", target_os = "ios")))]
573    #[test]
574    fn apply_filter_with_no_patterns_does_not_panic() {
575        let dialog = apply_filter(tfd::FileDialog::new("title"), file_type_list(&[], ""));
576        assert!(dialog.filter_patterns().is_empty());
577        assert_eq!(dialog.filter_description(), "");
578    }
579
580    #[cfg(not(any(target_os = "android", target_os = "ios")))]
581    #[test]
582    fn apply_filter_with_a_default_constructed_string_vec_does_not_panic() {
583        // `StringVec::new()` is the empty/possibly-null-pointer case that
584        // `into_library_owned_vec` has to survive.
585        let filter = FileTypeList {
586            document_types: StringVec::new(),
587            document_descriptor: s("no types"),
588        };
589        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
590        assert!(dialog.filter_patterns().is_empty());
591        assert_eq!(dialog.filter_description(), "no types");
592    }
593
594    #[cfg(not(any(target_os = "android", target_os = "ios")))]
595    #[test]
596    fn apply_filter_preserves_patterns_verbatim_and_in_order() {
597        let filter = file_type_list(&["*.png", "*.jpg", "*.png", ""], "Images");
598        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
599
600        // Duplicates and the empty pattern survive: the filter is a pass-through,
601        // not a set.
602        assert_eq!(dialog.filter_patterns(), &["*.png", "*.jpg", "*.png", ""]);
603        assert_eq!(dialog.filter_description(), "Images");
604    }
605
606    #[cfg(not(any(target_os = "android", target_os = "ios")))]
607    #[test]
608    fn apply_filter_preserves_unicode_patterns() {
609        let patterns = [
610            "*.图片",          // CJK
611            "*.🎨",            // astral-plane emoji
612            "*.مِلَف",           // RTL with combining marks
613            "*.e\u{0301}xt",   // decomposed é — must not be normalised away
614            "*.\u{200B}zwsp",  // zero-width space
615        ];
616        let filter = file_type_list(&patterns, "Ünïcödé — файлы 🎨");
617        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
618
619        assert_eq!(dialog.filter_patterns(), &patterns);
620        assert_eq!(dialog.filter_description(), "Ünïcödé — файлы 🎨");
621    }
622
623    #[cfg(not(any(target_os = "android", target_os = "ios")))]
624    #[test]
625    fn apply_filter_does_not_truncate_at_interior_nul_bytes() {
626        // A NUL is a legal Rust `str` byte but terminates a C string. `apply_filter`
627        // is pure Rust, so it must hand the bytes on intact rather than silently
628        // cutting the pattern short (a truncation here would turn "*.png\0evil" into
629        // a filter the caller never asked for).
630        let filter = file_type_list(&["*.pn\0g", "\0", "a\u{1}b\u{7f}"], "desc\0ription");
631        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
632
633        assert_eq!(dialog.filter_patterns(), &["*.pn\0g", "\0", "a\u{1}b\u{7f}"]);
634        assert_eq!(dialog.filter_description(), "desc\0ription");
635        assert_eq!(dialog.filter_patterns()[0].len(), 6); // bytes kept, not cut at the NUL
636    }
637
638    #[cfg(not(any(target_os = "android", target_os = "ios")))]
639    #[test]
640    fn apply_filter_passes_shell_metacharacters_through_unchanged() {
641        // Documents the ACTUAL behaviour: unlike `MsgBox::ok` (which strips quotes
642        // before handing the string to tfd), `apply_filter` sanitises nothing. If a
643        // sanitisation step is ever added, this test should be updated deliberately
644        // — it must not change by accident.
645        let hostile = ["\"", "'", "$(id)", "`id`", "a;b", "x\ny", "--", "*"];
646        let filter = file_type_list(&hostile, "\"quoted\" $(id)");
647        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
648
649        assert_eq!(dialog.filter_patterns(), &hostile);
650        assert_eq!(dialog.filter_description(), "\"quoted\" $(id)");
651    }
652
653    #[cfg(not(any(target_os = "android", target_os = "ios")))]
654    #[test]
655    fn apply_filter_survives_a_huge_filter_list() {
656        let patterns: Vec<String> = (0..2000).map(|i| format!("*.ext{i}")).collect();
657        let descriptor = "d".repeat(64 * 1024);
658        let filter = FileTypeList {
659            document_types: StringVec::from_vec(
660                patterns.iter().map(|p| s(p)).collect::<Vec<AzString>>(),
661            ),
662            document_descriptor: s(&descriptor),
663        };
664
665        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
666
667        assert_eq!(dialog.filter_patterns().len(), 2000);
668        assert_eq!(dialog.filter_patterns()[0], "*.ext0");
669        assert_eq!(dialog.filter_patterns()[1999], "*.ext1999");
670        assert_eq!(dialog.filter_description().len(), 64 * 1024);
671    }
672
673    #[cfg(not(any(target_os = "android", target_os = "ios")))]
674    #[test]
675    fn apply_filter_overwrites_rather_than_appends() {
676        // tfd's `with_filter` assigns, so applying twice is last-write-wins. Worth
677        // pinning: an `open_file` caller that expects the two lists to merge would
678        // silently lose the first set of extensions.
679        let dialog = tfd::FileDialog::new("title");
680        let dialog = apply_filter(dialog, file_type_list(&["*.png"], "Images"));
681        let dialog = apply_filter(dialog, file_type_list(&["*.txt"], "Text"));
682
683        assert_eq!(dialog.filter_patterns(), &["*.txt"]);
684        assert_eq!(dialog.filter_description(), "Text");
685    }
686
687    #[cfg(not(any(target_os = "android", target_os = "ios")))]
688    #[test]
689    fn apply_filter_leaves_the_rest_of_the_dialog_alone() {
690        // `open_multiple_files` sets the path + multi-select BEFORE calling
691        // apply_filter; the filter must not clobber either.
692        let dialog = tfd::FileDialog::new("title")
693            .with_path("/tmp/somewhere")
694            .with_multiple_selection(true);
695        let dialog = apply_filter(dialog, file_type_list(&["*.png"], "Images"));
696
697        assert_eq!(dialog.path(), "/tmp/somewhere");
698        assert!(dialog.multiple_selection());
699        assert_eq!(dialog.filter_patterns(), &["*.png"]);
700    }
701
702    // ---------------------------------------------------------------------
703    // FileTypeList / OptionFileTypeList container invariants
704    // ---------------------------------------------------------------------
705
706    #[test]
707    fn string_vec_round_trips_through_into_library_owned_vec() {
708        // This is the exact conversion `apply_filter` performs internally.
709        let original: Vec<AzString> = vec![s("*.png"), s(""), s("*.🎨"), s("a\0b")];
710        let round_tripped = StringVec::from_vec(original.clone()).into_library_owned_vec();
711        assert_eq!(round_tripped, original);
712
713        // ... and the empty case, which takes the null/zero-length branch.
714        let empty = StringVec::from_vec(Vec::<AzString>::new()).into_library_owned_vec();
715        assert!(empty.is_empty());
716    }
717
718    #[test]
719    fn file_type_list_clone_is_equal_and_orders_reflexively() {
720        use std::cmp::Ordering;
721
722        let filter = file_type_list(&["*.png", "*.jpg"], "Images");
723        let cloned = filter.clone();
724
725        assert_eq!(cloned, filter);
726        assert_eq!(filter.partial_cmp(&filter), Some(Ordering::Equal));
727        assert_eq!(cloned.document_types.len(), 2);
728        assert_eq!(cloned.document_descriptor.as_str(), "Images");
729    }
730
731    #[test]
732    fn file_type_list_ordering_follows_the_descriptor_when_types_match() {
733        use std::cmp::Ordering;
734
735        let a = file_type_list(&["*.png"], "aaa");
736        let b = file_type_list(&["*.png"], "bbb");
737        assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
738        assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater));
739        assert_ne!(a, b);
740    }
741
742    #[test]
743    fn option_file_type_list_round_trips() {
744        let filter = file_type_list(&["*.png"], "Images");
745
746        let some = OptionFileTypeList::Some(filter.clone());
747        assert!(some.is_some());
748        assert!(!some.is_none());
749        assert_eq!(some.as_option(), Some(&filter));
750        assert_eq!(some.clone().into_option(), Some(filter));
751
752        let none = OptionFileTypeList::None;
753        assert!(none.is_none());
754        assert_eq!(none.as_option(), None);
755        assert_eq!(OptionFileTypeList::default(), OptionFileTypeList::None);
756    }
757
758    // ---------------------------------------------------------------------
759    // Modal entry points: signature guard only — calling these would block
760    // ---------------------------------------------------------------------
761
762    #[test]
763    fn modal_entry_points_keep_their_ffi_signatures() {
764        // Coercing to a fn pointer type-checks every exported signature WITHOUT
765        // invoking it. api.json / the C bindings are generated from these exact
766        // shapes, so an argument reorder or a changed return type must not slip
767        // through unnoticed just because no test can safely call them.
768        let _ok: fn(AzString, AzString, MsgBoxIcon) = MsgBox::ok;
769        let _ok_cancel: fn(AzString, AzString, MsgBoxIcon, OkCancel) -> OkCancel = MsgBox::ok_cancel;
770        let _yes_no: fn(AzString, AzString, MsgBoxIcon, YesNo) -> YesNo = MsgBox::yes_no;
771        let _info: fn(AzString) = MsgBox::info;
772        let _color: fn(AzString, OptionColorU) -> OptionColorU = ColorPickerDialog::open;
773        let _open_file: fn(AzString, OptionString, OptionFileTypeList) -> OptionString =
774            FileDialog::open_file;
775        let _open_dir: fn(AzString, OptionString) -> OptionString = FileDialog::open_directory;
776        let _open_many: fn(AzString, OptionString, OptionFileTypeList) -> OptionStringVec =
777            FileDialog::open_multiple_files;
778        let _save_file: fn(AzString, OptionString) -> OptionString = FileDialog::save_file;
779        let _msg_box: fn(&str) = msg_box;
780    }
781
782    // ---------------------------------------------------------------------
783    // android / iOS: the no-op branch is the one that CAN be executed safely
784    // ---------------------------------------------------------------------
785
786    #[cfg(any(target_os = "android", target_os = "ios"))]
787    #[test]
788    fn mobile_message_boxes_are_silent_no_ops() {
789        MsgBox::ok(s("title"), s("message"), MsgBoxIcon::Error);
790        MsgBox::info(s(""));
791        msg_box("");
792        msg_box("\0\u{1}🎨");
793    }
794
795    #[cfg(any(target_os = "android", target_os = "ios"))]
796    #[test]
797    fn mobile_answer_dialogs_echo_the_default_back() {
798        for default in [OkCancel::Ok, OkCancel::Cancel] {
799            let answer = MsgBox::ok_cancel(s("t"), s("m"), MsgBoxIcon::Question, default);
800            assert_eq!(answer, default);
801        }
802        for default in [YesNo::Yes, YesNo::No] {
803            let answer = MsgBox::yes_no(s("t"), s("m"), MsgBoxIcon::Question, default);
804            assert_eq!(answer, default);
805        }
806    }
807
808    #[cfg(any(target_os = "android", target_os = "ios"))]
809    #[test]
810    fn mobile_color_picker_echoes_the_default_back() {
811        let default = ColorU {
812            r: 1,
813            g: 2,
814            b: 3,
815            a: 4,
816        };
817        let picked = ColorPickerDialog::open(s("t"), OptionColorU::Some(default));
818        match picked.as_option() {
819            Some(c) => {
820                assert_eq!((c.r, c.g, c.b), (1, 2, 3));
821                // NB: the mobile stub keeps the caller's alpha, while the desktop
822                // path forces ColorU::ALPHA_OPAQUE. Pinned deliberately.
823                assert_eq!(c.a, 4);
824            }
825            None => panic!("mobile stub must return the default it was given"),
826        }
827        assert!(ColorPickerDialog::open(s("t"), OptionColorU::None).is_none());
828    }
829
830    #[cfg(any(target_os = "android", target_os = "ios"))]
831    #[test]
832    fn mobile_file_dialogs_report_cancellation() {
833        assert!(FileDialog::open_file(s("t"), OptionString::None, OptionFileTypeList::None).is_none());
834        assert!(FileDialog::open_directory(s("t"), OptionString::None).is_none());
835        assert!(FileDialog::save_file(s("t"), OptionString::Some(s("/tmp"))).is_none());
836        assert!(
837            FileDialog::open_multiple_files(s("t"), OptionString::None, OptionFileTypeList::None)
838                .is_none()
839        );
840    }
841}