1
//! File input button, same as `Button`, but triggers a
2
//! user-supplied path-change callback when clicked
3

            
4
use azul_core::{
5
    callbacks::{CoreCallbackData, Update},
6
    dom::Dom,
7
    refany::RefAny,
8
    resources::OptionImageRef,
9
};
10
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
11
use azul_css::{
12
    dynamic_selector::CssPropertyWithConditionsVec,
13
    props::{
14
        basic::*,
15
        layout::*,
16
        property::{CssProperty, *},
17
        style::*,
18
    },
19
    *,
20
};
21

            
22
use crate::{
23
    callbacks::{Callback, CallbackInfo},
24
    widgets::button::{Button, ButtonOnClick, ButtonOnClickCallback},
25
};
26

            
27
#[derive(Debug, Clone, PartialEq, Eq)]
28
#[repr(C)]
29
pub struct FileInput {
30
    /// State of the file input
31
    pub file_input_state: FileInputStateWrapper,
32
    /// Default text to display when no file has been selected
33
    /// (default = "Select File...")
34
    pub default_text: AzString,
35

            
36
    /// Optional image that is displayed next to the label
37
    pub image: OptionImageRef,
38
    /// Style for this button container
39
    pub container_style: CssPropertyWithConditionsVec,
40
    /// Style of the label
41
    pub label_style: CssPropertyWithConditionsVec,
42
    /// Style of the image
43
    pub image_style: CssPropertyWithConditionsVec,
44
}
45

            
46
impl Default for FileInput {
47
205
    fn default() -> Self {
48
205
        let default_button = Button::create(AzString::from_const_str(""));
49
205
        Self {
50
205
            file_input_state: FileInputStateWrapper::default(),
51
205
            default_text: "Select File...".into(),
52
205
            image: None.into(),
53
205
            container_style: default_button.container_style,
54
205
            label_style: default_button.label_style,
55
205
            image_style: default_button.image_style,
56
205
        }
57
205
    }
58
}
59

            
60
#[derive(Debug, Clone, PartialEq, Eq)]
61
#[repr(C)]
62
pub struct FileInputStateWrapper {
63
    pub inner: FileInputState,
64
    pub on_path_change: OptionFileInputOnPathChange,
65
    /// Title displayed in the file selection dialog
66
    pub file_dialog_title: AzString,
67
    /// Default directory of file input
68
    pub default_dir: OptionString,
69
}
70

            
71
impl Default for FileInputStateWrapper {
72
385
    fn default() -> Self {
73
385
        Self {
74
385
            inner: FileInputState::default(),
75
385
            on_path_change: None.into(),
76
385
            file_dialog_title: "Select File".into(),
77
385
            default_dir: None.into(),
78
385
        }
79
385
    }
80
}
81

            
82
/// Current state of the file input (selected path)
83
#[derive(Debug, Clone, PartialEq, Eq)]
84
#[repr(C)]
85
pub struct FileInputState {
86
    pub path: OptionString,
87
}
88

            
89
impl Default for FileInputState {
90
388
    fn default() -> Self {
91
388
        Self { path: None.into() }
92
388
    }
93
}
94

            
95
/// Callback type invoked when the file input path changes
96
pub type FileInputOnPathChangeCallbackType =
97
    extern "C" fn(RefAny, CallbackInfo, FileInputState) -> Update;
98

            
99
impl_widget_callback!(
100
    FileInputOnPathChange,
101
    OptionFileInputOnPathChange,
102
    FileInputOnPathChangeCallback,
103
    FileInputOnPathChangeCallbackType
104
);
105

            
106
azul_core::impl_managed_callback! {
107
    wrapper:        FileInputOnPathChangeCallback,
108
    info_ty:        CallbackInfo,
109
    return_ty:      Update,
110
    default_ret:    Update::DoNothing,
111
    invoker_static: FILE_INPUT_ON_PATH_CHANGE_INVOKER,
112
    invoker_ty:     AzFileInputOnPathChangeCallbackInvoker,
113
    thunk_fn:       az_file_input_on_path_change_callback_thunk,
114
    setter_fn:      AzApp_setFileInputOnPathChangeCallbackInvoker,
115
    from_handle_fn: AzFileInputOnPathChangeCallback_createFromHostHandle,
116
    extra_args:     [ state: FileInputState ],
117
}
118

            
119
impl FileInput {
120
180
    #[must_use] pub fn create(path: OptionString) -> Self {
121
180
        Self {
122
180
            file_input_state: FileInputStateWrapper {
123
180
                inner: FileInputState { path },
124
180
                ..Default::default()
125
180
            },
126
180
            ..Default::default()
127
180
        }
128
180
    }
129

            
130
    #[inline]
131
    #[must_use]
132
6
    pub fn swap_with_default(&mut self) -> Self {
133
6
        let mut s = Self::create(None.into());
134
6
        core::mem::swap(&mut s, self);
135
6
        s
136
6
    }
137

            
138
    #[inline]
139
80
    pub fn set_default_text(&mut self, default_text: AzString) {
140
80
        self.default_text = default_text;
141
80
    }
142

            
143
    #[inline]
144
41
    #[must_use] pub fn with_default_text(mut self, default_text: AzString) -> Self {
145
41
        self.set_default_text(default_text);
146
41
        self
147
41
    }
148

            
149
    #[inline]
150
13
    pub fn set_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
151
13
        &mut self,
152
13
        refany: RefAny,
153
13
        callback: I,
154
13
    ) {
155
13
        self.file_input_state.on_path_change = Some(FileInputOnPathChange {
156
13
            callback: callback.into(),
157
13
            refany,
158
13
        })
159
13
        .into();
160
13
    }
161

            
162
    #[inline]
163
    #[must_use]
164
7
    pub fn with_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
165
7
        mut self,
166
7
        refany: RefAny,
167
7
        callback: I,
168
7
    ) -> Self {
169
7
        self.set_on_path_change(refany, callback);
170
7
        self
171
7
    }
172

            
173
    #[inline]
174
86
    #[must_use] pub fn dom(self) -> Dom {
175
        // either show the default text or the file name
176
        // including the extension as the button label
177
86
        let button_label = match self.file_input_state.inner.path.as_ref() {
178
67
            Some(path) => std::path::Path::new(path.as_str())
179
67
                .file_name()
180
67
                .map_or_else(
181
29
                    || self.default_text.as_str().to_string(),
182
38
                    |s| s.to_string_lossy().to_string(),
183
                )
184
67
                .into(),
185
19
            None => self.default_text.clone(),
186
        };
187

            
188
86
        Button {
189
86
            label: button_label,
190
86
            image: self.image,
191
86
            icon: AzString::from_const_str(""),
192
86
            trailing_icon: AzString::from_const_str(""),
193
86
            button_type: crate::widgets::button::ButtonType::Default,
194
86
            container_style: self.container_style,
195
86
            label_style: self.label_style,
196
86
            image_style: self.image_style,
197
86
            icon_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
198
86
            trailing_icon_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
199
86
            on_click: Some(ButtonOnClick {
200
86
                refany: RefAny::new(self.file_input_state),
201
86
                callback: ButtonOnClickCallback {
202
86
                    cb: fileinput_on_click,
203
86
                    ctx: azul_core::refany::OptionRefAny::None,
204
86
                },
205
86
            })
206
86
            .into(),
207
86
        }
208
86
        .dom()
209
86
    }
210
}
211

            
212
3
extern "C" fn fileinput_on_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
213
3
    let Some(mut fileinputstatewrapper) = refany.downcast_mut::<FileInputStateWrapper>() else {
214
3
        return Update::DoNothing;
215
    };
216
    let fileinputstatewrapper = &mut *fileinputstatewrapper;
217

            
218
    // `tfd` is desktop-only (target-gated in Cargo.toml to not(android|ios)); the
219
    // `extra` feature does nothing on mobile, so gate the dialog block by the same
220
    // target cfg to avoid referencing the unlinked `tfd` crate on iOS/Android.
221
    #[cfg(all(feature = "extra", not(any(target_os = "android", target_os = "ios"))))]
222
    {
223
        let mut dialog = tfd::FileDialog::new(fileinputstatewrapper.file_dialog_title.as_str());
224
        if let Some(dir) = fileinputstatewrapper.default_dir.as_ref() {
225
            dialog = dialog.with_path(dir.as_str());
226
        }
227
        let Some(selected_path) = dialog.open_file() else {
228
            return Update::DoNothing;
229
        };
230
        fileinputstatewrapper.inner.path = Some(selected_path.into()).into();
231
    }
232

            
233
    let inner = fileinputstatewrapper.inner.clone();
234
    let mut result = match fileinputstatewrapper.on_path_change.as_mut() {
235
        Some(FileInputOnPathChange { refany, callback }) => {
236
            (callback.cb)(refany.clone(), info, inner)
237
        }
238
        None => Update::RefreshDom,
239
    };
240

            
241
    result.max_self(Update::RefreshDom);
242

            
243
    result
244
3
}
245

            
246
#[cfg(all(test, feature = "std"))]
247
#[allow(clippy::too_many_lines)] // table-driven cases; splitting them hides the case list
248
mod autotest_generated {
249
    use std::{
250
        collections::{BTreeMap, HashMap},
251
        sync::{Arc, Mutex},
252
    };
253

            
254
    use azul_core::{
255
        dom::{
256
            DomId, DomNodeId, EventFilter, HoverEventFilter, IdOrClass, NodeId, NodeType, TabIndex,
257
        },
258
        geom::{LogicalRect, OptionLogicalPosition},
259
        gl::OptionGlContextPtr,
260
        hit_test::ScrollPosition,
261
        refany::OptionRefAny,
262
        resources::{ImageRef, RawImageFormat, RendererResources},
263
        styled_dom::{NodeHierarchyItemId, StyledDom},
264
        window::{MonitorVec, RawWindowHandle},
265
    };
266
    use rust_fontconfig::FcFontCache;
267

            
268
    use super::*;
269
    #[cfg(feature = "icu")]
270
    use crate::icu::IcuLocalizerHandle;
271
    use crate::{
272
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
273
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
274
        widgets::button::ButtonType,
275
        window::{DomLayoutResult, LayoutWindow},
276
        window_state::FullWindowState,
277
    };
278

            
279
    // ------------------------------------------------------------------
280
    // Fixtures
281
    // ------------------------------------------------------------------
282

            
283
    /// The label a path-less file input renders, per the doc comment on
284
    /// `FileInput::default_text`.
285
    const DEFAULT_TEXT: &str = "Select File...";
286

            
287
    /// The title `FileInputStateWrapper::default` puts on the native dialog. Note it is
288
    /// *not* the same string as `DEFAULT_TEXT` (no ellipsis) — a "cleanup" that unified
289
    /// the two would silently retitle every file dialog.
290
    const DEFAULT_DIALOG_TITLE: &str = "Select File";
291

            
292
    /// Paths whose *last* component is a real file name, paired with the label
293
    /// `dom()` must render for them. Only shapes that mean the same thing on every
294
    /// platform live here (`/` is a separator on Windows too); the backslash- and
295
    /// double-slash-specific shapes are tested per-platform below.
296
    fn file_name_cases() -> Vec<(String, String)> {
297
        [
298
            ("/tmp/report.pdf", "report.pdf"),
299
            ("report.pdf", "report.pdf"),
300
            ("/tmp/dir/", "dir"),         // a trailing separator is not a component
301
            ("/tmp/dir///", "dir"),       // ...and neither are three of them
302
            ("a/.", "a"),                 // a trailing `.` normalizes away
303
            ("/a/b/c/d/e/f/g.txt", "g.txt"),
304
            (".hidden", ".hidden"),       // a leading dot is part of the name
305
            ("...", "..."),               // only `.` and `..` are special
306
            ("..a", "..a"),
307
            ("/tmp/archive.tar.gz", "archive.tar.gz"),
308
            ("  ", "  "),                 // whitespace is a legal file name
309
            ("/tmp/a b.txt", "a b.txt"),
310
            ("/tmp/a\nb.txt", "a\nb.txt"), // control chars survive verbatim
311
            ("/tmp/a\tb.txt", "a\tb.txt"),
312
            ("a\0b", "a\0b"),             // OsStr allows interior NULs; must not truncate
313
            ("/tmp/a\0b.txt", "a\0b.txt"),
314
            ("/tmp/日本語.txt", "日本語.txt"),
315
            ("/tmp/e\u{0301}.txt", "e\u{0301}.txt"), // decomposed é: no normalization
316
            (
317
                "/tmp/\u{1F469}\u{200D}\u{1F467}.png", // ZWJ emoji sequence
318
                "\u{1F469}\u{200D}\u{1F467}.png",
319
            ),
320
            ("/tmp/\u{202E}gpj.exe", "\u{202E}gpj.exe"), // RTL-override spoof, kept as-is
321
            ("/tmp/\u{FFFD}.bin", "\u{FFFD}.bin"),
322
            ("/Select File.../x", "x"), // a directory named like the default text
323
        ]
324
        .iter()
325
        .map(|(p, l)| ((*p).to_string(), (*l).to_string()))
326
        .collect()
327
    }
328

            
329
    /// Paths with *no* file-name component: `Path::file_name` returns `None` for each,
330
    /// so `dom()` must fall back to `default_text` rather than render an empty button.
331
    fn no_file_name_cases() -> Vec<String> {
332
        ["", "/", ".", "..", "./", "/.", "/..", "a/..", "a/b/../", "../.."]
333
            .iter()
334
            .map(|s| (*s).to_string())
335
            .collect()
336
    }
337

            
338
    /// Adversarial strings for the free-form text fields (`default_text`,
339
    /// `file_dialog_title`, `default_dir`): empty, whitespace-only, combining marks,
340
    /// ZWJ emoji, RTL, embedded NULs (`AzString` is length-based, so a NUL must not
341
    /// truncate), bidi overrides — plus one string far longer than any real label.
342
    fn adversarial_strings() -> Vec<String> {
343
        let mut v: Vec<String> = [
344
            "",
345
            " ",
346
            "Pick a file",
347
            "e\u{0301}",
348
            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
349
            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",
350
            "\0",
351
            "a\0b",
352
            "\u{FFFD}\u{202E}\u{200B}",
353
            "…\t\r\n",
354
            DEFAULT_TEXT,
355
        ]
356
        .iter()
357
        .map(|s| (*s).to_string())
358
        .collect();
359
        v.push("x".repeat(100_000));
360
        v
361
    }
362

            
363
    fn opt(s: &str) -> OptionString {
364
        Some(AzString::from(s)).into()
365
    }
366

            
367
    /// A file input with every field set to something distinguishable, so that a
368
    /// mutator touching a field it should not is visible.
369
    ///
370
    /// Deliberately image-*less*: `ImageRef` equality is identity-based (a fresh `id`
371
    /// per constructor call), so two independently-built images never compare equal and
372
    /// an image here would break every `assert_eq!` between two fixtures.
373
    fn populated() -> FileInput {
374
        let mut fi = FileInput::create(opt("/tmp/original.txt"));
375
        fi.default_text = "custom default".into();
376
        fi.file_input_state.file_dialog_title = "custom title".into();
377
        fi.file_input_state.default_dir = opt("/tmp/custom-dir");
378
        fi
379
    }
380

            
381
    /// [`populated`] plus an image. Only for tests that compare a widget against its own
382
    /// `clone()` (clones share the image identity) or do not compare widgets at all.
383
    fn populated_with_image() -> FileInput {
384
        let mut fi = populated();
385
        fi.image = Some(ImageRef::null_image(
386
            3,
387
            5,
388
            RawImageFormat::RGBA8,
389
            b"file-input-probe".to_vec(),
390
        ))
391
        .into();
392
        fi
393
    }
394

            
395
    // ------------------------------------------------------------------
396
    // DOM probes
397
    // ------------------------------------------------------------------
398

            
399
    fn text_of(dom: &Dom) -> Option<&str> {
400
        match dom.root.get_node_type() {
401
            NodeType::Text(s) => Some(s.as_ref().as_str()),
402
            _ => None,
403
        }
404
    }
405

            
406
    /// The rendered button label. `Button::dom` always appends the label *last*
407
    /// (an optional image is pushed as the first child), block-formatted as
408
    /// `<p>` wrapping the text node.
409
    fn rendered_label(dom: &Dom) -> String {
410
        let children = dom.children.as_ref();
411
        let last = children.last().expect("the button has no label child");
412
        assert!(
413
            matches!(last.root.get_node_type(), NodeType::P),
414
            "the button label is not block-formatted (<p>)"
415
        );
416
        let inner = last.children.as_ref();
417
        assert_eq!(inner.len(), 1, "the label <p> wraps exactly one text node");
418
        text_of(&inner[0])
419
            .expect("the button label is not a text node")
420
            .to_string()
421
    }
422

            
423
    fn classes(dom: &Dom) -> Vec<String> {
424
        dom.root
425
            .get_ids_and_classes()
426
            .as_ref()
427
            .iter()
428
            .filter_map(|c| match c {
429
                IdOrClass::Class(s) => Some(s.as_str().to_string()),
430
                IdOrClass::Id(_) => None,
431
            })
432
            .collect()
433
    }
434

            
435
    /// The recursive descendant count — `Dom::estimated_total_children` is a *cached*
436
    /// value that, if too small, makes `convert_dom_into_compact_dom` under-allocate
437
    /// its arenas and panic on out-of-bounds writes.
438
    fn count_descendants(dom: &Dom) -> usize {
439
        dom.children
440
            .as_ref()
441
            .iter()
442
            .map(|c| 1 + count_descendants(c))
443
            .sum()
444
    }
445

            
446
    /// The state wrapper the rendered DOM handed to its own click handler.
447
    fn registered_state(dom: &Dom) -> RefAny {
448
        let callbacks = dom.root.callbacks.as_ref();
449
        assert_eq!(
450
            callbacks.len(),
451
            1,
452
            "a file input must register exactly one callback",
453
        );
454
        callbacks[0].refany.clone()
455
    }
456

            
457
    fn state_of(refany: &RefAny) -> FileInputStateWrapper {
458
        let mut refany = refany.clone();
459
        let wrapper = refany
460
            .downcast_ref::<FileInputStateWrapper>()
461
            .expect("the widget state changed type");
462
        wrapper.clone()
463
    }
464

            
465
    // ------------------------------------------------------------------
466
    // Callback fixtures
467
    // ------------------------------------------------------------------
468

            
469
    /// A payload the path-change callback writes into. It arrives as the `refany`
470
    /// argument — a *shared* clone of what the test still holds — so the test reads
471
    /// back exactly what the widget passed, with no global state.
472
    #[derive(Debug, Clone, PartialEq, Eq)]
473
    struct PathLog {
474
        seen: Vec<Option<String>>,
475
        payload: u32,
476
    }
477

            
478
    fn log_refany() -> RefAny {
479
        RefAny::new(PathLog {
480
            seen: Vec::new(),
481
            payload: 0xDEAD_BEEF,
482
        })
483
    }
484

            
485
    fn read_log(probe: &RefAny) -> PathLog {
486
        let mut probe = probe.clone();
487
        let log = probe
488
            .downcast_ref::<PathLog>()
489
            .expect("the user payload changed type");
490
        log.clone()
491
    }
492

            
493
    extern "C" fn record_path(
494
        mut refany: RefAny,
495
        _info: CallbackInfo,
496
        state: FileInputState,
497
    ) -> Update {
498
        if let Some(mut log) = refany.downcast_mut::<PathLog>() {
499
            log.seen
500
                .push(state.path.as_ref().map(|p| p.as_str().to_string()));
501
        }
502
        Update::DoNothing
503
    }
504

            
505
    // Only reachable from `without_the_native_dialog`, which is compiled out on a
506
    // default (`extra`) desktop build.
507
    #[allow(dead_code)]
508
    extern "C" fn path_do_nothing(
509
        _refany: RefAny,
510
        _info: CallbackInfo,
511
        _state: FileInputState,
512
    ) -> Update {
513
        Update::DoNothing
514
    }
515

            
516
    extern "C" fn path_refresh_all(
517
        _refany: RefAny,
518
        _info: CallbackInfo,
519
        _state: FileInputState,
520
    ) -> Update {
521
        Update::RefreshDomAllWindows
522
    }
523

            
524
    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in, which the
525
    /// `From<Callback>` arm *transmutes* into the 3-arg file-input slot. Never called.
526
    extern "C" fn generic_shaped(_refany: RefAny, _info: CallbackInfo) -> Update {
527
        Update::DoNothing
528
    }
529

            
530
    fn cb_addr(cb: &FileInputOnPathChangeCallback) -> usize {
531
        cb.cb as *const () as usize
532
    }
533

            
534
    // ------------------------------------------------------------------
535
    // CallbackInfo harness
536
    // ------------------------------------------------------------------
537

            
538
    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
539
    fn node(idx: usize) -> DomNodeId {
540
        DomNodeId {
541
            dom: DomId::ROOT_ID,
542
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
543
        }
544
    }
545

            
546
    /// A `DomNodeId` whose node component is `None` — the "no concrete node was hit"
547
    /// case. `fileinput_on_click` never queries the hit node, so it must survive it.
548
    fn node_none() -> DomNodeId {
549
        DomNodeId {
550
            dom: DomId::ROOT_ID,
551
            node: NodeHierarchyItemId::NONE,
552
        }
553
    }
554

            
555
    /// A `DomLayoutResult` carrying only a `styled_dom`: `fileinput_on_click` never
556
    /// queries the layout, so no real layout (and no font) is needed.
557
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
558
        DomLayoutResult {
559
            styled_dom,
560
            layout_tree: LayoutTree {
561
                nodes: Vec::new(),
562
                warm: Vec::new(),
563
                cold: Vec::new(),
564
                root: 0,
565
                dom_to_layout: BTreeMap::new(),
566
                children_arena: Vec::new(),
567
                children_offsets: Vec::new(),
568
                subtree_needs_intrinsic: Vec::new(),
569
            },
570
            calculated_positions: Vec::new(),
571
            viewport: LogicalRect::zero(),
572
            display_list: Arc::new(DisplayList::default()),
573
            scroll_ids: HashMap::new(),
574
            scroll_id_to_node_id: HashMap::new(),
575
        }
576
    }
577

            
578
    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root DOM
579
    /// and whose hit node is `hit`. Returns `f`'s value plus every change the callback
580
    /// pushed onto the transaction log.
581
    fn with_info<R>(
582
        styled_dom: StyledDom,
583
        hit: DomNodeId,
584
        f: impl FnOnce(&mut CallbackInfo) -> R,
585
    ) -> (R, Vec<CallbackChange>) {
586
        let mut layout_window =
587
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
588
        layout_window
589
            .layout_results
590
            .insert(DomId::ROOT_ID, layout_result(styled_dom));
591

            
592
        let renderer_resources = RendererResources::default();
593
        let previous_window_state: Option<FullWindowState> = None;
594
        let current_window_state = FullWindowState::default();
595
        let gl_context = OptionGlContextPtr::None;
596
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
597
            BTreeMap::new();
598
        let window_handle = RawWindowHandle::Unsupported;
599
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
600

            
601
        let ref_data = CallbackInfoRefData {
602
            layout_window: &layout_window,
603
            renderer_resources: &renderer_resources,
604
            previous_window_state: &previous_window_state,
605
            current_window_state: &current_window_state,
606
            gl_context: &gl_context,
607
            current_scroll_manager: &scroll_states,
608
            current_window_handle: &window_handle,
609
            system_callbacks: &system_callbacks,
610
            system_style: Arc::new(system::SystemStyle::default()),
611
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
612
            #[cfg(feature = "icu")]
613
            icu_localizer: IcuLocalizerHandle::default(),
614
            ctx: OptionRefAny::None,
615
        };
616

            
617
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
618

            
619
        let mut info = CallbackInfo::new(
620
            &ref_data,
621
            &changes,
622
            hit,
623
            OptionLogicalPosition::None,
624
            OptionLogicalPosition::None,
625
        );
626

            
627
        let r = f(&mut info);
628
        let pushed = info.take_changes();
629
        (r, pushed)
630
    }
631

            
632
    /// Renders `file_input`, then hands back both the laid-out DOM *and* the very
633
    /// `RefAny` the widget registered on its own mouse-up callback. Driving the handler
634
    /// with these two is the real wiring — nothing is re-created by hand, so a mismatch
635
    /// between what `dom()` stores and what the handler expects cannot hide behind the
636
    /// fixture.
637
    #[allow(dead_code)] // only used by `without_the_native_dialog` (see its doc comment)
638
    fn laid_out(file_input: FileInput) -> (StyledDom, RefAny) {
639
        let dom = file_input.dom();
640
        let state = registered_state(&dom);
641
        (StyledDom::create_from_dom(dom), state)
642
    }
643

            
644
    /// One "mouse-up on `hit`" delivered to the widget's own registered handler.
645
    fn click(
646
        styled_dom: StyledDom,
647
        state: &RefAny,
648
        hit: DomNodeId,
649
    ) -> (Update, Vec<CallbackChange>) {
650
        with_info(styled_dom, hit, |info| {
651
            fileinput_on_click(state.clone(), *info)
652
        })
653
    }
654

            
655
    // ==================================================================
656
    // FileInput::create
657
    // ==================================================================
658

            
659
    #[test]
660
    fn create_stores_the_path_verbatim() {
661
        // Byte-for-byte: a NUL must not truncate (AzString is length-based), a 100k
662
        // path must not be capped, and no normalization may happen at construction.
663
        let mut paths: Vec<String> = file_name_cases().into_iter().map(|(p, _)| p).collect();
664
        paths.extend(no_file_name_cases());
665
        paths.extend(adversarial_strings());
666
        paths.push(format!("/tmp/{}", "x".repeat(100_000)));
667

            
668
        for p in paths {
669
            let fi = FileInput::create(opt(&p));
670
            let stored = fi
671
                .file_input_state
672
                .inner
673
                .path
674
                .as_ref()
675
                .expect("create(Some(..)) dropped the path");
676
            assert_eq!(stored.as_str(), p, "create({p:?}) altered the path");
677
            assert_eq!(
678
                stored.as_str().len(),
679
                p.len(),
680
                "create({p:?}) changed the byte length of the path",
681
            );
682
        }
683
    }
684

            
685
    #[test]
686
    fn create_with_no_path_equals_the_default_widget() {
687
        let created = FileInput::create(None.into());
688
        assert!(
689
            created.file_input_state.inner.path.is_none(),
690
            "create(None) invented a path",
691
        );
692
        assert_eq!(
693
            created,
694
            FileInput::default(),
695
            "create(None) and Default disagree — the two constructors have drifted",
696
        );
697
    }
698

            
699
    #[test]
700
    fn create_uses_the_documented_defaults_for_every_other_field() {
701
        for p in [None.into(), opt(""), opt("/tmp/x.txt")] {
702
            let fi = FileInput::create(p);
703
            assert_eq!(fi.default_text.as_str(), DEFAULT_TEXT);
704
            assert_eq!(
705
                fi.file_input_state.file_dialog_title.as_str(),
706
                DEFAULT_DIALOG_TITLE,
707
            );
708
            assert!(fi.file_input_state.default_dir.is_none());
709
            assert!(
710
                fi.file_input_state.on_path_change.as_ref().is_none(),
711
                "create() invented a path-change callback out of nowhere",
712
            );
713
            assert!(fi.image.as_ref().is_none());
714
        }
715
    }
716

            
717
    #[test]
718
    fn create_inherits_the_button_styling_verbatim() {
719
        // The widget is documented as "same as `Button`" — it must not fork the
720
        // button's style vecs, or a restyle of Button would silently skip it.
721
        let button = Button::create(AzString::from_const_str(""));
722
        let fi = FileInput::create(opt("/tmp/x.txt"));
723
        assert_eq!(fi.container_style, button.container_style);
724
        assert_eq!(fi.label_style, button.label_style);
725
        assert_eq!(fi.image_style, button.image_style);
726
    }
727

            
728
    #[test]
729
    fn create_is_pure_and_repeatable() {
730
        // Same argument, same widget — no hidden counter, clock or global state.
731
        for p in ["", "/tmp/a.txt", "\0"] {
732
            assert_eq!(FileInput::create(opt(p)), FileInput::create(opt(p)));
733
        }
734
    }
735

            
736
    // ==================================================================
737
    // FileInput::swap_with_default
738
    // ==================================================================
739

            
740
    #[test]
741
    fn swap_with_default_returns_the_original_and_resets_self() {
742
        let mut fi = populated_with_image()
743
            .with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
744
        let before = fi.clone();
745

            
746
        let taken = fi.swap_with_default();
747

            
748
        assert_eq!(taken, before, "swap_with_default did not return the original");
749
        assert_eq!(
750
            fi,
751
            FileInput::default(),
752
            "swap_with_default left the widget in a non-default state",
753
        );
754
        assert!(
755
            fi.file_input_state.on_path_change.as_ref().is_none(),
756
            "the callback survived the reset — a stale RefAny would keep firing",
757
        );
758
        assert!(fi.image.as_ref().is_none(), "the image survived the reset");
759
    }
760

            
761
    #[test]
762
    fn swap_with_default_moves_the_callback_out_intact() {
763
        let probe = log_refany();
764
        let mut fi = FileInput::create(opt("/tmp/x"))
765
            .with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
766

            
767
        let taken = fi.swap_with_default();
768

            
769
        let moved = taken
770
            .file_input_state
771
            .on_path_change
772
            .as_ref()
773
            .expect("the callback was lost in the swap");
774
        assert_eq!(
775
            cb_addr(&moved.callback),
776
            record_path as *const () as usize,
777
            "the moved-out callback points somewhere else",
778
        );
779
        assert_eq!(read_log(&moved.refany).payload, 0xDEAD_BEEF);
780
    }
781

            
782
    #[test]
783
    fn swap_with_default_twice_yields_the_default_the_second_time() {
784
        let mut fi = populated_with_image();
785
        let first = fi.swap_with_default();
786
        let second = fi.swap_with_default();
787

            
788
        assert_ne!(first, second, "the first swap did not actually take anything");
789
        assert_eq!(second, FileInput::default());
790
        assert_eq!(fi, FileInput::default(), "the second swap dirtied the widget");
791
    }
792

            
793
    #[test]
794
    fn swap_with_default_on_a_default_widget_is_a_no_op() {
795
        let mut fi = FileInput::default();
796
        let taken = fi.swap_with_default();
797
        assert_eq!(taken, FileInput::default());
798
        assert_eq!(fi, FileInput::default());
799
    }
800

            
801
    #[test]
802
    fn swap_with_default_preserves_extreme_field_values() {
803
        // A 100k default text and a NUL-bearing path must move across the swap
804
        // untouched — `mem::swap` is byte-wise, so any change means a rebuild.
805
        let huge = "x".repeat(100_000);
806
        let mut fi = FileInput::create(opt("a\0b"));
807
        fi.set_default_text(huge.as_str().into());
808

            
809
        let taken = fi.swap_with_default();
810

            
811
        assert_eq!(taken.default_text.as_str().len(), huge.len());
812
        assert_eq!(
813
            taken
814
                .file_input_state
815
                .inner
816
                .path
817
                .as_ref()
818
                .map(|p| p.as_str().to_string()),
819
            Some("a\0b".to_string()),
820
        );
821
    }
822

            
823
    // ==================================================================
824
    // FileInput::set_default_text / with_default_text
825
    // ==================================================================
826

            
827
    #[test]
828
    fn set_default_text_stores_the_text_verbatim() {
829
        for s in adversarial_strings() {
830
            let mut fi = FileInput::default();
831
            fi.set_default_text(s.as_str().into());
832
            assert_eq!(fi.default_text.as_str(), s, "set_default_text({s:?}) altered the text");
833
            assert_eq!(
834
                fi.default_text.as_str().len(),
835
                s.len(),
836
                "set_default_text({s:?}) changed the byte length (NUL truncation?)",
837
            );
838
        }
839
    }
840

            
841
    #[test]
842
    fn set_default_text_is_last_write_wins() {
843
        let mut fi = FileInput::default();
844
        for s in adversarial_strings() {
845
            fi.set_default_text(s.as_str().into());
846
            assert_eq!(fi.default_text.as_str(), s);
847
        }
848
        fi.set_default_text("final".into());
849
        assert_eq!(fi.default_text.as_str(), "final");
850
    }
851

            
852
    #[test]
853
    fn set_default_text_touches_nothing_else() {
854
        let mut fi = populated_with_image();
855
        let before = fi.clone();
856
        fi.set_default_text("something else entirely".into());
857

            
858
        assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
859
        assert_eq!(
860
            fi.file_input_state.file_dialog_title,
861
            before.file_input_state.file_dialog_title,
862
        );
863
        assert_eq!(
864
            fi.file_input_state.default_dir,
865
            before.file_input_state.default_dir,
866
        );
867
        assert_eq!(fi.container_style, before.container_style);
868
        assert_eq!(fi.label_style, before.label_style);
869
        assert_eq!(fi.image_style, before.image_style);
870
        assert_eq!(fi.image, before.image);
871
    }
872

            
873
    #[test]
874
    fn with_default_text_matches_the_setter() {
875
        for s in adversarial_strings() {
876
            let mut by_setter = populated();
877
            by_setter.set_default_text(s.as_str().into());
878
            let by_builder = populated().with_default_text(s.as_str().into());
879
            assert_eq!(
880
                by_builder, by_setter,
881
                "with_default_text({s:?}) and set_default_text disagree",
882
            );
883
        }
884
    }
885

            
886
    #[test]
887
    fn with_default_text_chains_last_wins() {
888
        let fi = FileInput::default()
889
            .with_default_text("first".into())
890
            .with_default_text("second".into())
891
            .with_default_text("".into());
892
        assert_eq!(fi.default_text.as_str(), "");
893
    }
894

            
895
    // ==================================================================
896
    // FileInput::set_on_path_change / with_on_path_change
897
    // ==================================================================
898

            
899
    #[test]
900
    fn set_on_path_change_stores_the_function_pointer_and_the_data() {
901
        let probe = log_refany();
902
        let mut fi = FileInput::default();
903
        fi.set_on_path_change(probe.clone(), record_path as FileInputOnPathChangeCallbackType);
904

            
905
        let stored = fi
906
            .file_input_state
907
            .on_path_change
908
            .as_ref()
909
            .expect("the callback was not stored");
910
        assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
911
        assert_eq!(
912
            stored.refany, probe,
913
            "the widget stored a different RefAny than the one it was handed",
914
        );
915
        assert_eq!(read_log(&stored.refany).payload, 0xDEAD_BEEF);
916
    }
917

            
918
    #[test]
919
    fn set_on_path_change_overwrites_a_previous_callback() {
920
        // Two live callbacks would fire twice per click; the setter must replace.
921
        let mut fi = FileInput::default();
922
        fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
923
        fi.set_on_path_change(
924
            RefAny::new(7_u32),
925
            path_refresh_all as FileInputOnPathChangeCallbackType,
926
        );
927

            
928
        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
929
        assert_eq!(
930
            cb_addr(&stored.callback),
931
            path_refresh_all as *const () as usize,
932
            "the first callback survived the overwrite",
933
        );
934
        let mut data = stored.refany.clone();
935
        assert!(
936
            data.downcast_ref::<PathLog>().is_none(),
937
            "the first callback's data survived the overwrite",
938
        );
939
    }
940

            
941
    #[test]
942
    fn set_on_path_change_touches_nothing_else() {
943
        let mut fi = populated_with_image();
944
        let before = fi.clone();
945
        fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
946

            
947
        assert_eq!(fi.default_text, before.default_text);
948
        assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
949
        assert_eq!(
950
            fi.file_input_state.file_dialog_title,
951
            before.file_input_state.file_dialog_title,
952
        );
953
        assert_eq!(
954
            fi.file_input_state.default_dir,
955
            before.file_input_state.default_dir,
956
        );
957
        assert_eq!(fi.image, before.image);
958
        assert_eq!(fi.container_style, before.container_style);
959
    }
960

            
961
    #[test]
962
    fn with_on_path_change_matches_the_setter() {
963
        let probe = log_refany();
964
        let mut by_setter = populated();
965
        by_setter.set_on_path_change(
966
            probe.clone(),
967
            record_path as FileInputOnPathChangeCallbackType,
968
        );
969
        let by_builder = populated()
970
            .with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
971
        assert_eq!(by_builder, by_setter);
972
    }
973

            
974
    #[test]
975
    fn a_generic_callback_keeps_its_address_and_context_through_the_transmute() {
976
        // FFI bindings hand in a 2-arg `Callback`; the `From<Callback>` arm transmutes
977
        // it into the 3-arg slot. The transmute must preserve the code address (calling
978
        // the wrong function) and the `ctx` payload (losing the Python/Lua callable).
979
        let ctx = RefAny::new(0xABCD_u32);
980
        let generic = Callback {
981
            cb: generic_shaped,
982
            ctx: OptionRefAny::Some(ctx.clone()),
983
        };
984
        let fi = FileInput::default().with_on_path_change(log_refany(), generic);
985

            
986
        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
987
        assert_eq!(
988
            cb_addr(&stored.callback),
989
            generic_shaped as *const () as usize,
990
            "the transmute moved the function pointer",
991
        );
992
        assert_eq!(
993
            stored.callback.ctx,
994
            OptionRefAny::Some(ctx),
995
            "the FFI context was dropped by the transmute",
996
        );
997
    }
998

            
999
    #[test]
    fn a_raw_function_pointer_gets_no_ffi_context() {
        let fi = FileInput::default()
            .with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
        assert_eq!(
            stored.callback.ctx,
            OptionRefAny::None,
            "a native Rust callback must not carry an FFI context",
        );
    }
    // ==================================================================
    // FileInput::dom
    // ==================================================================
    #[test]
    fn dom_labels_the_button_with_the_file_name() {
        for (path, expected) in file_name_cases() {
            let dom = FileInput::create(opt(&path)).dom();
            assert_eq!(
                rendered_label(&dom),
                expected,
                "dom() mislabelled the button for path {path:?}",
            );
        }
    }
    #[test]
    fn dom_falls_back_to_the_default_text_when_the_path_has_no_file_name() {
        // `/`, `.`, `..` and friends have no final `Normal` component. Rendering an
        // *empty* button there would leave the user with an unlabelled control.
        for path in no_file_name_cases() {
            let dom = FileInput::create(opt(&path)).dom();
            assert_eq!(
                rendered_label(&dom),
                DEFAULT_TEXT,
                "dom() did not fall back to the default text for path {path:?}",
            );
        }
    }
    #[test]
    fn dom_falls_back_to_the_default_text_when_no_path_is_set() {
        let dom = FileInput::create(None.into()).dom();
        assert_eq!(rendered_label(&dom), DEFAULT_TEXT);
    }
    #[test]
    fn dom_renders_a_custom_default_text_verbatim_when_there_is_no_file_name() {
        for s in adversarial_strings() {
            let fi = FileInput::create(None.into()).with_default_text(s.as_str().into());
            assert_eq!(rendered_label(&fi.dom()), s, "custom default text {s:?} was altered");
            // ...and the same for a path that has no file-name component.
            let fi = FileInput::create(opt("/")).with_default_text(s.as_str().into());
            assert_eq!(rendered_label(&fi.dom()), s);
        }
    }
    #[test]
    fn dom_survives_a_100k_byte_file_name() {
        let huge = "x".repeat(100_000);
        let dom = FileInput::create(opt(&format!("/tmp/{huge}"))).dom();
        let label = rendered_label(&dom);
        assert_eq!(label.len(), huge.len(), "the 100k file name was truncated");
        assert_eq!(label, huge);
    }
    #[test]
    fn dom_prefers_the_file_name_over_the_default_text() {
        // A non-empty default text must not shadow a real selection.
        let fi = FileInput::create(opt("/tmp/chosen.txt")).with_default_text("NOT THIS".into());
        assert_eq!(rendered_label(&fi.dom()), "chosen.txt");
    }
    #[test]
    fn dom_renders_an_empty_label_when_both_the_path_and_the_default_text_are_empty() {
        let fi = FileInput::create(opt("")).with_default_text("".into());
        assert_eq!(rendered_label(&fi.dom()), "");
    }
    #[test]
    fn dom_ignores_the_dialog_title_and_default_dir_when_labelling() {
        // Only `path`/`default_text` may reach the label; leaking the dialog title or
        // the default directory into the button would be visible to the user.
        let mut fi = FileInput::create(None.into());
        fi.file_input_state.file_dialog_title = "TITLE-LEAK".into();
        fi.file_input_state.default_dir = opt("/DIR-LEAK");
        let label = rendered_label(&fi.dom());
        assert_eq!(label, DEFAULT_TEXT);
        assert!(!label.contains("LEAK"));
    }
    #[test]
    fn dom_renders_a_native_button_node() {
        let dom = FileInput::create(opt("/tmp/x.txt")).dom();
        assert!(
            matches!(dom.root.get_node_type(), NodeType::Button),
            "the file input no longer renders a <button>",
        );
        assert_eq!(
            classes(&dom),
            vec![
                "__azul-native-button".to_string(),
                ButtonType::Default.class_name().to_string(),
            ],
            "the file input must be styleable as a default-type native button",
        );
        assert!(
            matches!(dom.root.get_tab_index(), Some(TabIndex::Auto)),
            "the file input dropped the button's keyboard focusability",
        );
    }
    #[test]
    fn dom_registers_exactly_one_mouseup_callback_into_fileinput_on_click() {
        let dom = FileInput::create(opt("/tmp/x.txt")).dom();
        let callbacks = dom.root.callbacks.as_ref();
        assert_eq!(callbacks.len(), 1, "a click must fire exactly one handler");
        assert_eq!(callbacks[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
        assert_eq!(
            callbacks[0].callback.cb,
            fileinput_on_click as *const () as usize,
            "the DOM is wired to a different handler than fileinput_on_click",
        );
        assert_eq!(
            callbacks[0].callback.ctx,
            OptionRefAny::None,
            "the internal handler must not carry an FFI context",
        );
    }
    #[test]
    fn dom_hands_the_whole_state_wrapper_to_the_handler() {
        // The click handler reads the dialog title, the default dir *and* the user
        // callback out of this RefAny — dropping any of them silently disables them.
        let probe = log_refany();
        let mut fi = FileInput::create(opt("/tmp/original.txt"));
        fi.file_input_state.file_dialog_title = "custom title".into();
        fi.file_input_state.default_dir = opt("/tmp/custom-dir");
        fi.set_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
        let expected = fi.file_input_state.clone();
        let state = state_of(&registered_state(&fi.dom()));
        assert_eq!(state.inner, expected.inner);
        assert_eq!(state.file_dialog_title, expected.file_dialog_title);
        assert_eq!(state.default_dir, expected.default_dir);
        let stored = state.on_path_change.as_ref().expect("the user callback was dropped");
        assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
    }
    #[test]
    fn dom_child_count_matches_the_cached_descendant_count() {
        // A too-small `estimated_total_children` makes `convert_dom_into_compact_dom`
        // under-allocate its arenas and panic on out-of-bounds writes.
        for fi in [
            FileInput::create(None.into()),
            FileInput::create(opt("/tmp/x.txt")),
            populated_with_image(),
        ] {
            let has_image = fi.image.as_ref().is_some();
            let dom = fi.dom();
            assert_eq!(
                dom.estimated_total_children,
                count_descendants(&dom),
                "estimated_total_children is out of sync with the real subtree",
            );
            assert_eq!(
                dom.children.as_ref().len(),
                usize::from(has_image) + 1,
                "unexpected child count",
            );
        }
    }
    #[test]
    fn dom_renders_the_image_before_the_label() {
        let fi = populated_with_image();
        let dom = fi.dom();
        let children = dom.children.as_ref();
        assert_eq!(children.len(), 2);
        assert!(
            matches!(children[0].root.get_node_type(), NodeType::Image(_)),
            "the image is not the first child",
        );
        assert_eq!(rendered_label(&dom), "original.txt");
    }
    #[test]
    fn dom_is_deterministic_for_identical_widgets() {
        for path in ["/tmp/a.txt", "/", ""] {
            let a = FileInput::create(opt(path)).dom();
            let b = FileInput::create(opt(path)).dom();
            assert_eq!(a.root.get_node_type(), b.root.get_node_type());
            assert_eq!(classes(&a), classes(&b));
            assert_eq!(rendered_label(&a), rendered_label(&b));
            assert_eq!(a.children.as_ref().len(), b.children.as_ref().len());
        }
    }
    #[cfg(unix)]
    #[test]
    fn dom_does_not_treat_a_backslash_as_a_separator_on_unix() {
        // A backslash is a perfectly legal *character* in a POSIX file name; splitting
        // on it would mislabel (and, worse, misreport) the selected file.
        let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
        assert_eq!(rendered_label(&dom), "C:\\dir\\file.txt");
        let dom = FileInput::create(opt("/tmp/a\\b.txt")).dom();
        assert_eq!(rendered_label(&dom), "a\\b.txt");
    }
    #[cfg(unix)]
    #[test]
    fn dom_handles_multiple_leading_slashes_on_unix() {
        assert_eq!(rendered_label(&FileInput::create(opt("//")).dom()), DEFAULT_TEXT);
        assert_eq!(rendered_label(&FileInput::create(opt("///")).dom()), DEFAULT_TEXT);
        assert_eq!(rendered_label(&FileInput::create(opt("//a.txt")).dom()), "a.txt");
    }
    #[cfg(windows)]
    #[test]
    fn dom_treats_a_backslash_as_a_separator_on_windows() {
        let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
        assert_eq!(rendered_label(&dom), "file.txt");
    }
    // ==================================================================
    // fileinput_on_click
    // ==================================================================
    #[test]
    fn click_with_a_foreign_refany_does_nothing() {
        // The handler is reached through an FFI-visible `usize` function pointer, so a
        // mismatched payload is reachable in practice; it must be rejected by the
        // type check rather than reinterpreted.
        let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
        let foreign = RefAny::new(0xDEAD_BEEF_u32);
        let (update, changes) = click(styled, &foreign, node(0));
        assert_eq!(
            update,
            Update::DoNothing,
            "a foreign payload must not trigger a relayout",
        );
        assert!(changes.is_empty(), "a foreign payload pushed changes: {changes:?}");
        let mut foreign = foreign;
        assert_eq!(
            *foreign.downcast_ref::<u32>().expect("the payload was overwritten"),
            0xDEAD_BEEF,
        );
    }
    #[test]
    fn click_rejects_the_inner_state_mistaken_for_the_wrapper() {
        // `FileInputState` is the *inner* half of `FileInputStateWrapper` and is what
        // the user callback receives — passing it back in is the most likely confusion,
        // and reinterpreting it would read an `OptionString` as a callback pointer.
        let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
        let inner = RefAny::new(FileInputState { path: opt("/tmp/x.txt") });
        let (update, changes) = click(styled, &inner, node(0));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn click_with_a_foreign_refany_ignores_the_hit_node() {
        // `node_none()` is the "nothing concrete was hit" id that panics several
        // `CallbackInfo` queries — the handler must never reach them.
        let styled = StyledDom::create_from_dom(FileInput::create(None.into()).dom());
        let foreign = RefAny::new(0_u8);
        let (update, changes) = click(styled, &foreign, node_none());
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    /// `fileinput_on_click` opens a **modal native file dialog** before it ever reaches
    /// the user callback whenever the `extra` feature is on (which it is by default, on
    /// desktop). Driving the handler with a well-typed payload would block the test run
    /// on a GUI prompt, so everything past the downcast is only exercised in builds
    /// where that block is compiled out. The type-rejection path above runs everywhere.
    #[cfg(any(
        not(feature = "extra"),
        target_os = "android",
        target_os = "ios"
    ))]
    mod without_the_native_dialog {
        use super::*;
        #[test]
        fn click_without_a_user_callback_refreshes_the_dom() {
            let (styled, state) = laid_out(FileInput::create(opt("/tmp/x.txt")));
            let (update, changes) = click(styled, &state, node(0));
            assert_eq!(
                update,
                Update::RefreshDom,
                "a click must relayout so the new label is drawn",
            );
            assert!(changes.is_empty(), "the handler pushed unexpected changes: {changes:?}");
            assert_eq!(
                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
                Some("/tmp/x.txt".to_string()),
                "the handler mutated the path without a dialog",
            );
        }
        #[test]
        fn click_upgrades_a_do_nothing_user_callback_to_a_refresh() {
            // `result.max_self(Update::RefreshDom)` deliberately swallows the user's
            // `DoNothing` — the label may have changed, so the DOM must be rebuilt.
            let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
                RefAny::new(0_u8),
                path_do_nothing as FileInputOnPathChangeCallbackType,
            );
            let (styled, state) = laid_out(fi);
            let (update, _) = click(styled, &state, node(0));
            assert_eq!(update, Update::RefreshDom);
        }
        #[test]
        fn click_preserves_a_stronger_user_update() {
            // `max_self` must not *downgrade* RefreshDomAllWindows to RefreshDom.
            let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
                RefAny::new(0_u8),
                path_refresh_all as FileInputOnPathChangeCallbackType,
            );
            let (styled, state) = laid_out(fi);
            let (update, _) = click(styled, &state, node(0));
            assert_eq!(update, Update::RefreshDomAllWindows);
        }
        #[test]
        fn click_hands_the_current_path_to_the_user_callback() {
            for path in ["/tmp/a.txt", "", "a\0b", "/tmp/日本語.txt"] {
                let probe = log_refany();
                let fi = FileInput::create(opt(path)).with_on_path_change(
                    probe.clone(),
                    record_path as FileInputOnPathChangeCallbackType,
                );
                let (styled, state) = laid_out(fi);
                let (_, _) = click(styled, &state, node(0));
                assert_eq!(
                    read_log(&probe).seen,
                    vec![Some(path.to_string())],
                    "the callback saw the wrong path for {path:?}",
                );
            }
        }
        #[test]
        fn click_hands_a_missing_path_through_as_none() {
            let probe = log_refany();
            let fi = FileInput::create(None.into()).with_on_path_change(
                probe.clone(),
                record_path as FileInputOnPathChangeCallbackType,
            );
            let (styled, state) = laid_out(fi);
            let (_, _) = click(styled, &state, node(0));
            assert_eq!(read_log(&probe).seen, vec![None]);
        }
        #[test]
        fn repeated_clicks_fire_once_each_and_leave_the_state_alone() {
            let probe = log_refany();
            let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
                probe.clone(),
                record_path as FileInputOnPathChangeCallbackType,
            );
            let (styled, state) = laid_out(fi);
            for _ in 0..3 {
                let (update, _) = click(styled.clone(), &state, node(0));
                assert_eq!(update, Update::RefreshDom);
            }
            assert_eq!(read_log(&probe).seen.len(), 3, "clicks were dropped or doubled");
            assert_eq!(
                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
                Some("/tmp/a.txt".to_string()),
            );
        }
        /// Records whether a *re-entrant* borrow of the widget state succeeded while the
        /// click handler still holds its own `downcast_mut` guard.
        struct ReentryProbe {
            state: RefAny,
            saw_mut: Option<bool>,
            saw_ref: Option<bool>,
        }
        extern "C" fn probe_reentry(
            mut refany: RefAny,
            _info: CallbackInfo,
            _state: FileInputState,
        ) -> Update {
            let Some(mut probe) = refany.downcast_mut::<ReentryProbe>() else {
                return Update::DoNothing;
            };
            let probe = &mut *probe;
            let saw_mut = probe.state.downcast_mut::<FileInputStateWrapper>().is_some();
            let saw_ref = probe.state.downcast_ref::<FileInputStateWrapper>().is_some();
            probe.saw_mut = Some(saw_mut);
            probe.saw_ref = Some(saw_ref);
            Update::DoNothing
        }
        #[test]
        fn a_reentrant_borrow_of_the_state_is_refused_rather_than_aliased() {
            // The handler holds a `RefMut` on the state for its whole body, including
            // the user callback. A user callback that grabs the same state must be told
            // "no" (None) — handing out a second `&mut` to the same bytes would be UB,
            // and panicking/deadlocking would take the app down on a mere double-borrow.
            let probe = RefAny::new(ReentryProbe {
                state: RefAny::new(0_u8),
                saw_mut: None,
                saw_ref: None,
            });
            let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
                probe.clone(),
                probe_reentry as FileInputOnPathChangeCallbackType,
            );
            let (styled, state) = laid_out(fi);
            {
                let mut handle = probe.clone();
                let mut guard = handle
                    .downcast_mut::<ReentryProbe>()
                    .expect("the probe changed type");
                guard.state = state.clone();
            }
            let (update, _) = click(styled, &state, node(0));
            let mut handle = probe.clone();
            let guard = handle
                .downcast_mut::<ReentryProbe>()
                .expect("the probe changed type");
            assert_eq!(guard.saw_mut, Some(false), "a second &mut to the state was handed out");
            assert_eq!(guard.saw_ref, Some(false), "a & alongside the live &mut was handed out");
            drop(guard);
            assert_eq!(update, Update::RefreshDom);
            assert_eq!(
                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
                Some("/tmp/a.txt".to_string()),
                "the state was corrupted by the re-entrant attempt",
            );
        }
    }
}