1use azul_core::{
5 callbacks::{CoreCallbackData, Update},
6 dom::Dom,
7 refany::RefAny,
8 resources::OptionImageRef,
9};
10#[allow(clippy::wildcard_imports)] use 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 pub file_input_state: FileInputStateWrapper,
32 pub default_text: AzString,
35
36 pub image: OptionImageRef,
38 pub container_style: CssPropertyWithConditionsVec,
40 pub label_style: CssPropertyWithConditionsVec,
42 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 pub file_dialog_title: AzString,
67 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#[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
95pub 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 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 #[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)] mod 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 const DEFAULT_TEXT: &str = "Select File...";
282
283 const DEFAULT_DIALOG_TITLE: &str = "Select File";
287
288 fn file_name_cases() -> Vec<(String, String)> {
293 [
294 ("/tmp/report.pdf", "report.pdf"),
295 ("report.pdf", "report.pdf"),
296 ("/tmp/dir/", "dir"), ("/tmp/dir///", "dir"), ("a/.", "a"), ("/a/b/c/d/e/f/g.txt", "g.txt"),
300 (".hidden", ".hidden"), ("...", "..."), ("..a", "..a"),
303 ("/tmp/archive.tar.gz", "archive.tar.gz"),
304 (" ", " "), ("/tmp/a b.txt", "a b.txt"),
306 ("/tmp/a\nb.txt", "a\nb.txt"), ("/tmp/a\tb.txt", "a\tb.txt"),
308 ("a\0b", "a\0b"), ("/tmp/a\0b.txt", "a\0b.txt"),
310 ("/tmp/日本語.txt", "日本語.txt"),
311 ("/tmp/e\u{0301}.txt", "e\u{0301}.txt"), (
313 "/tmp/\u{1F469}\u{200D}\u{1F467}.png", "\u{1F469}\u{200D}\u{1F467}.png",
315 ),
316 ("/tmp/\u{202E}gpj.exe", "\u{202E}gpj.exe"), ("/tmp/\u{FFFD}.bin", "\u{FFFD}.bin"),
318 ("/Select File.../x", "x"), ]
320 .iter()
321 .map(|(p, l)| ((*p).to_string(), (*l).to_string()))
322 .collect()
323 }
324
325 fn no_file_name_cases() -> Vec<String> {
328 ["", "/", ".", "..", "./", "/.", "/..", "a/..", "a/b/../", "../.."]
329 .iter()
330 .map(|s| (*s).to_string())
331 .collect()
332 }
333
334 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 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 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 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 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 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 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 #[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 #[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 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 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 fn node_none() -> DomNodeId {
538 DomNodeId {
539 dom: DomId::ROOT_ID,
540 node: NodeHierarchyItemId::NONE,
541 }
542 }
543
544 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 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: ¤t_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 #[allow(dead_code)] 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 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 #[test]
649 fn create_stores_the_path_verbatim() {
650 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 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 for p in ["", "/tmp/a.txt", "\0"] {
721 assert_eq!(FileInput::create(opt(p)), FileInput::create(opt(p)));
722 }
723 }
724
725 #[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 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 #[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 #[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 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 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 #[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 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 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 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 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 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(®istered_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 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 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 #[test]
1221 fn click_with_a_foreign_refany_does_nothing() {
1222 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 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 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 #[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 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 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 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 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}