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

            
10
use 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")))]
18
use 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
24
pub 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
32
pub 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
40
pub struct ColorPickerDialog {
41
    pub _reserved: u8,
42
}
43

            
44
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
45
#[repr(C)]
46
pub enum OkCancel {
47
    Ok,
48
    Cancel,
49
}
50

            
51
#[cfg(not(any(target_os = "android", target_os = "ios")))]
52
impl From<tfd::OkCancel> for OkCancel {
53
    #[inline]
54
4
    fn from(e: tfd::OkCancel) -> Self {
55
4
        match e {
56
2
            tfd::OkCancel::Ok => Self::Ok,
57
2
            tfd::OkCancel::Cancel => Self::Cancel,
58
        }
59
4
    }
60
}
61

            
62
#[cfg(not(any(target_os = "android", target_os = "ios")))]
63
impl From<OkCancel> for tfd::OkCancel {
64
    #[inline]
65
3
    fn from(e: OkCancel) -> Self {
66
3
        match e {
67
2
            OkCancel::Ok => Self::Ok,
68
1
            OkCancel::Cancel => Self::Cancel,
69
        }
70
3
    }
71
}
72

            
73
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
74
#[repr(C)]
75
pub enum YesNo {
76
    Yes,
77
    No,
78
}
79

            
80
#[cfg(not(any(target_os = "android", target_os = "ios")))]
81
impl From<YesNo> for tfd::YesNo {
82
    #[inline]
83
3
    fn from(e: YesNo) -> Self {
84
3
        match e {
85
2
            YesNo::Yes => Self::Yes,
86
1
            YesNo::No => Self::No,
87
        }
88
3
    }
89
}
90

            
91
#[cfg(not(any(target_os = "android", target_os = "ios")))]
92
impl From<tfd::YesNo> for YesNo {
93
    #[inline]
94
4
    fn from(e: tfd::YesNo) -> Self {
95
4
        match e {
96
2
            tfd::YesNo::Yes => Self::Yes,
97
2
            tfd::YesNo::No => Self::No,
98
        }
99
4
    }
100
}
101

            
102
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
103
#[repr(C)]
104
pub enum MsgBoxIcon {
105
    Info,
106
    Warning,
107
    Error,
108
    Question,
109
}
110

            
111
#[cfg(not(any(target_os = "android", target_os = "ios")))]
112
impl From<MsgBoxIcon> for MessageBoxIcon {
113
    #[inline]
114
8
    fn from(e: MsgBoxIcon) -> Self {
115
8
        match e {
116
2
            MsgBoxIcon::Info => Self::Info,
117
2
            MsgBoxIcon::Warning => Self::Warning,
118
2
            MsgBoxIcon::Error => Self::Error,
119
2
            MsgBoxIcon::Question => Self::Question,
120
        }
121
8
    }
122
}
123

            
124
impl Default for MsgBox {
125
1
    fn default() -> Self {
126
1
        Self::new()
127
1
    }
128
}
129

            
130
impl 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
6
    #[must_use] pub const fn new() -> Self {
135
6
        Self { _reserved: 0 }
136
6
    }
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

            
212
impl Default for ColorPickerDialog {
213
1
    fn default() -> Self {
214
1
        Self::new()
215
1
    }
216
}
217

            
218
impl ColorPickerDialog {
219
    /// Returns a zero-initialised namespace handle. Static-only — the struct
220
    /// is just a hook for the FFI layer.
221
5
    #[must_use] pub const fn new() -> Self {
222
5
        Self { _reserved: 0 }
223
5
    }
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)]
258
pub struct FileTypeList {
259
    pub document_types: StringVec,
260
    pub document_descriptor: AzString,
261
}
262

            
263
impl_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)]
274
10
fn apply_filter(mut dialog: tfd::FileDialog, filter: FileTypeList) -> tfd::FileDialog {
275
10
    let v = filter.document_types.clone().into_library_owned_vec();
276
10
    let patterns: Vec<&str> = v.iter().map(AzString::as_str).collect();
277
10
    dialog = dialog.with_filter(&patterns, filter.document_descriptor.as_str());
278
10
    dialog
279
10
}
280

            
281
impl Default for FileDialog {
282
1
    fn default() -> Self {
283
1
        Self::new()
284
1
    }
285
}
286

            
287
impl FileDialog {
288
    /// Returns a zero-initialised namespace handle. Static-only — the struct
289
    /// is just a hook for the FFI layer.
290
5
    #[must_use] pub const fn new() -> Self {
291
5
        Self { _reserved: 0 }
292
5
    }
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.
387
pub fn msg_box(content: &str) {
388
    MsgBox::info(AzString::from(content));
389
}
390

            
391
#[cfg(test)]
392
mod 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!(size_of::<MsgBox>(), 1);
456
        assert_eq!(size_of::<FileDialog>(), 1);
457
        assert_eq!(size_of::<ColorPickerDialog>(), 1);
458
        assert_eq!(align_of::<MsgBox>(), 1);
459
        assert_eq!(align_of::<FileDialog>(), 1);
460
        assert_eq!(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
}