use std::string::String;
use azul_core::{
callbacks::{CoreCallbackData, Update},
dom::Dom,
refany::RefAny,
};
#[allow(clippy::wildcard_imports)] use azul_css::{
dynamic_selector::CssPropertyWithConditionsVec,
props::{
basic::*,
layout::*,
property::{CssProperty, *},
style::*,
},
*,
};
use crate::{
callbacks::{Callback, CallbackInfo},
widgets::text_input::{
OnTextInputReturn, TextInput, TextInputOnFocusLostCallback,
TextInputOnFocusLostCallbackType, TextInputOnTextInputCallback,
TextInputOnTextInputCallbackType, TextInputOnVirtualKeyDownCallback,
TextInputOnVirtualKeyDownCallbackType, TextInputState, TextInputValid,
},
};
pub type NumberInputOnValueChangeCallbackType =
extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
impl_widget_callback!(
NumberInputOnValueChange,
OptionNumberInputOnValueChange,
NumberInputOnValueChangeCallback,
NumberInputOnValueChangeCallbackType
);
azul_core::impl_managed_callback! {
wrapper: NumberInputOnValueChangeCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: NUMBER_INPUT_ON_VALUE_CHANGE_INVOKER,
invoker_ty: AzNumberInputOnValueChangeCallbackInvoker,
thunk_fn: az_number_input_on_value_change_callback_thunk,
setter_fn: AzApp_setNumberInputOnValueChangeCallbackInvoker,
from_handle_fn: AzNumberInputOnValueChangeCallback_createFromHostHandle,
extra_args: [ state: NumberInputState ],
}
pub type NumberInputOnFocusLostCallbackType =
extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
impl_widget_callback!(
NumberInputOnFocusLost,
OptionNumberInputOnFocusLost,
NumberInputOnFocusLostCallback,
NumberInputOnFocusLostCallbackType
);
azul_core::impl_managed_callback! {
wrapper: NumberInputOnFocusLostCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: NUMBER_INPUT_ON_FOCUS_LOST_INVOKER,
invoker_ty: AzNumberInputOnFocusLostCallbackInvoker,
thunk_fn: az_number_input_on_focus_lost_callback_thunk,
setter_fn: AzApp_setNumberInputOnFocusLostCallbackInvoker,
from_handle_fn: AzNumberInputOnFocusLostCallback_createFromHostHandle,
extra_args: [ state: NumberInputState ],
}
#[derive(Debug, Default, Clone, PartialEq)]
#[repr(C)]
pub struct NumberInput {
pub number_input_state: NumberInputStateWrapper,
pub text_input: TextInput,
pub style: CssPropertyWithConditionsVec,
}
#[derive(Debug, Default, Clone, PartialEq)]
#[repr(C)]
pub struct NumberInputStateWrapper {
pub inner: NumberInputState,
pub on_value_change: OptionNumberInputOnValueChange,
pub on_focus_lost: OptionNumberInputOnFocusLost,
}
#[derive(Copy, Debug, Clone, PartialEq)]
#[repr(C)]
pub struct NumberInputState {
pub previous: f32,
pub number: f32,
pub min: f32,
pub max: f32,
}
impl Default for NumberInputState {
fn default() -> Self {
Self {
previous: 0.0,
number: 0.0,
min: core::f32::MIN,
max: core::f32::MAX,
}
}
}
impl NumberInput {
#[must_use] pub fn create(input: f32) -> Self {
Self {
number_input_state: NumberInputStateWrapper {
inner: NumberInputState {
number: input,
..Default::default()
},
..Default::default()
},
..Default::default()
}
}
pub fn set_on_text_input<C: Into<TextInputOnTextInputCallback>>(
&mut self,
refany: RefAny,
callback: C,
) {
self.text_input.set_on_text_input(refany, callback);
}
#[must_use]
pub fn with_on_text_input<C: Into<TextInputOnTextInputCallback>>(
mut self,
refany: RefAny,
callback: C,
) -> Self {
self.set_on_text_input(refany, callback);
self
}
pub fn set_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
&mut self,
refany: RefAny,
callback: C,
) {
self.text_input.set_on_virtual_key_down(refany, callback);
}
#[must_use]
pub fn with_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
mut self,
refany: RefAny,
callback: C,
) -> Self {
self.set_on_virtual_key_down(refany, callback);
self
}
pub fn set_placeholder_style(&mut self, style: CssPropertyWithConditionsVec) {
self.text_input.placeholder_style = style;
}
#[must_use] pub fn with_placeholder_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
self.set_placeholder_style(style);
self
}
pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
self.text_input.container_style = style;
}
#[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
self.set_container_style(style);
self
}
pub fn set_label_style(&mut self, style: CssPropertyWithConditionsVec) {
self.text_input.label_style = style;
}
#[must_use] pub fn with_label_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
self.set_label_style(style);
self
}
pub fn set_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
&mut self,
refany: RefAny,
callback: C,
) {
self.number_input_state.on_value_change = Some(NumberInputOnValueChange {
callback: callback.into(),
refany,
})
.into();
}
#[must_use]
pub fn with_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
mut self,
refany: RefAny,
callback: C,
) -> Self {
self.set_on_value_change(refany, callback);
self
}
pub fn set_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
&mut self,
refany: RefAny,
callback: C,
) {
self.number_input_state.on_focus_lost = Some(NumberInputOnFocusLost {
callback: callback.into(),
refany,
})
.into();
}
#[must_use]
pub fn with_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
mut self,
refany: RefAny,
callback: C,
) -> Self {
self.set_on_focus_lost(refany, callback);
self
}
#[must_use]
pub fn swap_with_default(&mut self) -> Self {
let mut s = Self::create(0.0);
core::mem::swap(&mut s, self);
s
}
#[must_use] pub fn dom(mut self) -> Dom {
let number_string = format!("{}", self.number_input_state.inner.number);
self.text_input.text_input_state.inner.text = number_string
.chars()
.map(|s| s as u32)
.collect::<Vec<_>>()
.into();
let state = RefAny::new(self.number_input_state);
let validate: TextInputOnTextInputCallbackType = validate_text_input;
self.text_input.set_on_text_input(state.clone(), validate);
let focus_lost: TextInputOnFocusLostCallbackType = on_focus_lost;
self.text_input.set_on_focus_lost(state, focus_lost);
self.text_input.dom()
}
}
extern "C" fn on_focus_lost(
mut refany: RefAny,
info: CallbackInfo,
_state: TextInputState,
) -> Update {
let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
return Update::DoNothing;
};
let number_input = &mut *refany;
let onfocuslost = &mut number_input.on_focus_lost;
let inner = number_input.inner;
match onfocuslost.as_mut() {
Some(NumberInputOnFocusLost { callback, refany }) => {
(callback.cb)(refany.clone(), info, inner)
}
None => Update::DoNothing,
}
}
fn clamp_to_range(value: f32, min: f32, max: f32) -> f32 {
let (lo, hi) = match (min.is_nan(), max.is_nan()) {
(true, true) => return value,
(true, false) => (max, max),
(false, true) => (min, min),
(false, false) if min <= max => (min, max),
(false, false) => (max, min),
};
value.clamp(lo, hi)
}
extern "C" fn validate_text_input(
mut refany: RefAny,
info: CallbackInfo,
state: TextInputState,
) -> OnTextInputReturn {
let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
return OnTextInputReturn {
update: Update::DoNothing,
valid: TextInputValid::Yes,
};
};
let validated_input: String = state
.text
.iter()
.filter_map(|c| core::char::from_u32(*c))
.map(|c| if c == ',' { '.' } else { c })
.collect();
let Ok(validated_f32) = validated_input.parse::<f32>() else {
return OnTextInputReturn {
update: Update::DoNothing,
valid: TextInputValid::No,
};
};
let number_input = &mut *refany;
let onvaluechange = &mut number_input.on_value_change;
let inner = &mut number_input.inner;
inner.previous = inner.number;
let clamped = clamp_to_range(validated_f32, inner.min, inner.max);
inner.number = clamped;
let inner_clone = *inner;
let update = match onvaluechange.as_mut() {
Some(NumberInputOnValueChange { callback, refany }) => {
(callback.cb)(refany.clone(), info, inner_clone)
}
None => Update::DoNothing,
};
OnTextInputReturn {
update,
valid: TextInputValid::Yes,
}
}
#[cfg(all(test, feature = "std"))]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
use std::{
collections::BTreeMap,
panic::{catch_unwind, AssertUnwindSafe},
sync::{Arc, Mutex},
};
use azul_core::{
dom::{DomId, DomNodeId},
geom::OptionLogicalPosition,
gl::OptionGlContextPtr,
hit_test::ScrollPosition,
refany::OptionRefAny,
resources::RendererResources,
styled_dom::NodeHierarchyItemId,
window::{MonitorVec, RawWindowHandle},
};
use azul_css::dynamic_selector::CssPropertyWithConditions;
use rust_fontconfig::FcFontCache;
use super::*;
#[cfg(feature = "icu")]
use crate::icu::IcuLocalizerHandle;
use crate::{
callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
widgets::text_input::TextInputStateWrapper,
window::LayoutWindow,
window_state::FullWindowState,
};
fn finite_samples() -> [f32; 16] {
[
0.0,
-0.0,
1.0,
-1.0,
0.5,
-0.5,
42.25,
-2.5,
0.1,
16_777_216.0,
f32::MIN,
f32::MAX,
f32::MIN_POSITIVE,
-f32::MIN_POSITIVE,
f32::EPSILON,
f32::from_bits(1),
]
}
const MALFORMED: [&str; 26] = [
"", " ", " 1",
"1 ",
"\t1",
"\n",
"abc",
"e",
"e5",
"E",
"+",
"-",
".",
"..",
"--1",
"1.2.3",
"1e",
"1e+",
"0x10", "0b1",
"1_000", "1/2",
"1%",
"½", "∞", "1\u{200b}0", ];
const NON_ASCII_DIGITS: [&str; 6] = [
"١٢٣", "١٫٥", "123", "𝟏", "٣.5", "Ⅻ", ];
const ACCEPTED: [(&str, f32); 17] = [
("0", 0.0),
("-0", -0.0),
("+1", 1.0),
("1.", 1.0),
(".5", 0.5),
("-.5", -0.5),
("1e3", 1000.0),
("1E3", 1000.0),
("1e+3", 1000.0),
("1e-3", 0.001),
("00042.2500", 42.25),
("inf", f32::MAX),
("infinity", f32::MAX),
("-inf", f32::MIN),
("nan", f32::NAN),
("NaN", f32::NAN),
("NAN", f32::NAN),
];
fn same(a: f32, b: f32) -> bool {
if a.is_nan() || b.is_nan() {
a.is_nan() && b.is_nan()
} else {
a.to_bits() == b.to_bits()
}
}
fn wrapper(number: f32, min: f32, max: f32) -> NumberInputStateWrapper {
NumberInputStateWrapper {
inner: NumberInputState {
previous: 0.0,
number,
min,
max,
},
on_value_change: OptionNumberInputOnValueChange::None,
on_focus_lost: OptionNumberInputOnFocusLost::None,
}
}
fn text_state(text: &str) -> TextInputState {
TextInputState {
text: text.chars().map(|c| c as u32).collect::<Vec<_>>().into(),
..TextInputState::default()
}
}
fn raw_text_state(units: &[u32]) -> TextInputState {
TextInputState {
text: units.to_vec().into(),
..TextInputState::default()
}
}
fn read(state: &RefAny) -> NumberInputState {
let mut state = state.clone();
let wrapper = state
.downcast_ref::<NumberInputStateWrapper>()
.expect("the payload must still be a NumberInputStateWrapper");
wrapper.inner
}
fn reset(state: &RefAny, number: f32) {
let mut state = state.clone();
let mut wrapper = state
.downcast_mut::<NumberInputStateWrapper>()
.expect("the payload must still be a NumberInputStateWrapper");
wrapper.inner.number = number;
wrapper.inner.previous = 0.0;
}
fn style(n: usize) -> CssPropertyWithConditionsVec {
let all: Vec<CssPropertyWithConditions> =
TextInput::default().container_style.as_ref().to_vec();
assert!(n <= all.len(), "not enough default properties to slice");
CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
}
struct Recorder {
seen: Vec<NumberInputState>,
ret: Update,
}
impl Recorder {
fn new(ret: Update) -> Self {
Self {
seen: Vec::new(),
ret,
}
}
}
extern "C" fn record_value_change(
mut data: RefAny,
_: CallbackInfo,
state: NumberInputState,
) -> Update {
let Some(mut log) = data.downcast_mut::<Recorder>() else {
return Update::DoNothing;
};
log.seen.push(state);
log.ret
}
extern "C" fn record_focus_lost(
mut data: RefAny,
_: CallbackInfo,
state: NumberInputState,
) -> Update {
match data.downcast_mut::<Recorder>() {
Some(mut log) => {
log.seen.push(state);
log.ret
}
None => Update::DoNothing,
}
}
extern "C" fn accept_everything(
_: RefAny,
_: CallbackInfo,
_: TextInputState,
) -> OnTextInputReturn {
OnTextInputReturn {
update: Update::RefreshDomAllWindows,
valid: TextInputValid::Yes,
}
}
extern "C" fn reject_everything(
_: RefAny,
_: CallbackInfo,
_: TextInputState,
) -> OnTextInputReturn {
OnTextInputReturn {
update: Update::RefreshDomAllWindows,
valid: TextInputValid::No,
}
}
fn recorded(recorder: &RefAny) -> Vec<NumberInputState> {
let mut recorder = recorder.clone();
let log = recorder
.downcast_ref::<Recorder>()
.expect("the payload must still be a Recorder");
log.seen.clone()
}
fn wrapper_with_value_hook(
number: f32,
min: f32,
max: f32,
recorder: &RefAny,
) -> NumberInputStateWrapper {
NumberInputStateWrapper {
on_value_change: Some(NumberInputOnValueChange {
refany: recorder.clone(),
callback: (record_value_change as NumberInputOnValueChangeCallbackType).into(),
})
.into(),
..wrapper(number, min, max)
}
}
fn wrapper_with_focus_hook(
number: f32,
min: f32,
max: f32,
recorder: &RefAny,
) -> NumberInputStateWrapper {
NumberInputStateWrapper {
on_focus_lost: Some(NumberInputOnFocusLost {
refany: recorder.clone(),
callback: (record_focus_lost as NumberInputOnFocusLostCallbackType).into(),
})
.into(),
..wrapper(number, min, max)
}
}
fn with_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> R {
let layout_window =
LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
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 info = CallbackInfo::new(
&ref_data,
&changes,
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::NONE,
},
OptionLogicalPosition::None,
OptionLogicalPosition::None,
);
f(info)
}
fn validate_one(state: &RefAny, text: &str) -> (OnTextInputReturn, NumberInputState) {
with_info(|info| {
let r = validate_text_input(state.clone(), info, text_state(text));
(r, read(state))
})
}
fn validate_raw(state: &RefAny, units: &[u32]) -> (OnTextInputReturn, NumberInputState) {
with_info(|info| {
let r = validate_text_input(state.clone(), info, raw_text_state(units));
(r, read(state))
})
}
fn focus_lost(state: &RefAny, text: &str) -> Update {
with_info(|info| on_focus_lost(state.clone(), info, text_state(text)))
}
const PLACEHOLDER: usize = 0;
const LABEL: usize = 1;
fn dataset_of(dom: &Dom) -> RefAny {
dom.root
.get_dataset()
.cloned()
.expect("TextInput::dom must attach its state as the node's dataset")
}
fn buffer_text(dom: &Dom) -> String {
let mut dataset = dataset_of(dom);
let wrapper = dataset
.downcast_ref::<TextInputStateWrapper>()
.expect("the dataset must be a TextInputStateWrapper");
wrapper.inner.get_text()
}
fn displayed_text(dom: &Dom) -> String {
dom.children.as_ref()[LABEL]
.root
.get_node_type()
.format()
.expect("the label child must be a text node")
}
fn cursor_pos(dom: &Dom) -> usize {
let mut dataset = dataset_of(dom);
let wrapper = dataset
.downcast_ref::<TextInputStateWrapper>()
.expect("the dataset must be a TextInputStateWrapper");
wrapper.inner.cursor_pos
}
fn number_state_of(dom: &Dom) -> RefAny {
let mut dataset = dataset_of(dom);
let wrapper = dataset
.downcast_ref::<TextInputStateWrapper>()
.expect("the dataset must be a TextInputStateWrapper");
wrapper
.on_text_input
.as_ref()
.expect("NumberInput::dom must install a text-input hook")
.refany
.clone()
}
fn drive_text_input(dom: &Dom, text: &str) -> OnTextInputReturn {
let mut dataset = dataset_of(dom);
let hook = dataset
.downcast_ref::<TextInputStateWrapper>()
.expect("the dataset must be a TextInputStateWrapper")
.on_text_input
.as_ref()
.expect("NumberInput::dom must install a text-input hook")
.clone();
with_info(|info| (hook.callback.cb)(hook.refany.clone(), info, text_state(text)))
}
fn drive_virtual_key_down(dom: &Dom) -> Option<OnTextInputReturn> {
let mut dataset = dataset_of(dom);
let hook = dataset
.downcast_ref::<TextInputStateWrapper>()
.expect("the dataset must be a TextInputStateWrapper")
.on_virtual_key_down
.as_ref()
.cloned()?;
Some(with_info(|info| {
(hook.callback.cb)(hook.refany.clone(), info, text_state(""))
}))
}
#[test]
fn create_zero_is_exactly_the_default_widget() {
assert_eq!(
NumberInput::create(0.0),
NumberInput::default(),
"create(0.0) must not perturb anything Default already set",
);
}
#[test]
fn create_preserves_every_sample_value_bit_exactly() {
for v in finite_samples() {
let state = NumberInput::create(v).number_input_state.inner;
assert!(
same(state.number, v),
"create({v:?}) stored {:?}",
state.number,
);
assert!(
same(state.previous, 0.0),
"create({v:?}) must start with no history, got previous = {:?}",
state.previous,
);
assert!(
same(state.min, f32::MIN) && same(state.max, f32::MAX),
"create({v:?}) must leave the range wide open, got [{}, {}]",
state.min,
state.max,
);
}
}
#[test]
fn create_accepts_nan_and_infinities_without_panicking() {
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let state = NumberInput::create(v).number_input_state.inner;
assert!(
same(state.number, v),
"create({v:?}) stored {:?}",
state.number,
);
}
assert!(NumberInput::create(f32::NAN)
.number_input_state
.inner
.number
.is_nan());
}
#[test]
fn create_never_clamps_its_argument() {
let state = NumberInput::create(f32::INFINITY).number_input_state.inner;
assert!(
state.number.is_infinite(),
"create must store the value verbatim, got {}",
state.number,
);
assert!(state.number > state.max, "…even outside its own range");
}
#[test]
fn the_default_range_leaves_every_finite_value_untouched() {
let d = NumberInputState::default();
assert!(
d.min <= d.max,
"the default range must be non-empty — f32::clamp panics otherwise",
);
assert!(same(d.min, f32::MIN) && same(d.max, f32::MAX));
assert!(same(d.number, 0.0) && same(d.previous, 0.0));
for v in finite_samples() {
assert!(
same(v.clamp(d.min, d.max), v),
"{v:?} must pass through the default range untouched",
);
}
}
#[test]
fn number_input_state_is_a_value_type() {
let a = NumberInputState::default();
let mut b = a;
b.number = 5.0;
assert!(
same(a.number, 0.0),
"NumberInputState is Copy — mutating a copy must not alias the original",
);
assert_ne!(a, b);
}
#[test]
fn a_fresh_wrapper_has_no_hooks() {
let w = NumberInputStateWrapper::default();
assert!(w.on_value_change.as_ref().is_none());
assert!(w.on_focus_lost.as_ref().is_none());
assert_eq!(w.inner, NumberInputState::default());
}
#[test]
fn with_and_set_style_pairs_are_equivalent() {
for n in 0..4 {
let s = style(n);
let a = NumberInput::create(1.0).with_placeholder_style(s.clone());
let mut b = NumberInput::create(1.0);
b.set_placeholder_style(s.clone());
assert_eq!(a, b, "with_placeholder_style != set_placeholder_style ({n})");
let a = NumberInput::create(1.0).with_container_style(s.clone());
let mut b = NumberInput::create(1.0);
b.set_container_style(s.clone());
assert_eq!(a, b, "with_container_style != set_container_style ({n})");
let a = NumberInput::create(1.0).with_label_style(s.clone());
let mut b = NumberInput::create(1.0);
b.set_label_style(s);
assert_eq!(a, b, "with_label_style != set_label_style ({n})");
}
}
#[test]
fn style_setters_write_to_disjoint_fields() {
let placeholder = style(1);
let container = style(2);
let label = style(3);
assert_ne!(placeholder, container, "the fixture must be distinguishable");
assert_ne!(container, label, "the fixture must be distinguishable");
let input = NumberInput::create(0.0)
.with_placeholder_style(placeholder.clone())
.with_container_style(container.clone())
.with_label_style(label.clone());
assert_eq!(input.text_input.placeholder_style, placeholder);
assert_eq!(input.text_input.container_style, container);
assert_eq!(input.text_input.label_style, label);
assert_eq!(
input.style,
NumberInput::default().style,
"NumberInput::style is not a dumping ground for the TextInput styles",
);
}
#[test]
fn with_and_set_callback_pairs_are_equivalent() {
let data = RefAny::new(0u32);
let a = NumberInput::create(1.0).with_on_value_change(
data.clone(),
record_value_change as NumberInputOnValueChangeCallbackType,
);
let mut b = NumberInput::create(1.0);
b.set_on_value_change(
data.clone(),
record_value_change as NumberInputOnValueChangeCallbackType,
);
assert_eq!(a, b, "with_on_value_change != set_on_value_change");
let a = NumberInput::create(1.0).with_on_focus_lost(
data.clone(),
record_focus_lost as NumberInputOnFocusLostCallbackType,
);
let mut b = NumberInput::create(1.0);
b.set_on_focus_lost(
data.clone(),
record_focus_lost as NumberInputOnFocusLostCallbackType,
);
assert_eq!(a, b, "with_on_focus_lost != set_on_focus_lost");
let a = NumberInput::create(1.0).with_on_text_input(
data.clone(),
accept_everything as TextInputOnTextInputCallbackType,
);
let mut b = NumberInput::create(1.0);
b.set_on_text_input(
data.clone(),
accept_everything as TextInputOnTextInputCallbackType,
);
assert_eq!(a, b, "with_on_text_input != set_on_text_input");
let a = NumberInput::create(1.0).with_on_virtual_key_down(
data.clone(),
reject_everything as TextInputOnVirtualKeyDownCallbackType,
);
let mut b = NumberInput::create(1.0);
b.set_on_virtual_key_down(data, reject_everything as TextInputOnVirtualKeyDownCallbackType);
assert_eq!(a, b, "with_on_virtual_key_down != set_on_virtual_key_down");
}
#[test]
fn setting_a_hook_twice_keeps_the_last_one() {
let first = RefAny::new(1u32);
let second = RefAny::new(2u32);
let mut input = NumberInput::create(0.0);
input.set_on_value_change(
first.clone(),
record_value_change as NumberInputOnValueChangeCallbackType,
);
input.set_on_value_change(
second.clone(),
record_value_change as NumberInputOnValueChangeCallbackType,
);
let stored = input
.number_input_state
.on_value_change
.as_ref()
.expect("the hook must be set");
assert_eq!(stored.refany, second, "the last hook must win");
assert_ne!(stored.refany, first, "the first hook must be released");
}
#[test]
fn swap_with_default_hands_back_the_original_and_leaves_a_fresh_widget() {
let data = RefAny::new(0u32);
let mut input = NumberInput::create(7.5)
.with_on_value_change(
data,
record_value_change as NumberInputOnValueChangeCallbackType,
)
.with_label_style(style(2));
let original = input.clone();
let taken = input.swap_with_default();
assert_eq!(taken, original, "swap_with_default must return the original");
assert_eq!(
input,
NumberInput::create(0.0),
"the receiver must be left as a fresh 0.0 widget",
);
assert_eq!(
input,
NumberInput::default(),
"…which is also exactly the Default widget",
);
let second = input.swap_with_default();
assert_eq!(second, NumberInput::default());
assert_eq!(input, NumberInput::default());
}
#[test]
fn dom_wires_the_text_input_and_parks_the_cursor_at_the_end() {
let dom = NumberInput::create(-12.5).dom();
assert_eq!(
dom.children.as_ref().len(),
2,
"TextInput renders a placeholder node and a label node",
);
assert_eq!(
dom.root.callbacks.as_ref().len(),
5,
"focus received/lost, text input, virtual key down, hover",
);
assert!(
dom.children.as_ref()[PLACEHOLDER]
.root
.get_node_type()
.format()
.is_some(),
"the placeholder child must be a text node",
);
assert_eq!(buffer_text(&dom), "-12.5");
assert_eq!(displayed_text(&dom), "-12.5");
assert_eq!(
cursor_pos(&dom),
"-12.5".chars().count(),
"the cursor must sit at the end of the rendered number",
);
}
#[test]
fn dom_text_round_trips_back_to_the_same_f32() {
for v in finite_samples() {
let dom = NumberInput::create(v).dom();
let text = buffer_text(&dom);
let parsed: f32 = text.parse().unwrap_or_else(|e| {
panic!("the widget rendered {v:?} as {text:?}, which is not a float: {e}")
});
assert!(
same(parsed, v),
"{v:?} was rendered as {text:?} and read back as {parsed:?}",
);
assert_eq!(
displayed_text(&dom),
text,
"the visible label and the edit buffer must agree for {v:?}",
);
}
}
#[test]
fn dom_renders_the_shortest_round_trip_form() {
for (value, expected) in [
(0.0f32, "0"),
(1.0, "1"),
(-1.5, "-1.5"),
(42.25, "42.25"),
(0.5, "0.5"),
] {
assert_eq!(buffer_text(&NumberInput::create(value).dom()), expected);
}
}
#[test]
fn dom_renders_non_finite_values_as_inf_and_nan() {
assert_eq!(buffer_text(&NumberInput::create(f32::INFINITY).dom()), "inf");
assert_eq!(
buffer_text(&NumberInput::create(f32::NEG_INFINITY).dom()),
"-inf",
);
assert_eq!(buffer_text(&NumberInput::create(f32::NAN).dom()), "NaN");
assert_eq!(
buffer_text(&NumberInput::create(-f32::NAN).dom()),
"NaN",
"the sign of a NaN is not rendered, so it cannot round-trip",
);
}
#[test]
fn every_string_the_widget_renders_is_accepted_by_its_own_validator() {
let mut values = finite_samples().to_vec();
values.extend_from_slice(&[f32::INFINITY, f32::NEG_INFINITY, f32::NAN]);
for v in values {
let text = buffer_text(&NumberInput::create(v).dom());
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let (r, _) = validate_one(&state, &text);
assert_eq!(
r.valid,
TextInputValid::Yes,
"the widget renders {v:?} as {text:?} but then refuses to parse it back",
);
}
}
#[test]
fn dom_renders_a_value_that_is_outside_its_own_range() {
let mut input = NumberInput::create(1000.0);
input.number_input_state.inner.min = 0.0;
input.number_input_state.inner.max = 10.0;
assert_eq!(buffer_text(&input.dom()), "1000");
}
#[test]
fn dom_replaces_a_user_supplied_text_input_hook_with_the_numeric_validator() {
let dom = NumberInput::create(1.0)
.with_on_text_input(
RefAny::new(0u32),
accept_everything as TextInputOnTextInputCallbackType,
)
.dom();
let r = drive_text_input(&dom, "abc");
assert_eq!(
r.valid,
TextInputValid::No,
"the numeric validator must own the text-input hook after dom()",
);
assert_eq!(r.update, Update::DoNothing);
}
#[test]
fn dom_keeps_a_user_supplied_virtual_key_hook() {
let dom = NumberInput::create(1.0)
.with_on_virtual_key_down(
RefAny::new(0u32),
reject_everything as TextInputOnVirtualKeyDownCallbackType,
)
.dom();
let r = drive_virtual_key_down(&dom)
.expect("with_on_virtual_key_down must survive rendering");
assert_eq!(r.update, Update::RefreshDomAllWindows);
assert_eq!(r.valid, TextInputValid::No);
}
#[test]
fn dom_wires_the_value_change_hook_through_the_rendered_widget() {
let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
let dom = NumberInput::create(0.0)
.with_on_value_change(
recorder.clone(),
record_value_change as NumberInputOnValueChangeCallbackType,
)
.dom();
let r = drive_text_input(&dom, "12,5");
assert_eq!(r.valid, TextInputValid::Yes);
assert_eq!(
r.update,
Update::RefreshDom,
"validate must return whatever the user's hook returned",
);
let seen = recorded(&recorder);
assert_eq!(seen.len(), 1, "the hook must fire exactly once per edit");
assert!(same(seen[0].number, 12.5));
assert!(same(seen[0].previous, 0.0));
assert!(
same(read(&number_state_of(&dom)).number, 12.5),
"the state behind the rendered DOM must have been updated too",
);
}
#[test]
fn validate_rejects_malformed_input_without_touching_the_state() {
let state = RefAny::new(wrapper(7.5, -100.0, 100.0));
with_info(|info| {
for text in MALFORMED {
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
assert_eq!(
r.update,
Update::DoNothing,
"a rejected edit must not trigger a relayout ({text:?})",
);
let after = read(&state);
assert!(
same(after.number, 7.5),
"{text:?} changed the value to {}",
after.number,
);
assert!(
same(after.previous, 0.0),
"{text:?} touched `previous` ({})",
after.previous,
);
}
});
}
#[test]
fn validate_rejects_digits_that_are_not_ascii_digits() {
let state = RefAny::new(wrapper(3.0, f32::MIN, f32::MAX));
with_info(|info| {
for text in NON_ASCII_DIGITS {
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
assert!(
same(read(&state).number, 3.0),
"{text:?} must not change the value",
);
}
});
}
#[test]
fn validate_accepts_every_form_the_rust_float_parser_accepts() {
let state = RefAny::new(wrapper(-1.0, f32::MIN, f32::MAX));
with_info(|info| {
for (text, expected) in ACCEPTED {
reset(&state, -1.0);
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
assert_eq!(
r.update,
Update::DoNothing,
"no hook is installed, so there is nothing to redraw ({text:?})",
);
let after = read(&state);
assert!(
same(after.number, expected),
"{text:?} stored {} (expected {expected})",
after.number,
);
assert!(
same(after.previous, -1.0),
"{text:?} must push the old value into `previous`, got {}",
after.previous,
);
}
});
}
#[test]
fn validate_reads_a_comma_as_a_decimal_point() {
with_info(|info| {
for (text, expected) in [
("1,5", 1.5f32),
("-1,5", -1.5),
(",5", 0.5),
("1,", 1.0),
("1,25e2", 125.0),
] {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
assert!(
same(read(&state).number, expected),
"{text:?} stored {} (expected {expected})",
read(&state).number,
);
}
for text in [",", ",,", "1,,5", "1,5,5"] {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
}
});
}
#[test]
fn validate_reads_a_thousands_separator_as_a_decimal_point() {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let (r, after) = validate_one(&state, "1,000");
assert_eq!(r.valid, TextInputValid::Yes);
assert!(
same(after.number, 1.0),
"\"1,000\" is read as {} (the comma is a decimal point here)",
after.number,
);
let (r, _) = validate_one(&state, "1,000,000");
assert_eq!(
r.valid,
TextInputValid::No,
"a second group makes it un-parseable rather than ambiguous",
);
}
#[test]
fn validate_silently_drops_code_units_that_are_not_unicode_scalars() {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let (r, after) = validate_raw(&state, &[0x31, 0xD800, 0x35]);
assert_eq!(r.valid, TextInputValid::Yes);
assert!(
same(after.number, 15.0),
"a non-scalar code unit between two digits is dropped, got {}",
after.number,
);
let (r, after) = validate_raw(&state, &[0xD800, 0xDFFF, 0x0011_0000]);
assert_eq!(r.valid, TextInputValid::No);
assert!(
same(after.number, 15.0),
"a rejected edit must not change the value",
);
}
#[test]
fn validate_survives_pathologically_long_input() {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
for (text, expected) in [
("9".repeat(10_000), f32::MAX), (format!("-{}", "9".repeat(10_000)), f32::MIN),
(format!("{}1", "0".repeat(10_000)), 1.0), (format!("0.{}", "0".repeat(10_000)), 0.0),
("1e999999999".to_string(), f32::MAX), ("1e-999999999".to_string(), 0.0), ] {
let (r, after) = validate_one(&state, &text);
assert_eq!(
r.valid,
TextInputValid::Yes,
"a {}-char input must parse, not error",
text.len(),
);
assert!(
same(after.number, expected),
"a {}-char input stored {} (expected {expected})",
text.len(),
after.number,
);
}
}
#[test]
fn validate_saturates_overflow_at_the_configured_bounds() {
let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
let (r, after) = validate_one(&state, "1e39");
assert_eq!(r.valid, TextInputValid::Yes);
assert!(
same(after.number, f32::MAX),
"an overflowing value must saturate at `max`, got {}",
after.number,
);
let (_, after) = validate_one(&state, "-1e39");
assert!(
same(after.number, f32::MIN),
"…and at `min` on the negative side, got {}",
after.number,
);
}
#[test]
fn validate_underflow_keeps_the_sign_of_zero() {
let state = RefAny::new(wrapper(1.0, f32::MIN, f32::MAX));
let (_, after) = validate_one(&state, "1e-46");
assert!(
same(after.number, 0.0),
"a positive underflow must land on +0.0, got {}",
after.number,
);
let (_, after) = validate_one(&state, "-1e-46");
assert!(
same(after.number, -0.0),
"a negative underflow must land on -0.0, got {}",
after.number,
);
}
#[test]
fn validate_clamps_into_range() {
with_info(|info| {
for (min, max, text, expected) in [
(-10.0f32, 10.0f32, "1000", 10.0f32),
(-10.0, 10.0, "-1000", -10.0),
(-10.0, 10.0, "inf", 10.0),
(-10.0, 10.0, "-inf", -10.0),
(-10.0, 10.0, "10", 10.0), (-10.0, 10.0, "-10", -10.0),
(-10.0, 10.0, "10.000001", 10.0), (-10.0, 10.0, "0", 0.0),
(0.0, 0.0, "5", 0.0), (0.0, 0.0, "-5", 0.0),
(5.0, 5.0, "0", 5.0),
] {
let state = RefAny::new(wrapper(0.0, min, max));
let r = validate_text_input(state.clone(), info, text_state(text));
assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
let after = read(&state);
assert!(
same(after.number, expected),
"{text:?} in [{min}, {max}] stored {} (expected {expected})",
after.number,
);
assert!(
after.number >= min && after.number <= max,
"{text:?} escaped [{min}, {max}] as {}",
after.number,
);
}
});
}
#[test]
fn validate_stores_nan_unclamped() {
let state = RefAny::new(wrapper(0.0, -1.0, 1.0));
let (r, after) = validate_one(&state, "NaN");
assert_eq!(
r.valid,
TextInputValid::Yes,
"the Rust float parser accepts \"NaN\", so the widget does too",
);
assert!(
after.number.is_nan(),
"NaN survives the clamp untouched, got {}",
after.number,
);
}
#[test]
fn validate_tracks_previous_as_the_last_accepted_value() {
let state = RefAny::new(wrapper(0.0, 0.0, 10.0));
with_info(|info| {
for (text, previous, number) in [
("1", 0.0f32, 1.0f32),
("2", 1.0, 2.0),
("100", 2.0, 10.0), ("200", 10.0, 10.0), ("abc", 10.0, 10.0), ("-5", 10.0, 0.0),
] {
let _ = validate_text_input(state.clone(), info, text_state(text));
let after = read(&state);
assert!(
same(after.previous, previous),
"after {text:?}: previous = {} (expected {previous})",
after.previous,
);
assert!(
same(after.number, number),
"after {text:?}: number = {} (expected {number})",
after.number,
);
}
});
}
#[test]
fn validate_with_a_degenerate_range_must_not_panic() {
let degenerate: [(f32, f32); 5] = [
(10.0, 5.0),
(1.0, -1.0),
(f32::NAN, 10.0),
(0.0, f32::NAN),
(f32::NAN, f32::NAN),
];
let panicked: Vec<(f32, f32)> = degenerate
.iter()
.copied()
.filter(|&(min, max)| {
let state = RefAny::new(wrapper(0.0, min, max));
catch_unwind(AssertUnwindSafe(|| {
let _ = validate_one(&state, "1");
}))
.is_err()
})
.collect();
assert!(
panicked.is_empty(),
"typing a digit into a NumberInput whose [min, max] range is inverted or \
NaN-bounded panics (f32::clamp asserts min <= max) instead of rejecting \
the input; offending ranges: {panicked:?}",
);
}
#[test]
fn validate_with_a_foreign_payload_accepts_the_edit_unchanged() {
let state = RefAny::new(0u32);
let r = with_info(|info| {
validate_text_input(state.clone(), info, text_state("not a number"))
});
assert_eq!(r.update, Update::DoNothing);
assert_eq!(r.valid, TextInputValid::Yes);
let mut state = state;
assert_eq!(
*state
.downcast_ref::<u32>()
.expect("the foreign payload must be left alone"),
0,
);
}
#[test]
fn validate_does_not_invoke_the_value_change_hook_for_rejected_input() {
let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
let state = RefAny::new(wrapper_with_value_hook(
1.0,
f32::MIN,
f32::MAX,
&recorder,
));
with_info(|info| {
for text in MALFORMED {
let _ = validate_text_input(state.clone(), info, text_state(text));
}
});
assert!(
recorded(&recorder).is_empty(),
"a rejected edit must not reach the user's hook",
);
let (r, _) = validate_one(&state, "2");
assert_eq!(r.update, Update::RefreshDom);
assert_eq!(recorded(&recorder).len(), 1);
}
#[test]
fn validate_hands_the_hook_the_clamped_state_and_returns_its_update() {
let recorder = RefAny::new(Recorder::new(Update::RefreshDomAllWindows));
let state = RefAny::new(wrapper_with_value_hook(4.0, 0.0, 10.0, &recorder));
let (r, after) = validate_one(&state, "1000");
assert_eq!(
r.update,
Update::RefreshDomAllWindows,
"validate must forward the hook's Update verbatim",
);
assert_eq!(r.valid, TextInputValid::Yes);
let seen = recorded(&recorder);
assert_eq!(seen.len(), 1);
assert!(
same(seen[0].number, 10.0),
"the hook must see the clamped value, not the raw 1000, got {}",
seen[0].number,
);
assert!(same(seen[0].previous, 4.0), "…and the previous value");
assert_eq!(
seen[0], after,
"the hook's copy and the stored state must agree",
);
}
#[test]
fn focus_lost_with_a_foreign_payload_does_nothing() {
let state = RefAny::new(0u32);
assert_eq!(focus_lost(&state, "123"), Update::DoNothing);
let mut state = state;
assert_eq!(
*state
.downcast_ref::<u32>()
.expect("the foreign payload must be left alone"),
0,
);
}
#[test]
fn focus_lost_without_a_hook_does_nothing() {
let state = RefAny::new(wrapper(1.5, 0.0, 10.0));
assert_eq!(focus_lost(&state, "123"), Update::DoNothing);
let after = read(&state);
assert!(same(after.number, 1.5) && same(after.previous, 0.0));
}
#[test]
fn focus_lost_reports_the_stored_number_and_ignores_the_text_buffer() {
let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
let state = RefAny::new(wrapper_with_focus_hook(1.5, 0.0, 10.0, &recorder));
assert_eq!(
focus_lost(&state, "999"),
Update::RefreshDom,
"the hook's Update must be forwarded verbatim",
);
let seen = recorded(&recorder);
assert_eq!(seen.len(), 1, "the hook must fire exactly once");
assert!(
same(seen[0].number, 1.5),
"the hook saw {} — the text buffer must not be re-parsed",
seen[0].number,
);
assert!(same(seen[0].previous, 0.0));
}
#[test]
fn focus_lost_neither_mutates_nor_clamps() {
let recorder = RefAny::new(Recorder::new(Update::DoNothing));
let state = RefAny::new(wrapper_with_focus_hook(1000.0, 0.0, 10.0, &recorder));
assert_eq!(focus_lost(&state, ""), Update::DoNothing);
let seen = recorded(&recorder);
assert_eq!(seen.len(), 1);
assert!(
same(seen[0].number, 1000.0),
"focus-lost must not clamp, got {}",
seen[0].number,
);
let after = read(&state);
assert!(
same(after.number, 1000.0) && same(after.previous, 0.0),
"focus-lost must not mutate the state",
);
}
#[test]
fn focus_lost_is_repeatable() {
let recorder = RefAny::new(Recorder::new(Update::DoNothing));
let state = RefAny::new(wrapper_with_focus_hook(2.5, 0.0, 10.0, &recorder));
for _ in 0..8 {
assert_eq!(focus_lost(&state, "2.5"), Update::DoNothing);
}
let seen = recorded(&recorder);
assert_eq!(seen.len(), 8, "every focus loss must reach the hook");
assert!(
seen.iter().all(|s| same(s.number, 2.5)),
"repeated focus losses must keep reporting the same value",
);
}
}