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_core::{refany::RefAny, task::RequestId};
11
use azul_css::{
12
    corety::OptionString,
13
    impl_option, impl_option_inner,
14
    props::basic::color::{ColorU, OptionColorU},
15
    AzString, OptionStringVec, StringVec, U8Vec,
16
};
17
#[cfg(not(any(target_os = "android", target_os = "ios")))]
18
use tfd::{DefaultColorValue, MessageBoxIcon};
19

            
20
use crate::{
21
    callbacks::ResumeCallback,
22
    file::{FilePath, FilePathVec, OptionFilePath},
23
    request,
24
};
25

            
26
/// Static-method namespace for `tfd`-backed message-box dialogs.
27
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
28
#[repr(C)]
29
#[allow(clippy::pub_underscore_fields)] // _reserved: FFI/api.json static-namespace placeholder
30
                                        // field
31
pub struct MsgBox {
32
    pub _reserved: u8,
33
}
34

            
35
/// Static-method namespace for `tfd`-backed file dialogs.
36
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
37
#[repr(C)]
38
#[allow(clippy::pub_underscore_fields)] // _reserved: FFI/api.json static-namespace placeholder
39
                                        // field
40
pub struct FileDialog {
41
    pub _reserved: u8,
42
}
43

            
44
/// Static-method namespace for the `tfd`-backed color picker.
45
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
46
#[repr(C)]
47
#[allow(clippy::pub_underscore_fields)] // _reserved: FFI/api.json static-namespace placeholder
48
                                        // field
49
pub struct ColorPickerDialog {
50
    pub _reserved: u8,
51
}
52

            
53
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
54
#[repr(C)]
55
pub enum OkCancel {
56
    Ok,
57
    Cancel,
58
}
59

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

            
71
#[cfg(not(any(target_os = "android", target_os = "ios")))]
72
impl From<OkCancel> for tfd::OkCancel {
73
    #[inline]
74
3
    fn from(e: OkCancel) -> Self {
75
3
        match e {
76
2
            OkCancel::Ok => Self::Ok,
77
1
            OkCancel::Cancel => Self::Cancel,
78
        }
79
3
    }
80
}
81

            
82
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
83
#[repr(C)]
84
pub enum YesNo {
85
    Yes,
86
    No,
87
}
88

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

            
100
#[cfg(not(any(target_os = "android", target_os = "ios")))]
101
impl From<tfd::YesNo> for YesNo {
102
    #[inline]
103
4
    fn from(e: tfd::YesNo) -> Self {
104
4
        match e {
105
2
            tfd::YesNo::Yes => Self::Yes,
106
2
            tfd::YesNo::No => Self::No,
107
        }
108
4
    }
109
}
110

            
111
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
112
#[repr(C)]
113
pub enum MsgBoxIcon {
114
    Info,
115
    Warning,
116
    Error,
117
    Question,
118
}
119

            
120
#[cfg(not(any(target_os = "android", target_os = "ios")))]
121
impl From<MsgBoxIcon> for MessageBoxIcon {
122
    #[inline]
123
8
    fn from(e: MsgBoxIcon) -> Self {
124
8
        match e {
125
2
            MsgBoxIcon::Info => Self::Info,
126
2
            MsgBoxIcon::Warning => Self::Warning,
127
2
            MsgBoxIcon::Error => Self::Error,
128
2
            MsgBoxIcon::Question => Self::Question,
129
        }
130
8
    }
131
}
132

            
133
impl Default for MsgBox {
134
1
    fn default() -> Self {
135
1
        Self::new()
136
1
    }
137
}
138

            
139
impl MsgBox {
140
    /// Returns a zero-initialised namespace handle. The struct itself carries
141
    /// no state — instances exist only so the FFI layer can hang static
142
    /// methods off the type.
143
    #[must_use]
144
6
    pub const fn new() -> Self {
145
6
        Self { _reserved: 0 }
146
6
    }
147

            
148
    /// "Ok" message box — title, message, icon. Quotes are stripped from the
149
    /// message to work around `tfd` misinterpreting them as shell metacharacters
150
    /// on some platforms.
151
    // owned C-ABI dialog types (AzString/MsgBoxIcon) are passed by value per the azul FFI
152
    // / api.json convention; taking them by reference would break the exported signature.
153
    #[allow(clippy::needless_pass_by_value)]
154
    pub fn ok(title: AzString, message: AzString, icon: MsgBoxIcon) {
155
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
156
        {
157
            let mut msg = message.as_str().to_string();
158
            msg = msg.replace('\"', "");
159
            msg = msg.replace('\'', "");
160
            tfd::MessageBox::new(title.as_str(), &msg)
161
                .with_icon(icon.into())
162
                .run_modal();
163
        }
164
        #[cfg(any(target_os = "android", target_os = "ios"))]
165
        {
166
            let _ = (title, message, icon);
167
        }
168
    }
169

            
170
    /// "Ok / Cancel" message box — title, message, icon, default button.
171
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
172
    #[allow(clippy::needless_pass_by_value)]
173
    #[must_use]
174
    pub fn ok_cancel(
175
        title: AzString,
176
        message: AzString,
177
        icon: MsgBoxIcon,
178
        default: OkCancel,
179
    ) -> OkCancel {
180
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
181
        {
182
            tfd::MessageBox::new(title.as_str(), message.as_str())
183
                .with_icon(icon.into())
184
                .run_modal_ok_cancel(default.into())
185
                .into()
186
        }
187
        #[cfg(any(target_os = "android", target_os = "ios"))]
188
        {
189
            let _ = (title, message, icon);
190
            default
191
        }
192
    }
193

            
194
    /// "Yes / No" message box — title, message, icon, default button.
195
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
196
    #[allow(clippy::needless_pass_by_value)]
197
    #[must_use]
198
    pub fn yes_no(title: AzString, message: AzString, icon: MsgBoxIcon, default: YesNo) -> YesNo {
199
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
200
        {
201
            tfd::MessageBox::new(title.as_str(), message.as_str())
202
                .with_icon(icon.into())
203
                .run_modal_yes_no(default.into())
204
                .into()
205
        }
206
        #[cfg(any(target_os = "android", target_os = "ios"))]
207
        {
208
            let _ = (title, message, icon);
209
            default
210
        }
211
    }
212

            
213
    /// Convenience: "Ok" message box with the title "Info" and an info icon.
214
    pub fn info(content: AzString) {
215
        Self::ok(AzString::from("Info"), content, MsgBoxIcon::Info);
216
    }
217
}
218

            
219
impl Default for ColorPickerDialog {
220
1
    fn default() -> Self {
221
1
        Self::new()
222
1
    }
223
}
224

            
225
impl ColorPickerDialog {
226
    /// Returns a zero-initialised namespace handle. Static-only — the struct
227
    /// is just a hook for the FFI layer.
228
    #[must_use]
229
5
    pub const fn new() -> Self {
230
5
        Self { _reserved: 0 }
231
5
    }
232

            
233
    /// Opens the system color picker and resumes `on_result` with a
234
    /// [`ColorPickResult`] (`color` is `None` if the user cancelled).
235
    ///
236
    /// The callback never runs re-entrantly inside the requesting activation:
237
    /// on desktop the picker is modal and the callback runs right after the
238
    /// current activation returns; on web it runs on a later task. Browsers
239
    /// only open the picker from a user gesture, and `<input type=color>` has
240
    /// no cancel event everywhere, so a blur without a change resolves as
241
    /// `None`.
242
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
243
    #[allow(clippy::needless_pass_by_value)]
244
    #[must_use]
245
    pub fn open(
246
        title: AzString,
247
        default_value: OptionColorU,
248
        data: RefAny,
249
        on_result: ResumeCallback,
250
    ) -> RequestId {
251
        match request::mock::take_color_pick() {
252
            request::mock::Answer::NotArmed => {}
253
            request::mock::Answer::Mocked(color) => {
254
                return request::complete(
255
                    data,
256
                    on_result,
257
                    ColorPickResult {
258
                        color: color.into(),
259
                    },
260
                );
261
            }
262
            request::mock::Answer::Unmocked => {
263
                return request::complete(
264
                    data,
265
                    on_result,
266
                    ColorPickResult {
267
                        color: OptionColorU::None,
268
                    },
269
                );
270
            }
271
        }
272
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
273
        let color = {
274
            let rgb = default_value
275
                .into_option()
276
                .map_or([0, 0, 0], |c| [c.r, c.g, c.b]);
277
            let default_color = DefaultColorValue::RGB(rgb);
278
            let result = tfd::ColorChooser::new(title.as_str())
279
                .with_default_color(default_color)
280
                .run_modal();
281
            match result {
282
                Some(r) => OptionColorU::Some(ColorU {
283
                    r: r.1[0],
284
                    g: r.1[1],
285
                    b: r.1[2],
286
                    a: ColorU::ALPHA_OPAQUE,
287
                }),
288
                None => OptionColorU::None,
289
            }
290
        };
291
        // No native color picker exists on mobile; the request resolves as
292
        // cancelled rather than never resolving.
293
        #[cfg(any(target_os = "android", target_os = "ios"))]
294
        let color = {
295
            let _ = (title, default_value);
296
            OptionColorU::None
297
        };
298
        request::complete(data, on_result, ColorPickResult { color })
299
    }
300
}
301

            
302
// ============================================================================
303
// Resumable dialog results
304
// ============================================================================
305
//
306
// Every `FileDialog` / `ColorPickerDialog` request resumes its
307
// `ResumeCallback` with one of these structs, type-erased into a `RefAny`;
308
// the static `downcast(result)` accessor is the binding-portable way back
309
// to the typed value.
310

            
311
/// Result of [`FileDialog::open_file`] / [`FileDialog::open_directory`].
312
/// `path` is `None` if the user cancelled.
313
#[derive(Debug, Clone, PartialEq, Eq)]
314
#[repr(C)]
315
pub struct FileOpenResult {
316
    pub path: OptionFilePath,
317
}
318

            
319
impl_option!(
320
    FileOpenResult,
321
    OptionFileOpenResult,
322
    copy = false,
323
    [Debug, Clone, PartialEq, Eq]
324
);
325

            
326
impl FileOpenResult {
327
    /// Downcast the `result` `RefAny` delivered to a `ResumeCallback`.
328
    #[must_use]
329
    pub fn downcast(mut result: RefAny) -> OptionFileOpenResult {
330
        result.downcast_ref::<Self>().map(|r| r.clone()).into()
331
    }
332
}
333

            
334
/// Result of [`FileDialog::open_multiple_files`]. `paths` is empty if the
335
/// user cancelled.
336
#[derive(Debug, Clone, PartialEq)]
337
#[repr(C)]
338
pub struct FileOpenMultiResult {
339
    pub paths: FilePathVec,
340
}
341

            
342
impl_option!(
343
    FileOpenMultiResult,
344
    OptionFileOpenMultiResult,
345
    copy = false,
346
    [Debug, Clone, PartialEq]
347
);
348

            
349
impl FileOpenMultiResult {
350
    /// Downcast the `result` `RefAny` delivered to a `ResumeCallback`.
351
    #[must_use]
352
    pub fn downcast(mut result: RefAny) -> OptionFileOpenMultiResult {
353
        result.downcast_ref::<Self>().map(|r| r.clone()).into()
354
    }
355
}
356

            
357
/// Result of [`ColorPickerDialog::open`]. `color` is `None` if the user
358
/// cancelled.
359
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360
#[repr(C)]
361
pub struct ColorPickResult {
362
    pub color: OptionColorU,
363
}
364

            
365
impl_option!(
366
    ColorPickResult,
367
    OptionColorPickResult,
368
    copy = false,
369
    [Debug, Clone, PartialEq, Eq]
370
);
371

            
372
impl ColorPickResult {
373
    /// Downcast the `result` `RefAny` delivered to a `ResumeCallback`.
374
    #[must_use]
375
    pub fn downcast(mut result: RefAny) -> OptionColorPickResult {
376
        result.downcast_ref::<Self>().map(|r| *r).into()
377
    }
378
}
379

            
380
/// What kind of write target a [`SaveTarget`] is.
381
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
382
#[repr(C)]
383
pub enum SaveTargetKind {
384
    /// A real filesystem path (`as_path` is `Some`) - desktop and mobile.
385
    Path,
386
    /// A browser File-System-Access handle (Chromium); writes go to the
387
    /// user's chosen file, `as_path` is `None`.
388
    WebHandle,
389
    /// The portable browser fallback: `write_bytes` triggers a download of
390
    /// the bytes under the suggested name, `as_path` is `None`.
391
    Download,
392
}
393

            
394
/// An opaque write target obtained from [`FileDialog::save_file`].
395
///
396
/// Desktop: a real path. Web: a File-System-Access handle (Chromium) or a
397
/// `Download` sentinel (Firefox / Safari, which will not ship the handle
398
/// API), where [`SaveTarget::as_path`] returns `None`. Apps that only ever
399
/// export bytes should call [`FileDialog::save_bytes`] instead and never
400
/// touch this type.
401
#[derive(Debug, Clone, PartialEq, Eq)]
402
#[repr(C)]
403
pub struct SaveTarget {
404
    pub kind: SaveTargetKind,
405
    pub path: OptionFilePath,
406
    /// Identifies the browser-side handle for `WebHandle` targets; `0`
407
    /// otherwise.
408
    pub handle_id: u64,
409
}
410

            
411
impl_option!(
412
    SaveTarget,
413
    OptionSaveTarget,
414
    copy = false,
415
    [Debug, Clone, PartialEq, Eq]
416
);
417

            
418
impl SaveTarget {
419
    /// Writes `bytes` to the target. Fire-and-forget: `true` means the write
420
    /// was performed (desktop) or scheduled (web); durability is not implied.
421
    #[must_use]
422
    pub fn write_bytes(&self, bytes: U8Vec) -> bool {
423
        match self.kind {
424
            SaveTargetKind::Path => match self.path.as_ref() {
425
                Some(p) => crate::file::file_write(p.as_str(), bytes.as_ref()).is_ok(),
426
                None => false,
427
            },
428
            // Browser handles are serviced by the web host, never by native code.
429
            SaveTargetKind::WebHandle | SaveTargetKind::Download => false,
430
        }
431
    }
432

            
433
    /// The real path behind the target, or `None` on the browser fallbacks.
434
    #[must_use]
435
    pub fn as_path(&self) -> OptionFilePath {
436
        self.path.clone()
437
    }
438
}
439

            
440
/// Result of [`FileDialog::save_file`]. `target` is `None` if the user
441
/// cancelled.
442
#[derive(Debug, Clone, PartialEq, Eq)]
443
#[repr(C)]
444
pub struct SaveTargetResult {
445
    pub target: OptionSaveTarget,
446
}
447

            
448
impl_option!(
449
    SaveTargetResult,
450
    OptionSaveTargetResult,
451
    copy = false,
452
    [Debug, Clone, PartialEq, Eq]
453
);
454

            
455
impl SaveTargetResult {
456
    /// Downcast the `result` `RefAny` delivered to a `ResumeCallback`.
457
    #[must_use]
458
    pub fn downcast(mut result: RefAny) -> OptionSaveTargetResult {
459
        result.downcast_ref::<Self>().map(|r| r.clone()).into()
460
    }
461
}
462

            
463
/// Turns a picker status into the [`FileOpenResult`] the resumable API
464
/// delivers; `None` while the picker is still open. Multiple selections
465
/// collapse to the first path here (single-file request).
466
#[cfg(any(target_os = "android", target_os = "ios"))]
467
fn open_result_from_status(status: FilePickerStatus) -> Option<RefAny> {
468
    let path = match status {
469
        FilePickerStatus::Pending => return None,
470
        FilePickerStatus::Selected(p) => OptionFilePath::Some(FilePath::new(p)),
471
        FilePickerStatus::SelectedMultiple(v) => {
472
            v.as_ref().first().cloned().map(FilePath::new).into()
473
        }
474
        FilePickerStatus::Cancelled | FilePickerStatus::Error(_) => OptionFilePath::None,
475
    };
476
    Some(RefAny::new(FileOpenResult { path }))
477
}
478

            
479
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
480
#[repr(C)]
481
pub struct FileTypeList {
482
    pub document_types: StringVec,
483
    pub document_descriptor: AzString,
484
}
485

            
486
impl_option!(
487
    FileTypeList,
488
    OptionFileTypeList,
489
    copy = false,
490
    [Debug, Clone, PartialEq, Eq, PartialOrd]
491
);
492

            
493
/// Apply a [`FileTypeList`] filter to a `tfd::FileDialog`.
494
#[cfg(not(any(target_os = "android", target_os = "ios")))]
495
// consumes the FileTypeList forwarded from the by-value FFI file-dialog API.
496
#[allow(clippy::needless_pass_by_value)]
497
10
fn apply_filter(mut dialog: tfd::FileDialog, filter: FileTypeList) -> tfd::FileDialog {
498
10
    let v = filter.document_types.clone().into_library_owned_vec();
499
10
    let patterns: Vec<&str> = v.iter().map(AzString::as_str).collect();
500
10
    dialog = dialog.with_filter(&patterns, filter.document_descriptor.as_str());
501
10
    dialog
502
10
}
503

            
504
impl Default for FileDialog {
505
1
    fn default() -> Self {
506
1
        Self::new()
507
1
    }
508
}
509

            
510
impl FileDialog {
511
    /// Returns a zero-initialised namespace handle. Static-only — the struct
512
    /// is just a hook for the FFI layer.
513
    #[must_use]
514
5
    pub const fn new() -> Self {
515
5
        Self { _reserved: 0 }
516
5
    }
517

            
518
    /// Open a single file and resume `on_result` with a [`FileOpenResult`]
519
    /// (`path` is `None` if the user cancelled).
520
    ///
521
    /// Never blocks the calling activation in an observable way: on desktop
522
    /// the native modal dialog runs here and the callback runs right after
523
    /// the current activation returns; on mobile the OS picker is presented
524
    /// and the callback runs when its delegate answers; on web the callback
525
    /// always runs on a later task. `data` is handed back untouched.
526
    ///
527
    /// Browsers only open a picker from a user gesture: a request issued
528
    /// outside one (from a timer, for example) resolves with `path: None`.
529
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
530
    #[allow(clippy::needless_pass_by_value)]
531
    #[must_use]
532
    pub fn open_file(
533
        title: AzString,
534
        default_path: OptionString,
535
        filter_list: OptionFileTypeList,
536
        data: RefAny,
537
        on_result: ResumeCallback,
538
    ) -> RequestId {
539
        // Under an e2e run the picker is answered from the mock store (or
540
        // resolves as cancelled, loudly); a real dialog would hang the test.
541
        match request::mock::take_file_open("FileDialog::open_file") {
542
            request::mock::Answer::NotArmed => {}
543
            request::mock::Answer::Mocked(path) => {
544
                return request::complete(
545
                    data,
546
                    on_result,
547
                    FileOpenResult {
548
                        path: path.map(FilePath::new).into(),
549
                    },
550
                );
551
            }
552
            request::mock::Answer::Unmocked => {
553
                return request::complete(
554
                    data,
555
                    on_result,
556
                    FileOpenResult {
557
                        path: OptionFilePath::None,
558
                    },
559
                );
560
            }
561
        }
562
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
563
        {
564
            let mut dialog = tfd::FileDialog::new(title.as_str());
565
            if let Some(path) = default_path.as_option() {
566
                dialog = dialog.with_path(path.as_str());
567
            }
568
            if let Some(filter) = filter_list.into_option() {
569
                dialog = apply_filter(dialog, filter);
570
            }
571
            let path = dialog
572
                .open_file()
573
                .map(|p| FilePath::new(AzString::from(p)))
574
                .into();
575
            request::complete(data, on_result, FileOpenResult { path })
576
        }
577
        #[cfg(any(target_os = "android", target_os = "ios"))]
578
        {
579
            match FILE_PICKER_BACKEND.get() {
580
                Some(backend) => {
581
                    let handle = (backend.open_file)(
582
                        title,
583
                        default_path,
584
                        filter_patterns(filter_list),
585
                        false,
586
                    );
587
                    request::defer(
588
                        data,
589
                        on_result,
590
                        Box::new(move || open_result_from_status(handle.poll())),
591
                    )
592
                }
593
                // A shell that registered no picker: resolve as cancelled
594
                // instead of leaving the request open forever.
595
                None => request::complete(
596
                    data,
597
                    on_result,
598
                    FileOpenResult {
599
                        path: OptionFilePath::None,
600
                    },
601
                ),
602
            }
603
        }
604
    }
605

            
606
    /// Open a directory and resume `on_result` with a [`FileOpenResult`]
607
    /// whose `path` is the chosen directory (`None` if cancelled). Same
608
    /// contract as [`Self::open_file`].
609
    ///
610
    /// On web only Chromium has a directory picker; the portable fallback
611
    /// yields a read-only snapshot of the chosen tree, and `path` is the
612
    /// virtual root that snapshot is mounted at.
613
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
614
    #[allow(clippy::needless_pass_by_value)]
615
    #[must_use]
616
    pub fn open_directory(
617
        title: AzString,
618
        default_path: OptionString,
619
        data: RefAny,
620
        on_result: ResumeCallback,
621
    ) -> RequestId {
622
        match request::mock::take_file_open("FileDialog::open_directory") {
623
            request::mock::Answer::NotArmed => {}
624
            request::mock::Answer::Mocked(path) => {
625
                return request::complete(
626
                    data,
627
                    on_result,
628
                    FileOpenResult {
629
                        path: path.map(FilePath::new).into(),
630
                    },
631
                );
632
            }
633
            request::mock::Answer::Unmocked => {
634
                return request::complete(
635
                    data,
636
                    on_result,
637
                    FileOpenResult {
638
                        path: OptionFilePath::None,
639
                    },
640
                );
641
            }
642
        }
643
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
644
        {
645
            let mut dialog = tfd::FileDialog::new(title.as_str());
646
            if let Some(path) = default_path.as_option() {
647
                dialog = dialog.with_path(path.as_str());
648
            }
649
            let path = dialog
650
                .select_folder()
651
                .map(|p| FilePath::new(AzString::from(p)))
652
                .into();
653
            request::complete(data, on_result, FileOpenResult { path })
654
        }
655
        #[cfg(any(target_os = "android", target_os = "ios"))]
656
        {
657
            match FILE_PICKER_BACKEND.get() {
658
                Some(backend) => {
659
                    let handle = (backend.open_directory)(title, default_path);
660
                    request::defer(
661
                        data,
662
                        on_result,
663
                        Box::new(move || open_result_from_status(handle.poll())),
664
                    )
665
                }
666
                None => request::complete(
667
                    data,
668
                    on_result,
669
                    FileOpenResult {
670
                        path: OptionFilePath::None,
671
                    },
672
                ),
673
            }
674
        }
675
    }
676

            
677
    /// Open multiple files and resume `on_result` with a
678
    /// [`FileOpenMultiResult`] (`paths` is empty if the user cancelled).
679
    /// Same contract as [`Self::open_file`].
680
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
681
    #[allow(clippy::needless_pass_by_value)]
682
    #[must_use]
683
    pub fn open_multiple_files(
684
        title: AzString,
685
        default_path: OptionString,
686
        filter_list: OptionFileTypeList,
687
        data: RefAny,
688
        on_result: ResumeCallback,
689
    ) -> RequestId {
690
        match request::mock::take_file_open_multi() {
691
            request::mock::Answer::NotArmed => {}
692
            request::mock::Answer::Mocked(paths) => {
693
                let paths = paths.into_iter().map(FilePath::new).collect::<Vec<_>>();
694
                return request::complete(
695
                    data,
696
                    on_result,
697
                    FileOpenMultiResult {
698
                        paths: FilePathVec::from_vec(paths),
699
                    },
700
                );
701
            }
702
            request::mock::Answer::Unmocked => {
703
                return request::complete(
704
                    data,
705
                    on_result,
706
                    FileOpenMultiResult {
707
                        paths: FilePathVec::from_vec(Vec::new()),
708
                    },
709
                );
710
            }
711
        }
712
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
713
        {
714
            let mut dialog = tfd::FileDialog::new(title.as_str()).with_multiple_selection(true);
715
            if let Some(path) = default_path.as_option() {
716
                dialog = dialog.with_path(path.as_str());
717
            }
718
            if let Some(filter) = filter_list.into_option() {
719
                dialog = apply_filter(dialog, filter);
720
            }
721
            let paths = dialog
722
                .open_files()
723
                .unwrap_or_default()
724
                .into_iter()
725
                .map(|p| FilePath::new(AzString::from(p)))
726
                .collect::<Vec<_>>();
727
            request::complete(
728
                data,
729
                on_result,
730
                FileOpenMultiResult {
731
                    paths: FilePathVec::from_vec(paths),
732
                },
733
            )
734
        }
735
        #[cfg(any(target_os = "android", target_os = "ios"))]
736
        {
737
            match FILE_PICKER_BACKEND.get() {
738
                Some(backend) => {
739
                    let handle = (backend.open_file)(
740
                        title,
741
                        default_path,
742
                        filter_patterns(filter_list),
743
                        true,
744
                    );
745
                    request::defer(
746
                        data,
747
                        on_result,
748
                        Box::new(move || {
749
                            let paths = match handle.poll() {
750
                                FilePickerStatus::Pending => return None,
751
                                FilePickerStatus::Selected(p) => vec![FilePath::new(p)],
752
                                FilePickerStatus::SelectedMultiple(v) => {
753
                                    v.as_ref().iter().cloned().map(FilePath::new).collect()
754
                                }
755
                                FilePickerStatus::Cancelled | FilePickerStatus::Error(_) => {
756
                                    Vec::new()
757
                                }
758
                            };
759
                            Some(RefAny::new(FileOpenMultiResult {
760
                                paths: FilePathVec::from_vec(paths),
761
                            }))
762
                        }),
763
                    )
764
                }
765
                None => request::complete(
766
                    data,
767
                    on_result,
768
                    FileOpenMultiResult {
769
                        paths: FilePathVec::from_vec(Vec::new()),
770
                    },
771
                ),
772
            }
773
        }
774
    }
775

            
776
    /// Save-file dialog: resumes `on_result` with a [`SaveTargetResult`]
777
    /// whose `target` (`None` if cancelled) is *where to write*, not a
778
    /// string path - see [`SaveTarget`]. `suggested_name` is the file name
779
    /// the dialog proposes, not a path.
780
    ///
781
    /// Decision tree: an app that only ever exports a blob of bytes (a PDF,
782
    /// an image) should call [`Self::save_bytes`] and never see a target;
783
    /// use `save_file` when the app needs to write the same file again later
784
    /// (a document it keeps open). On web the real-path form exists only on
785
    /// Chromium; Firefox and Safari resolve with a `Download` target whose
786
    /// `as_path` is `None`.
787
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
788
    #[allow(clippy::needless_pass_by_value)]
789
    #[must_use]
790
    pub fn save_file(
791
        title: AzString,
792
        suggested_name: AzString,
793
        data: RefAny,
794
        on_result: ResumeCallback,
795
    ) -> RequestId {
796
        match request::mock::take_save_file() {
797
            request::mock::Answer::NotArmed => {}
798
            request::mock::Answer::Mocked(path) => {
799
                let target = path.map(|p| SaveTarget {
800
                    kind: SaveTargetKind::Path,
801
                    path: OptionFilePath::Some(FilePath::new(p)),
802
                    handle_id: 0,
803
                });
804
                return request::complete(
805
                    data,
806
                    on_result,
807
                    SaveTargetResult {
808
                        target: target.into(),
809
                    },
810
                );
811
            }
812
            request::mock::Answer::Unmocked => {
813
                return request::complete(
814
                    data,
815
                    on_result,
816
                    SaveTargetResult {
817
                        target: OptionSaveTarget::None,
818
                    },
819
                );
820
            }
821
        }
822
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
823
        {
824
            let mut dialog = tfd::FileDialog::new(title.as_str());
825
            if !suggested_name.as_str().is_empty() {
826
                dialog = dialog.with_path(suggested_name.as_str());
827
            }
828
            let target = dialog.save_file().map(|p| SaveTarget {
829
                kind: SaveTargetKind::Path,
830
                path: OptionFilePath::Some(FilePath::new(AzString::from(p))),
831
                handle_id: 0,
832
            });
833
            request::complete(
834
                data,
835
                on_result,
836
                SaveTargetResult {
837
                    target: target.into(),
838
                },
839
            )
840
        }
841
        #[cfg(any(target_os = "android", target_os = "ios"))]
842
        {
843
            match FILE_PICKER_BACKEND.get() {
844
                Some(backend) => {
845
                    let suggested = if suggested_name.as_str().is_empty() {
846
                        OptionString::None
847
                    } else {
848
                        OptionString::Some(suggested_name)
849
                    };
850
                    let handle = (backend.save_file)(title, suggested);
851
                    request::defer(
852
                        data,
853
                        on_result,
854
                        Box::new(move || {
855
                            let target = match handle.poll() {
856
                                FilePickerStatus::Pending => return None,
857
                                FilePickerStatus::Selected(p) => Some(SaveTarget {
858
                                    kind: SaveTargetKind::Path,
859
                                    path: OptionFilePath::Some(FilePath::new(p)),
860
                                    handle_id: 0,
861
                                }),
862
                                FilePickerStatus::SelectedMultiple(v) => {
863
                                    v.as_ref().first().cloned().map(|p| SaveTarget {
864
                                        kind: SaveTargetKind::Path,
865
                                        path: OptionFilePath::Some(FilePath::new(p)),
866
                                        handle_id: 0,
867
                                    })
868
                                }
869
                                FilePickerStatus::Cancelled | FilePickerStatus::Error(_) => None,
870
                            };
871
                            Some(RefAny::new(SaveTargetResult {
872
                                target: target.into(),
873
                            }))
874
                        }),
875
                    )
876
                }
877
                None => request::complete(
878
                    data,
879
                    on_result,
880
                    SaveTargetResult {
881
                        target: OptionSaveTarget::None,
882
                    },
883
                ),
884
            }
885
        }
886
    }
887

            
888
    /// Hand the user a file: `bytes` under `suggested_name` (a file name,
889
    /// not a path) with the given MIME type. Fire-and-forget; `true` means
890
    /// the export was performed (desktop: the user picked a location in the
891
    /// native save dialog and the file was written) or scheduled (web: a
892
    /// download was triggered). `false` means cancelled or failed.
893
    ///
894
    /// This is the portable "export a document" primitive: it works from
895
    /// any event-driven callback chain on every target, needs no write
896
    /// target and no path. Compose it with `CallbackInfo::take_screenshot`
897
    /// or `Pdf::save_to_bytes` for "save this as a file".
898
    // owned C-ABI dialog types passed by value per the azul FFI / api.json convention.
899
    #[allow(clippy::needless_pass_by_value)]
900
    #[must_use]
901
    pub fn save_bytes(suggested_name: AzString, mime: AzString, bytes: U8Vec) -> bool {
902
        // An e2e run records the export instead of showing a dialog; the
903
        // scenario reads it back with `assert_saved_file`.
904
        if let Some(accepted) =
905
            request::mock::record_saved_file(&suggested_name, &mime, bytes.as_ref())
906
        {
907
            return accepted;
908
        }
909
        // The MIME type only matters to the browser (the download's
910
        // Content-Type); native save dialogs key off the name's extension.
911
        drop(mime);
912
        #[cfg(not(any(target_os = "android", target_os = "ios")))]
913
        {
914
            let mut dialog = tfd::FileDialog::new("Save");
915
            if !suggested_name.as_str().is_empty() {
916
                dialog = dialog.with_path(suggested_name.as_str());
917
            }
918
            match dialog.save_file() {
919
                Some(path) => crate::file::file_write(&path, bytes.as_ref()).is_ok(),
920
                None => false,
921
            }
922
        }
923
        #[cfg(any(target_os = "android", target_os = "ios"))]
924
        {
925
            // No save dialog on mobile: the file lands in the app's
926
            // documents directory under the suggested name.
927
            let Some(dir) = FilePath::get_document_dir().or_else(FilePath::get_data_dir) else {
928
                return false;
929
            };
930
            let name = if suggested_name.as_str().is_empty() {
931
                "download"
932
            } else {
933
                suggested_name.as_str()
934
            };
935
            let target = dir.join_str(&AzString::from(name.to_string()));
936
            crate::file::file_write(target.as_str(), bytes.as_ref()).is_ok()
937
        }
938
    }
939
}
940

            
941
// ============================================================================
942
// Async file picker
943
// ============================================================================
944
//
945
// `FileDialog::open_file` above BLOCKS until the user answers. That is fine
946
// on the desktop (tfd runs a nested modal loop) and fatal on mobile: the iOS
947
// document picker is sheet-modal and reports through a delegate on the main
948
// thread, Android's is an `Intent` whose result arrives at
949
// `onActivityResult` — blocking the UI thread waiting for either deadlocks
950
// the app. So the mobile shape is a HANDLE the caller polls from its normal
951
// callbacks, and the desktop answers the same handle synchronously so one
952
// application code path works everywhere.
953
//
954
// The OS plumbing lives in the dll (`desktop/extra/file_picker/{ios,android}`)
955
// and cannot be called from here — azul-layout sits below azul-dll — so it is
956
// REGISTERED, the same way the camera and microphone capture backends are:
957
// the dll installs a [`FilePickerBackend`] at startup, and
958
// the resumable `FileDialog::open_file` dispatches to it when one is present.
959

            
960
use std::sync::{Arc, Mutex, OnceLock};
961

            
962
/// Result of polling a [`FilePickerHandle`]. Mirrors the `W3C`
963
/// `showOpenFilePicker()` promise shape so a web backend lands without API
964
/// churn.
965
#[derive(Debug, Clone, PartialEq)]
966
#[repr(C, u8)]
967
pub enum FilePickerStatus {
968
    /// Picker is still on-screen; no user action yet.
969
    Pending,
970
    /// User dismissed the picker without selecting anything. Maps to the
971
    /// `W3C` `<input type="file">` cancel semantics (an empty selection).
972
    Cancelled,
973
    /// Single-file picker resolved: the chosen path.
974
    Selected(AzString),
975
    /// Multi-file picker resolved. Empty vec means the user dismissed
976
    /// without picking — equivalent to `Cancelled`.
977
    SelectedMultiple(StringVec),
978
    /// Platform-level error (sandbox denial, intent failure, no backend on
979
    /// this platform, …). The message is user-presentable; the caller is
980
    /// expected to surface it.
981
    Error(AzString),
982
}
983

            
984
/// Shared state behind [`FilePickerHandle`].
985
///
986
/// Held in an `Arc<Mutex<…>>` so
987
/// the OS delegate / activity-result handler can write into it from the UI
988
/// thread while the layout callback reads it from the engine thread.
989
#[derive(Debug)]
990
struct FilePickerInner {
991
    status: FilePickerStatus,
992
}
993

            
994
type SharedInner = Mutex<FilePickerInner>;
995

            
996
/// Opaque handle the user holds across event-loop ticks.
997
///
998
/// The FFI shape of every engine-resource handle (`Db`, `Pdf`, …): a
999
/// pointer plus a destructor flag, `#[repr(C)]`. Unlike those, this one is
/// REFERENCE-COUNTED — `ptr` is an `Arc<Mutex<FilePickerInner>>` and every
/// handle owns one strong count — because the OS backend keeps a clone and
/// writes the answer into it later, possibly after the user dropped theirs.
/// A shallow, non-owning clone would be a use-after-free waiting for the
/// picker to dismiss. A null `ptr` (the `Default`) polls as an `Error`.
#[derive(Debug)]
#[repr(C)]
pub struct FilePickerHandle {
    /// `Arc::into_raw` of the shared slot; one strong count per handle.
    pub ptr: *const core::ffi::c_void,
    /// `true` when dropping this handle releases its strong count — every
    /// live handle; `false` only for the null `Default`.
    pub run_destructor: bool,
}
// SAFETY: the only thing behind `ptr` is an `Arc<Mutex<FilePickerInner>>`,
// which is `Send + Sync`; the handle is that `Arc` with its type erased.
unsafe impl Send for FilePickerHandle {}
unsafe impl Sync for FilePickerHandle {}
impl FilePickerHandle {
    /// A fresh handle in `Pending` state. The platform backend retains a
    /// clone, fills in the status on user dismissal, and drops its clone — at
    /// which point only the user-side handle remains.
    #[must_use]
2
    pub fn new_pending() -> Self {
2
        Self::with_status(FilePickerStatus::Pending)
2
    }
    /// A handle that is ALREADY answered — what the desktop returns after its
    /// synchronous dialog, and what a platform with no picker returns with an
    /// `Error`. The first `poll` sees the answer.
    #[must_use]
3
    pub fn with_status(status: FilePickerStatus) -> Self {
3
        let arc: Arc<SharedInner> = Arc::new(Mutex::new(FilePickerInner { status }));
3
        Self {
3
            ptr: Arc::into_raw(arc).cast::<core::ffi::c_void>(),
3
            run_destructor: true,
3
        }
3
    }
    /// The shared slot, or `None` for the null `Default` handle.
13
    const fn inner(&self) -> Option<&SharedInner> {
13
        if self.ptr.is_null() {
3
            return None;
10
        }
        // SAFETY: a non-null `ptr` came from `Arc::into_raw` in `with_status`
        // and this handle holds a strong count, so the allocation is alive
        // for as long as `&self` is.
10
        Some(unsafe { &*self.ptr.cast::<SharedInner>() })
13
    }
    /// Sync read of the current status. Returns a clone so the caller can
    /// destructure without holding the mutex.
    #[must_use]
9
    pub fn poll(&self) -> FilePickerStatus {
9
        match self.inner().map(Mutex::lock) {
7
            Some(Ok(g)) => g.status.clone(),
            Some(Err(_)) => FilePickerStatus::Error(AzString::from("file picker mutex poisoned")),
2
            None => FilePickerStatus::Error(AzString::from(
2
                "null file picker handle (a Default, not one a FileDialog returned)",
2
            )),
        }
9
    }
    /// `true` once the picker has been answered (anything but `Pending`).
    #[must_use]
4
    pub fn is_done(&self) -> bool {
4
        !matches!(self.poll(), FilePickerStatus::Pending)
4
    }
    /// Platform-backend write path. Replaces the slot with the latest
    /// status. Idempotent — repeated writes from a flaky delegate keep the
    /// most recent value.
4
    pub fn set_status(&self, next: FilePickerStatus) {
4
        if let Some(Ok(mut g)) = self.inner().map(Mutex::lock) {
3
            g.status = next;
3
        }
4
    }
}
impl Clone for FilePickerHandle {
    /// Another owner of the SAME slot — every clone observes the same status
    /// updates. Increments the strong count; the clone releases it on drop.
3
    fn clone(&self) -> Self {
3
        if self.ptr.is_null() {
1
            return Self::default();
2
        }
        // SAFETY: see `inner`; incrementing while we hold a count is sound.
2
        unsafe { Arc::increment_strong_count(self.ptr.cast::<SharedInner>()) };
2
        Self {
2
            ptr: self.ptr,
2
            run_destructor: true,
2
        }
3
    }
}
impl Default for FilePickerHandle {
    /// The null handle: polls as an `Error`, clones to another null, drops
    /// to nothing. What the FFI hands out for "no handle".
2
    fn default() -> Self {
2
        Self {
2
            ptr: core::ptr::null(),
2
            run_destructor: false,
2
        }
2
    }
}
impl Drop for FilePickerHandle {
7
    fn drop(&mut self) {
7
        if self.run_destructor && !self.ptr.is_null() {
5
            // SAFETY: this handle's own strong count, taken in
5
            // `with_status` / `clone`, released exactly once here.
5
            drop(unsafe { Arc::from_raw(self.ptr.cast::<SharedInner>()) });
5
            self.ptr = core::ptr::null();
5
            self.run_destructor = false;
5
        }
7
    }
}
/// The OS file-picker plumbing a platform shell installs at startup — the
/// async equivalent of the `tfd` calls above.
///
/// Each function must return
/// IMMEDIATELY with a `Pending` handle it later resolves from the OS callback.
#[derive(Debug, Clone, Copy)]
pub struct FilePickerBackend {
    /// `(title, default_path, filter patterns, allow_multiple)`.
    pub open_file: fn(AzString, OptionString, OptionStringVec, bool) -> FilePickerHandle,
    /// `(title, default_path)`.
    pub save_file: fn(AzString, OptionString) -> FilePickerHandle,
    /// `(title, default_path)`.
    pub open_directory: fn(AzString, OptionString) -> FilePickerHandle,
}
static FILE_PICKER_BACKEND: OnceLock<FilePickerBackend> = OnceLock::new();
/// Install the platform's async file picker.
///
/// The first registration wins;
/// returns `false` when one was already installed (the shells register from
/// a `OnceLock`-guarded site, so that is a programming error, not a race to
/// paper over).
pub fn register_file_picker_backend(backend: FilePickerBackend) -> bool {
    FILE_PICKER_BACKEND.set(backend).is_ok()
}
/// Whether an async backend has been installed (i.e. whether the `*_async`
/// calls will go to the OS picker or answer synchronously / with an error).
#[must_use]
pub fn has_file_picker_backend() -> bool {
    FILE_PICKER_BACKEND.get().is_some()
}
/// The filter patterns of a [`FileTypeList`], in the shape the async
/// backends take: the descriptor is desktop-dialog chrome that neither
/// mobile picker displays.
2
fn filter_patterns(filter_list: OptionFileTypeList) -> OptionStringVec {
2
    filter_list.into_option().map(|f| f.document_types).into()
2
}
/// Convenience shim: show a default "Info" message box.
pub fn msg_box(content: &str) {
    MsgBox::info(AzString::from(content));
}
#[cfg(test)]
mod autotest_generated {
    use super::*;
    // Every dialog entry point in this file (`MsgBox::ok`, `FileDialog::open_file`,
    // `ColorPickerDialog::open`, `msg_box`, ...) ends in a `run_modal()` /
    // `open_file()` call that blocks on a native modal window. Calling one from a
    // test would hang the test binary forever (or shell out to zenity/kdialog on
    // a headless box), so they are NEVER invoked here. Instead they are covered by
    // a signature guard (below) that type-checks the FFI surface without running
    // it, and by the android/iOS no-op contract tests, which exercise the branch
    // that genuinely returns without showing a dialog.
    //
    // What IS exercised for real: the three const namespace constructors, the
    // `tfd` enum conversions, and `apply_filter` — a pure builder that never
    // opens anything.
    fn s(value: &str) -> AzString {
        AzString::from(value.to_string())
    }
    fn file_type_list(patterns: &[&str], descriptor: &str) -> FileTypeList {
        FileTypeList {
            document_types: StringVec::from_vec(patterns.iter().map(|p| s(p)).collect()),
            document_descriptor: s(descriptor),
        }
    }
    // ---------------------------------------------------------------------
    // Constructors: MsgBox::new / FileDialog::new / ColorPickerDialog::new
    // ---------------------------------------------------------------------
    #[test]
    fn namespace_handles_are_zero_initialised() {
        assert_eq!(MsgBox::new()._reserved, 0);
        assert_eq!(FileDialog::new()._reserved, 0);
        assert_eq!(ColorPickerDialog::new()._reserved, 0);
    }
    #[test]
    fn namespace_handles_are_const_evaluable() {
        // `new()` is `const fn`; if it ever stops being usable in a const context
        // the FFI/api.json static-namespace contract breaks. This fails to compile
        // rather than fails at runtime, which is the point.
        const MSG_BOX: MsgBox = MsgBox::new();
        const FILE_DIALOG: FileDialog = FileDialog::new();
        const COLOR_PICKER: ColorPickerDialog = ColorPickerDialog::new();
        assert_eq!(MSG_BOX._reserved, 0);
        assert_eq!(FILE_DIALOG._reserved, 0);
        assert_eq!(COLOR_PICKER._reserved, 0);
    }
    #[test]
    fn namespace_handles_default_matches_new() {
        assert_eq!(MsgBox::default(), MsgBox::new());
        assert_eq!(FileDialog::default(), FileDialog::new());
        assert_eq!(ColorPickerDialog::default(), ColorPickerDialog::new());
    }
    #[test]
    fn namespace_handles_are_stateless_single_byte_shims() {
        // These types are `#[repr(C)]` placeholders that the FFI layer hangs static
        // methods off. A field creeping in would silently change the C ABI.
        assert_eq!(size_of::<MsgBox>(), 1);
        assert_eq!(size_of::<FileDialog>(), 1);
        assert_eq!(size_of::<ColorPickerDialog>(), 1);
        assert_eq!(align_of::<MsgBox>(), 1);
        assert_eq!(align_of::<FileDialog>(), 1);
        assert_eq!(align_of::<ColorPickerDialog>(), 1);
    }
    #[test]
    fn namespace_handles_are_copy_and_hash_consistently() {
        use std::{
            collections::hash_map::DefaultHasher,
            hash::{Hash, Hasher},
        };
        fn hash_of<T: Hash>(value: &T) -> u64 {
            let mut hasher = DefaultHasher::new();
            value.hash(&mut hasher);
            hasher.finish()
        }
        let original = MsgBox::new();
        let copied = original; // Copy, not a move
        assert_eq!(original, copied);
        assert_eq!(hash_of(&original), hash_of(&copied));
        assert_eq!(hash_of(&MsgBox::new()), hash_of(&MsgBox::new()));
        assert_eq!(hash_of(&FileDialog::new()), hash_of(&FileDialog::new()));
        assert_eq!(
            hash_of(&ColorPickerDialog::new()),
            hash_of(&ColorPickerDialog::new())
        );
    }
    // ---------------------------------------------------------------------
    // Enum conversions to/from `tfd` (round-trip: encode == decode)
    // ---------------------------------------------------------------------
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn ok_cancel_round_trips_through_tfd() {
        for variant in [OkCancel::Ok, OkCancel::Cancel] {
            let encoded: tfd::OkCancel = variant.into();
            let decoded: OkCancel = encoded.into();
            assert_eq!(decoded, variant, "round-trip lost {variant:?}");
        }
        // ... and the other direction, exhaustively.
        assert_eq!(OkCancel::from(tfd::OkCancel::Ok), OkCancel::Ok);
        assert_eq!(OkCancel::from(tfd::OkCancel::Cancel), OkCancel::Cancel);
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn yes_no_round_trips_through_tfd() {
        for variant in [YesNo::Yes, YesNo::No] {
            let encoded: tfd::YesNo = variant.into();
            let decoded: YesNo = encoded.into();
            assert_eq!(decoded, variant, "round-trip lost {variant:?}");
        }
        assert_eq!(YesNo::from(tfd::YesNo::Yes), YesNo::Yes);
        assert_eq!(YesNo::from(tfd::YesNo::No), YesNo::No);
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn answer_enums_must_be_converted_by_variant_never_by_discriminant() {
        // azul declares `OkCancel { Ok, Cancel }` (Ok = 0) but tfd declares
        // `OkCancel { Cancel = 0, Ok = 1 }` — the discriminants are INVERTED.
        // Same story for YesNo. So a `transmute` or an `as`-cast in place of the
        // `From` impls would silently turn "Ok" into "Cancel", i.e. hand the caller
        // the exact opposite of what the user clicked. This test pins the mismatch
        // so nobody "optimises" the match arms into a cast.
        assert_eq!(OkCancel::Ok as u8, 0);
        assert_eq!(OkCancel::Cancel as u8, 1);
        assert_eq!(tfd::OkCancel::Ok as u8, 1);
        assert_eq!(tfd::OkCancel::Cancel as u8, 0);
        assert_eq!(YesNo::Yes as u8, 0);
        assert_eq!(YesNo::No as u8, 1);
        assert_eq!(tfd::YesNo::Yes as u8, 1);
        assert_eq!(tfd::YesNo::No as u8, 0);
        // The conversions must follow the variant, not the number.
        assert_eq!(tfd::OkCancel::from(OkCancel::Ok), tfd::OkCancel::Ok);
        assert_eq!(tfd::YesNo::from(YesNo::Yes), tfd::YesNo::Yes);
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn msg_box_icon_maps_to_the_matching_tfd_icon() {
        let mapping = [
            (MsgBoxIcon::Info, MessageBoxIcon::Info),
            (MsgBoxIcon::Warning, MessageBoxIcon::Warning),
            (MsgBoxIcon::Error, MessageBoxIcon::Error),
            (MsgBoxIcon::Question, MessageBoxIcon::Question),
        ];
        for (ours, theirs) in mapping {
            assert_eq!(
                MessageBoxIcon::from(ours),
                theirs,
                "wrong icon for {ours:?}"
            );
        }
        // Injective: four distinct inputs must not collapse onto three icons.
        let encoded: Vec<MessageBoxIcon> = mapping
            .iter()
            .map(|(ours, _)| MessageBoxIcon::from(*ours))
            .collect();
        for (i, a) in encoded.iter().enumerate() {
            for b in encoded.iter().skip(i + 1) {
                assert_ne!(a, b, "two MsgBoxIcon variants map to the same tfd icon");
            }
        }
    }
    // ---------------------------------------------------------------------
    // apply_filter — the only non-modal logic in this file
    // ---------------------------------------------------------------------
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_with_no_patterns_does_not_panic() {
        let dialog = apply_filter(tfd::FileDialog::new("title"), file_type_list(&[], ""));
        assert!(dialog.filter_patterns().is_empty());
        assert_eq!(dialog.filter_description(), "");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_with_a_default_constructed_string_vec_does_not_panic() {
        // `StringVec::new()` is the empty/possibly-null-pointer case that
        // `into_library_owned_vec` has to survive.
        let filter = FileTypeList {
            document_types: StringVec::new(),
            document_descriptor: s("no types"),
        };
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        assert!(dialog.filter_patterns().is_empty());
        assert_eq!(dialog.filter_description(), "no types");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_preserves_patterns_verbatim_and_in_order() {
        let filter = file_type_list(&["*.png", "*.jpg", "*.png", ""], "Images");
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        // Duplicates and the empty pattern survive: the filter is a pass-through,
        // not a set.
        assert_eq!(dialog.filter_patterns(), &["*.png", "*.jpg", "*.png", ""]);
        assert_eq!(dialog.filter_description(), "Images");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_preserves_unicode_patterns() {
        let patterns = [
            "*.图片",         // CJK
            "*.🎨",           // astral-plane emoji
            "*.مِلَف",          // RTL with combining marks
            "*.e\u{0301}xt",  // decomposed é — must not be normalised away
            "*.\u{200B}zwsp", // zero-width space
        ];
        let filter = file_type_list(&patterns, "Ünïcödé — файлы 🎨");
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        assert_eq!(dialog.filter_patterns(), &patterns);
        assert_eq!(dialog.filter_description(), "Ünïcödé — файлы 🎨");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_does_not_truncate_at_interior_nul_bytes() {
        // A NUL is a legal Rust `str` byte but terminates a C string. `apply_filter`
        // is pure Rust, so it must hand the bytes on intact rather than silently
        // cutting the pattern short (a truncation here would turn "*.png\0evil" into
        // a filter the caller never asked for).
        let filter = file_type_list(&["*.pn\0g", "\0", "a\u{1}b\u{7f}"], "desc\0ription");
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        assert_eq!(
            dialog.filter_patterns(),
            &["*.pn\0g", "\0", "a\u{1}b\u{7f}"]
        );
        assert_eq!(dialog.filter_description(), "desc\0ription");
        assert_eq!(dialog.filter_patterns()[0].len(), 6); // bytes kept, not cut at the NUL
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_passes_shell_metacharacters_through_unchanged() {
        // Documents the ACTUAL behaviour: unlike `MsgBox::ok` (which strips quotes
        // before handing the string to tfd), `apply_filter` sanitises nothing. If a
        // sanitisation step is ever added, this test should be updated deliberately
        // — it must not change by accident.
        let hostile = ["\"", "'", "$(id)", "`id`", "a;b", "x\ny", "--", "*"];
        let filter = file_type_list(&hostile, "\"quoted\" $(id)");
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        assert_eq!(dialog.filter_patterns(), &hostile);
        assert_eq!(dialog.filter_description(), "\"quoted\" $(id)");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_survives_a_huge_filter_list() {
        let patterns: Vec<String> = (0..2000).map(|i| format!("*.ext{i}")).collect();
        let descriptor = "d".repeat(64 * 1024);
        let filter = FileTypeList {
            document_types: StringVec::from_vec(
                patterns.iter().map(|p| s(p)).collect::<Vec<AzString>>(),
            ),
            document_descriptor: s(&descriptor),
        };
        let dialog = apply_filter(tfd::FileDialog::new("title"), filter);
        assert_eq!(dialog.filter_patterns().len(), 2000);
        assert_eq!(dialog.filter_patterns()[0], "*.ext0");
        assert_eq!(dialog.filter_patterns()[1999], "*.ext1999");
        assert_eq!(dialog.filter_description().len(), 64 * 1024);
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_overwrites_rather_than_appends() {
        // tfd's `with_filter` assigns, so applying twice is last-write-wins. Worth
        // pinning: an `open_file` caller that expects the two lists to merge would
        // silently lose the first set of extensions.
        let dialog = tfd::FileDialog::new("title");
        let dialog = apply_filter(dialog, file_type_list(&["*.png"], "Images"));
        let dialog = apply_filter(dialog, file_type_list(&["*.txt"], "Text"));
        assert_eq!(dialog.filter_patterns(), &["*.txt"]);
        assert_eq!(dialog.filter_description(), "Text");
    }
    #[cfg(not(any(target_os = "android", target_os = "ios")))]
    #[test]
    fn apply_filter_leaves_the_rest_of_the_dialog_alone() {
        // `open_multiple_files` sets the path + multi-select BEFORE calling
        // apply_filter; the filter must not clobber either.
        let dialog = tfd::FileDialog::new("title")
            .with_path("/tmp/somewhere")
            .with_multiple_selection(true);
        let dialog = apply_filter(dialog, file_type_list(&["*.png"], "Images"));
        assert_eq!(dialog.path(), "/tmp/somewhere");
        assert!(dialog.multiple_selection());
        assert_eq!(dialog.filter_patterns(), &["*.png"]);
    }
    // ---------------------------------------------------------------------
    // FileTypeList / OptionFileTypeList container invariants
    // ---------------------------------------------------------------------
    #[test]
    fn string_vec_round_trips_through_into_library_owned_vec() {
        // This is the exact conversion `apply_filter` performs internally.
        let original: Vec<AzString> = vec![s("*.png"), s(""), s("*.🎨"), s("a\0b")];
        let round_tripped = StringVec::from_vec(original.clone()).into_library_owned_vec();
        assert_eq!(round_tripped, original);
        // ... and the empty case, which takes the null/zero-length branch.
        let empty = StringVec::from_vec(Vec::<AzString>::new()).into_library_owned_vec();
        assert!(empty.is_empty());
    }
    #[test]
    fn file_type_list_clone_is_equal_and_orders_reflexively() {
        use std::cmp::Ordering;
        let filter = file_type_list(&["*.png", "*.jpg"], "Images");
        let cloned = filter.clone();
        assert_eq!(cloned, filter);
        assert_eq!(filter.partial_cmp(&filter), Some(Ordering::Equal));
        assert_eq!(cloned.document_types.len(), 2);
        assert_eq!(cloned.document_descriptor.as_str(), "Images");
    }
    #[test]
    fn file_type_list_ordering_follows_the_descriptor_when_types_match() {
        use std::cmp::Ordering;
        let a = file_type_list(&["*.png"], "aaa");
        let b = file_type_list(&["*.png"], "bbb");
        assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
        assert_eq!(b.partial_cmp(&a), Some(Ordering::Greater));
        assert_ne!(a, b);
    }
    #[test]
    fn option_file_type_list_round_trips() {
        let filter = file_type_list(&["*.png"], "Images");
        let some = OptionFileTypeList::Some(filter.clone());
        assert!(some.is_some());
        assert!(!some.is_none());
        assert_eq!(some.as_option(), Some(&filter));
        assert_eq!(some.clone().into_option(), Some(filter));
        let none = OptionFileTypeList::None;
        assert!(none.is_none());
        assert_eq!(none.as_option(), None);
        assert_eq!(OptionFileTypeList::default(), OptionFileTypeList::None);
    }
    // ---------------------------------------------------------------------
    // Modal entry points: signature guard only — calling these would block
    // ---------------------------------------------------------------------
    #[test]
    fn modal_entry_points_keep_their_ffi_signatures() {
        // Coercing to a fn pointer type-checks every exported signature WITHOUT
        // invoking it. api.json / the C bindings are generated from these exact
        // shapes, so an argument reorder or a changed return type must not slip
        // through unnoticed just because no test can safely call them.
        let _ok: fn(AzString, AzString, MsgBoxIcon) = MsgBox::ok;
        let _ok_cancel: fn(AzString, AzString, MsgBoxIcon, OkCancel) -> OkCancel =
            MsgBox::ok_cancel;
        let _yes_no: fn(AzString, AzString, MsgBoxIcon, YesNo) -> YesNo = MsgBox::yes_no;
        let _info: fn(AzString) = MsgBox::info;
        // The pickers are requests: they take the app's context + resume
        // callback and answer through the runtime queue.
        let _color: fn(AzString, OptionColorU, RefAny, ResumeCallback) -> RequestId =
            ColorPickerDialog::open;
        let _open_file: fn(
            AzString,
            OptionString,
            OptionFileTypeList,
            RefAny,
            ResumeCallback,
        ) -> RequestId = FileDialog::open_file;
        let _open_dir: fn(AzString, OptionString, RefAny, ResumeCallback) -> RequestId =
            FileDialog::open_directory;
        let _open_many: fn(
            AzString,
            OptionString,
            OptionFileTypeList,
            RefAny,
            ResumeCallback,
        ) -> RequestId = FileDialog::open_multiple_files;
        let _save_file: fn(AzString, AzString, RefAny, ResumeCallback) -> RequestId =
            FileDialog::save_file;
        let _save_bytes: fn(AzString, AzString, U8Vec) -> bool = FileDialog::save_bytes;
        let _msg_box: fn(&str) = msg_box;
    }
    extern "C" fn resume_noop(
        _: RefAny,
        _: crate::callbacks::CallbackInfo,
        _: RefAny,
    ) -> azul_core::callbacks::Update {
        azul_core::callbacks::Update::DoNothing
    }
    /// Drains the runtime queue and returns the result struct the one request
    /// issued by the test resumed with.
    fn take_single_result() -> RefAny {
        let mut completed = crate::request::take_completed();
        assert_eq!(completed.len(), 1, "exactly one completion expected");
        completed.remove(0).result
    }
    // ---------------------------------------------------------------------
    // Async picker handle: the part that never touches a native dialog
    // ---------------------------------------------------------------------
    /// A fresh handle is `Pending`; a backend's `set_status` is what every
    /// clone sees; a pre-answered handle is done on its first poll.
    #[test]
    fn picker_handle_is_shared_between_its_clones_and_answers_once_set() {
        let user_side = FilePickerHandle::new_pending();
        let backend_side = user_side.clone();
        assert_eq!(user_side.poll(), FilePickerStatus::Pending);
        assert!(!user_side.is_done());
        backend_side.set_status(FilePickerStatus::Selected(s("/tmp/a.txt")));
        drop(backend_side); // the backend drops its clone after answering
        assert!(user_side.is_done());
        assert_eq!(
            user_side.poll(),
            FilePickerStatus::Selected(s("/tmp/a.txt"))
        );
        // A flaky delegate that fires twice keeps the LATEST answer.
        user_side.set_status(FilePickerStatus::Cancelled);
        assert_eq!(user_side.poll(), FilePickerStatus::Cancelled);
        let answered = FilePickerHandle::with_status(FilePickerStatus::SelectedMultiple(
            StringVec::from_vec(vec![s("a"), s("b")]),
        ));
        assert!(
            answered.is_done(),
            "a pre-answered handle is done on its first poll"
        );
        // The backend answering AFTER the user dropped their handle must be
        // sound: the clone owns its own strong count. And the null `Default`
        // is an answered Error, never a handle that stays Pending.
        let user = FilePickerHandle::new_pending();
        let backend = user.clone();
        drop(user);
        backend.set_status(FilePickerStatus::Cancelled);
        assert_eq!(backend.poll(), FilePickerStatus::Cancelled);
        drop(backend);
        let null = FilePickerHandle::default();
        assert!(null.ptr.is_null() && !null.run_destructor);
        assert!(matches!(null.poll(), FilePickerStatus::Error(_)));
        assert!(null.is_done());
        assert!(null.clone().ptr.is_null());
        null.set_status(FilePickerStatus::Cancelled); // a no-op, not a crash
    }
    /// Without a registered backend nothing here may block, and the filter
    /// conversion hands the backends exactly the patterns, not the descriptor.
    #[test]
    fn filter_patterns_keep_the_types_and_drop_the_descriptor() {
        let list = file_type_list(&["*.png", "*.jpg"], "Images");
        let patterns = filter_patterns(OptionFileTypeList::Some(list));
        let v = patterns
            .into_option()
            .expect("patterns present")
            .into_library_owned_vec();
        let got: Vec<&str> = v.iter().map(AzString::as_str).collect();
        assert_eq!(got, vec!["*.png", "*.jpg"]);
        assert!(filter_patterns(OptionFileTypeList::None)
            .into_option()
            .is_none());
    }
    // ---------------------------------------------------------------------
    // android / iOS: the no-op branch is the one that CAN be executed safely
    // ---------------------------------------------------------------------
    #[cfg(any(target_os = "android", target_os = "ios"))]
    #[test]
    fn mobile_message_boxes_are_silent_no_ops() {
        MsgBox::ok(s("title"), s("message"), MsgBoxIcon::Error);
        MsgBox::info(s(""));
        msg_box("");
        msg_box("\0\u{1}🎨");
    }
    #[cfg(any(target_os = "android", target_os = "ios"))]
    #[test]
    fn mobile_answer_dialogs_echo_the_default_back() {
        for default in [OkCancel::Ok, OkCancel::Cancel] {
            let answer = MsgBox::ok_cancel(s("t"), s("m"), MsgBoxIcon::Question, default);
            assert_eq!(answer, default);
        }
        for default in [YesNo::Yes, YesNo::No] {
            let answer = MsgBox::yes_no(s("t"), s("m"), MsgBoxIcon::Question, default);
            assert_eq!(answer, default);
        }
    }
    // No native color picker exists on mobile: the request resolves as
    // cancelled through the runtime queue instead of never resolving.
    #[cfg(any(target_os = "android", target_os = "ios"))]
    #[test]
    fn mobile_color_picker_resolves_as_cancelled() {
        let _ = crate::request::take_completed();
        let default = ColorU {
            r: 1,
            g: 2,
            b: 3,
            a: 4,
        };
        let id = ColorPickerDialog::open(
            s("t"),
            OptionColorU::Some(default),
            RefAny::new(()),
            ResumeCallback::create(resume_noop),
        );
        assert!(id.is_valid());
        let picked = ColorPickResult::downcast(take_single_result())
            .into_option()
            .expect("a ColorPickResult");
        assert!(picked.color.is_none());
    }
    // A mobile build whose shell registered no picker backend resolves every
    // file dialog as cancelled - never a request that stays open forever.
    #[cfg(any(target_os = "android", target_os = "ios"))]
    #[test]
    fn mobile_file_dialogs_without_a_backend_resolve_as_cancelled() {
        if has_file_picker_backend() {
            return;
        }
        let _ = crate::request::take_completed();
        let cb = ResumeCallback::create(resume_noop);
        FileDialog::open_file(
            s("t"),
            OptionString::None,
            OptionFileTypeList::None,
            RefAny::new(()),
            cb.clone(),
        );
        let open = FileOpenResult::downcast(take_single_result())
            .into_option()
            .expect("a FileOpenResult");
        assert!(open.path.is_none());
        FileDialog::open_directory(s("t"), OptionString::None, RefAny::new(()), cb.clone());
        let dir = FileOpenResult::downcast(take_single_result())
            .into_option()
            .expect("a FileOpenResult");
        assert!(dir.path.is_none());
        FileDialog::save_file(s("t"), s("doc.md"), RefAny::new(()), cb.clone());
        let save = SaveTargetResult::downcast(take_single_result())
            .into_option()
            .expect("a SaveTargetResult");
        assert!(save.target.is_none());
        FileDialog::open_multiple_files(
            s("t"),
            OptionString::None,
            OptionFileTypeList::None,
            RefAny::new(()),
            cb,
        );
        let many = FileOpenMultiResult::downcast(take_single_result())
            .into_option()
            .expect("a FileOpenMultiResult");
        assert!(many.paths.as_ref().is_empty());
    }
}