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

            
8
use std::string::String;
9

            
10
use 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
16
use azul_css::{
17
    dynamic_selector::CssPropertyWithConditionsVec,
18
    props::{
19
        basic::*,
20
        layout::*,
21
        property::{CssProperty, *},
22
        style::*,
23
    },
24
    *,
25
};
26

            
27
use 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.
38
pub type NumberInputOnValueChangeCallbackType =
39
    extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
40
impl_widget_callback!(
41
    NumberInputOnValueChange,
42
    OptionNumberInputOnValueChange,
43
    NumberInputOnValueChangeCallback,
44
    NumberInputOnValueChangeCallbackType
45
);
46

            
47
azul_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.
61
pub type NumberInputOnFocusLostCallbackType =
62
    extern "C" fn(RefAny, CallbackInfo, NumberInputState) -> Update;
63
impl_widget_callback!(
64
    NumberInputOnFocusLost,
65
    OptionNumberInputOnFocusLost,
66
    NumberInputOnFocusLostCallback,
67
    NumberInputOnFocusLostCallbackType
68
);
69

            
70
azul_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)]
86
pub 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)]
95
pub 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)]
104
pub 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

            
115
impl Default for NumberInputState {
116
368
    fn default() -> Self {
117
368
        Self {
118
368
            previous: 0.0,
119
368
            number: 0.0,
120
368
            min: core::f32::MIN,
121
368
            max: core::f32::MAX,
122
368
        }
123
368
    }
124
}
125

            
126
impl NumberInput {
127
    /// Creates a new `NumberInput` with the given initial value.
128
116
    #[must_use] pub fn create(input: f32) -> Self {
129
116
        Self {
130
116
            number_input_state: NumberInputStateWrapper {
131
116
                inner: NumberInputState {
132
116
                    number: input,
133
116
                    ..Default::default()
134
116
                },
135
116
                ..Default::default()
136
116
            },
137
116
            ..Default::default()
138
116
        }
139
116
    }
140

            
141
3
    pub fn set_on_text_input<C: Into<TextInputOnTextInputCallback>>(
142
3
        &mut self,
143
3
        refany: RefAny,
144
3
        callback: C,
145
3
    ) {
146
3
        self.text_input.set_on_text_input(refany, callback);
147
3
    }
148

            
149
    #[must_use]
150
2
    pub fn with_on_text_input<C: Into<TextInputOnTextInputCallback>>(
151
2
        mut self,
152
2
        refany: RefAny,
153
2
        callback: C,
154
2
    ) -> Self {
155
2
        self.set_on_text_input(refany, callback);
156
2
        self
157
2
    }
158

            
159
3
    pub fn set_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
160
3
        &mut self,
161
3
        refany: RefAny,
162
3
        callback: C,
163
3
    ) {
164
3
        self.text_input.set_on_virtual_key_down(refany, callback);
165
3
    }
166

            
167
    #[must_use]
168
2
    pub fn with_on_virtual_key_down<C: Into<TextInputOnVirtualKeyDownCallback>>(
169
2
        mut self,
170
2
        refany: RefAny,
171
2
        callback: C,
172
2
    ) -> Self {
173
2
        self.set_on_virtual_key_down(refany, callback);
174
2
        self
175
2
    }
176

            
177
9
    pub fn set_placeholder_style(&mut self, style: CssPropertyWithConditionsVec) {
178
9
        self.text_input.placeholder_style = style;
179
9
    }
180

            
181
5
    #[must_use] pub fn with_placeholder_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
182
5
        self.set_placeholder_style(style);
183
5
        self
184
5
    }
185

            
186
9
    pub fn set_container_style(&mut self, style: CssPropertyWithConditionsVec) {
187
9
        self.text_input.container_style = style;
188
9
    }
189

            
190
5
    #[must_use] pub fn with_container_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
191
5
        self.set_container_style(style);
192
5
        self
193
5
    }
194

            
195
10
    pub fn set_label_style(&mut self, style: CssPropertyWithConditionsVec) {
196
10
        self.text_input.label_style = style;
197
10
    }
198

            
199
6
    #[must_use] pub fn with_label_style(mut self, style: CssPropertyWithConditionsVec) -> Self {
200
6
        self.set_label_style(style);
201
6
        self
202
6
    }
203

            
204
    // Function called when the input has been parsed as a number
205
6
    pub fn set_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
206
6
        &mut self,
207
6
        refany: RefAny,
208
6
        callback: C,
209
6
    ) {
210
6
        self.number_input_state.on_value_change = Some(NumberInputOnValueChange {
211
6
            callback: callback.into(),
212
6
            refany,
213
6
        })
214
6
        .into();
215
6
    }
216

            
217
    #[must_use]
218
3
    pub fn with_on_value_change<C: Into<NumberInputOnValueChangeCallback>>(
219
3
        mut self,
220
3
        refany: RefAny,
221
3
        callback: C,
222
3
    ) -> Self {
223
3
        self.set_on_value_change(refany, callback);
224
3
        self
225
3
    }
226

            
227
7
    pub fn set_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
228
7
        &mut self,
229
7
        refany: RefAny,
230
7
        callback: C,
231
7
    ) {
232
7
        self.number_input_state.on_focus_lost = Some(NumberInputOnFocusLost {
233
7
            callback: callback.into(),
234
7
            refany,
235
7
        })
236
7
        .into();
237
7
    }
238

            
239
    #[must_use]
240
6
    pub fn with_on_focus_lost<C: Into<NumberInputOnFocusLostCallback>>(
241
6
        mut self,
242
6
        refany: RefAny,
243
6
        callback: C,
244
6
    ) -> Self {
245
6
        self.set_on_focus_lost(refany, callback);
246
6
        self
247
6
    }
248

            
249
    #[must_use]
250
2
    pub fn swap_with_default(&mut self) -> Self {
251
2
        let mut s = Self::create(0.0);
252
2
        core::mem::swap(&mut s, self);
253
2
        s
254
2
    }
255

            
256
56
    #[must_use] pub fn dom(mut self) -> Dom {
257
56
        let number_string = format!("{}", self.number_input_state.inner.number);
258
56
        self.text_input.text_input_state.inner.text = number_string
259
56
            .chars()
260
643
            .map(|s| s as u32)
261
56
            .collect::<Vec<_>>()
262
56
            .into();
263

            
264
56
        let state = RefAny::new(self.number_input_state);
265

            
266
56
        let validate: TextInputOnTextInputCallbackType = validate_text_input;
267
56
        self.text_input.set_on_text_input(state.clone(), validate);
268
56
        let focus_lost: TextInputOnFocusLostCallbackType = on_focus_lost;
269
56
        self.text_input.set_on_focus_lost(state, focus_lost);
270
56
        self.text_input.dom()
271
56
    }
272
}
273

            
274
12
extern "C" fn on_focus_lost(
275
12
    mut refany: RefAny,
276
12
    info: CallbackInfo,
277
12
    _state: TextInputState,
278
12
) -> Update {
279
12
    let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
280
1
        return Update::DoNothing;
281
    };
282

            
283
11
    let number_input = &mut *refany;
284
11
    let onfocuslost = &mut number_input.on_focus_lost;
285
11
    let inner = number_input.inner;
286

            
287
11
    match onfocuslost.as_mut() {
288
10
        Some(NumberInputOnFocusLost { callback, refany }) => {
289
10
            (callback.cb)(refany.clone(), info, inner)
290
        }
291
1
        None => Update::DoNothing,
292
    }
293
12
}
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.
300
78
fn clamp_to_range(value: f32, min: f32, max: f32) -> f32 {
301
78
    let (lo, hi) = match (min.is_nan(), max.is_nan()) {
302
1
        (true, true) => return value,
303
1
        (true, false) => (max, max),
304
1
        (false, true) => (min, min),
305
75
        (false, false) if min <= max => (min, max),
306
2
        (false, false) => (max, min),
307
    };
308
77
    value.clamp(lo, hi)
309
78
}
310

            
311
145
extern "C" fn validate_text_input(
312
145
    mut refany: RefAny,
313
145
    info: CallbackInfo,
314
145
    state: TextInputState,
315
145
) -> OnTextInputReturn {
316
145
    let Some(mut refany) = refany.downcast_mut::<NumberInputStateWrapper>() else {
317
1
        return OnTextInputReturn {
318
1
            update: Update::DoNothing,
319
1
            valid: TextInputValid::Yes,
320
1
        };
321
    };
322

            
323
144
    let validated_input: String = state
324
144
        .text
325
144
        .iter()
326
40628
        .filter_map(|c| core::char::from_u32(*c))
327
40624
        .map(|c| if c == ',' { '.' } else { c })
328
144
        .collect();
329

            
330
144
    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
66
        return OnTextInputReturn {
334
66
            update: Update::DoNothing,
335
66
            valid: TextInputValid::No,
336
66
        };
337
    };
338

            
339
78
    let number_input = &mut *refany;
340
78
    let onvaluechange = &mut number_input.on_value_change;
341
78
    let inner = &mut number_input.inner;
342

            
343
78
    inner.previous = inner.number;
344
78
    let clamped = clamp_to_range(validated_f32, inner.min, inner.max);
345
78
    inner.number = clamped;
346
78
    let inner_clone = *inner;
347

            
348
78
    let update = match onvaluechange.as_mut() {
349
3
        Some(NumberInputOnValueChange { callback, refany }) => {
350
3
            (callback.cb)(refany.clone(), info, inner_clone)
351
        }
352
75
        None => Update::DoNothing,
353
    };
354

            
355
78
    OnTextInputReturn {
356
78
        update,
357
78
        valid: TextInputValid::Yes,
358
78
    }
359
145
}
360

            
361
#[cfg(all(test, feature = "std"))]
362
#[allow(clippy::float_cmp, clippy::too_many_lines)]
363
mod 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(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 (a styled `<p>`
771
    /// wrapping its bare text leaf per the label convention).
772
    fn displayed_text(dom: &Dom) -> String {
773
        let label = &dom.children.as_ref()[LABEL];
774
        label
775
            .root
776
            .get_node_type()
777
            .format()
778
            .or_else(|| {
779
                label.children.as_ref().first().and_then(|c| c.root.get_node_type().format())
780
            })
781
            .expect("the label child must wrap a text node")
782
    }
783

            
784
    fn cursor_pos(dom: &Dom) -> usize {
785
        let mut dataset = dataset_of(dom);
786
        let wrapper = dataset
787
            .downcast_ref::<TextInputStateWrapper>()
788
            .expect("the dataset must be a TextInputStateWrapper");
789
        wrapper.inner.cursor_pos
790
    }
791

            
792
    /// The `NumberInputStateWrapper` the rendered widget actually validates against —
793
    /// pulled out of the hook `dom()` installed, so nothing about the wiring is
794
    /// re-created by hand.
795
    fn number_state_of(dom: &Dom) -> RefAny {
796
        let mut dataset = dataset_of(dom);
797
        let wrapper = dataset
798
            .downcast_ref::<TextInputStateWrapper>()
799
            .expect("the dataset must be a TextInputStateWrapper");
800
        wrapper
801
            .on_text_input
802
            .as_ref()
803
            .expect("NumberInput::dom must install a text-input hook")
804
            .refany
805
            .clone()
806
    }
807

            
808
    /// Delivers `text` to whichever text-input hook the rendered widget registered.
809
    fn drive_text_input(dom: &Dom, text: &str) -> OnTextInputReturn {
810
        let mut dataset = dataset_of(dom);
811
        let hook = dataset
812
            .downcast_ref::<TextInputStateWrapper>()
813
            .expect("the dataset must be a TextInputStateWrapper")
814
            .on_text_input
815
            .as_ref()
816
            .expect("NumberInput::dom must install a text-input hook")
817
            .clone();
818
        with_info(|info| (hook.callback.cb)(hook.refany.clone(), info, text_state(text)))
819
    }
820

            
821
    /// Delivers a key-down to whichever virtual-key hook survived rendering, if any.
822
    fn drive_virtual_key_down(dom: &Dom) -> Option<OnTextInputReturn> {
823
        let mut dataset = dataset_of(dom);
824
        let hook = dataset
825
            .downcast_ref::<TextInputStateWrapper>()
826
            .expect("the dataset must be a TextInputStateWrapper")
827
            .on_virtual_key_down
828
            .as_ref()
829
            .cloned()?;
830
        Some(with_info(|info| {
831
            (hook.callback.cb)(hook.refany.clone(), info, text_state(""))
832
        }))
833
    }
834

            
835
    // ==================================================================
836
    // NumberInput::create — numeric limits
837
    // ==================================================================
838

            
839
    #[test]
840
    fn create_zero_is_exactly_the_default_widget() {
841
        assert_eq!(
842
            NumberInput::create(0.0),
843
            NumberInput::default(),
844
            "create(0.0) must not perturb anything Default already set",
845
        );
846
    }
847

            
848
    #[test]
849
    fn create_preserves_every_sample_value_bit_exactly() {
850
        for v in finite_samples() {
851
            let state = NumberInput::create(v).number_input_state.inner;
852
            assert!(
853
                same(state.number, v),
854
                "create({v:?}) stored {:?}",
855
                state.number,
856
            );
857
            assert!(
858
                same(state.previous, 0.0),
859
                "create({v:?}) must start with no history, got previous = {:?}",
860
                state.previous,
861
            );
862
            assert!(
863
                same(state.min, f32::MIN) && same(state.max, f32::MAX),
864
                "create({v:?}) must leave the range wide open, got [{}, {}]",
865
                state.min,
866
                state.max,
867
            );
868
        }
869
    }
870

            
871
    #[test]
872
    fn create_accepts_nan_and_infinities_without_panicking() {
873
        for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
874
            let state = NumberInput::create(v).number_input_state.inner;
875
            assert!(
876
                same(state.number, v),
877
                "create({v:?}) stored {:?}",
878
                state.number,
879
            );
880
        }
881
        assert!(NumberInput::create(f32::NAN)
882
            .number_input_state
883
            .inner
884
            .number
885
            .is_nan());
886
    }
887

            
888
    #[test]
889
    fn create_never_clamps_its_argument() {
890
        // `create` is documented as "the given initial value" — it does not consult
891
        // min/max, so `+inf` survives even though the default `max` is `f32::MAX`.
892
        // Clamping is the *input* path's job (see `validate_clamps_into_range`).
893
        let state = NumberInput::create(f32::INFINITY).number_input_state.inner;
894
        assert!(
895
            state.number.is_infinite(),
896
            "create must store the value verbatim, got {}",
897
            state.number,
898
        );
899
        assert!(state.number > state.max, "…even outside its own range");
900
    }
901

            
902
    #[test]
903
    fn the_default_range_leaves_every_finite_value_untouched() {
904
        let d = NumberInputState::default();
905
        assert!(
906
            d.min <= d.max,
907
            "the default range must be non-empty — f32::clamp panics otherwise",
908
        );
909
        assert!(same(d.min, f32::MIN) && same(d.max, f32::MAX));
910
        assert!(same(d.number, 0.0) && same(d.previous, 0.0));
911
        for v in finite_samples() {
912
            assert!(
913
                same(v.clamp(d.min, d.max), v),
914
                "{v:?} must pass through the default range untouched",
915
            );
916
        }
917
    }
918

            
919
    #[test]
920
    fn number_input_state_is_a_value_type() {
921
        let a = NumberInputState::default();
922
        let mut b = a;
923
        b.number = 5.0;
924
        assert!(
925
            same(a.number, 0.0),
926
            "NumberInputState is Copy — mutating a copy must not alias the original",
927
        );
928
        assert_ne!(a, b);
929
    }
930

            
931
    #[test]
932
    fn a_fresh_wrapper_has_no_hooks() {
933
        let w = NumberInputStateWrapper::default();
934
        assert!(w.on_value_change.as_ref().is_none());
935
        assert!(w.on_focus_lost.as_ref().is_none());
936
        assert_eq!(w.inner, NumberInputState::default());
937
    }
938

            
939
    // ==================================================================
940
    // Builders / setters — invariants
941
    // ==================================================================
942

            
943
    #[test]
944
    fn with_and_set_style_pairs_are_equivalent() {
945
        for n in 0..4 {
946
            let s = style(n);
947

            
948
            let a = NumberInput::create(1.0).with_placeholder_style(s.clone());
949
            let mut b = NumberInput::create(1.0);
950
            b.set_placeholder_style(s.clone());
951
            assert_eq!(a, b, "with_placeholder_style != set_placeholder_style ({n})");
952

            
953
            let a = NumberInput::create(1.0).with_container_style(s.clone());
954
            let mut b = NumberInput::create(1.0);
955
            b.set_container_style(s.clone());
956
            assert_eq!(a, b, "with_container_style != set_container_style ({n})");
957

            
958
            let a = NumberInput::create(1.0).with_label_style(s.clone());
959
            let mut b = NumberInput::create(1.0);
960
            b.set_label_style(s);
961
            assert_eq!(a, b, "with_label_style != set_label_style ({n})");
962
        }
963
    }
964

            
965
    #[test]
966
    fn style_setters_write_to_disjoint_fields() {
967
        let placeholder = style(1);
968
        let container = style(2);
969
        let label = style(3);
970
        assert_ne!(placeholder, container, "the fixture must be distinguishable");
971
        assert_ne!(container, label, "the fixture must be distinguishable");
972

            
973
        let input = NumberInput::create(0.0)
974
            .with_placeholder_style(placeholder.clone())
975
            .with_container_style(container.clone())
976
            .with_label_style(label.clone());
977

            
978
        assert_eq!(input.text_input.placeholder_style, placeholder);
979
        assert_eq!(input.text_input.container_style, container);
980
        assert_eq!(input.text_input.label_style, label);
981
        assert_eq!(
982
            input.style,
983
            NumberInput::default().style,
984
            "NumberInput::style is not a dumping ground for the TextInput styles",
985
        );
986
    }
987

            
988
    #[test]
989
    fn with_and_set_callback_pairs_are_equivalent() {
990
        // The same `RefAny` handle on both sides: `RefAny` equality is identity of the
991
        // shared allocation, so two independent `RefAny::new(0u32)` would never match.
992
        let data = RefAny::new(0u32);
993

            
994
        let a = NumberInput::create(1.0).with_on_value_change(
995
            data.clone(),
996
            record_value_change as NumberInputOnValueChangeCallbackType,
997
        );
998
        let mut b = NumberInput::create(1.0);
999
        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",
        );
        // Idempotent on an already-defaulted receiver.
        let second = input.swap_with_default();
        assert_eq!(second, NumberInput::default());
        assert_eq!(input, NumberInput::default());
    }
    // ==================================================================
    // NumberInput::dom — encode / decode round-trip
    // ==================================================================
    #[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]
                .children
                .as_ref()
                .first()
                .and_then(|c| c.root.get_node_type().format())
                .is_some(),
            "the placeholder child must wrap 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() {
        // Neither `create` nor `dom` consults min/max — only typed input is clamped.
        // A widget constructed out of range therefore *shows* a number it would never
        // accept from the keyboard.
        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() {
        // `dom()` unconditionally overwrites `on_text_input`, so a hook installed via
        // `with_on_text_input` never fires. `accept_everything` would answer `Yes` to
        // "abc"; the numeric validator answers `No`.
        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",
        );
    }
    // ==================================================================
    // validate_text_input — the parser
    // ==================================================================
    #[test]
    fn validate_rejects_malformed_input_without_touching_the_state() {
        // One state for the whole table: a rejected edit must not accumulate either.
        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,
                );
            }
            // A comma is rewritten, not deleted: a second one is still a parse error.
            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() {
        // The `,` -> `.` rewrite is unconditional, so "1,000" (US grouping for one
        // thousand) is silently read as *one*. That is the price of supporting the
        // European decimal comma, and it is worth pinning down: the value a user
        // typed changes by three orders of magnitude with no rejection.
        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() {
        // The edit buffer is a `U32Vec`, so it can hold unpaired surrogates and
        // out-of-range code units. `char::from_u32` returns None for those and the
        // filter *drops* them, so "1<D800>5" is read as the number 15 rather than
        // being rejected as malformed.
        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,
        );
        // …but a buffer made *only* of non-scalars collapses to the empty string,
        // which is rejected rather than read as zero.
        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),          // overflows to +inf, saturates
            (format!("-{}", "9".repeat(10_000)), f32::MIN),
            (format!("{}1", "0".repeat(10_000)), 1.0), // leading zeros
            (format!("0.{}", "0".repeat(10_000)), 0.0),
            ("1e999999999".to_string(), f32::MAX),   // exponent overflow
            ("1e-999999999".to_string(), 0.0),       // exponent underflow
        ] {
            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,
            );
        }
    }
    // ==================================================================
    // validate_text_input — numeric limits
    // ==================================================================
    #[test]
    fn validate_saturates_overflow_at_the_configured_bounds() {
        let state = RefAny::new(wrapper(0.0, f32::MIN, f32::MAX));
        // "1e39" is well past f32::MAX, so the parser returns +inf rather than an
        // error — the saturation has to happen here, in the clamp.
        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),          // exactly on the bound
                (-10.0, 10.0, "-10", -10.0),
                (-10.0, 10.0, "10.000001", 10.0),   // one ulp past the bound
                (-10.0, 10.0, "0", 0.0),
                (0.0, 0.0, "5", 0.0),               // a single-point range is legal
                (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() {
        // `f32::clamp` compares, and every comparison against NaN is false, so a NaN
        // walks straight through the range check. The widget's "number is always in
        // [min, max]" invariant therefore has exactly one hole, and it is reachable
        // by typing "nan" into the field.
        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),   // clamped
                ("200", 10.0, 10.0),  // `previous` is the *clamped* old value
                ("abc", 10.0, 10.0),  // rejected: neither field moves
                ("-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,
                );
            }
        });
    }
    /// `validate_text_input` runs `f32::clamp(min, max)` on every value it parses,
    /// and `f32::clamp` **panics** unless `min <= max` — which a NaN bound also
    /// fails. `min`/`max` are `pub` fields on a `#[repr(C)]` struct that crosses the
    /// C/FFI boundary, so nothing stops a caller from handing the widget an inverted
    /// or NaN-bounded range, and a panic inside a UI callback takes the app with it.
    /// Rejecting the edit (`TextInputValid::No`) or normalising the range would both
    /// be safe; unwinding is not.
    #[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:?}",
        );
    }
    // ==================================================================
    // validate_text_input — hooks and payload handling
    // ==================================================================
    #[test]
    fn validate_with_a_foreign_payload_accepts_the_edit_unchanged() {
        // The downcast guard bails out *before* parsing, so a mis-wired NumberInput
        // reports arbitrary text as valid instead of rejecting it.
        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",
        );
        // Sanity: the hook *is* wired up and does fire for a well-formed edit.
        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",
        );
    }
    // ==================================================================
    // on_focus_lost
    // ==================================================================
    #[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() {
        // `on_focus_lost` never looks at the `TextInputState` it is handed: the value
        // it reports is the one the *validator* accepted, not whatever happens to be
        // sitting in the 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() {
        // A state built out of range (see `dom_renders_a_value_that_is_outside_its_own_range`)
        // is reported verbatim: focus-lost is a read-only notification.
        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",
        );
    }
}