Skip to main content

azul_layout/widgets/
file_input.rs

1//! File input button, same as `Button`, but triggers a
2//! user-supplied path-change callback when clicked
3
4use 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
11use azul_css::{
12    dynamic_selector::CssPropertyWithConditionsVec,
13    props::{
14        basic::*,
15        layout::*,
16        property::{CssProperty, *},
17        style::*,
18    },
19    *,
20};
21
22use crate::{
23    callbacks::{Callback, CallbackInfo},
24    widgets::button::{Button, ButtonOnClick, ButtonOnClickCallback},
25};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[repr(C)]
29pub 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
46impl Default for FileInput {
47    fn default() -> Self {
48        let default_button = Button::create(AzString::from_const_str(""));
49        Self {
50            file_input_state: FileInputStateWrapper::default(),
51            default_text: "Select File...".into(),
52            image: None.into(),
53            container_style: default_button.container_style,
54            label_style: default_button.label_style,
55            image_style: default_button.image_style,
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[repr(C)]
62pub 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
71impl Default for FileInputStateWrapper {
72    fn default() -> Self {
73        Self {
74            inner: FileInputState::default(),
75            on_path_change: None.into(),
76            file_dialog_title: "Select File".into(),
77            default_dir: None.into(),
78        }
79    }
80}
81
82/// Current state of the file input (selected path)
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[repr(C)]
85pub struct FileInputState {
86    pub path: OptionString,
87}
88
89impl Default for FileInputState {
90    fn default() -> Self {
91        Self { path: None.into() }
92    }
93}
94
95/// Callback type invoked when the file input path changes
96pub type FileInputOnPathChangeCallbackType =
97    extern "C" fn(RefAny, CallbackInfo, FileInputState) -> Update;
98
99impl_widget_callback!(
100    FileInputOnPathChange,
101    OptionFileInputOnPathChange,
102    FileInputOnPathChangeCallback,
103    FileInputOnPathChangeCallbackType
104);
105
106azul_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
119impl FileInput {
120    #[must_use] pub fn create(path: OptionString) -> Self {
121        Self {
122            file_input_state: FileInputStateWrapper {
123                inner: FileInputState { path },
124                ..Default::default()
125            },
126            ..Default::default()
127        }
128    }
129
130    #[inline]
131    #[must_use]
132    pub fn swap_with_default(&mut self) -> Self {
133        let mut s = Self::create(None.into());
134        core::mem::swap(&mut s, self);
135        s
136    }
137
138    #[inline]
139    pub fn set_default_text(&mut self, default_text: AzString) {
140        self.default_text = default_text;
141    }
142
143    #[inline]
144    #[must_use] pub fn with_default_text(mut self, default_text: AzString) -> Self {
145        self.set_default_text(default_text);
146        self
147    }
148
149    #[inline]
150    pub fn set_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
151        &mut self,
152        refany: RefAny,
153        callback: I,
154    ) {
155        self.file_input_state.on_path_change = Some(FileInputOnPathChange {
156            callback: callback.into(),
157            refany,
158        })
159        .into();
160    }
161
162    #[inline]
163    #[must_use]
164    pub fn with_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
165        mut self,
166        refany: RefAny,
167        callback: I,
168    ) -> Self {
169        self.set_on_path_change(refany, callback);
170        self
171    }
172
173    #[inline]
174    #[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        let button_label = match self.file_input_state.inner.path.as_ref() {
178            Some(path) => std::path::Path::new(path.as_str())
179                .file_name()
180                .map_or_else(
181                    || self.default_text.as_str().to_string(),
182                    |s| s.to_string_lossy().to_string(),
183                )
184                .into(),
185            None => self.default_text.clone(),
186        };
187
188        Button {
189            label: button_label,
190            image: self.image,
191            button_type: crate::widgets::button::ButtonType::Default,
192            container_style: self.container_style,
193            label_style: self.label_style,
194            image_style: self.image_style,
195            on_click: Some(ButtonOnClick {
196                refany: RefAny::new(self.file_input_state),
197                callback: ButtonOnClickCallback {
198                    cb: fileinput_on_click,
199                    ctx: azul_core::refany::OptionRefAny::None,
200                },
201            })
202            .into(),
203        }
204        .dom()
205    }
206}
207
208extern "C" fn fileinput_on_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
209    let Some(mut fileinputstatewrapper) = refany.downcast_mut::<FileInputStateWrapper>() else {
210        return Update::DoNothing;
211    };
212    let fileinputstatewrapper = &mut *fileinputstatewrapper;
213
214    // `tfd` is desktop-only (target-gated in Cargo.toml to not(android|ios)); the
215    // `extra` feature does nothing on mobile, so gate the dialog block by the same
216    // target cfg to avoid referencing the unlinked `tfd` crate on iOS/Android.
217    #[cfg(all(feature = "extra", not(any(target_os = "android", target_os = "ios"))))]
218    {
219        let mut dialog = tfd::FileDialog::new(fileinputstatewrapper.file_dialog_title.as_str());
220        if let Some(dir) = fileinputstatewrapper.default_dir.as_ref() {
221            dialog = dialog.with_path(dir.as_str());
222        }
223        let Some(selected_path) = dialog.open_file() else {
224            return Update::DoNothing;
225        };
226        fileinputstatewrapper.inner.path = Some(selected_path.into()).into();
227    }
228
229    let inner = fileinputstatewrapper.inner.clone();
230    let mut result = match fileinputstatewrapper.on_path_change.as_mut() {
231        Some(FileInputOnPathChange { refany, callback }) => {
232            (callback.cb)(refany.clone(), info, inner)
233        }
234        None => Update::RefreshDom,
235    };
236
237    result.max_self(Update::RefreshDom);
238
239    result
240}
241
242#[cfg(all(test, feature = "std"))]
243#[allow(clippy::too_many_lines)] // table-driven cases; splitting them hides the case list
244mod autotest_generated {
245    use std::{
246        collections::{BTreeMap, HashMap},
247        sync::{Arc, Mutex},
248    };
249
250    use azul_core::{
251        dom::{
252            DomId, DomNodeId, EventFilter, HoverEventFilter, IdOrClass, NodeId, NodeType, TabIndex,
253        },
254        geom::{LogicalRect, OptionLogicalPosition},
255        gl::OptionGlContextPtr,
256        hit_test::ScrollPosition,
257        refany::OptionRefAny,
258        resources::{ImageRef, RawImageFormat, RendererResources},
259        styled_dom::{NodeHierarchyItemId, StyledDom},
260        window::{MonitorVec, RawWindowHandle},
261    };
262    use rust_fontconfig::FcFontCache;
263
264    use super::*;
265    #[cfg(feature = "icu")]
266    use crate::icu::IcuLocalizerHandle;
267    use crate::{
268        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
269        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
270        widgets::button::ButtonType,
271        window::{DomLayoutResult, LayoutWindow},
272        window_state::FullWindowState,
273    };
274
275    // ------------------------------------------------------------------
276    // Fixtures
277    // ------------------------------------------------------------------
278
279    /// The label a path-less file input renders, per the doc comment on
280    /// `FileInput::default_text`.
281    const DEFAULT_TEXT: &str = "Select File...";
282
283    /// The title `FileInputStateWrapper::default` puts on the native dialog. Note it is
284    /// *not* the same string as `DEFAULT_TEXT` (no ellipsis) — a "cleanup" that unified
285    /// the two would silently retitle every file dialog.
286    const DEFAULT_DIALOG_TITLE: &str = "Select File";
287
288    /// Paths whose *last* component is a real file name, paired with the label
289    /// `dom()` must render for them. Only shapes that mean the same thing on every
290    /// platform live here (`/` is a separator on Windows too); the backslash- and
291    /// double-slash-specific shapes are tested per-platform below.
292    fn file_name_cases() -> Vec<(String, String)> {
293        [
294            ("/tmp/report.pdf", "report.pdf"),
295            ("report.pdf", "report.pdf"),
296            ("/tmp/dir/", "dir"),         // a trailing separator is not a component
297            ("/tmp/dir///", "dir"),       // ...and neither are three of them
298            ("a/.", "a"),                 // a trailing `.` normalizes away
299            ("/a/b/c/d/e/f/g.txt", "g.txt"),
300            (".hidden", ".hidden"),       // a leading dot is part of the name
301            ("...", "..."),               // only `.` and `..` are special
302            ("..a", "..a"),
303            ("/tmp/archive.tar.gz", "archive.tar.gz"),
304            ("  ", "  "),                 // whitespace is a legal file name
305            ("/tmp/a b.txt", "a b.txt"),
306            ("/tmp/a\nb.txt", "a\nb.txt"), // control chars survive verbatim
307            ("/tmp/a\tb.txt", "a\tb.txt"),
308            ("a\0b", "a\0b"),             // OsStr allows interior NULs; must not truncate
309            ("/tmp/a\0b.txt", "a\0b.txt"),
310            ("/tmp/日本語.txt", "日本語.txt"),
311            ("/tmp/e\u{0301}.txt", "e\u{0301}.txt"), // decomposed é: no normalization
312            (
313                "/tmp/\u{1F469}\u{200D}\u{1F467}.png", // ZWJ emoji sequence
314                "\u{1F469}\u{200D}\u{1F467}.png",
315            ),
316            ("/tmp/\u{202E}gpj.exe", "\u{202E}gpj.exe"), // RTL-override spoof, kept as-is
317            ("/tmp/\u{FFFD}.bin", "\u{FFFD}.bin"),
318            ("/Select File.../x", "x"), // a directory named like the default text
319        ]
320        .iter()
321        .map(|(p, l)| ((*p).to_string(), (*l).to_string()))
322        .collect()
323    }
324
325    /// Paths with *no* file-name component: `Path::file_name` returns `None` for each,
326    /// so `dom()` must fall back to `default_text` rather than render an empty button.
327    fn no_file_name_cases() -> Vec<String> {
328        ["", "/", ".", "..", "./", "/.", "/..", "a/..", "a/b/../", "../.."]
329            .iter()
330            .map(|s| (*s).to_string())
331            .collect()
332    }
333
334    /// Adversarial strings for the free-form text fields (`default_text`,
335    /// `file_dialog_title`, `default_dir`): empty, whitespace-only, combining marks,
336    /// ZWJ emoji, RTL, embedded NULs (`AzString` is length-based, so a NUL must not
337    /// truncate), bidi overrides — plus one string far longer than any real label.
338    fn adversarial_strings() -> Vec<String> {
339        let mut v: Vec<String> = [
340            "",
341            " ",
342            "Pick a file",
343            "e\u{0301}",
344            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
345            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",
346            "\0",
347            "a\0b",
348            "\u{FFFD}\u{202E}\u{200B}",
349            "…\t\r\n",
350            DEFAULT_TEXT,
351        ]
352        .iter()
353        .map(|s| (*s).to_string())
354        .collect();
355        v.push("x".repeat(100_000));
356        v
357    }
358
359    fn opt(s: &str) -> OptionString {
360        Some(AzString::from(s)).into()
361    }
362
363    /// A file input with every field set to something distinguishable, so that a
364    /// mutator touching a field it should not is visible.
365    ///
366    /// Deliberately image-*less*: `ImageRef` equality is identity-based (a fresh `id`
367    /// per constructor call), so two independently-built images never compare equal and
368    /// an image here would break every `assert_eq!` between two fixtures.
369    fn populated() -> FileInput {
370        let mut fi = FileInput::create(opt("/tmp/original.txt"));
371        fi.default_text = "custom default".into();
372        fi.file_input_state.file_dialog_title = "custom title".into();
373        fi.file_input_state.default_dir = opt("/tmp/custom-dir");
374        fi
375    }
376
377    /// [`populated`] plus an image. Only for tests that compare a widget against its own
378    /// `clone()` (clones share the image identity) or do not compare widgets at all.
379    fn populated_with_image() -> FileInput {
380        let mut fi = populated();
381        fi.image = Some(ImageRef::null_image(
382            3,
383            5,
384            RawImageFormat::RGBA8,
385            b"file-input-probe".to_vec(),
386        ))
387        .into();
388        fi
389    }
390
391    // ------------------------------------------------------------------
392    // DOM probes
393    // ------------------------------------------------------------------
394
395    fn text_of(dom: &Dom) -> Option<&str> {
396        match dom.root.get_node_type() {
397            NodeType::Text(s) => Some(s.as_ref().as_str()),
398            _ => None,
399        }
400    }
401
402    /// The rendered button label. `Button::dom` always appends the label *last*
403    /// (an optional image is pushed as the first child).
404    fn rendered_label(dom: &Dom) -> String {
405        let children = dom.children.as_ref();
406        let last = children.last().expect("the button has no label child");
407        text_of(last)
408            .expect("the button label is not a text node")
409            .to_string()
410    }
411
412    fn classes(dom: &Dom) -> Vec<String> {
413        dom.root
414            .get_ids_and_classes()
415            .as_ref()
416            .iter()
417            .filter_map(|c| match c {
418                IdOrClass::Class(s) => Some(s.as_str().to_string()),
419                IdOrClass::Id(_) => None,
420            })
421            .collect()
422    }
423
424    /// The recursive descendant count — `Dom::estimated_total_children` is a *cached*
425    /// value that, if too small, makes `convert_dom_into_compact_dom` under-allocate
426    /// its arenas and panic on out-of-bounds writes.
427    fn count_descendants(dom: &Dom) -> usize {
428        dom.children
429            .as_ref()
430            .iter()
431            .map(|c| 1 + count_descendants(c))
432            .sum()
433    }
434
435    /// The state wrapper the rendered DOM handed to its own click handler.
436    fn registered_state(dom: &Dom) -> RefAny {
437        let callbacks = dom.root.callbacks.as_ref();
438        assert_eq!(
439            callbacks.len(),
440            1,
441            "a file input must register exactly one callback",
442        );
443        callbacks[0].refany.clone()
444    }
445
446    fn state_of(refany: &RefAny) -> FileInputStateWrapper {
447        let mut refany = refany.clone();
448        let wrapper = refany
449            .downcast_ref::<FileInputStateWrapper>()
450            .expect("the widget state changed type");
451        wrapper.clone()
452    }
453
454    // ------------------------------------------------------------------
455    // Callback fixtures
456    // ------------------------------------------------------------------
457
458    /// A payload the path-change callback writes into. It arrives as the `refany`
459    /// argument — a *shared* clone of what the test still holds — so the test reads
460    /// back exactly what the widget passed, with no global state.
461    #[derive(Debug, Clone, PartialEq, Eq)]
462    struct PathLog {
463        seen: Vec<Option<String>>,
464        payload: u32,
465    }
466
467    fn log_refany() -> RefAny {
468        RefAny::new(PathLog {
469            seen: Vec::new(),
470            payload: 0xDEAD_BEEF,
471        })
472    }
473
474    fn read_log(probe: &RefAny) -> PathLog {
475        let mut probe = probe.clone();
476        let log = probe
477            .downcast_ref::<PathLog>()
478            .expect("the user payload changed type");
479        log.clone()
480    }
481
482    extern "C" fn record_path(
483        mut refany: RefAny,
484        _info: CallbackInfo,
485        state: FileInputState,
486    ) -> Update {
487        if let Some(mut log) = refany.downcast_mut::<PathLog>() {
488            log.seen
489                .push(state.path.as_ref().map(|p| p.as_str().to_string()));
490        }
491        Update::DoNothing
492    }
493
494    // Only reachable from `without_the_native_dialog`, which is compiled out on a
495    // default (`extra`) desktop build.
496    #[allow(dead_code)]
497    extern "C" fn path_do_nothing(
498        _refany: RefAny,
499        _info: CallbackInfo,
500        _state: FileInputState,
501    ) -> Update {
502        Update::DoNothing
503    }
504
505    extern "C" fn path_refresh_all(
506        _refany: RefAny,
507        _info: CallbackInfo,
508        _state: FileInputState,
509    ) -> Update {
510        Update::RefreshDomAllWindows
511    }
512
513    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in, which the
514    /// `From<Callback>` arm *transmutes* into the 3-arg file-input slot. Never called.
515    extern "C" fn generic_shaped(_refany: RefAny, _info: CallbackInfo) -> Update {
516        Update::DoNothing
517    }
518
519    fn cb_addr(cb: &FileInputOnPathChangeCallback) -> usize {
520        cb.cb as *const () as usize
521    }
522
523    // ------------------------------------------------------------------
524    // CallbackInfo harness
525    // ------------------------------------------------------------------
526
527    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
528    fn node(idx: usize) -> DomNodeId {
529        DomNodeId {
530            dom: DomId::ROOT_ID,
531            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
532        }
533    }
534
535    /// A `DomNodeId` whose node component is `None` — the "no concrete node was hit"
536    /// case. `fileinput_on_click` never queries the hit node, so it must survive it.
537    fn node_none() -> DomNodeId {
538        DomNodeId {
539            dom: DomId::ROOT_ID,
540            node: NodeHierarchyItemId::NONE,
541        }
542    }
543
544    /// A `DomLayoutResult` carrying only a `styled_dom`: `fileinput_on_click` never
545    /// queries the layout, so no real layout (and no font) is needed.
546    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
547        DomLayoutResult {
548            styled_dom,
549            layout_tree: LayoutTree {
550                nodes: Vec::new(),
551                warm: Vec::new(),
552                cold: Vec::new(),
553                root: 0,
554                dom_to_layout: BTreeMap::new(),
555                children_arena: Vec::new(),
556                children_offsets: Vec::new(),
557                subtree_needs_intrinsic: Vec::new(),
558            },
559            calculated_positions: Vec::new(),
560            viewport: LogicalRect::zero(),
561            display_list: DisplayList::default(),
562            scroll_ids: HashMap::new(),
563            scroll_id_to_node_id: HashMap::new(),
564        }
565    }
566
567    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root DOM
568    /// and whose hit node is `hit`. Returns `f`'s value plus every change the callback
569    /// pushed onto the transaction log.
570    fn with_info<R>(
571        styled_dom: StyledDom,
572        hit: DomNodeId,
573        f: impl FnOnce(&mut CallbackInfo) -> R,
574    ) -> (R, Vec<CallbackChange>) {
575        let mut layout_window =
576            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
577        layout_window
578            .layout_results
579            .insert(DomId::ROOT_ID, layout_result(styled_dom));
580
581        let renderer_resources = RendererResources::default();
582        let previous_window_state: Option<FullWindowState> = None;
583        let current_window_state = FullWindowState::default();
584        let gl_context = OptionGlContextPtr::None;
585        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
586            BTreeMap::new();
587        let window_handle = RawWindowHandle::Unsupported;
588        let system_callbacks = ExternalSystemCallbacks::rust_internal();
589
590        let ref_data = CallbackInfoRefData {
591            layout_window: &layout_window,
592            renderer_resources: &renderer_resources,
593            previous_window_state: &previous_window_state,
594            current_window_state: &current_window_state,
595            gl_context: &gl_context,
596            current_scroll_manager: &scroll_states,
597            current_window_handle: &window_handle,
598            system_callbacks: &system_callbacks,
599            system_style: Arc::new(azul_css::system::SystemStyle::default()),
600            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
601            #[cfg(feature = "icu")]
602            icu_localizer: IcuLocalizerHandle::default(),
603            ctx: OptionRefAny::None,
604        };
605
606        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
607
608        let mut info = CallbackInfo::new(
609            &ref_data,
610            &changes,
611            hit,
612            OptionLogicalPosition::None,
613            OptionLogicalPosition::None,
614        );
615
616        let r = f(&mut info);
617        let pushed = info.take_changes();
618        (r, pushed)
619    }
620
621    /// Renders `file_input`, then hands back both the laid-out DOM *and* the very
622    /// `RefAny` the widget registered on its own mouse-up callback. Driving the handler
623    /// with these two is the real wiring — nothing is re-created by hand, so a mismatch
624    /// between what `dom()` stores and what the handler expects cannot hide behind the
625    /// fixture.
626    #[allow(dead_code)] // only used by `without_the_native_dialog` (see its doc comment)
627    fn laid_out(file_input: FileInput) -> (StyledDom, RefAny) {
628        let dom = file_input.dom();
629        let state = registered_state(&dom);
630        (StyledDom::create_from_dom(dom), state)
631    }
632
633    /// One "mouse-up on `hit`" delivered to the widget's own registered handler.
634    fn click(
635        styled_dom: StyledDom,
636        state: &RefAny,
637        hit: DomNodeId,
638    ) -> (Update, Vec<CallbackChange>) {
639        with_info(styled_dom, hit, |info| {
640            fileinput_on_click(state.clone(), *info)
641        })
642    }
643
644    // ==================================================================
645    // FileInput::create
646    // ==================================================================
647
648    #[test]
649    fn create_stores_the_path_verbatim() {
650        // Byte-for-byte: a NUL must not truncate (AzString is length-based), a 100k
651        // path must not be capped, and no normalization may happen at construction.
652        let mut paths: Vec<String> = file_name_cases().into_iter().map(|(p, _)| p).collect();
653        paths.extend(no_file_name_cases());
654        paths.extend(adversarial_strings());
655        paths.push(format!("/tmp/{}", "x".repeat(100_000)));
656
657        for p in paths {
658            let fi = FileInput::create(opt(&p));
659            let stored = fi
660                .file_input_state
661                .inner
662                .path
663                .as_ref()
664                .expect("create(Some(..)) dropped the path");
665            assert_eq!(stored.as_str(), p, "create({p:?}) altered the path");
666            assert_eq!(
667                stored.as_str().len(),
668                p.len(),
669                "create({p:?}) changed the byte length of the path",
670            );
671        }
672    }
673
674    #[test]
675    fn create_with_no_path_equals_the_default_widget() {
676        let created = FileInput::create(None.into());
677        assert!(
678            created.file_input_state.inner.path.is_none(),
679            "create(None) invented a path",
680        );
681        assert_eq!(
682            created,
683            FileInput::default(),
684            "create(None) and Default disagree — the two constructors have drifted",
685        );
686    }
687
688    #[test]
689    fn create_uses_the_documented_defaults_for_every_other_field() {
690        for p in [None.into(), opt(""), opt("/tmp/x.txt")] {
691            let fi = FileInput::create(p);
692            assert_eq!(fi.default_text.as_str(), DEFAULT_TEXT);
693            assert_eq!(
694                fi.file_input_state.file_dialog_title.as_str(),
695                DEFAULT_DIALOG_TITLE,
696            );
697            assert!(fi.file_input_state.default_dir.is_none());
698            assert!(
699                fi.file_input_state.on_path_change.as_ref().is_none(),
700                "create() invented a path-change callback out of nowhere",
701            );
702            assert!(fi.image.as_ref().is_none());
703        }
704    }
705
706    #[test]
707    fn create_inherits_the_button_styling_verbatim() {
708        // The widget is documented as "same as `Button`" — it must not fork the
709        // button's style vecs, or a restyle of Button would silently skip it.
710        let button = Button::create(AzString::from_const_str(""));
711        let fi = FileInput::create(opt("/tmp/x.txt"));
712        assert_eq!(fi.container_style, button.container_style);
713        assert_eq!(fi.label_style, button.label_style);
714        assert_eq!(fi.image_style, button.image_style);
715    }
716
717    #[test]
718    fn create_is_pure_and_repeatable() {
719        // Same argument, same widget — no hidden counter, clock or global state.
720        for p in ["", "/tmp/a.txt", "\0"] {
721            assert_eq!(FileInput::create(opt(p)), FileInput::create(opt(p)));
722        }
723    }
724
725    // ==================================================================
726    // FileInput::swap_with_default
727    // ==================================================================
728
729    #[test]
730    fn swap_with_default_returns_the_original_and_resets_self() {
731        let mut fi = populated_with_image()
732            .with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
733        let before = fi.clone();
734
735        let taken = fi.swap_with_default();
736
737        assert_eq!(taken, before, "swap_with_default did not return the original");
738        assert_eq!(
739            fi,
740            FileInput::default(),
741            "swap_with_default left the widget in a non-default state",
742        );
743        assert!(
744            fi.file_input_state.on_path_change.as_ref().is_none(),
745            "the callback survived the reset — a stale RefAny would keep firing",
746        );
747        assert!(fi.image.as_ref().is_none(), "the image survived the reset");
748    }
749
750    #[test]
751    fn swap_with_default_moves_the_callback_out_intact() {
752        let probe = log_refany();
753        let mut fi = FileInput::create(opt("/tmp/x"))
754            .with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
755
756        let taken = fi.swap_with_default();
757
758        let moved = taken
759            .file_input_state
760            .on_path_change
761            .as_ref()
762            .expect("the callback was lost in the swap");
763        assert_eq!(
764            cb_addr(&moved.callback),
765            record_path as *const () as usize,
766            "the moved-out callback points somewhere else",
767        );
768        assert_eq!(read_log(&moved.refany).payload, 0xDEAD_BEEF);
769    }
770
771    #[test]
772    fn swap_with_default_twice_yields_the_default_the_second_time() {
773        let mut fi = populated_with_image();
774        let first = fi.swap_with_default();
775        let second = fi.swap_with_default();
776
777        assert_ne!(first, second, "the first swap did not actually take anything");
778        assert_eq!(second, FileInput::default());
779        assert_eq!(fi, FileInput::default(), "the second swap dirtied the widget");
780    }
781
782    #[test]
783    fn swap_with_default_on_a_default_widget_is_a_no_op() {
784        let mut fi = FileInput::default();
785        let taken = fi.swap_with_default();
786        assert_eq!(taken, FileInput::default());
787        assert_eq!(fi, FileInput::default());
788    }
789
790    #[test]
791    fn swap_with_default_preserves_extreme_field_values() {
792        // A 100k default text and a NUL-bearing path must move across the swap
793        // untouched — `mem::swap` is byte-wise, so any change means a rebuild.
794        let huge = "x".repeat(100_000);
795        let mut fi = FileInput::create(opt("a\0b"));
796        fi.set_default_text(huge.as_str().into());
797
798        let taken = fi.swap_with_default();
799
800        assert_eq!(taken.default_text.as_str().len(), huge.len());
801        assert_eq!(
802            taken
803                .file_input_state
804                .inner
805                .path
806                .as_ref()
807                .map(|p| p.as_str().to_string()),
808            Some("a\0b".to_string()),
809        );
810    }
811
812    // ==================================================================
813    // FileInput::set_default_text / with_default_text
814    // ==================================================================
815
816    #[test]
817    fn set_default_text_stores_the_text_verbatim() {
818        for s in adversarial_strings() {
819            let mut fi = FileInput::default();
820            fi.set_default_text(s.as_str().into());
821            assert_eq!(fi.default_text.as_str(), s, "set_default_text({s:?}) altered the text");
822            assert_eq!(
823                fi.default_text.as_str().len(),
824                s.len(),
825                "set_default_text({s:?}) changed the byte length (NUL truncation?)",
826            );
827        }
828    }
829
830    #[test]
831    fn set_default_text_is_last_write_wins() {
832        let mut fi = FileInput::default();
833        for s in adversarial_strings() {
834            fi.set_default_text(s.as_str().into());
835            assert_eq!(fi.default_text.as_str(), s);
836        }
837        fi.set_default_text("final".into());
838        assert_eq!(fi.default_text.as_str(), "final");
839    }
840
841    #[test]
842    fn set_default_text_touches_nothing_else() {
843        let mut fi = populated_with_image();
844        let before = fi.clone();
845        fi.set_default_text("something else entirely".into());
846
847        assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
848        assert_eq!(
849            fi.file_input_state.file_dialog_title,
850            before.file_input_state.file_dialog_title,
851        );
852        assert_eq!(
853            fi.file_input_state.default_dir,
854            before.file_input_state.default_dir,
855        );
856        assert_eq!(fi.container_style, before.container_style);
857        assert_eq!(fi.label_style, before.label_style);
858        assert_eq!(fi.image_style, before.image_style);
859        assert_eq!(fi.image, before.image);
860    }
861
862    #[test]
863    fn with_default_text_matches_the_setter() {
864        for s in adversarial_strings() {
865            let mut by_setter = populated();
866            by_setter.set_default_text(s.as_str().into());
867            let by_builder = populated().with_default_text(s.as_str().into());
868            assert_eq!(
869                by_builder, by_setter,
870                "with_default_text({s:?}) and set_default_text disagree",
871            );
872        }
873    }
874
875    #[test]
876    fn with_default_text_chains_last_wins() {
877        let fi = FileInput::default()
878            .with_default_text("first".into())
879            .with_default_text("second".into())
880            .with_default_text("".into());
881        assert_eq!(fi.default_text.as_str(), "");
882    }
883
884    // ==================================================================
885    // FileInput::set_on_path_change / with_on_path_change
886    // ==================================================================
887
888    #[test]
889    fn set_on_path_change_stores_the_function_pointer_and_the_data() {
890        let probe = log_refany();
891        let mut fi = FileInput::default();
892        fi.set_on_path_change(probe.clone(), record_path as FileInputOnPathChangeCallbackType);
893
894        let stored = fi
895            .file_input_state
896            .on_path_change
897            .as_ref()
898            .expect("the callback was not stored");
899        assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
900        assert_eq!(
901            stored.refany, probe,
902            "the widget stored a different RefAny than the one it was handed",
903        );
904        assert_eq!(read_log(&stored.refany).payload, 0xDEAD_BEEF);
905    }
906
907    #[test]
908    fn set_on_path_change_overwrites_a_previous_callback() {
909        // Two live callbacks would fire twice per click; the setter must replace.
910        let mut fi = FileInput::default();
911        fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
912        fi.set_on_path_change(
913            RefAny::new(7_u32),
914            path_refresh_all as FileInputOnPathChangeCallbackType,
915        );
916
917        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
918        assert_eq!(
919            cb_addr(&stored.callback),
920            path_refresh_all as *const () as usize,
921            "the first callback survived the overwrite",
922        );
923        let mut data = stored.refany.clone();
924        assert!(
925            data.downcast_ref::<PathLog>().is_none(),
926            "the first callback's data survived the overwrite",
927        );
928    }
929
930    #[test]
931    fn set_on_path_change_touches_nothing_else() {
932        let mut fi = populated_with_image();
933        let before = fi.clone();
934        fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
935
936        assert_eq!(fi.default_text, before.default_text);
937        assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
938        assert_eq!(
939            fi.file_input_state.file_dialog_title,
940            before.file_input_state.file_dialog_title,
941        );
942        assert_eq!(
943            fi.file_input_state.default_dir,
944            before.file_input_state.default_dir,
945        );
946        assert_eq!(fi.image, before.image);
947        assert_eq!(fi.container_style, before.container_style);
948    }
949
950    #[test]
951    fn with_on_path_change_matches_the_setter() {
952        let probe = log_refany();
953        let mut by_setter = populated();
954        by_setter.set_on_path_change(
955            probe.clone(),
956            record_path as FileInputOnPathChangeCallbackType,
957        );
958        let by_builder = populated()
959            .with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
960        assert_eq!(by_builder, by_setter);
961    }
962
963    #[test]
964    fn a_generic_callback_keeps_its_address_and_context_through_the_transmute() {
965        // FFI bindings hand in a 2-arg `Callback`; the `From<Callback>` arm transmutes
966        // it into the 3-arg slot. The transmute must preserve the code address (calling
967        // the wrong function) and the `ctx` payload (losing the Python/Lua callable).
968        let ctx = RefAny::new(0xABCD_u32);
969        let generic = Callback {
970            cb: generic_shaped,
971            ctx: OptionRefAny::Some(ctx.clone()),
972        };
973        let fi = FileInput::default().with_on_path_change(log_refany(), generic);
974
975        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
976        assert_eq!(
977            cb_addr(&stored.callback),
978            generic_shaped as *const () as usize,
979            "the transmute moved the function pointer",
980        );
981        assert_eq!(
982            stored.callback.ctx,
983            OptionRefAny::Some(ctx),
984            "the FFI context was dropped by the transmute",
985        );
986    }
987
988    #[test]
989    fn a_raw_function_pointer_gets_no_ffi_context() {
990        let fi = FileInput::default()
991            .with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
992        let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
993        assert_eq!(
994            stored.callback.ctx,
995            OptionRefAny::None,
996            "a native Rust callback must not carry an FFI context",
997        );
998    }
999
1000    // ==================================================================
1001    // FileInput::dom
1002    // ==================================================================
1003
1004    #[test]
1005    fn dom_labels_the_button_with_the_file_name() {
1006        for (path, expected) in file_name_cases() {
1007            let dom = FileInput::create(opt(&path)).dom();
1008            assert_eq!(
1009                rendered_label(&dom),
1010                expected,
1011                "dom() mislabelled the button for path {path:?}",
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn dom_falls_back_to_the_default_text_when_the_path_has_no_file_name() {
1018        // `/`, `.`, `..` and friends have no final `Normal` component. Rendering an
1019        // *empty* button there would leave the user with an unlabelled control.
1020        for path in no_file_name_cases() {
1021            let dom = FileInput::create(opt(&path)).dom();
1022            assert_eq!(
1023                rendered_label(&dom),
1024                DEFAULT_TEXT,
1025                "dom() did not fall back to the default text for path {path:?}",
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn dom_falls_back_to_the_default_text_when_no_path_is_set() {
1032        let dom = FileInput::create(None.into()).dom();
1033        assert_eq!(rendered_label(&dom), DEFAULT_TEXT);
1034    }
1035
1036    #[test]
1037    fn dom_renders_a_custom_default_text_verbatim_when_there_is_no_file_name() {
1038        for s in adversarial_strings() {
1039            let fi = FileInput::create(None.into()).with_default_text(s.as_str().into());
1040            assert_eq!(rendered_label(&fi.dom()), s, "custom default text {s:?} was altered");
1041
1042            // ...and the same for a path that has no file-name component.
1043            let fi = FileInput::create(opt("/")).with_default_text(s.as_str().into());
1044            assert_eq!(rendered_label(&fi.dom()), s);
1045        }
1046    }
1047
1048    #[test]
1049    fn dom_survives_a_100k_byte_file_name() {
1050        let huge = "x".repeat(100_000);
1051        let dom = FileInput::create(opt(&format!("/tmp/{huge}"))).dom();
1052        let label = rendered_label(&dom);
1053        assert_eq!(label.len(), huge.len(), "the 100k file name was truncated");
1054        assert_eq!(label, huge);
1055    }
1056
1057    #[test]
1058    fn dom_prefers_the_file_name_over_the_default_text() {
1059        // A non-empty default text must not shadow a real selection.
1060        let fi = FileInput::create(opt("/tmp/chosen.txt")).with_default_text("NOT THIS".into());
1061        assert_eq!(rendered_label(&fi.dom()), "chosen.txt");
1062    }
1063
1064    #[test]
1065    fn dom_renders_an_empty_label_when_both_the_path_and_the_default_text_are_empty() {
1066        let fi = FileInput::create(opt("")).with_default_text("".into());
1067        assert_eq!(rendered_label(&fi.dom()), "");
1068    }
1069
1070    #[test]
1071    fn dom_ignores_the_dialog_title_and_default_dir_when_labelling() {
1072        // Only `path`/`default_text` may reach the label; leaking the dialog title or
1073        // the default directory into the button would be visible to the user.
1074        let mut fi = FileInput::create(None.into());
1075        fi.file_input_state.file_dialog_title = "TITLE-LEAK".into();
1076        fi.file_input_state.default_dir = opt("/DIR-LEAK");
1077        let label = rendered_label(&fi.dom());
1078        assert_eq!(label, DEFAULT_TEXT);
1079        assert!(!label.contains("LEAK"));
1080    }
1081
1082    #[test]
1083    fn dom_renders_a_native_button_node() {
1084        let dom = FileInput::create(opt("/tmp/x.txt")).dom();
1085        assert!(
1086            matches!(dom.root.get_node_type(), NodeType::Button),
1087            "the file input no longer renders a <button>",
1088        );
1089        assert_eq!(
1090            classes(&dom),
1091            vec![
1092                "__azul-native-button".to_string(),
1093                ButtonType::Default.class_name().to_string(),
1094            ],
1095            "the file input must be styleable as a default-type native button",
1096        );
1097        assert!(
1098            matches!(dom.root.get_tab_index(), Some(TabIndex::Auto)),
1099            "the file input dropped the button's keyboard focusability",
1100        );
1101    }
1102
1103    #[test]
1104    fn dom_registers_exactly_one_mouseup_callback_into_fileinput_on_click() {
1105        let dom = FileInput::create(opt("/tmp/x.txt")).dom();
1106        let callbacks = dom.root.callbacks.as_ref();
1107        assert_eq!(callbacks.len(), 1, "a click must fire exactly one handler");
1108        assert_eq!(callbacks[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
1109        assert_eq!(
1110            callbacks[0].callback.cb,
1111            fileinput_on_click as *const () as usize,
1112            "the DOM is wired to a different handler than fileinput_on_click",
1113        );
1114        assert_eq!(
1115            callbacks[0].callback.ctx,
1116            OptionRefAny::None,
1117            "the internal handler must not carry an FFI context",
1118        );
1119    }
1120
1121    #[test]
1122    fn dom_hands_the_whole_state_wrapper_to_the_handler() {
1123        // The click handler reads the dialog title, the default dir *and* the user
1124        // callback out of this RefAny — dropping any of them silently disables them.
1125        let probe = log_refany();
1126        let mut fi = FileInput::create(opt("/tmp/original.txt"));
1127        fi.file_input_state.file_dialog_title = "custom title".into();
1128        fi.file_input_state.default_dir = opt("/tmp/custom-dir");
1129        fi.set_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
1130        let expected = fi.file_input_state.clone();
1131
1132        let state = state_of(&registered_state(&fi.dom()));
1133        assert_eq!(state.inner, expected.inner);
1134        assert_eq!(state.file_dialog_title, expected.file_dialog_title);
1135        assert_eq!(state.default_dir, expected.default_dir);
1136        let stored = state.on_path_change.as_ref().expect("the user callback was dropped");
1137        assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
1138    }
1139
1140    #[test]
1141    fn dom_child_count_matches_the_cached_descendant_count() {
1142        // A too-small `estimated_total_children` makes `convert_dom_into_compact_dom`
1143        // under-allocate its arenas and panic on out-of-bounds writes.
1144        for fi in [
1145            FileInput::create(None.into()),
1146            FileInput::create(opt("/tmp/x.txt")),
1147            populated_with_image(),
1148        ] {
1149            let has_image = fi.image.as_ref().is_some();
1150            let dom = fi.dom();
1151            assert_eq!(
1152                dom.estimated_total_children,
1153                count_descendants(&dom),
1154                "estimated_total_children is out of sync with the real subtree",
1155            );
1156            assert_eq!(
1157                dom.children.as_ref().len(),
1158                usize::from(has_image) + 1,
1159                "unexpected child count",
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn dom_renders_the_image_before_the_label() {
1166        let fi = populated_with_image();
1167        let dom = fi.dom();
1168        let children = dom.children.as_ref();
1169        assert_eq!(children.len(), 2);
1170        assert!(
1171            matches!(children[0].root.get_node_type(), NodeType::Image(_)),
1172            "the image is not the first child",
1173        );
1174        assert_eq!(rendered_label(&dom), "original.txt");
1175    }
1176
1177    #[test]
1178    fn dom_is_deterministic_for_identical_widgets() {
1179        for path in ["/tmp/a.txt", "/", ""] {
1180            let a = FileInput::create(opt(path)).dom();
1181            let b = FileInput::create(opt(path)).dom();
1182            assert_eq!(a.root.get_node_type(), b.root.get_node_type());
1183            assert_eq!(classes(&a), classes(&b));
1184            assert_eq!(rendered_label(&a), rendered_label(&b));
1185            assert_eq!(a.children.as_ref().len(), b.children.as_ref().len());
1186        }
1187    }
1188
1189    #[cfg(unix)]
1190    #[test]
1191    fn dom_does_not_treat_a_backslash_as_a_separator_on_unix() {
1192        // A backslash is a perfectly legal *character* in a POSIX file name; splitting
1193        // on it would mislabel (and, worse, misreport) the selected file.
1194        let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
1195        assert_eq!(rendered_label(&dom), "C:\\dir\\file.txt");
1196
1197        let dom = FileInput::create(opt("/tmp/a\\b.txt")).dom();
1198        assert_eq!(rendered_label(&dom), "a\\b.txt");
1199    }
1200
1201    #[cfg(unix)]
1202    #[test]
1203    fn dom_handles_multiple_leading_slashes_on_unix() {
1204        assert_eq!(rendered_label(&FileInput::create(opt("//")).dom()), DEFAULT_TEXT);
1205        assert_eq!(rendered_label(&FileInput::create(opt("///")).dom()), DEFAULT_TEXT);
1206        assert_eq!(rendered_label(&FileInput::create(opt("//a.txt")).dom()), "a.txt");
1207    }
1208
1209    #[cfg(windows)]
1210    #[test]
1211    fn dom_treats_a_backslash_as_a_separator_on_windows() {
1212        let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
1213        assert_eq!(rendered_label(&dom), "file.txt");
1214    }
1215
1216    // ==================================================================
1217    // fileinput_on_click
1218    // ==================================================================
1219
1220    #[test]
1221    fn click_with_a_foreign_refany_does_nothing() {
1222        // The handler is reached through an FFI-visible `usize` function pointer, so a
1223        // mismatched payload is reachable in practice; it must be rejected by the
1224        // type check rather than reinterpreted.
1225        let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
1226        let foreign = RefAny::new(0xDEAD_BEEF_u32);
1227
1228        let (update, changes) = click(styled, &foreign, node(0));
1229
1230        assert_eq!(
1231            update,
1232            Update::DoNothing,
1233            "a foreign payload must not trigger a relayout",
1234        );
1235        assert!(changes.is_empty(), "a foreign payload pushed changes: {changes:?}");
1236        let mut foreign = foreign;
1237        assert_eq!(
1238            *foreign.downcast_ref::<u32>().expect("the payload was overwritten"),
1239            0xDEAD_BEEF,
1240        );
1241    }
1242
1243    #[test]
1244    fn click_rejects_the_inner_state_mistaken_for_the_wrapper() {
1245        // `FileInputState` is the *inner* half of `FileInputStateWrapper` and is what
1246        // the user callback receives — passing it back in is the most likely confusion,
1247        // and reinterpreting it would read an `OptionString` as a callback pointer.
1248        let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
1249        let inner = RefAny::new(FileInputState { path: opt("/tmp/x.txt") });
1250
1251        let (update, changes) = click(styled, &inner, node(0));
1252
1253        assert_eq!(update, Update::DoNothing);
1254        assert!(changes.is_empty());
1255    }
1256
1257    #[test]
1258    fn click_with_a_foreign_refany_ignores_the_hit_node() {
1259        // `node_none()` is the "nothing concrete was hit" id that panics several
1260        // `CallbackInfo` queries — the handler must never reach them.
1261        let styled = StyledDom::create_from_dom(FileInput::create(None.into()).dom());
1262        let foreign = RefAny::new(0_u8);
1263        let (update, changes) = click(styled, &foreign, node_none());
1264        assert_eq!(update, Update::DoNothing);
1265        assert!(changes.is_empty());
1266    }
1267
1268    /// `fileinput_on_click` opens a **modal native file dialog** before it ever reaches
1269    /// the user callback whenever the `extra` feature is on (which it is by default, on
1270    /// desktop). Driving the handler with a well-typed payload would block the test run
1271    /// on a GUI prompt, so everything past the downcast is only exercised in builds
1272    /// where that block is compiled out. The type-rejection path above runs everywhere.
1273    #[cfg(any(
1274        not(feature = "extra"),
1275        target_os = "android",
1276        target_os = "ios"
1277    ))]
1278    mod without_the_native_dialog {
1279        use super::*;
1280
1281        #[test]
1282        fn click_without_a_user_callback_refreshes_the_dom() {
1283            let (styled, state) = laid_out(FileInput::create(opt("/tmp/x.txt")));
1284            let (update, changes) = click(styled, &state, node(0));
1285
1286            assert_eq!(
1287                update,
1288                Update::RefreshDom,
1289                "a click must relayout so the new label is drawn",
1290            );
1291            assert!(changes.is_empty(), "the handler pushed unexpected changes: {changes:?}");
1292            assert_eq!(
1293                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
1294                Some("/tmp/x.txt".to_string()),
1295                "the handler mutated the path without a dialog",
1296            );
1297        }
1298
1299        #[test]
1300        fn click_upgrades_a_do_nothing_user_callback_to_a_refresh() {
1301            // `result.max_self(Update::RefreshDom)` deliberately swallows the user's
1302            // `DoNothing` — the label may have changed, so the DOM must be rebuilt.
1303            let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
1304                RefAny::new(0_u8),
1305                path_do_nothing as FileInputOnPathChangeCallbackType,
1306            );
1307            let (styled, state) = laid_out(fi);
1308            let (update, _) = click(styled, &state, node(0));
1309            assert_eq!(update, Update::RefreshDom);
1310        }
1311
1312        #[test]
1313        fn click_preserves_a_stronger_user_update() {
1314            // `max_self` must not *downgrade* RefreshDomAllWindows to RefreshDom.
1315            let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
1316                RefAny::new(0_u8),
1317                path_refresh_all as FileInputOnPathChangeCallbackType,
1318            );
1319            let (styled, state) = laid_out(fi);
1320            let (update, _) = click(styled, &state, node(0));
1321            assert_eq!(update, Update::RefreshDomAllWindows);
1322        }
1323
1324        #[test]
1325        fn click_hands_the_current_path_to_the_user_callback() {
1326            for path in ["/tmp/a.txt", "", "a\0b", "/tmp/日本語.txt"] {
1327                let probe = log_refany();
1328                let fi = FileInput::create(opt(path)).with_on_path_change(
1329                    probe.clone(),
1330                    record_path as FileInputOnPathChangeCallbackType,
1331                );
1332                let (styled, state) = laid_out(fi);
1333                let (_, _) = click(styled, &state, node(0));
1334
1335                assert_eq!(
1336                    read_log(&probe).seen,
1337                    vec![Some(path.to_string())],
1338                    "the callback saw the wrong path for {path:?}",
1339                );
1340            }
1341        }
1342
1343        #[test]
1344        fn click_hands_a_missing_path_through_as_none() {
1345            let probe = log_refany();
1346            let fi = FileInput::create(None.into()).with_on_path_change(
1347                probe.clone(),
1348                record_path as FileInputOnPathChangeCallbackType,
1349            );
1350            let (styled, state) = laid_out(fi);
1351            let (_, _) = click(styled, &state, node(0));
1352            assert_eq!(read_log(&probe).seen, vec![None]);
1353        }
1354
1355        #[test]
1356        fn repeated_clicks_fire_once_each_and_leave_the_state_alone() {
1357            let probe = log_refany();
1358            let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
1359                probe.clone(),
1360                record_path as FileInputOnPathChangeCallbackType,
1361            );
1362            let (styled, state) = laid_out(fi);
1363
1364            for _ in 0..3 {
1365                let (update, _) = click(styled.clone(), &state, node(0));
1366                assert_eq!(update, Update::RefreshDom);
1367            }
1368
1369            assert_eq!(read_log(&probe).seen.len(), 3, "clicks were dropped or doubled");
1370            assert_eq!(
1371                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
1372                Some("/tmp/a.txt".to_string()),
1373            );
1374        }
1375
1376        /// Records whether a *re-entrant* borrow of the widget state succeeded while the
1377        /// click handler still holds its own `downcast_mut` guard.
1378        struct ReentryProbe {
1379            state: RefAny,
1380            saw_mut: Option<bool>,
1381            saw_ref: Option<bool>,
1382        }
1383
1384        extern "C" fn probe_reentry(
1385            mut refany: RefAny,
1386            _info: CallbackInfo,
1387            _state: FileInputState,
1388        ) -> Update {
1389            let Some(mut probe) = refany.downcast_mut::<ReentryProbe>() else {
1390                return Update::DoNothing;
1391            };
1392            let probe = &mut *probe;
1393            let saw_mut = probe.state.downcast_mut::<FileInputStateWrapper>().is_some();
1394            let saw_ref = probe.state.downcast_ref::<FileInputStateWrapper>().is_some();
1395            probe.saw_mut = Some(saw_mut);
1396            probe.saw_ref = Some(saw_ref);
1397            Update::DoNothing
1398        }
1399
1400        #[test]
1401        fn a_reentrant_borrow_of_the_state_is_refused_rather_than_aliased() {
1402            // The handler holds a `RefMut` on the state for its whole body, including
1403            // the user callback. A user callback that grabs the same state must be told
1404            // "no" (None) — handing out a second `&mut` to the same bytes would be UB,
1405            // and panicking/deadlocking would take the app down on a mere double-borrow.
1406            let probe = RefAny::new(ReentryProbe {
1407                state: RefAny::new(0_u8),
1408                saw_mut: None,
1409                saw_ref: None,
1410            });
1411            let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
1412                probe.clone(),
1413                probe_reentry as FileInputOnPathChangeCallbackType,
1414            );
1415            let (styled, state) = laid_out(fi);
1416
1417            {
1418                let mut handle = probe.clone();
1419                let mut guard = handle
1420                    .downcast_mut::<ReentryProbe>()
1421                    .expect("the probe changed type");
1422                guard.state = state.clone();
1423            }
1424
1425            let (update, _) = click(styled, &state, node(0));
1426
1427            let mut handle = probe.clone();
1428            let guard = handle
1429                .downcast_mut::<ReentryProbe>()
1430                .expect("the probe changed type");
1431            assert_eq!(guard.saw_mut, Some(false), "a second &mut to the state was handed out");
1432            assert_eq!(guard.saw_ref, Some(false), "a & alongside the live &mut was handed out");
1433            drop(guard);
1434
1435            assert_eq!(update, Update::RefreshDom);
1436            assert_eq!(
1437                state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
1438                Some("/tmp/a.txt".to_string()),
1439                "the state was corrupted by the re-entrant attempt",
1440            );
1441        }
1442    }
1443}