Skip to main content

azul_layout/widgets/
number_input.rs

1//! Numeric input widget that wraps `TextInput` with numeric validation.
2//!
3//! Exports `NumberInput`, `NumberInputState`, and callback types
4//! (`NumberInputOnValueChangeCallbackType`, `NumberInputOnFocusLostCallbackType`).
5//! Internally delegates to `TextInput` and validates that the entered text
6//! parses as an `f32` within the configured `min`/`max` range.
7
8use std::string::String;
9
10use azul_core::{
11    callbacks::{CoreCallbackData, Update},
12    dom::Dom,
13    refany::RefAny,
14};
15#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
16use azul_css::{
17    dynamic_selector::CssPropertyWithConditionsVec,
18    props::{
19        basic::*,
20        layout::*,
21        property::{CssProperty, *},
22        style::*,
23    },
24    *,
25};
26
27use crate::{
28    callbacks::{Callback, CallbackInfo},
29    widgets::text_input::{
30        OnTextInputReturn, TextInput, TextInputOnFocusLostCallback,
31        TextInputOnFocusLostCallbackType, TextInputOnTextInputCallback,
32        TextInputOnTextInputCallbackType, TextInputOnVirtualKeyDownCallback,
33        TextInputOnVirtualKeyDownCallbackType, TextInputState, TextInputValid,
34    },
35};
36
37/// Callback type invoked when the numeric value changes.
38pub type NumberInputOnValueChangeCallbackType =
39    extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
40impl_widget_callback!(
41    NumberInputOnValueChange,
42    OptionNumberInputOnValueChange,
43    NumberInputOnValueChangeCallback,
44    NumberInputOnValueChangeCallbackType
45);
46
47azul_core::impl_managed_callback! {
48    wrapper:        NumberInputOnValueChangeCallback,
49    info_ty:        CallbackInfo,
50    return_ty:      Update,
51    default_ret:    Update::DoNothing,
52    invoker_static: NUMBER_INPUT_ON_VALUE_CHANGE_INVOKER,
53    invoker_ty:     AzNumberInputOnValueChangeCallbackInvoker,
54    thunk_fn:       az_number_input_on_value_change_callback_thunk,
55    setter_fn:      AzApp_setNumberInputOnValueChangeCallbackInvoker,
56    from_handle_fn: AzNumberInputOnValueChangeCallback_createFromHostHandle,
57    extra_args:     [ state: NumberInputState ],
58}
59
60/// Callback type invoked when the number input loses focus.
61pub type NumberInputOnFocusLostCallbackType =
62    extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
63impl_widget_callback!(
64    NumberInputOnFocusLost,
65    OptionNumberInputOnFocusLost,
66    NumberInputOnFocusLostCallback,
67    NumberInputOnFocusLostCallbackType
68);
69
70azul_core::impl_managed_callback! {
71    wrapper:        NumberInputOnFocusLostCallback,
72    info_ty:        CallbackInfo,
73    return_ty:      Update,
74    default_ret:    Update::DoNothing,
75    invoker_static: NUMBER_INPUT_ON_FOCUS_LOST_INVOKER,
76    invoker_ty:     AzNumberInputOnFocusLostCallbackInvoker,
77    thunk_fn:       az_number_input_on_focus_lost_callback_thunk,
78    setter_fn:      AzApp_setNumberInputOnFocusLostCallbackInvoker,
79    from_handle_fn: AzNumberInputOnFocusLostCallback_createFromHostHandle,
80    extra_args:     [ state: NumberInputState ],
81}
82
83/// A numeric input widget that wraps `TextInput` with `f32` validation.
84#[derive(Debug, Default, Clone, PartialEq)]
85#[repr(C)]
86pub struct NumberInput {
87    pub number_input_state: NumberInputStateWrapper,
88    pub text_input: TextInput,
89    pub style: CssPropertyWithConditionsVec,
90}
91
92/// Wraps `NumberInputState` together with its value-change and focus-lost callbacks.
93#[derive(Debug, Default, Clone, PartialEq)]
94#[repr(C)]
95pub struct NumberInputStateWrapper {
96    pub inner: NumberInputState,
97    pub on_value_change: OptionNumberInputOnValueChange,
98    pub on_focus_lost: OptionNumberInputOnFocusLost,
99}
100
101/// State of a `NumberInput`: the current and previous value, plus allowed range.
102#[derive(Copy, Debug, Clone, PartialEq)]
103#[repr(C)]
104pub struct NumberInputState {
105    /// The value before the most recent change.
106    pub previous: f32,
107    /// The current numeric value.
108    pub number: f32,
109    /// Minimum allowed value (inclusive).
110    pub min: f32,
111    /// Maximum allowed value (inclusive).
112    pub max: f32,
113}
114
115impl Default for NumberInputState {
116    fn default() -> Self {
117        Self {
118            previous: 0.0,
119            number: 0.0,
120            min: core::f32::MIN,
121            max: core::f32::MAX,
122        }
123    }
124}
125
126impl NumberInput {
127    /// Creates a new `NumberInput` with the given initial value.
128    #[must_use] pub fn create(input: f32) -> Self {
129        Self {
130            number_input_state: NumberInputStateWrapper {
131                inner: NumberInputState {
132                    number: input,
133                    ..Default::default()
134                },
135                ..Default::default()
136            },
137            ..Default::default()
138        }
139    }
140
141    pub fn set_on_text_input<C: Into<TextInputOnTextInputCallback>>(
142        &mut self,
143        refany: RefAny,
144        callback: C,
145    ) {
146        self.text_input.set_on_text_input(refany, callback);
147    }
148
149    #[must_use]
150    pub fn with_on_text_input<C: Into<TextInputOnTextInputCallback>>(
151        mut self,
152        refany: RefAny,
153        callback: C,
154    ) -> Self {
155        self.set_on_text_input(refany, callback);
156        self
157    }
158
159    pub fn set_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
160        &mut self,
161        refany: RefAny,
162        callback: C,
163    ) {
164        self.text_input.set_on_virtual_key_down(refany, callback);
165    }
166
167    #[must_use]
168    pub fn with_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
169        mut self,
170        refany: RefAny,
171        callback: C,
172    ) -> Self {
173        self.set_on_virtual_key_down(refany, callback);
174        self
175    }
176
177    pub fn set_placeholder_style(&mut self, style: CssPropertyWithConditionsVec) {
178        self.text_input.placeholder_style = style;
179    }
180
181    #[must_use] pub fn with_placeholder_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
182        self.set_placeholder_style(style);
183        self
184    }
185
186    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
187        self.text_input.container_style = style;
188    }
189
190    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
191        self.set_container_style(style);
192        self
193    }
194
195    pub fn set_label_style(&mut self, style: CssPropertyWithConditionsVec) {
196        self.text_input.label_style = style;
197    }
198
199    #[must_use] pub fn with_label_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
200        self.set_label_style(style);
201        self
202    }
203
204    // Function called when the input has been parsed as a number
205    pub fn set_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
206        &mut self,
207        refany: RefAny,
208        callback: C,
209    ) {
210        self.number_input_state.on_value_change = Some(NumberInputOnValueChange {
211            callback: callback.into(),
212            refany,
213        })
214        .into();
215    }
216
217    #[must_use]
218    pub fn with_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
219        mut self,
220        refany: RefAny,
221        callback: C,
222    ) -> Self {
223        self.set_on_value_change(refany, callback);
224        self
225    }
226
227    pub fn set_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
228        &mut self,
229        refany: RefAny,
230        callback: C,
231    ) {
232        self.number_input_state.on_focus_lost = Some(NumberInputOnFocusLost {
233            callback: callback.into(),
234            refany,
235        })
236        .into();
237    }
238
239    #[must_use]
240    pub fn with_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
241        mut self,
242        refany: RefAny,
243        callback: C,
244    ) -> Self {
245        self.set_on_focus_lost(refany, callback);
246        self
247    }
248
249    #[must_use]
250    pub fn swap_with_default(&mut self) -> Self {
251        let mut s = Self::create(0.0);
252        core::mem::swap(&mut s, self);
253        s
254    }
255
256    #[must_use] pub fn dom(mut self) -> Dom {
257        let number_string = format!("{}", self.number_input_state.inner.number);
258        self.text_input.text_input_state.inner.text = number_string
259            .chars()
260            .map(|s| s as u32)
261            .collect::<Vec<_>>()
262            .into();
263
264        let state = RefAny::new(self.number_input_state);
265
266        let validate: TextInputOnTextInputCallbackType = validate_text_input;
267        self.text_input.set_on_text_input(state.clone(), validate);
268        let focus_lost: TextInputOnFocusLostCallbackType = on_focus_lost;
269        self.text_input.set_on_focus_lost(state, focus_lost);
270        self.text_input.dom()
271    }
272}
273
274extern "C" fn on_focus_lost(
275    mut refany: RefAny,
276    info: CallbackInfo,
277    _state: TextInputState,
278) -> Update {
279    let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
280        return Update::DoNothing;
281    };
282
283    let number_input = &mut *refany;
284    let onfocuslost = &mut number_input.on_focus_lost;
285    let inner = number_input.inner;
286
287    match onfocuslost.as_mut() {
288        Some(NumberInputOnFocusLost { callback, refany }) => {
289            (callback.cb)(refany.clone(), info, inner)
290        }
291        None => Update::DoNothing,
292    }
293}
294
295/// Clamps `value` into `[min, max]`, tolerating the degenerate bounds that
296/// `f32::clamp` panics on: an inverted range (`min > max`) is swapped and a NaN
297/// bound is dropped (both NaN → value untouched). `min`/`max` are `pub` fields on
298/// a `#[repr(C)]` `NumberInputState` reachable across the C/FFI boundary, so a
299/// caller can invert or NaN them; a panic here would unwind across that boundary.
300fn clamp_to_range(value: f32, min: f32, max: f32) -> f32 {
301    let (lo, hi) = match (min.is_nan(), max.is_nan()) {
302        (true, true) => return value,
303        (true, false) => (max, max),
304        (false, true) => (min, min),
305        (false, false) if min <= max => (min, max),
306        (false, false) => (max, min),
307    };
308    value.clamp(lo, hi)
309}
310
311extern "C" fn validate_text_input(
312    mut refany: RefAny,
313    info: CallbackInfo,
314    state: TextInputState,
315) -> OnTextInputReturn {
316    let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
317        return OnTextInputReturn {
318            update: Update::DoNothing,
319            valid: TextInputValid::Yes,
320        };
321    };
322
323    let validated_input: String = state
324        .text
325        .iter()
326        .filter_map(|c| core::char::from_u32(*c))
327        .map(|c| if c == ',' { '.' } else { c })
328        .collect();
329
330    let Ok(validated_f32) = validated_input.parse::<f32>() else {
331        // do not re-layout the entire screen,
332        // but don't handle the character
333        return OnTextInputReturn {
334            update: Update::DoNothing,
335            valid: TextInputValid::No,
336        };
337    };
338
339    let number_input = &mut *refany;
340    let onvaluechange = &mut number_input.on_value_change;
341    let inner = &mut number_input.inner;
342
343    inner.previous = inner.number;
344    let clamped = clamp_to_range(validated_f32, inner.min, inner.max);
345    inner.number = clamped;
346    let inner_clone = *inner;
347
348    let update = match onvaluechange.as_mut() {
349        Some(NumberInputOnValueChange { callback, refany }) => {
350            (callback.cb)(refany.clone(), info, inner_clone)
351        }
352        None => Update::DoNothing,
353    };
354
355    OnTextInputReturn {
356        update,
357        valid: TextInputValid::Yes,
358    }
359}
360
361#[cfg(all(test, feature = "std"))]
362#[allow(clippy::float_cmp, clippy::too_many_lines)]
363mod autotest_generated {
364    use std::{
365        collections::BTreeMap,
366        panic::{catch_unwind, AssertUnwindSafe},
367        sync::{Arc, Mutex},
368    };
369
370    use azul_core::{
371        dom::{DomId, DomNodeId},
372        geom::OptionLogicalPosition,
373        gl::OptionGlContextPtr,
374        hit_test::ScrollPosition,
375        refany::OptionRefAny,
376        resources::RendererResources,
377        styled_dom::NodeHierarchyItemId,
378        window::{MonitorVec, RawWindowHandle},
379    };
380    use azul_css::dynamic_selector::CssPropertyWithConditions;
381    use rust_fontconfig::FcFontCache;
382
383    use super::*;
384    #[cfg(feature = "icu")]
385    use crate::icu::IcuLocalizerHandle;
386    use crate::{
387        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
388        widgets::text_input::TextInputStateWrapper,
389        window::LayoutWindow,
390        window_state::FullWindowState,
391    };
392
393    // ------------------------------------------------------------------
394    // Sample values
395    // ------------------------------------------------------------------
396
397    /// Every finite `f32` the widget has to survive a format/parse round-trip on:
398    /// both zeros (the sign of `-0.0` is the classic casualty), both ends of the
399    /// range, the smallest normal, the smallest and largest subnormals, and `2^24`
400    /// — the point where `f32` stops being able to count.
401    fn finite_samples() -> [f32; 16] {
402        [
403            0.0,
404            -0.0,
405            1.0,
406            -1.0,
407            0.5,
408            -0.5,
409            42.25,
410            -2.5,
411            0.1,
412            16_777_216.0,
413            f32::MIN,
414            f32::MAX,
415            f32::MIN_POSITIVE,
416            -f32::MIN_POSITIVE,
417            f32::EPSILON,
418            // smallest positive subnormal (~1.4e-45); written as bits so no float
419            // literal in this file is ever out of range for `f32`.
420            f32::from_bits(1),
421        ]
422    }
423
424    /// Strings `<f32 as FromStr>` rejects outright. None of them contains a comma, so
425    /// the widget's `,` -> `.` rewrite cannot rescue any of them either.
426    const MALFORMED: [&str; 26] = [
427        "",             // the empty buffer: select-all + delete
428        " ",            // `from_str` does not trim
429        " 1",
430        "1 ",
431        "\t1",
432        "\n",
433        "abc",
434        "e",
435        "e5",
436        "E",
437        "+",
438        "-",
439        ".",
440        "..",
441        "--1",
442        "1.2.3",
443        "1e",
444        "1e+",
445        "0x10",         // hex is not float syntax
446        "0b1",
447        "1_000",        // Rust *literal* syntax is not *parse* syntax
448        "1/2",
449        "1%",
450        "½",            // vulgar fraction
451        "∞",            // the symbol is not the word "inf"
452        "1\u{200b}0",   // zero-width space wedged between two digits
453    ];
454
455    /// Digits that are digits to a human but not to `from_str`.
456    const NON_ASCII_DIGITS: [&str; 6] = [
457        "١٢٣",        // Arabic-Indic
458        "١٫٥",        // Arabic-Indic + the Arabic decimal separator
459        "123",      // fullwidth
460        "𝟏",          // MATHEMATICAL BOLD DIGIT ONE
461        "٣.5",        // mixed script
462        "Ⅻ",          // roman numeral twelve
463    ];
464
465    /// Every spelling the Rust float parser accepts, paired with the value the widget
466    /// must end up storing under the default range. `inf` / `-inf` are listed with
467    /// their *clamped* results — saturating them is the widget's job, not the parser's.
468    const ACCEPTED: [(&str, f32); 17] = [
469        ("0", 0.0),
470        ("-0", -0.0),
471        ("+1", 1.0),
472        ("1.", 1.0),
473        (".5", 0.5),
474        ("-.5", -0.5),
475        ("1e3", 1000.0),
476        ("1E3", 1000.0),
477        ("1e+3", 1000.0),
478        ("1e-3", 0.001),
479        ("00042.2500", 42.25),
480        ("inf", f32::MAX),
481        ("infinity", f32::MAX),
482        ("-inf", f32::MIN),
483        ("nan", f32::NAN),
484        ("NaN", f32::NAN),
485        ("NAN", f32::NAN),
486    ];
487
488    // ------------------------------------------------------------------
489    // Fixtures
490    // ------------------------------------------------------------------
491
492    /// Bit-exact float comparison — `-0.0 != 0.0` here, because losing the sign of a
493    /// zero is exactly the kind of round-trip damage these tests are looking for.
494    /// NaNs compare equal to each other: the widget renders every NaN as `"NaN"`, so
495    /// the payload and sign cannot survive anyway.
496    fn same(a: f32, b: f32) -> bool {
497        if a.is_nan() || b.is_nan() {
498            a.is_nan() && b.is_nan()
499        } else {
500            a.to_bits() == b.to_bits()
501        }
502    }
503
504    /// A `NumberInputStateWrapper` with no hooks: `previous` starts at `0.0` so any
505    /// write to it is visible.
506    fn wrapper(number: f32, min: f32, max: f32) -> NumberInputStateWrapper {
507        NumberInputStateWrapper {
508            inner: NumberInputState {
509                previous: 0.0,
510                number,
511                min,
512                max,
513            },
514            on_value_change: OptionNumberInputOnValueChange::None,
515            on_focus_lost: OptionNumberInputOnFocusLost::None,
516        }
517    }
518
519    /// The widget's edit buffer, built from a `&str` the way `TextInput` builds it.
520    fn text_state(text: &str) -> TextInputState {
521        TextInputState {
522            text: text.chars().map(|c| c as u32).collect::<Vec<_>>().into(),
523            ..TextInputState::default()
524        }
525    }
526
527    /// An edit buffer built from *raw* `u32` code units — the buffer is a `U32Vec`,
528    /// so it can hold values that are not Unicode scalars at all.
529    fn raw_text_state(units: &[u32]) -> TextInputState {
530        TextInputState {
531            text: units.to_vec().into(),
532            ..TextInputState::default()
533        }
534    }
535
536    /// The state currently stored behind a `NumberInputStateWrapper` payload.
537    fn read(state: &RefAny) -> NumberInputState {
538        let mut state = state.clone();
539        let wrapper = state
540            .downcast_ref::<NumberInputStateWrapper>()
541            .expect("the payload must still be a NumberInputStateWrapper");
542        wrapper.inner
543    }
544
545    /// Overwrites the stored value and clears the history, so one `LayoutWindow` can
546    /// serve a whole table of cases.
547    fn reset(state: &RefAny, number: f32) {
548        let mut state = state.clone();
549        let mut wrapper = state
550            .downcast_mut::<NumberInputStateWrapper>()
551            .expect("the payload must still be a NumberInputStateWrapper");
552        wrapper.inner.number = number;
553        wrapper.inner.previous = 0.0;
554    }
555
556    /// `n` properties lifted off the default container style — an easy way to mint
557    /// style vectors that are pairwise distinct without hard-coding CSS.
558    fn style(n: usize) -> CssPropertyWithConditionsVec {
559        let all: Vec<CssPropertyWithConditions> =
560            TextInput::default().container_style.as_ref().to_vec();
561        assert!(n <= all.len(), "not enough default properties to slice");
562        CssPropertyWithConditionsVec::from_vec(all.into_iter().take(n).collect())
563    }
564
565    // ---- recording hooks --------------------------------------------------
566
567    /// Records every `NumberInputState` a hook is handed, and answers with `ret`.
568    struct Recorder {
569        seen: Vec<NumberInputState>,
570        ret: Update,
571    }
572
573    impl Recorder {
574        fn new(ret: Update) -> Self {
575            Self {
576                seen: Vec::new(),
577                ret,
578            }
579        }
580    }
581
582    extern "C" fn record_value_change(
583        mut data: RefAny,
584        _: CallbackInfo,
585        state: NumberInputState,
586    ) -> Update {
587        let Some(mut log) = data.downcast_mut::<Recorder>() else {
588            return Update::DoNothing;
589        };
590        log.seen.push(state);
591        log.ret
592    }
593
594    // Deliberately *not* the same body as `record_value_change`: two hooks with
595    // identical bodies can be folded onto one symbol, and these two have to stay
596    // distinguishable.
597    extern "C" fn record_focus_lost(
598        mut data: RefAny,
599        _: CallbackInfo,
600        state: NumberInputState,
601    ) -> Update {
602        match data.downcast_mut::<Recorder>() {
603            Some(mut log) => {
604                log.seen.push(state);
605                log.ret
606            }
607            None => Update::DoNothing,
608        }
609    }
610
611    /// A user-supplied text-input hook that accepts *everything*: if `dom()` kept it,
612    /// `"abc"` would come back as `TextInputValid::Yes`.
613    extern "C" fn accept_everything(
614        _: RefAny,
615        _: CallbackInfo,
616        _: TextInputState,
617    ) -> OnTextInputReturn {
618        OnTextInputReturn {
619            update: Update::RefreshDomAllWindows,
620            valid: TextInputValid::Yes,
621        }
622    }
623
624    /// A user-supplied virtual-key hook with a signature no other hook here returns.
625    extern "C" fn reject_everything(
626        _: RefAny,
627        _: CallbackInfo,
628        _: TextInputState,
629    ) -> OnTextInputReturn {
630        OnTextInputReturn {
631            update: Update::RefreshDomAllWindows,
632            valid: TextInputValid::No,
633        }
634    }
635
636    fn recorded(recorder: &RefAny) -> Vec<NumberInputState> {
637        let mut recorder = recorder.clone();
638        let log = recorder
639            .downcast_ref::<Recorder>()
640            .expect("the payload must still be a Recorder");
641        log.seen.clone()
642    }
643
644    fn wrapper_with_value_hook(
645        number: f32,
646        min: f32,
647        max: f32,
648        recorder: &RefAny,
649    ) -> NumberInputStateWrapper {
650        NumberInputStateWrapper {
651            on_value_change: Some(NumberInputOnValueChange {
652                refany: recorder.clone(),
653                callback: (record_value_change as NumberInputOnValueChangeCallbackType).into(),
654            })
655            .into(),
656            ..wrapper(number, min, max)
657        }
658    }
659
660    fn wrapper_with_focus_hook(
661        number: f32,
662        min: f32,
663        max: f32,
664        recorder: &RefAny,
665    ) -> NumberInputStateWrapper {
666        NumberInputStateWrapper {
667            on_focus_lost: Some(NumberInputOnFocusLost {
668                refany: recorder.clone(),
669                callback: (record_focus_lost as NumberInputOnFocusLostCallbackType).into(),
670            })
671            .into(),
672            ..wrapper(number, min, max)
673        }
674    }
675
676    // ---- CallbackInfo harness --------------------------------------------
677
678    /// Runs `f` with a real `CallbackInfo` over an empty `LayoutWindow`. Neither
679    /// `validate_text_input` nor `on_focus_lost` queries the DOM through it — they
680    /// only hand it to the user's hook — so an empty window is enough. `CallbackInfo`
681    /// is `Copy`, so a whole table of cases can share one window (building one per
682    /// case would dominate the runtime).
683    fn with_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> R {
684        let layout_window =
685            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
686        let renderer_resources = RendererResources::default();
687        let previous_window_state: Option<FullWindowState> = None;
688        let current_window_state = FullWindowState::default();
689        let gl_context = OptionGlContextPtr::None;
690        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
691            BTreeMap::new();
692        let window_handle = RawWindowHandle::Unsupported;
693        let system_callbacks = ExternalSystemCallbacks::rust_internal();
694
695        let ref_data = CallbackInfoRefData {
696            layout_window: &layout_window,
697            renderer_resources: &renderer_resources,
698            previous_window_state: &previous_window_state,
699            current_window_state: &current_window_state,
700            gl_context: &gl_context,
701            current_scroll_manager: &scroll_states,
702            current_window_handle: &window_handle,
703            system_callbacks: &system_callbacks,
704            system_style: Arc::new(azul_css::system::SystemStyle::default()),
705            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
706            #[cfg(feature = "icu")]
707            icu_localizer: IcuLocalizerHandle::default(),
708            ctx: OptionRefAny::None,
709        };
710
711        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
712
713        let info = CallbackInfo::new(
714            &ref_data,
715            &changes,
716            DomNodeId {
717                dom: DomId::ROOT_ID,
718                node: NodeHierarchyItemId::NONE,
719            },
720            OptionLogicalPosition::None,
721            OptionLogicalPosition::None,
722        );
723
724        f(info)
725    }
726
727    /// One edit delivered to `validate_text_input`; returns its answer plus the state
728    /// it left behind.
729    fn validate_one(state: &RefAny, text: &str) -> (OnTextInputReturn, NumberInputState) {
730        with_info(|info| {
731            let r = validate_text_input(state.clone(), info, text_state(text));
732            (r, read(state))
733        })
734    }
735
736    /// One edit made of raw code units (which need not be Unicode scalars).
737    fn validate_raw(state: &RefAny, units: &[u32]) -> (OnTextInputReturn, NumberInputState) {
738        with_info(|info| {
739            let r = validate_text_input(state.clone(), info, raw_text_state(units));
740            (r, read(state))
741        })
742    }
743
744    fn focus_lost(state: &RefAny, text: &str) -> Update {
745        with_info(|info| on_focus_lost(state.clone(), info, text_state(text)))
746    }
747
748    // ---- DOM probes -------------------------------------------------------
749
750    /// Flattened child indices of `TextInput::dom()`.
751    const PLACEHOLDER: usize = 0;
752    const LABEL: usize = 1;
753
754    fn dataset_of(dom: &Dom) -> RefAny {
755        dom.root
756            .get_dataset()
757            .cloned()
758            .expect("TextInput::dom must attach its state as the node's dataset")
759    }
760
761    /// The text sitting in the widget's *edit buffer*.
762    fn buffer_text(dom: &Dom) -> String {
763        let mut dataset = dataset_of(dom);
764        let wrapper = dataset
765            .downcast_ref::<TextInputStateWrapper>()
766            .expect("the dataset must be a TextInputStateWrapper");
767        wrapper.inner.get_text()
768    }
769
770    /// The text actually *rendered* into the label node.
771    fn displayed_text(dom: &Dom) -> String {
772        dom.children.as_ref()[LABEL]
773            .root
774            .get_node_type()
775            .format()
776            .expect("the label child must be a text node")
777    }
778
779    fn cursor_pos(dom: &Dom) -> usize {
780        let mut dataset = dataset_of(dom);
781        let wrapper = dataset
782            .downcast_ref::<TextInputStateWrapper>()
783            .expect("the dataset must be a TextInputStateWrapper");
784        wrapper.inner.cursor_pos
785    }
786
787    /// The `NumberInputStateWrapper` the rendered widget actually validates against —
788    /// pulled out of the hook `dom()` installed, so nothing about the wiring is
789    /// re-created by hand.
790    fn number_state_of(dom: &Dom) -> RefAny {
791        let mut dataset = dataset_of(dom);
792        let wrapper = dataset
793            .downcast_ref::<TextInputStateWrapper>()
794            .expect("the dataset must be a TextInputStateWrapper");
795        wrapper
796            .on_text_input
797            .as_ref()
798            .expect("NumberInput::dom must install a text-input hook")
799            .refany
800            .clone()
801    }
802
803    /// Delivers `text` to whichever text-input hook the rendered widget registered.
804    fn drive_text_input(dom: &Dom, text: &str) -> OnTextInputReturn {
805        let mut dataset = dataset_of(dom);
806        let hook = dataset
807            .downcast_ref::<TextInputStateWrapper>()
808            .expect("the dataset must be a TextInputStateWrapper")
809            .on_text_input
810            .as_ref()
811            .expect("NumberInput::dom must install a text-input hook")
812            .clone();
813        with_info(|info| (hook.callback.cb)(hook.refany.clone(), info, text_state(text)))
814    }
815
816    /// Delivers a key-down to whichever virtual-key hook survived rendering, if any.
817    fn drive_virtual_key_down(dom: &Dom) -> Option<OnTextInputReturn> {
818        let mut dataset = dataset_of(dom);
819        let hook = dataset
820            .downcast_ref::<TextInputStateWrapper>()
821            .expect("the dataset must be a TextInputStateWrapper")
822            .on_virtual_key_down
823            .as_ref()
824            .cloned()?;
825        Some(with_info(|info| {
826            (hook.callback.cb)(hook.refany.clone(), info, text_state(""))
827        }))
828    }
829
830    // ==================================================================
831    // NumberInput::create — numeric limits
832    // ==================================================================
833
834    #[test]
835    fn create_zero_is_exactly_the_default_widget() {
836        assert_eq!(
837            NumberInput::create(0.0),
838            NumberInput::default(),
839            "create(0.0) must not perturb anything Default already set",
840        );
841    }
842
843    #[test]
844    fn create_preserves_every_sample_value_bit_exactly() {
845        for v in finite_samples() {
846            let state = NumberInput::create(v).number_input_state.inner;
847            assert!(
848                same(state.number, v),
849                "create({v:?}) stored {:?}",
850                state.number,
851            );
852            assert!(
853                same(state.previous, 0.0),
854                "create({v:?}) must start with no history, got previous = {:?}",
855                state.previous,
856            );
857            assert!(
858                same(state.min, f32::MIN) && same(state.max, f32::MAX),
859                "create({v:?}) must leave the range wide open, got [{}, {}]",
860                state.min,
861                state.max,
862            );
863        }
864    }
865
866    #[test]
867    fn create_accepts_nan_and_infinities_without_panicking() {
868        for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
869            let state = NumberInput::create(v).number_input_state.inner;
870            assert!(
871                same(state.number, v),
872                "create({v:?}) stored {:?}",
873                state.number,
874            );
875        }
876        assert!(NumberInput::create(f32::NAN)
877            .number_input_state
878            .inner
879            .number
880            .is_nan());
881    }
882
883    #[test]
884    fn create_never_clamps_its_argument() {
885        // `create` is documented as "the given initial value" — it does not consult
886        // min/max, so `+inf` survives even though the default `max` is `f32::MAX`.
887        // Clamping is the *input* path's job (see `validate_clamps_into_range`).
888        let state = NumberInput::create(f32::INFINITY).number_input_state.inner;
889        assert!(
890            state.number.is_infinite(),
891            "create must store the value verbatim, got {}",
892            state.number,
893        );
894        assert!(state.number > state.max, "…even outside its own range");
895    }
896
897    #[test]
898    fn the_default_range_leaves_every_finite_value_untouched() {
899        let d = NumberInputState::default();
900        assert!(
901            d.min <= d.max,
902            "the default range must be non-empty — f32::clamp panics otherwise",
903        );
904        assert!(same(d.min, f32::MIN) && same(d.max, f32::MAX));
905        assert!(same(d.number, 0.0) && same(d.previous, 0.0));
906        for v in finite_samples() {
907            assert!(
908                same(v.clamp(d.min, d.max), v),
909                "{v:?} must pass through the default range untouched",
910            );
911        }
912    }
913
914    #[test]
915    fn number_input_state_is_a_value_type() {
916        let a = NumberInputState::default();
917        let mut b = a;
918        b.number = 5.0;
919        assert!(
920            same(a.number, 0.0),
921            "NumberInputState is Copy — mutating a copy must not alias the original",
922        );
923        assert_ne!(a, b);
924    }
925
926    #[test]
927    fn a_fresh_wrapper_has_no_hooks() {
928        let w = NumberInputStateWrapper::default();
929        assert!(w.on_value_change.as_ref().is_none());
930        assert!(w.on_focus_lost.as_ref().is_none());
931        assert_eq!(w.inner, NumberInputState::default());
932    }
933
934    // ==================================================================
935    // Builders / setters — invariants
936    // ==================================================================
937
938    #[test]
939    fn with_and_set_style_pairs_are_equivalent() {
940        for n in 0..4 {
941            let s = style(n);
942
943            let a = NumberInput::create(1.0).with_placeholder_style(s.clone());
944            let mut b = NumberInput::create(1.0);
945            b.set_placeholder_style(s.clone());
946            assert_eq!(a, b, "with_placeholder_style != set_placeholder_style ({n})");
947
948            let a = NumberInput::create(1.0).with_container_style(s.clone());
949            let mut b = NumberInput::create(1.0);
950            b.set_container_style(s.clone());
951            assert_eq!(a, b, "with_container_style != set_container_style ({n})");
952
953            let a = NumberInput::create(1.0).with_label_style(s.clone());
954            let mut b = NumberInput::create(1.0);
955            b.set_label_style(s);
956            assert_eq!(a, b, "with_label_style != set_label_style ({n})");
957        }
958    }
959
960    #[test]
961    fn style_setters_write_to_disjoint_fields() {
962        let placeholder = style(1);
963        let container = style(2);
964        let label = style(3);
965        assert_ne!(placeholder, container, "the fixture must be distinguishable");
966        assert_ne!(container, label, "the fixture must be distinguishable");
967
968        let input = NumberInput::create(0.0)
969            .with_placeholder_style(placeholder.clone())
970            .with_container_style(container.clone())
971            .with_label_style(label.clone());
972
973        assert_eq!(input.text_input.placeholder_style, placeholder);
974        assert_eq!(input.text_input.container_style, container);
975        assert_eq!(input.text_input.label_style, label);
976        assert_eq!(
977            input.style,
978            NumberInput::default().style,
979            "NumberInput::style is not a dumping ground for the TextInput styles",
980        );
981    }
982
983    #[test]
984    fn with_and_set_callback_pairs_are_equivalent() {
985        // The same `RefAny` handle on both sides: `RefAny` equality is identity of the
986        // shared allocation, so two independent `RefAny::new(0u32)` would never match.
987        let data = RefAny::new(0u32);
988
989        let a = NumberInput::create(1.0).with_on_value_change(
990            data.clone(),
991            record_value_change as NumberInputOnValueChangeCallbackType,
992        );
993        let mut b = NumberInput::create(1.0);
994        b.set_on_value_change(
995            data.clone(),
996            record_value_change as NumberInputOnValueChangeCallbackType,
997        );
998        assert_eq!(a, b, "with_on_value_change != set_on_value_change");
999
1000        let a = NumberInput::create(1.0).with_on_focus_lost(
1001            data.clone(),
1002            record_focus_lost as NumberInputOnFocusLostCallbackType,
1003        );
1004        let mut b = NumberInput::create(1.0);
1005        b.set_on_focus_lost(
1006            data.clone(),
1007            record_focus_lost as NumberInputOnFocusLostCallbackType,
1008        );
1009        assert_eq!(a, b, "with_on_focus_lost != set_on_focus_lost");
1010
1011        let a = NumberInput::create(1.0).with_on_text_input(
1012            data.clone(),
1013            accept_everything as TextInputOnTextInputCallbackType,
1014        );
1015        let mut b = NumberInput::create(1.0);
1016        b.set_on_text_input(
1017            data.clone(),
1018            accept_everything as TextInputOnTextInputCallbackType,
1019        );
1020        assert_eq!(a, b, "with_on_text_input != set_on_text_input");
1021
1022        let a = NumberInput::create(1.0).with_on_virtual_key_down(
1023            data.clone(),
1024            reject_everything as TextInputOnVirtualKeyDownCallbackType,
1025        );
1026        let mut b = NumberInput::create(1.0);
1027        b.set_on_virtual_key_down(data, reject_everything as TextInputOnVirtualKeyDownCallbackType);
1028        assert_eq!(a, b, "with_on_virtual_key_down != set_on_virtual_key_down");
1029    }
1030
1031    #[test]
1032    fn setting_a_hook_twice_keeps_the_last_one() {
1033        let first = RefAny::new(1u32);
1034        let second = RefAny::new(2u32);
1035
1036        let mut input = NumberInput::create(0.0);
1037        input.set_on_value_change(
1038            first.clone(),
1039            record_value_change as NumberInputOnValueChangeCallbackType,
1040        );
1041        input.set_on_value_change(
1042            second.clone(),
1043            record_value_change as NumberInputOnValueChangeCallbackType,
1044        );
1045
1046        let stored = input
1047            .number_input_state
1048            .on_value_change
1049            .as_ref()
1050            .expect("the hook must be set");
1051        assert_eq!(stored.refany, second, "the last hook must win");
1052        assert_ne!(stored.refany, first, "the first hook must be released");
1053    }
1054
1055    #[test]
1056    fn swap_with_default_hands_back_the_original_and_leaves_a_fresh_widget() {
1057        let data = RefAny::new(0u32);
1058        let mut input = NumberInput::create(7.5)
1059            .with_on_value_change(
1060                data,
1061                record_value_change as NumberInputOnValueChangeCallbackType,
1062            )
1063            .with_label_style(style(2));
1064        let original = input.clone();
1065
1066        let taken = input.swap_with_default();
1067        assert_eq!(taken, original, "swap_with_default must return the original");
1068        assert_eq!(
1069            input,
1070            NumberInput::create(0.0),
1071            "the receiver must be left as a fresh 0.0 widget",
1072        );
1073        assert_eq!(
1074            input,
1075            NumberInput::default(),
1076            "…which is also exactly the Default widget",
1077        );
1078
1079        // Idempotent on an already-defaulted receiver.
1080        let second = input.swap_with_default();
1081        assert_eq!(second, NumberInput::default());
1082        assert_eq!(input, NumberInput::default());
1083    }
1084
1085    // ==================================================================
1086    // NumberInput::dom — encode / decode round-trip
1087    // ==================================================================
1088
1089    #[test]
1090    fn dom_wires_the_text_input_and_parks_the_cursor_at_the_end() {
1091        let dom = NumberInput::create(-12.5).dom();
1092        assert_eq!(
1093            dom.children.as_ref().len(),
1094            2,
1095            "TextInput renders a placeholder node and a label node",
1096        );
1097        assert_eq!(
1098            dom.root.callbacks.as_ref().len(),
1099            5,
1100            "focus received/lost, text input, virtual key down, hover",
1101        );
1102        assert!(
1103            dom.children.as_ref()[PLACEHOLDER]
1104                .root
1105                .get_node_type()
1106                .format()
1107                .is_some(),
1108            "the placeholder child must be a text node",
1109        );
1110        assert_eq!(buffer_text(&dom), "-12.5");
1111        assert_eq!(displayed_text(&dom), "-12.5");
1112        assert_eq!(
1113            cursor_pos(&dom),
1114            "-12.5".chars().count(),
1115            "the cursor must sit at the end of the rendered number",
1116        );
1117    }
1118
1119    #[test]
1120    fn dom_text_round_trips_back_to_the_same_f32() {
1121        for v in finite_samples() {
1122            let dom = NumberInput::create(v).dom();
1123            let text = buffer_text(&dom);
1124            let parsed: f32 = text.parse().unwrap_or_else(|e| {
1125                panic!("the widget rendered {v:?} as {text:?}, which is not a float: {e}")
1126            });
1127            assert!(
1128                same(parsed, v),
1129                "{v:?} was rendered as {text:?} and read back as {parsed:?}",
1130            );
1131            assert_eq!(
1132                displayed_text(&dom),
1133                text,
1134                "the visible label and the edit buffer must agree for {v:?}",
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn dom_renders_the_shortest_round_trip_form() {
1141        for (value, expected) in [
1142            (0.0f32, "0"),
1143            (1.0, "1"),
1144            (-1.5, "-1.5"),
1145            (42.25, "42.25"),
1146            (0.5, "0.5"),
1147        ] {
1148            assert_eq!(buffer_text(&NumberInput::create(value).dom()), expected);
1149        }
1150    }
1151
1152    #[test]
1153    fn dom_renders_non_finite_values_as_inf_and_nan() {
1154        assert_eq!(buffer_text(&NumberInput::create(f32::INFINITY).dom()), "inf");
1155        assert_eq!(
1156            buffer_text(&NumberInput::create(f32::NEG_INFINITY).dom()),
1157            "-inf",
1158        );
1159        assert_eq!(buffer_text(&NumberInput::create(f32::NAN).dom()), "NaN");
1160        assert_eq!(
1161            buffer_text(&NumberInput::create(-f32::NAN).dom()),
1162            "NaN",
1163            "the sign of a NaN is not rendered, so it cannot round-trip",
1164        );
1165    }
1166
1167    #[test]
1168    fn every_string_the_widget_renders_is_accepted_by_its_own_validator() {
1169        let mut values = finite_samples().to_vec();
1170        values.extend_from_slice(&[f32::INFINITY, f32::NEG_INFINITY, f32::NAN]);
1171
1172        for v in values {
1173            let text = buffer_text(&NumberInput::create(v).dom());
1174            let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1175            let (r, _) = validate_one(&state, &text);
1176            assert_eq!(
1177                r.valid,
1178                TextInputValid::Yes,
1179                "the widget renders {v:?} as {text:?} but then refuses to parse it back",
1180            );
1181        }
1182    }
1183
1184    #[test]
1185    fn dom_renders_a_value_that_is_outside_its_own_range() {
1186        // Neither `create` nor `dom` consults min/max — only typed input is clamped.
1187        // A widget constructed out of range therefore *shows* a number it would never
1188        // accept from the keyboard.
1189        let mut input = NumberInput::create(1000.0);
1190        input.number_input_state.inner.min = 0.0;
1191        input.number_input_state.inner.max = 10.0;
1192        assert_eq!(buffer_text(&input.dom()), "1000");
1193    }
1194
1195    #[test]
1196    fn dom_replaces_a_user_supplied_text_input_hook_with_the_numeric_validator() {
1197        // `dom()` unconditionally overwrites `on_text_input`, so a hook installed via
1198        // `with_on_text_input` never fires. `accept_everything` would answer `Yes` to
1199        // "abc"; the numeric validator answers `No`.
1200        let dom = NumberInput::create(1.0)
1201            .with_on_text_input(
1202                RefAny::new(0u32),
1203                accept_everything as TextInputOnTextInputCallbackType,
1204            )
1205            .dom();
1206
1207        let r = drive_text_input(&dom, "abc");
1208        assert_eq!(
1209            r.valid,
1210            TextInputValid::No,
1211            "the numeric validator must own the text-input hook after dom()",
1212        );
1213        assert_eq!(r.update, Update::DoNothing);
1214    }
1215
1216    #[test]
1217    fn dom_keeps_a_user_supplied_virtual_key_hook() {
1218        let dom = NumberInput::create(1.0)
1219            .with_on_virtual_key_down(
1220                RefAny::new(0u32),
1221                reject_everything as TextInputOnVirtualKeyDownCallbackType,
1222            )
1223            .dom();
1224
1225        let r = drive_virtual_key_down(&dom)
1226            .expect("with_on_virtual_key_down must survive rendering");
1227        assert_eq!(r.update, Update::RefreshDomAllWindows);
1228        assert_eq!(r.valid, TextInputValid::No);
1229    }
1230
1231    #[test]
1232    fn dom_wires_the_value_change_hook_through_the_rendered_widget() {
1233        let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
1234        let dom = NumberInput::create(0.0)
1235            .with_on_value_change(
1236                recorder.clone(),
1237                record_value_change as NumberInputOnValueChangeCallbackType,
1238            )
1239            .dom();
1240
1241        let r = drive_text_input(&dom, "12,5");
1242        assert_eq!(r.valid, TextInputValid::Yes);
1243        assert_eq!(
1244            r.update,
1245            Update::RefreshDom,
1246            "validate must return whatever the user's hook returned",
1247        );
1248
1249        let seen = recorded(&recorder);
1250        assert_eq!(seen.len(), 1, "the hook must fire exactly once per edit");
1251        assert!(same(seen[0].number, 12.5));
1252        assert!(same(seen[0].previous, 0.0));
1253
1254        assert!(
1255            same(read(&number_state_of(&dom)).number, 12.5),
1256            "the state behind the rendered DOM must have been updated too",
1257        );
1258    }
1259
1260    // ==================================================================
1261    // validate_text_input — the parser
1262    // ==================================================================
1263
1264    #[test]
1265    fn validate_rejects_malformed_input_without_touching_the_state() {
1266        // One state for the whole table: a rejected edit must not accumulate either.
1267        let state = RefAny::new(wrapper(7.5, -100.0, 100.0));
1268        with_info(|info| {
1269            for text in MALFORMED {
1270                let r = validate_text_input(state.clone(), info, text_state(text));
1271                assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
1272                assert_eq!(
1273                    r.update,
1274                    Update::DoNothing,
1275                    "a rejected edit must not trigger a relayout ({text:?})",
1276                );
1277                let after = read(&state);
1278                assert!(
1279                    same(after.number, 7.5),
1280                    "{text:?} changed the value to {}",
1281                    after.number,
1282                );
1283                assert!(
1284                    same(after.previous, 0.0),
1285                    "{text:?} touched `previous` ({})",
1286                    after.previous,
1287                );
1288            }
1289        });
1290    }
1291
1292    #[test]
1293    fn validate_rejects_digits_that_are_not_ascii_digits() {
1294        let state = RefAny::new(wrapper(3.0, f32::MIN, f32::MAX));
1295        with_info(|info| {
1296            for text in NON_ASCII_DIGITS {
1297                let r = validate_text_input(state.clone(), info, text_state(text));
1298                assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
1299                assert!(
1300                    same(read(&state).number, 3.0),
1301                    "{text:?} must not change the value",
1302                );
1303            }
1304        });
1305    }
1306
1307    #[test]
1308    fn validate_accepts_every_form_the_rust_float_parser_accepts() {
1309        let state = RefAny::new(wrapper(-1.0, f32::MIN, f32::MAX));
1310        with_info(|info| {
1311            for (text, expected) in ACCEPTED {
1312                reset(&state, -1.0);
1313                let r = validate_text_input(state.clone(), info, text_state(text));
1314                assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
1315                assert_eq!(
1316                    r.update,
1317                    Update::DoNothing,
1318                    "no hook is installed, so there is nothing to redraw ({text:?})",
1319                );
1320                let after = read(&state);
1321                assert!(
1322                    same(after.number, expected),
1323                    "{text:?} stored {} (expected {expected})",
1324                    after.number,
1325                );
1326                assert!(
1327                    same(after.previous, -1.0),
1328                    "{text:?} must push the old value into `previous`, got {}",
1329                    after.previous,
1330                );
1331            }
1332        });
1333    }
1334
1335    #[test]
1336    fn validate_reads_a_comma_as_a_decimal_point() {
1337        with_info(|info| {
1338            for (text, expected) in [
1339                ("1,5", 1.5f32),
1340                ("-1,5", -1.5),
1341                (",5", 0.5),
1342                ("1,", 1.0),
1343                ("1,25e2", 125.0),
1344            ] {
1345                let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1346                let r = validate_text_input(state.clone(), info, text_state(text));
1347                assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
1348                assert!(
1349                    same(read(&state).number, expected),
1350                    "{text:?} stored {} (expected {expected})",
1351                    read(&state).number,
1352                );
1353            }
1354
1355            // A comma is rewritten, not deleted: a second one is still a parse error.
1356            for text in [",", ",,", "1,,5", "1,5,5"] {
1357                let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1358                let r = validate_text_input(state.clone(), info, text_state(text));
1359                assert_eq!(r.valid, TextInputValid::No, "{text:?} must be rejected");
1360            }
1361        });
1362    }
1363
1364    #[test]
1365    fn validate_reads_a_thousands_separator_as_a_decimal_point() {
1366        // The `,` -> `.` rewrite is unconditional, so "1,000" (US grouping for one
1367        // thousand) is silently read as *one*. That is the price of supporting the
1368        // European decimal comma, and it is worth pinning down: the value a user
1369        // typed changes by three orders of magnitude with no rejection.
1370        let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1371        let (r, after) = validate_one(&state, "1,000");
1372        assert_eq!(r.valid, TextInputValid::Yes);
1373        assert!(
1374            same(after.number, 1.0),
1375            "\"1,000\" is read as {} (the comma is a decimal point here)",
1376            after.number,
1377        );
1378
1379        let (r, _) = validate_one(&state, "1,000,000");
1380        assert_eq!(
1381            r.valid,
1382            TextInputValid::No,
1383            "a second group makes it un-parseable rather than ambiguous",
1384        );
1385    }
1386
1387    #[test]
1388    fn validate_silently_drops_code_units_that_are_not_unicode_scalars() {
1389        // The edit buffer is a `U32Vec`, so it can hold unpaired surrogates and
1390        // out-of-range code units. `char::from_u32` returns None for those and the
1391        // filter *drops* them, so "1<D800>5" is read as the number 15 rather than
1392        // being rejected as malformed.
1393        let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1394
1395        let (r, after) = validate_raw(&state, &[0x31, 0xD800, 0x35]);
1396        assert_eq!(r.valid, TextInputValid::Yes);
1397        assert!(
1398            same(after.number, 15.0),
1399            "a non-scalar code unit between two digits is dropped, got {}",
1400            after.number,
1401        );
1402
1403        // …but a buffer made *only* of non-scalars collapses to the empty string,
1404        // which is rejected rather than read as zero.
1405        let (r, after) = validate_raw(&state, &[0xD800, 0xDFFF, 0x0011_0000]);
1406        assert_eq!(r.valid, TextInputValid::No);
1407        assert!(
1408            same(after.number, 15.0),
1409            "a rejected edit must not change the value",
1410        );
1411    }
1412
1413    #[test]
1414    fn validate_survives_pathologically_long_input() {
1415        let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1416
1417        for (text, expected) in [
1418            ("9".repeat(10_000), f32::MAX),          // overflows to +inf, saturates
1419            (format!("-{}", "9".repeat(10_000)), f32::MIN),
1420            (format!("{}1", "0".repeat(10_000)), 1.0), // leading zeros
1421            (format!("0.{}", "0".repeat(10_000)), 0.0),
1422            ("1e999999999".to_string(), f32::MAX),   // exponent overflow
1423            ("1e-999999999".to_string(), 0.0),       // exponent underflow
1424        ] {
1425            let (r, after) = validate_one(&state, &text);
1426            assert_eq!(
1427                r.valid,
1428                TextInputValid::Yes,
1429                "a {}-char input must parse, not error",
1430                text.len(),
1431            );
1432            assert!(
1433                same(after.number, expected),
1434                "a {}-char input stored {} (expected {expected})",
1435                text.len(),
1436                after.number,
1437            );
1438        }
1439    }
1440
1441    // ==================================================================
1442    // validate_text_input — numeric limits
1443    // ==================================================================
1444
1445    #[test]
1446    fn validate_saturates_overflow_at_the_configured_bounds() {
1447        let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
1448
1449        // "1e39" is well past f32::MAX, so the parser returns +inf rather than an
1450        // error — the saturation has to happen here, in the clamp.
1451        let (r, after) = validate_one(&state, "1e39");
1452        assert_eq!(r.valid, TextInputValid::Yes);
1453        assert!(
1454            same(after.number, f32::MAX),
1455            "an overflowing value must saturate at `max`, got {}",
1456            after.number,
1457        );
1458
1459        let (_, after) = validate_one(&state, "-1e39");
1460        assert!(
1461            same(after.number, f32::MIN),
1462            "…and at `min` on the negative side, got {}",
1463            after.number,
1464        );
1465    }
1466
1467    #[test]
1468    fn validate_underflow_keeps_the_sign_of_zero() {
1469        let state = RefAny::new(wrapper(1.0, f32::MIN, f32::MAX));
1470
1471        let (_, after) = validate_one(&state, "1e-46");
1472        assert!(
1473            same(after.number, 0.0),
1474            "a positive underflow must land on +0.0, got {}",
1475            after.number,
1476        );
1477
1478        let (_, after) = validate_one(&state, "-1e-46");
1479        assert!(
1480            same(after.number, -0.0),
1481            "a negative underflow must land on -0.0, got {}",
1482            after.number,
1483        );
1484    }
1485
1486    #[test]
1487    fn validate_clamps_into_range() {
1488        with_info(|info| {
1489            for (min, max, text, expected) in [
1490                (-10.0f32, 10.0f32, "1000", 10.0f32),
1491                (-10.0, 10.0, "-1000", -10.0),
1492                (-10.0, 10.0, "inf", 10.0),
1493                (-10.0, 10.0, "-inf", -10.0),
1494                (-10.0, 10.0, "10", 10.0),          // exactly on the bound
1495                (-10.0, 10.0, "-10", -10.0),
1496                (-10.0, 10.0, "10.000001", 10.0),   // one ulp past the bound
1497                (-10.0, 10.0, "0", 0.0),
1498                (0.0, 0.0, "5", 0.0),               // a single-point range is legal
1499                (0.0, 0.0, "-5", 0.0),
1500                (5.0, 5.0, "0", 5.0),
1501            ] {
1502                let state = RefAny::new(wrapper(0.0, min, max));
1503                let r = validate_text_input(state.clone(), info, text_state(text));
1504                assert_eq!(r.valid, TextInputValid::Yes, "{text:?} must be accepted");
1505                let after = read(&state);
1506                assert!(
1507                    same(after.number, expected),
1508                    "{text:?} in [{min}, {max}] stored {} (expected {expected})",
1509                    after.number,
1510                );
1511                assert!(
1512                    after.number >= min && after.number <= max,
1513                    "{text:?} escaped [{min}, {max}] as {}",
1514                    after.number,
1515                );
1516            }
1517        });
1518    }
1519
1520    #[test]
1521    fn validate_stores_nan_unclamped() {
1522        // `f32::clamp` compares, and every comparison against NaN is false, so a NaN
1523        // walks straight through the range check. The widget's "number is always in
1524        // [min, max]" invariant therefore has exactly one hole, and it is reachable
1525        // by typing "nan" into the field.
1526        let state = RefAny::new(wrapper(0.0, -1.0, 1.0));
1527        let (r, after) = validate_one(&state, "NaN");
1528        assert_eq!(
1529            r.valid,
1530            TextInputValid::Yes,
1531            "the Rust float parser accepts \"NaN\", so the widget does too",
1532        );
1533        assert!(
1534            after.number.is_nan(),
1535            "NaN survives the clamp untouched, got {}",
1536            after.number,
1537        );
1538    }
1539
1540    #[test]
1541    fn validate_tracks_previous_as_the_last_accepted_value() {
1542        let state = RefAny::new(wrapper(0.0, 0.0, 10.0));
1543        with_info(|info| {
1544            for (text, previous, number) in [
1545                ("1", 0.0f32, 1.0f32),
1546                ("2", 1.0, 2.0),
1547                ("100", 2.0, 10.0),   // clamped
1548                ("200", 10.0, 10.0),  // `previous` is the *clamped* old value
1549                ("abc", 10.0, 10.0),  // rejected: neither field moves
1550                ("-5", 10.0, 0.0),
1551            ] {
1552                let _ = validate_text_input(state.clone(), info, text_state(text));
1553                let after = read(&state);
1554                assert!(
1555                    same(after.previous, previous),
1556                    "after {text:?}: previous = {} (expected {previous})",
1557                    after.previous,
1558                );
1559                assert!(
1560                    same(after.number, number),
1561                    "after {text:?}: number = {} (expected {number})",
1562                    after.number,
1563                );
1564            }
1565        });
1566    }
1567
1568    /// `validate_text_input` runs `f32::clamp(min, max)` on every value it parses,
1569    /// and `f32::clamp` **panics** unless `min <= max` — which a NaN bound also
1570    /// fails. `min`/`max` are `pub` fields on a `#[repr(C)]` struct that crosses the
1571    /// C/FFI boundary, so nothing stops a caller from handing the widget an inverted
1572    /// or NaN-bounded range, and a panic inside a UI callback takes the app with it.
1573    /// Rejecting the edit (`TextInputValid::No`) or normalising the range would both
1574    /// be safe; unwinding is not.
1575    #[test]
1576    fn validate_with_a_degenerate_range_must_not_panic() {
1577        let degenerate: [(f32, f32); 5] = [
1578            (10.0, 5.0),
1579            (1.0, -1.0),
1580            (f32::NAN, 10.0),
1581            (0.0, f32::NAN),
1582            (f32::NAN, f32::NAN),
1583        ];
1584
1585        let panicked: Vec<(f32, f32)> = degenerate
1586            .iter()
1587            .copied()
1588            .filter(|&(min, max)| {
1589                let state = RefAny::new(wrapper(0.0, min, max));
1590                catch_unwind(AssertUnwindSafe(|| {
1591                    let _ = validate_one(&state, "1");
1592                }))
1593                .is_err()
1594            })
1595            .collect();
1596
1597        assert!(
1598            panicked.is_empty(),
1599            "typing a digit into a NumberInput whose [min, max] range is inverted or \
1600             NaN-bounded panics (f32::clamp asserts min <= max) instead of rejecting \
1601             the input; offending ranges: {panicked:?}",
1602        );
1603    }
1604
1605    // ==================================================================
1606    // validate_text_input — hooks and payload handling
1607    // ==================================================================
1608
1609    #[test]
1610    fn validate_with_a_foreign_payload_accepts_the_edit_unchanged() {
1611        // The downcast guard bails out *before* parsing, so a mis-wired NumberInput
1612        // reports arbitrary text as valid instead of rejecting it.
1613        let state = RefAny::new(0u32);
1614        let r = with_info(|info| {
1615            validate_text_input(state.clone(), info, text_state("not a number"))
1616        });
1617        assert_eq!(r.update, Update::DoNothing);
1618        assert_eq!(r.valid, TextInputValid::Yes);
1619
1620        let mut state = state;
1621        assert_eq!(
1622            *state
1623                .downcast_ref::<u32>()
1624                .expect("the foreign payload must be left alone"),
1625            0,
1626        );
1627    }
1628
1629    #[test]
1630    fn validate_does_not_invoke_the_value_change_hook_for_rejected_input() {
1631        let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
1632        let state = RefAny::new(wrapper_with_value_hook(
1633            1.0,
1634            f32::MIN,
1635            f32::MAX,
1636            &recorder,
1637        ));
1638
1639        with_info(|info| {
1640            for text in MALFORMED {
1641                let _ = validate_text_input(state.clone(), info, text_state(text));
1642            }
1643        });
1644        assert!(
1645            recorded(&recorder).is_empty(),
1646            "a rejected edit must not reach the user's hook",
1647        );
1648
1649        // Sanity: the hook *is* wired up and does fire for a well-formed edit.
1650        let (r, _) = validate_one(&state, "2");
1651        assert_eq!(r.update, Update::RefreshDom);
1652        assert_eq!(recorded(&recorder).len(), 1);
1653    }
1654
1655    #[test]
1656    fn validate_hands_the_hook_the_clamped_state_and_returns_its_update() {
1657        let recorder = RefAny::new(Recorder::new(Update::RefreshDomAllWindows));
1658        let state = RefAny::new(wrapper_with_value_hook(4.0, 0.0, 10.0, &recorder));
1659
1660        let (r, after) = validate_one(&state, "1000");
1661        assert_eq!(
1662            r.update,
1663            Update::RefreshDomAllWindows,
1664            "validate must forward the hook's Update verbatim",
1665        );
1666        assert_eq!(r.valid, TextInputValid::Yes);
1667
1668        let seen = recorded(&recorder);
1669        assert_eq!(seen.len(), 1);
1670        assert!(
1671            same(seen[0].number, 10.0),
1672            "the hook must see the clamped value, not the raw 1000, got {}",
1673            seen[0].number,
1674        );
1675        assert!(same(seen[0].previous, 4.0), "…and the previous value");
1676        assert_eq!(
1677            seen[0], after,
1678            "the hook's copy and the stored state must agree",
1679        );
1680    }
1681
1682    // ==================================================================
1683    // on_focus_lost
1684    // ==================================================================
1685
1686    #[test]
1687    fn focus_lost_with_a_foreign_payload_does_nothing() {
1688        let state = RefAny::new(0u32);
1689        assert_eq!(focus_lost(&state, "123"), Update::DoNothing);
1690
1691        let mut state = state;
1692        assert_eq!(
1693            *state
1694                .downcast_ref::<u32>()
1695                .expect("the foreign payload must be left alone"),
1696            0,
1697        );
1698    }
1699
1700    #[test]
1701    fn focus_lost_without_a_hook_does_nothing() {
1702        let state = RefAny::new(wrapper(1.5, 0.0, 10.0));
1703        assert_eq!(focus_lost(&state, "123"), Update::DoNothing);
1704        let after = read(&state);
1705        assert!(same(after.number, 1.5) && same(after.previous, 0.0));
1706    }
1707
1708    #[test]
1709    fn focus_lost_reports_the_stored_number_and_ignores_the_text_buffer() {
1710        // `on_focus_lost` never looks at the `TextInputState` it is handed: the value
1711        // it reports is the one the *validator* accepted, not whatever happens to be
1712        // sitting in the buffer.
1713        let recorder = RefAny::new(Recorder::new(Update::RefreshDom));
1714        let state = RefAny::new(wrapper_with_focus_hook(1.5, 0.0, 10.0, &recorder));
1715
1716        assert_eq!(
1717            focus_lost(&state, "999"),
1718            Update::RefreshDom,
1719            "the hook's Update must be forwarded verbatim",
1720        );
1721
1722        let seen = recorded(&recorder);
1723        assert_eq!(seen.len(), 1, "the hook must fire exactly once");
1724        assert!(
1725            same(seen[0].number, 1.5),
1726            "the hook saw {} — the text buffer must not be re-parsed",
1727            seen[0].number,
1728        );
1729        assert!(same(seen[0].previous, 0.0));
1730    }
1731
1732    #[test]
1733    fn focus_lost_neither_mutates_nor_clamps() {
1734        // A state built out of range (see `dom_renders_a_value_that_is_outside_its_own_range`)
1735        // is reported verbatim: focus-lost is a read-only notification.
1736        let recorder = RefAny::new(Recorder::new(Update::DoNothing));
1737        let state = RefAny::new(wrapper_with_focus_hook(1000.0, 0.0, 10.0, &recorder));
1738
1739        assert_eq!(focus_lost(&state, ""), Update::DoNothing);
1740
1741        let seen = recorded(&recorder);
1742        assert_eq!(seen.len(), 1);
1743        assert!(
1744            same(seen[0].number, 1000.0),
1745            "focus-lost must not clamp, got {}",
1746            seen[0].number,
1747        );
1748
1749        let after = read(&state);
1750        assert!(
1751            same(after.number, 1000.0) && same(after.previous, 0.0),
1752            "focus-lost must not mutate the state",
1753        );
1754    }
1755
1756    #[test]
1757    fn focus_lost_is_repeatable() {
1758        let recorder = RefAny::new(Recorder::new(Update::DoNothing));
1759        let state = RefAny::new(wrapper_with_focus_hook(2.5, 0.0, 10.0, &recorder));
1760
1761        for _ in 0..8 {
1762            assert_eq!(focus_lost(&state, "2.5"), Update::DoNothing);
1763        }
1764
1765        let seen = recorded(&recorder);
1766        assert_eq!(seen.len(), 8, "every focus loss must reach the hook");
1767        assert!(
1768            seen.iter().all(|s| same(s.number, 2.5)),
1769            "repeated focus losses must keep reporting the same value",
1770        );
1771    }
1772}