use azul_core::{
callbacks::{CoreCallbackData, Update},
dom::Dom,
refany::RefAny,
resources::OptionImageRef,
};
#[allow(clippy::wildcard_imports)] use azul_css::{
dynamic_selector::CssPropertyWithConditionsVec,
props::{
basic::*,
layout::*,
property::{CssProperty, *},
style::*,
},
*,
};
use crate::{
callbacks::{Callback, CallbackInfo},
widgets::button::{Button, ButtonOnClick, ButtonOnClickCallback},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct FileInput {
pub file_input_state: FileInputStateWrapper,
pub default_text: AzString,
pub image: OptionImageRef,
pub container_style: CssPropertyWithConditionsVec,
pub label_style: CssPropertyWithConditionsVec,
pub image_style: CssPropertyWithConditionsVec,
}
impl Default for FileInput {
fn default() -> Self {
let default_button = Button::create(AzString::from_const_str(""));
Self {
file_input_state: FileInputStateWrapper::default(),
default_text: "Select File...".into(),
image: None.into(),
container_style: default_button.container_style,
label_style: default_button.label_style,
image_style: default_button.image_style,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct FileInputStateWrapper {
pub inner: FileInputState,
pub on_path_change: OptionFileInputOnPathChange,
pub file_dialog_title: AzString,
pub default_dir: OptionString,
}
impl Default for FileInputStateWrapper {
fn default() -> Self {
Self {
inner: FileInputState::default(),
on_path_change: None.into(),
file_dialog_title: "Select File".into(),
default_dir: None.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct FileInputState {
pub path: OptionString,
}
impl Default for FileInputState {
fn default() -> Self {
Self { path: None.into() }
}
}
pub type FileInputOnPathChangeCallbackType =
extern "C" fn(RefAny, CallbackInfo, FileInputState) -> Update;
impl_widget_callback!(
FileInputOnPathChange,
OptionFileInputOnPathChange,
FileInputOnPathChangeCallback,
FileInputOnPathChangeCallbackType
);
azul_core::impl_managed_callback! {
wrapper: FileInputOnPathChangeCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: FILE_INPUT_ON_PATH_CHANGE_INVOKER,
invoker_ty: AzFileInputOnPathChangeCallbackInvoker,
thunk_fn: az_file_input_on_path_change_callback_thunk,
setter_fn: AzApp_setFileInputOnPathChangeCallbackInvoker,
from_handle_fn: AzFileInputOnPathChangeCallback_createFromHostHandle,
extra_args: [ state: FileInputState ],
}
impl FileInput {
#[must_use] pub fn create(path: OptionString) -> Self {
Self {
file_input_state: FileInputStateWrapper {
inner: FileInputState { path },
..Default::default()
},
..Default::default()
}
}
#[inline]
#[must_use]
pub fn swap_with_default(&mut self) -> Self {
let mut s = Self::create(None.into());
core::mem::swap(&mut s, self);
s
}
#[inline]
pub fn set_default_text(&mut self, default_text: AzString) {
self.default_text = default_text;
}
#[inline]
#[must_use] pub fn with_default_text(mut self, default_text: AzString) -> Self {
self.set_default_text(default_text);
self
}
#[inline]
pub fn set_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
&mut self,
refany: RefAny,
callback: I,
) {
self.file_input_state.on_path_change = Some(FileInputOnPathChange {
callback: callback.into(),
refany,
})
.into();
}
#[inline]
#[must_use]
pub fn with_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
mut self,
refany: RefAny,
callback: I,
) -> Self {
self.set_on_path_change(refany, callback);
self
}
#[inline]
#[must_use] pub fn dom(self) -> Dom {
let button_label = match self.file_input_state.inner.path.as_ref() {
Some(path) => std::path::Path::new(path.as_str())
.file_name()
.map_or_else(
|| self.default_text.as_str().to_string(),
|s| s.to_string_lossy().to_string(),
)
.into(),
None => self.default_text.clone(),
};
Button {
label: button_label,
image: self.image,
button_type: crate::widgets::button::ButtonType::Default,
container_style: self.container_style,
label_style: self.label_style,
image_style: self.image_style,
on_click: Some(ButtonOnClick {
refany: RefAny::new(self.file_input_state),
callback: ButtonOnClickCallback {
cb: fileinput_on_click,
ctx: azul_core::refany::OptionRefAny::None,
},
})
.into(),
}
.dom()
}
}
extern "C" fn fileinput_on_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
let Some(mut fileinputstatewrapper) = refany.downcast_mut::<FileInputStateWrapper>() else {
return Update::DoNothing;
};
let fileinputstatewrapper = &mut *fileinputstatewrapper;
#[cfg(all(feature = "extra", not(any(target_os = "android", target_os = "ios"))))]
{
let mut dialog = tfd::FileDialog::new(fileinputstatewrapper.file_dialog_title.as_str());
if let Some(dir) = fileinputstatewrapper.default_dir.as_ref() {
dialog = dialog.with_path(dir.as_str());
}
let Some(selected_path) = dialog.open_file() else {
return Update::DoNothing;
};
fileinputstatewrapper.inner.path = Some(selected_path.into()).into();
}
let inner = fileinputstatewrapper.inner.clone();
let mut result = match fileinputstatewrapper.on_path_change.as_mut() {
Some(FileInputOnPathChange { refany, callback }) => {
(callback.cb)(refany.clone(), info, inner)
}
None => Update::RefreshDom,
};
result.max_self(Update::RefreshDom);
result
}
#[cfg(all(test, feature = "std"))]
#[allow(clippy::too_many_lines)] mod autotest_generated {
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, Mutex},
};
use azul_core::{
dom::{
DomId, DomNodeId, EventFilter, HoverEventFilter, IdOrClass, NodeId, NodeType, TabIndex,
},
geom::{LogicalRect, OptionLogicalPosition},
gl::OptionGlContextPtr,
hit_test::ScrollPosition,
refany::OptionRefAny,
resources::{ImageRef, RawImageFormat, RendererResources},
styled_dom::{NodeHierarchyItemId, StyledDom},
window::{MonitorVec, RawWindowHandle},
};
use rust_fontconfig::FcFontCache;
use super::*;
#[cfg(feature = "icu")]
use crate::icu::IcuLocalizerHandle;
use crate::{
callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
solver3::{display_list::DisplayList, layout_tree::LayoutTree},
widgets::button::ButtonType,
window::{DomLayoutResult, LayoutWindow},
window_state::FullWindowState,
};
const DEFAULT_TEXT: &str = "Select File...";
const DEFAULT_DIALOG_TITLE: &str = "Select File";
fn file_name_cases() -> Vec<(String, String)> {
[
("/tmp/report.pdf", "report.pdf"),
("report.pdf", "report.pdf"),
("/tmp/dir/", "dir"), ("/tmp/dir///", "dir"), ("a/.", "a"), ("/a/b/c/d/e/f/g.txt", "g.txt"),
(".hidden", ".hidden"), ("...", "..."), ("..a", "..a"),
("/tmp/archive.tar.gz", "archive.tar.gz"),
(" ", " "), ("/tmp/a b.txt", "a b.txt"),
("/tmp/a\nb.txt", "a\nb.txt"), ("/tmp/a\tb.txt", "a\tb.txt"),
("a\0b", "a\0b"), ("/tmp/a\0b.txt", "a\0b.txt"),
("/tmp/日本語.txt", "日本語.txt"),
("/tmp/e\u{0301}.txt", "e\u{0301}.txt"), (
"/tmp/\u{1F469}\u{200D}\u{1F467}.png", "\u{1F469}\u{200D}\u{1F467}.png",
),
("/tmp/\u{202E}gpj.exe", "\u{202E}gpj.exe"), ("/tmp/\u{FFFD}.bin", "\u{FFFD}.bin"),
("/Select File.../x", "x"), ]
.iter()
.map(|(p, l)| ((*p).to_string(), (*l).to_string()))
.collect()
}
fn no_file_name_cases() -> Vec<String> {
["", "/", ".", "..", "./", "/.", "/..", "a/..", "a/b/../", "../.."]
.iter()
.map(|s| (*s).to_string())
.collect()
}
fn adversarial_strings() -> Vec<String> {
let mut v: Vec<String> = [
"",
" ",
"Pick a file",
"e\u{0301}",
"\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}",
"\u{5E9}\u{5DC}\u{5D5}\u{5DD}",
"\0",
"a\0b",
"\u{FFFD}\u{202E}\u{200B}",
"…\t\r\n",
DEFAULT_TEXT,
]
.iter()
.map(|s| (*s).to_string())
.collect();
v.push("x".repeat(100_000));
v
}
fn opt(s: &str) -> OptionString {
Some(AzString::from(s)).into()
}
fn populated() -> FileInput {
let mut fi = FileInput::create(opt("/tmp/original.txt"));
fi.default_text = "custom default".into();
fi.file_input_state.file_dialog_title = "custom title".into();
fi.file_input_state.default_dir = opt("/tmp/custom-dir");
fi
}
fn populated_with_image() -> FileInput {
let mut fi = populated();
fi.image = Some(ImageRef::null_image(
3,
5,
RawImageFormat::RGBA8,
b"file-input-probe".to_vec(),
))
.into();
fi
}
fn text_of(dom: &Dom) -> Option<&str> {
match dom.root.get_node_type() {
NodeType::Text(s) => Some(s.as_ref().as_str()),
_ => None,
}
}
fn rendered_label(dom: &Dom) -> String {
let children = dom.children.as_ref();
let last = children.last().expect("the button has no label child");
text_of(last)
.expect("the button label is not a text node")
.to_string()
}
fn classes(dom: &Dom) -> Vec<String> {
dom.root
.get_ids_and_classes()
.as_ref()
.iter()
.filter_map(|c| match c {
IdOrClass::Class(s) => Some(s.as_str().to_string()),
IdOrClass::Id(_) => None,
})
.collect()
}
fn count_descendants(dom: &Dom) -> usize {
dom.children
.as_ref()
.iter()
.map(|c| 1 + count_descendants(c))
.sum()
}
fn registered_state(dom: &Dom) -> RefAny {
let callbacks = dom.root.callbacks.as_ref();
assert_eq!(
callbacks.len(),
1,
"a file input must register exactly one callback",
);
callbacks[0].refany.clone()
}
fn state_of(refany: &RefAny) -> FileInputStateWrapper {
let mut refany = refany.clone();
let wrapper = refany
.downcast_ref::<FileInputStateWrapper>()
.expect("the widget state changed type");
wrapper.clone()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PathLog {
seen: Vec<Option<String>>,
payload: u32,
}
fn log_refany() -> RefAny {
RefAny::new(PathLog {
seen: Vec::new(),
payload: 0xDEAD_BEEF,
})
}
fn read_log(probe: &RefAny) -> PathLog {
let mut probe = probe.clone();
let log = probe
.downcast_ref::<PathLog>()
.expect("the user payload changed type");
log.clone()
}
extern "C" fn record_path(
mut refany: RefAny,
_info: CallbackInfo,
state: FileInputState,
) -> Update {
if let Some(mut log) = refany.downcast_mut::<PathLog>() {
log.seen
.push(state.path.as_ref().map(|p| p.as_str().to_string()));
}
Update::DoNothing
}
#[allow(dead_code)]
extern "C" fn path_do_nothing(
_refany: RefAny,
_info: CallbackInfo,
_state: FileInputState,
) -> Update {
Update::DoNothing
}
extern "C" fn path_refresh_all(
_refany: RefAny,
_info: CallbackInfo,
_state: FileInputState,
) -> Update {
Update::RefreshDomAllWindows
}
extern "C" fn generic_shaped(_refany: RefAny, _info: CallbackInfo) -> Update {
Update::DoNothing
}
fn cb_addr(cb: &FileInputOnPathChangeCallback) -> usize {
cb.cb as *const () as usize
}
fn node(idx: usize) -> DomNodeId {
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
}
}
fn node_none() -> DomNodeId {
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::NONE,
}
}
fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
DomLayoutResult {
styled_dom,
layout_tree: LayoutTree {
nodes: Vec::new(),
warm: Vec::new(),
cold: Vec::new(),
root: 0,
dom_to_layout: BTreeMap::new(),
children_arena: Vec::new(),
children_offsets: Vec::new(),
subtree_needs_intrinsic: Vec::new(),
},
calculated_positions: Vec::new(),
viewport: LogicalRect::zero(),
display_list: DisplayList::default(),
scroll_ids: HashMap::new(),
scroll_id_to_node_id: HashMap::new(),
}
}
fn with_info<R>(
styled_dom: StyledDom,
hit: DomNodeId,
f: impl FnOnce(&mut CallbackInfo) -> R,
) -> (R, Vec<CallbackChange>) {
let mut layout_window =
LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
layout_window
.layout_results
.insert(DomId::ROOT_ID, layout_result(styled_dom));
let renderer_resources = RendererResources::default();
let previous_window_state: Option<FullWindowState> = None;
let current_window_state = FullWindowState::default();
let gl_context = OptionGlContextPtr::None;
let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
BTreeMap::new();
let window_handle = RawWindowHandle::Unsupported;
let system_callbacks = ExternalSystemCallbacks::rust_internal();
let ref_data = CallbackInfoRefData {
layout_window: &layout_window,
renderer_resources: &renderer_resources,
previous_window_state: &previous_window_state,
current_window_state: ¤t_window_state,
gl_context: &gl_context,
current_scroll_manager: &scroll_states,
current_window_handle: &window_handle,
system_callbacks: &system_callbacks,
system_style: Arc::new(azul_css::system::SystemStyle::default()),
monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
#[cfg(feature = "icu")]
icu_localizer: IcuLocalizerHandle::default(),
ctx: OptionRefAny::None,
};
let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
let mut info = CallbackInfo::new(
&ref_data,
&changes,
hit,
OptionLogicalPosition::None,
OptionLogicalPosition::None,
);
let r = f(&mut info);
let pushed = info.take_changes();
(r, pushed)
}
#[allow(dead_code)] fn laid_out(file_input: FileInput) -> (StyledDom, RefAny) {
let dom = file_input.dom();
let state = registered_state(&dom);
(StyledDom::create_from_dom(dom), state)
}
fn click(
styled_dom: StyledDom,
state: &RefAny,
hit: DomNodeId,
) -> (Update, Vec<CallbackChange>) {
with_info(styled_dom, hit, |info| {
fileinput_on_click(state.clone(), *info)
})
}
#[test]
fn create_stores_the_path_verbatim() {
let mut paths: Vec<String> = file_name_cases().into_iter().map(|(p, _)| p).collect();
paths.extend(no_file_name_cases());
paths.extend(adversarial_strings());
paths.push(format!("/tmp/{}", "x".repeat(100_000)));
for p in paths {
let fi = FileInput::create(opt(&p));
let stored = fi
.file_input_state
.inner
.path
.as_ref()
.expect("create(Some(..)) dropped the path");
assert_eq!(stored.as_str(), p, "create({p:?}) altered the path");
assert_eq!(
stored.as_str().len(),
p.len(),
"create({p:?}) changed the byte length of the path",
);
}
}
#[test]
fn create_with_no_path_equals_the_default_widget() {
let created = FileInput::create(None.into());
assert!(
created.file_input_state.inner.path.is_none(),
"create(None) invented a path",
);
assert_eq!(
created,
FileInput::default(),
"create(None) and Default disagree — the two constructors have drifted",
);
}
#[test]
fn create_uses_the_documented_defaults_for_every_other_field() {
for p in [None.into(), opt(""), opt("/tmp/x.txt")] {
let fi = FileInput::create(p);
assert_eq!(fi.default_text.as_str(), DEFAULT_TEXT);
assert_eq!(
fi.file_input_state.file_dialog_title.as_str(),
DEFAULT_DIALOG_TITLE,
);
assert!(fi.file_input_state.default_dir.is_none());
assert!(
fi.file_input_state.on_path_change.as_ref().is_none(),
"create() invented a path-change callback out of nowhere",
);
assert!(fi.image.as_ref().is_none());
}
}
#[test]
fn create_inherits_the_button_styling_verbatim() {
let button = Button::create(AzString::from_const_str(""));
let fi = FileInput::create(opt("/tmp/x.txt"));
assert_eq!(fi.container_style, button.container_style);
assert_eq!(fi.label_style, button.label_style);
assert_eq!(fi.image_style, button.image_style);
}
#[test]
fn create_is_pure_and_repeatable() {
for p in ["", "/tmp/a.txt", "\0"] {
assert_eq!(FileInput::create(opt(p)), FileInput::create(opt(p)));
}
}
#[test]
fn swap_with_default_returns_the_original_and_resets_self() {
let mut fi = populated_with_image()
.with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
let before = fi.clone();
let taken = fi.swap_with_default();
assert_eq!(taken, before, "swap_with_default did not return the original");
assert_eq!(
fi,
FileInput::default(),
"swap_with_default left the widget in a non-default state",
);
assert!(
fi.file_input_state.on_path_change.as_ref().is_none(),
"the callback survived the reset — a stale RefAny would keep firing",
);
assert!(fi.image.as_ref().is_none(), "the image survived the reset");
}
#[test]
fn swap_with_default_moves_the_callback_out_intact() {
let probe = log_refany();
let mut fi = FileInput::create(opt("/tmp/x"))
.with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
let taken = fi.swap_with_default();
let moved = taken
.file_input_state
.on_path_change
.as_ref()
.expect("the callback was lost in the swap");
assert_eq!(
cb_addr(&moved.callback),
record_path as *const () as usize,
"the moved-out callback points somewhere else",
);
assert_eq!(read_log(&moved.refany).payload, 0xDEAD_BEEF);
}
#[test]
fn swap_with_default_twice_yields_the_default_the_second_time() {
let mut fi = populated_with_image();
let first = fi.swap_with_default();
let second = fi.swap_with_default();
assert_ne!(first, second, "the first swap did not actually take anything");
assert_eq!(second, FileInput::default());
assert_eq!(fi, FileInput::default(), "the second swap dirtied the widget");
}
#[test]
fn swap_with_default_on_a_default_widget_is_a_no_op() {
let mut fi = FileInput::default();
let taken = fi.swap_with_default();
assert_eq!(taken, FileInput::default());
assert_eq!(fi, FileInput::default());
}
#[test]
fn swap_with_default_preserves_extreme_field_values() {
let huge = "x".repeat(100_000);
let mut fi = FileInput::create(opt("a\0b"));
fi.set_default_text(huge.as_str().into());
let taken = fi.swap_with_default();
assert_eq!(taken.default_text.as_str().len(), huge.len());
assert_eq!(
taken
.file_input_state
.inner
.path
.as_ref()
.map(|p| p.as_str().to_string()),
Some("a\0b".to_string()),
);
}
#[test]
fn set_default_text_stores_the_text_verbatim() {
for s in adversarial_strings() {
let mut fi = FileInput::default();
fi.set_default_text(s.as_str().into());
assert_eq!(fi.default_text.as_str(), s, "set_default_text({s:?}) altered the text");
assert_eq!(
fi.default_text.as_str().len(),
s.len(),
"set_default_text({s:?}) changed the byte length (NUL truncation?)",
);
}
}
#[test]
fn set_default_text_is_last_write_wins() {
let mut fi = FileInput::default();
for s in adversarial_strings() {
fi.set_default_text(s.as_str().into());
assert_eq!(fi.default_text.as_str(), s);
}
fi.set_default_text("final".into());
assert_eq!(fi.default_text.as_str(), "final");
}
#[test]
fn set_default_text_touches_nothing_else() {
let mut fi = populated_with_image();
let before = fi.clone();
fi.set_default_text("something else entirely".into());
assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
assert_eq!(
fi.file_input_state.file_dialog_title,
before.file_input_state.file_dialog_title,
);
assert_eq!(
fi.file_input_state.default_dir,
before.file_input_state.default_dir,
);
assert_eq!(fi.container_style, before.container_style);
assert_eq!(fi.label_style, before.label_style);
assert_eq!(fi.image_style, before.image_style);
assert_eq!(fi.image, before.image);
}
#[test]
fn with_default_text_matches_the_setter() {
for s in adversarial_strings() {
let mut by_setter = populated();
by_setter.set_default_text(s.as_str().into());
let by_builder = populated().with_default_text(s.as_str().into());
assert_eq!(
by_builder, by_setter,
"with_default_text({s:?}) and set_default_text disagree",
);
}
}
#[test]
fn with_default_text_chains_last_wins() {
let fi = FileInput::default()
.with_default_text("first".into())
.with_default_text("second".into())
.with_default_text("".into());
assert_eq!(fi.default_text.as_str(), "");
}
#[test]
fn set_on_path_change_stores_the_function_pointer_and_the_data() {
let probe = log_refany();
let mut fi = FileInput::default();
fi.set_on_path_change(probe.clone(), record_path as FileInputOnPathChangeCallbackType);
let stored = fi
.file_input_state
.on_path_change
.as_ref()
.expect("the callback was not stored");
assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
assert_eq!(
stored.refany, probe,
"the widget stored a different RefAny than the one it was handed",
);
assert_eq!(read_log(&stored.refany).payload, 0xDEAD_BEEF);
}
#[test]
fn set_on_path_change_overwrites_a_previous_callback() {
let mut fi = FileInput::default();
fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
fi.set_on_path_change(
RefAny::new(7_u32),
path_refresh_all as FileInputOnPathChangeCallbackType,
);
let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
assert_eq!(
cb_addr(&stored.callback),
path_refresh_all as *const () as usize,
"the first callback survived the overwrite",
);
let mut data = stored.refany.clone();
assert!(
data.downcast_ref::<PathLog>().is_none(),
"the first callback's data survived the overwrite",
);
}
#[test]
fn set_on_path_change_touches_nothing_else() {
let mut fi = populated_with_image();
let before = fi.clone();
fi.set_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
assert_eq!(fi.default_text, before.default_text);
assert_eq!(fi.file_input_state.inner, before.file_input_state.inner);
assert_eq!(
fi.file_input_state.file_dialog_title,
before.file_input_state.file_dialog_title,
);
assert_eq!(
fi.file_input_state.default_dir,
before.file_input_state.default_dir,
);
assert_eq!(fi.image, before.image);
assert_eq!(fi.container_style, before.container_style);
}
#[test]
fn with_on_path_change_matches_the_setter() {
let probe = log_refany();
let mut by_setter = populated();
by_setter.set_on_path_change(
probe.clone(),
record_path as FileInputOnPathChangeCallbackType,
);
let by_builder = populated()
.with_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
assert_eq!(by_builder, by_setter);
}
#[test]
fn a_generic_callback_keeps_its_address_and_context_through_the_transmute() {
let ctx = RefAny::new(0xABCD_u32);
let generic = Callback {
cb: generic_shaped,
ctx: OptionRefAny::Some(ctx.clone()),
};
let fi = FileInput::default().with_on_path_change(log_refany(), generic);
let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
assert_eq!(
cb_addr(&stored.callback),
generic_shaped as *const () as usize,
"the transmute moved the function pointer",
);
assert_eq!(
stored.callback.ctx,
OptionRefAny::Some(ctx),
"the FFI context was dropped by the transmute",
);
}
#[test]
fn a_raw_function_pointer_gets_no_ffi_context() {
let fi = FileInput::default()
.with_on_path_change(log_refany(), record_path as FileInputOnPathChangeCallbackType);
let stored = fi.file_input_state.on_path_change.as_ref().expect("no callback");
assert_eq!(
stored.callback.ctx,
OptionRefAny::None,
"a native Rust callback must not carry an FFI context",
);
}
#[test]
fn dom_labels_the_button_with_the_file_name() {
for (path, expected) in file_name_cases() {
let dom = FileInput::create(opt(&path)).dom();
assert_eq!(
rendered_label(&dom),
expected,
"dom() mislabelled the button for path {path:?}",
);
}
}
#[test]
fn dom_falls_back_to_the_default_text_when_the_path_has_no_file_name() {
for path in no_file_name_cases() {
let dom = FileInput::create(opt(&path)).dom();
assert_eq!(
rendered_label(&dom),
DEFAULT_TEXT,
"dom() did not fall back to the default text for path {path:?}",
);
}
}
#[test]
fn dom_falls_back_to_the_default_text_when_no_path_is_set() {
let dom = FileInput::create(None.into()).dom();
assert_eq!(rendered_label(&dom), DEFAULT_TEXT);
}
#[test]
fn dom_renders_a_custom_default_text_verbatim_when_there_is_no_file_name() {
for s in adversarial_strings() {
let fi = FileInput::create(None.into()).with_default_text(s.as_str().into());
assert_eq!(rendered_label(&fi.dom()), s, "custom default text {s:?} was altered");
let fi = FileInput::create(opt("/")).with_default_text(s.as_str().into());
assert_eq!(rendered_label(&fi.dom()), s);
}
}
#[test]
fn dom_survives_a_100k_byte_file_name() {
let huge = "x".repeat(100_000);
let dom = FileInput::create(opt(&format!("/tmp/{huge}"))).dom();
let label = rendered_label(&dom);
assert_eq!(label.len(), huge.len(), "the 100k file name was truncated");
assert_eq!(label, huge);
}
#[test]
fn dom_prefers_the_file_name_over_the_default_text() {
let fi = FileInput::create(opt("/tmp/chosen.txt")).with_default_text("NOT THIS".into());
assert_eq!(rendered_label(&fi.dom()), "chosen.txt");
}
#[test]
fn dom_renders_an_empty_label_when_both_the_path_and_the_default_text_are_empty() {
let fi = FileInput::create(opt("")).with_default_text("".into());
assert_eq!(rendered_label(&fi.dom()), "");
}
#[test]
fn dom_ignores_the_dialog_title_and_default_dir_when_labelling() {
let mut fi = FileInput::create(None.into());
fi.file_input_state.file_dialog_title = "TITLE-LEAK".into();
fi.file_input_state.default_dir = opt("/DIR-LEAK");
let label = rendered_label(&fi.dom());
assert_eq!(label, DEFAULT_TEXT);
assert!(!label.contains("LEAK"));
}
#[test]
fn dom_renders_a_native_button_node() {
let dom = FileInput::create(opt("/tmp/x.txt")).dom();
assert!(
matches!(dom.root.get_node_type(), NodeType::Button),
"the file input no longer renders a <button>",
);
assert_eq!(
classes(&dom),
vec![
"__azul-native-button".to_string(),
ButtonType::Default.class_name().to_string(),
],
"the file input must be styleable as a default-type native button",
);
assert!(
matches!(dom.root.get_tab_index(), Some(TabIndex::Auto)),
"the file input dropped the button's keyboard focusability",
);
}
#[test]
fn dom_registers_exactly_one_mouseup_callback_into_fileinput_on_click() {
let dom = FileInput::create(opt("/tmp/x.txt")).dom();
let callbacks = dom.root.callbacks.as_ref();
assert_eq!(callbacks.len(), 1, "a click must fire exactly one handler");
assert_eq!(callbacks[0].event, EventFilter::Hover(HoverEventFilter::MouseUp));
assert_eq!(
callbacks[0].callback.cb,
fileinput_on_click as *const () as usize,
"the DOM is wired to a different handler than fileinput_on_click",
);
assert_eq!(
callbacks[0].callback.ctx,
OptionRefAny::None,
"the internal handler must not carry an FFI context",
);
}
#[test]
fn dom_hands_the_whole_state_wrapper_to_the_handler() {
let probe = log_refany();
let mut fi = FileInput::create(opt("/tmp/original.txt"));
fi.file_input_state.file_dialog_title = "custom title".into();
fi.file_input_state.default_dir = opt("/tmp/custom-dir");
fi.set_on_path_change(probe, record_path as FileInputOnPathChangeCallbackType);
let expected = fi.file_input_state.clone();
let state = state_of(®istered_state(&fi.dom()));
assert_eq!(state.inner, expected.inner);
assert_eq!(state.file_dialog_title, expected.file_dialog_title);
assert_eq!(state.default_dir, expected.default_dir);
let stored = state.on_path_change.as_ref().expect("the user callback was dropped");
assert_eq!(cb_addr(&stored.callback), record_path as *const () as usize);
}
#[test]
fn dom_child_count_matches_the_cached_descendant_count() {
for fi in [
FileInput::create(None.into()),
FileInput::create(opt("/tmp/x.txt")),
populated_with_image(),
] {
let has_image = fi.image.as_ref().is_some();
let dom = fi.dom();
assert_eq!(
dom.estimated_total_children,
count_descendants(&dom),
"estimated_total_children is out of sync with the real subtree",
);
assert_eq!(
dom.children.as_ref().len(),
usize::from(has_image) + 1,
"unexpected child count",
);
}
}
#[test]
fn dom_renders_the_image_before_the_label() {
let fi = populated_with_image();
let dom = fi.dom();
let children = dom.children.as_ref();
assert_eq!(children.len(), 2);
assert!(
matches!(children[0].root.get_node_type(), NodeType::Image(_)),
"the image is not the first child",
);
assert_eq!(rendered_label(&dom), "original.txt");
}
#[test]
fn dom_is_deterministic_for_identical_widgets() {
for path in ["/tmp/a.txt", "/", ""] {
let a = FileInput::create(opt(path)).dom();
let b = FileInput::create(opt(path)).dom();
assert_eq!(a.root.get_node_type(), b.root.get_node_type());
assert_eq!(classes(&a), classes(&b));
assert_eq!(rendered_label(&a), rendered_label(&b));
assert_eq!(a.children.as_ref().len(), b.children.as_ref().len());
}
}
#[cfg(unix)]
#[test]
fn dom_does_not_treat_a_backslash_as_a_separator_on_unix() {
let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
assert_eq!(rendered_label(&dom), "C:\\dir\\file.txt");
let dom = FileInput::create(opt("/tmp/a\\b.txt")).dom();
assert_eq!(rendered_label(&dom), "a\\b.txt");
}
#[cfg(unix)]
#[test]
fn dom_handles_multiple_leading_slashes_on_unix() {
assert_eq!(rendered_label(&FileInput::create(opt("//")).dom()), DEFAULT_TEXT);
assert_eq!(rendered_label(&FileInput::create(opt("///")).dom()), DEFAULT_TEXT);
assert_eq!(rendered_label(&FileInput::create(opt("//a.txt")).dom()), "a.txt");
}
#[cfg(windows)]
#[test]
fn dom_treats_a_backslash_as_a_separator_on_windows() {
let dom = FileInput::create(opt("C:\\dir\\file.txt")).dom();
assert_eq!(rendered_label(&dom), "file.txt");
}
#[test]
fn click_with_a_foreign_refany_does_nothing() {
let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
let foreign = RefAny::new(0xDEAD_BEEF_u32);
let (update, changes) = click(styled, &foreign, node(0));
assert_eq!(
update,
Update::DoNothing,
"a foreign payload must not trigger a relayout",
);
assert!(changes.is_empty(), "a foreign payload pushed changes: {changes:?}");
let mut foreign = foreign;
assert_eq!(
*foreign.downcast_ref::<u32>().expect("the payload was overwritten"),
0xDEAD_BEEF,
);
}
#[test]
fn click_rejects_the_inner_state_mistaken_for_the_wrapper() {
let styled = StyledDom::create_from_dom(FileInput::create(opt("/tmp/x.txt")).dom());
let inner = RefAny::new(FileInputState { path: opt("/tmp/x.txt") });
let (update, changes) = click(styled, &inner, node(0));
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty());
}
#[test]
fn click_with_a_foreign_refany_ignores_the_hit_node() {
let styled = StyledDom::create_from_dom(FileInput::create(None.into()).dom());
let foreign = RefAny::new(0_u8);
let (update, changes) = click(styled, &foreign, node_none());
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty());
}
#[cfg(any(
not(feature = "extra"),
target_os = "android",
target_os = "ios"
))]
mod without_the_native_dialog {
use super::*;
#[test]
fn click_without_a_user_callback_refreshes_the_dom() {
let (styled, state) = laid_out(FileInput::create(opt("/tmp/x.txt")));
let (update, changes) = click(styled, &state, node(0));
assert_eq!(
update,
Update::RefreshDom,
"a click must relayout so the new label is drawn",
);
assert!(changes.is_empty(), "the handler pushed unexpected changes: {changes:?}");
assert_eq!(
state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
Some("/tmp/x.txt".to_string()),
"the handler mutated the path without a dialog",
);
}
#[test]
fn click_upgrades_a_do_nothing_user_callback_to_a_refresh() {
let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
RefAny::new(0_u8),
path_do_nothing as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
let (update, _) = click(styled, &state, node(0));
assert_eq!(update, Update::RefreshDom);
}
#[test]
fn click_preserves_a_stronger_user_update() {
let fi = FileInput::create(opt("/tmp/x.txt")).with_on_path_change(
RefAny::new(0_u8),
path_refresh_all as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
let (update, _) = click(styled, &state, node(0));
assert_eq!(update, Update::RefreshDomAllWindows);
}
#[test]
fn click_hands_the_current_path_to_the_user_callback() {
for path in ["/tmp/a.txt", "", "a\0b", "/tmp/日本語.txt"] {
let probe = log_refany();
let fi = FileInput::create(opt(path)).with_on_path_change(
probe.clone(),
record_path as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
let (_, _) = click(styled, &state, node(0));
assert_eq!(
read_log(&probe).seen,
vec![Some(path.to_string())],
"the callback saw the wrong path for {path:?}",
);
}
}
#[test]
fn click_hands_a_missing_path_through_as_none() {
let probe = log_refany();
let fi = FileInput::create(None.into()).with_on_path_change(
probe.clone(),
record_path as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
let (_, _) = click(styled, &state, node(0));
assert_eq!(read_log(&probe).seen, vec![None]);
}
#[test]
fn repeated_clicks_fire_once_each_and_leave_the_state_alone() {
let probe = log_refany();
let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
probe.clone(),
record_path as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
for _ in 0..3 {
let (update, _) = click(styled.clone(), &state, node(0));
assert_eq!(update, Update::RefreshDom);
}
assert_eq!(read_log(&probe).seen.len(), 3, "clicks were dropped or doubled");
assert_eq!(
state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
Some("/tmp/a.txt".to_string()),
);
}
struct ReentryProbe {
state: RefAny,
saw_mut: Option<bool>,
saw_ref: Option<bool>,
}
extern "C" fn probe_reentry(
mut refany: RefAny,
_info: CallbackInfo,
_state: FileInputState,
) -> Update {
let Some(mut probe) = refany.downcast_mut::<ReentryProbe>() else {
return Update::DoNothing;
};
let probe = &mut *probe;
let saw_mut = probe.state.downcast_mut::<FileInputStateWrapper>().is_some();
let saw_ref = probe.state.downcast_ref::<FileInputStateWrapper>().is_some();
probe.saw_mut = Some(saw_mut);
probe.saw_ref = Some(saw_ref);
Update::DoNothing
}
#[test]
fn a_reentrant_borrow_of_the_state_is_refused_rather_than_aliased() {
let probe = RefAny::new(ReentryProbe {
state: RefAny::new(0_u8),
saw_mut: None,
saw_ref: None,
});
let fi = FileInput::create(opt("/tmp/a.txt")).with_on_path_change(
probe.clone(),
probe_reentry as FileInputOnPathChangeCallbackType,
);
let (styled, state) = laid_out(fi);
{
let mut handle = probe.clone();
let mut guard = handle
.downcast_mut::<ReentryProbe>()
.expect("the probe changed type");
guard.state = state.clone();
}
let (update, _) = click(styled, &state, node(0));
let mut handle = probe.clone();
let guard = handle
.downcast_mut::<ReentryProbe>()
.expect("the probe changed type");
assert_eq!(guard.saw_mut, Some(false), "a second &mut to the state was handed out");
assert_eq!(guard.saw_ref, Some(false), "a & alongside the live &mut was handed out");
drop(guard);
assert_eq!(update, Update::RefreshDom);
assert_eq!(
state_of(&state).inner.path.as_ref().map(|p| p.as_str().to_string()),
Some("/tmp/a.txt".to_string()),
"the state was corrupted by the re-entrant attempt",
);
}
}
}