1
//! Rectangular input that displays a color and invokes a callback when clicked
2

            
3
use azul_core::{
4
    callbacks::Update,
5
    dom::Dom,
6
    refany::RefAny,
7
};
8
use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
9
#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
10
use azul_css::{
11
    props::{
12
        basic::*,
13
        layout::*,
14
        property::{CssProperty, *},
15
        style::*,
16
    },
17
    *,
18
};
19

            
20
use crate::callbacks::{Callback, CallbackInfo};
21

            
22
/// Rectangular input that displays a color and triggers a callback when clicked.
23
#[derive(Debug, Default, Clone, PartialEq, Eq)]
24
#[repr(C)]
25
pub struct ColorInput {
26
    pub color_input_state: ColorInputStateWrapper,
27
    pub style: CssPropertyWithConditionsVec,
28
}
29

            
30
/// Callback function type invoked when the color input value changes.
31
pub type ColorInputOnValueChangeCallbackType =
32
    extern "C" fn(RefAny, CallbackInfo, ColorInputState) -> Update;
33
impl_widget_callback!(
34
    ColorInputOnValueChange,
35
    OptionColorInputOnValueChange,
36
    ColorInputOnValueChangeCallback,
37
    ColorInputOnValueChangeCallbackType
38
);
39

            
40
azul_core::impl_managed_callback! {
41
    wrapper:        ColorInputOnValueChangeCallback,
42
    info_ty:        CallbackInfo,
43
    return_ty:      Update,
44
    default_ret:    Update::DoNothing,
45
    invoker_static: COLOR_INPUT_ON_VALUE_CHANGE_INVOKER,
46
    invoker_ty:     AzColorInputOnValueChangeCallbackInvoker,
47
    thunk_fn:       az_color_input_on_value_change_callback_thunk,
48
    setter_fn:      AzApp_setColorInputOnValueChangeCallbackInvoker,
49
    from_handle_fn: AzColorInputOnValueChangeCallback_createFromHostHandle,
50
    extra_args:     [ state: ColorInputState ],
51
}
52

            
53
/// Wrapper around [`ColorInputState`] that includes a title and an optional value-change callback.
54
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd)]
55
#[repr(C)]
56
pub struct ColorInputStateWrapper {
57
    pub inner: ColorInputState,
58
    pub title: AzString,
59
    pub on_value_change: OptionColorInputOnValueChange,
60
}
61

            
62
impl Default for ColorInputStateWrapper {
63
296
    fn default() -> Self {
64
296
        Self {
65
296
            inner: ColorInputState::default(),
66
296
            title: AzString::from_const_str("Pick color"),
67
296
            on_value_change: None.into(),
68
296
        }
69
296
    }
70
}
71

            
72
/// Holds the current color value of a [`ColorInput`] widget.
73
#[derive(Copy, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)]
74
#[repr(C)]
75
pub struct ColorInputState {
76
    pub color: ColorU,
77
}
78

            
79
impl Default for ColorInputState {
80
300
    fn default() -> Self {
81
300
        Self {
82
300
            color: ColorU {
83
300
                r: 255,
84
300
                g: 255,
85
300
                b: 255,
86
300
                a: 255,
87
300
            },
88
300
        }
89
300
    }
90
}
91

            
92
static DEFAULT_COLOR_INPUT_STYLE: &[CssPropertyWithConditions] = &[
93
    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Block)),
94
    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
95
    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(14))),
96
    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(14))),
97
    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
98
];
99

            
100
impl ColorInput {
101
    /// Creates a new `ColorInput` displaying the given color.
102
    #[inline]
103
    #[must_use]
104
267
    pub fn create(color: ColorU) -> Self {
105
267
        Self {
106
267
            color_input_state: ColorInputStateWrapper {
107
267
                inner: ColorInputState { color },
108
267
                ..Default::default()
109
267
            },
110
267
            style: CssPropertyWithConditionsVec::from_const_slice(DEFAULT_COLOR_INPUT_STYLE),
111
267
        }
112
267
    }
113

            
114
    /// Sets the callback invoked when the color value changes.
115
    #[inline]
116
44
    pub fn set_on_value_change<I: Into<ColorInputOnValueChangeCallback>>(
117
44
        &mut self,
118
44
        data: RefAny,
119
44
        callback: I,
120
44
    ) {
121
44
        self.color_input_state.on_value_change = Some(ColorInputOnValueChange {
122
44
            callback: callback.into(),
123
44
            refany: data,
124
44
        })
125
44
        .into();
126
44
    }
127

            
128
    /// Builder-style method to set the value-change callback.
129
    #[inline]
130
    #[must_use]
131
32
    pub fn with_on_value_change<C: Into<ColorInputOnValueChangeCallback>>(
132
32
        mut self,
133
32
        data: RefAny,
134
32
        callback: C,
135
32
    ) -> Self {
136
32
        self.set_on_value_change(data, callback);
137
32
        self
138
32
    }
139

            
140
    /// Replaces `self` with a default `ColorInput` and returns the previous value.
141
    #[inline]
142
    #[must_use]
143
12
    pub fn swap_with_default(&mut self) -> Self {
144
12
        let mut s = Self::default();
145
12
        core::mem::swap(&mut s, self);
146
12
        s
147
12
    }
148

            
149
    /// Converts this `ColorInput` into a styled [`Dom`] node with a click callback.
150
    #[inline]
151
    #[must_use]
152
99
    pub fn dom(self) -> Dom {
153
        use azul_core::{
154
            callbacks::{CoreCallback, CoreCallbackData},
155
            dom::{EventFilter, HoverEventFilter, IdOrClass::Class},
156
        };
157

            
158
99
        let mut style = self.style.into_library_owned_vec();
159
99
        style.push(CssPropertyWithConditions::simple(
160
99
            CssProperty::const_background_content(
161
99
                vec![StyleBackgroundContent::Color(
162
99
                    self.color_input_state.inner.color,
163
99
                )]
164
99
                .into(),
165
            ),
166
        ));
167

            
168
99
        Dom::create_div()
169
99
            .with_ids_and_classes(vec![Class("__azul_native_color_input".into())].into())
170
99
            .with_css_props(style.into())
171
99
            .with_callbacks(
172
99
                vec![CoreCallbackData {
173
99
                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
174
99
                    refany: RefAny::new(self.color_input_state),
175
99
                    callback: CoreCallback {
176
99
                        cb: on_color_input_clicked as usize,
177
99
                        ctx: azul_core::refany::OptionRefAny::None,
178
99
                    },
179
99
                }]
180
99
                .into(),
181
            )
182
99
    }
183
}
184

            
185
34
extern "C" fn on_color_input_clicked(mut data: RefAny, mut info: CallbackInfo) -> Update {
186
34
    let Some(mut color_input) = data.downcast_mut::<ColorInputStateWrapper>() else {
187
1
        return Update::DoNothing;
188
    };
189

            
190
    // No built-in color picker dialog — the on_value_change callback
191
    // receives the current color so the caller can open their own picker.
192
33
    let color_input = &mut *color_input;
193
33
    let onvaluechange = &mut color_input.on_value_change;
194
33
    let inner = color_input.inner;
195

            
196
33
    match onvaluechange.as_mut() {
197
        Some(ColorInputOnValueChange {
198
25
            callback,
199
25
            refany: data,
200
25
        }) => (callback.cb)(data.clone(), info, inner),
201
8
        None => Update::DoNothing,
202
    }
203
34
}
204

            
205
#[cfg(all(test, feature = "std"))]
206
#[allow(clippy::float_cmp, clippy::too_many_lines)]
207
mod autotest_generated {
208
    use std::{
209
        collections::{hash_map::DefaultHasher, BTreeMap, HashMap},
210
        hash::{Hash, Hasher},
211
        mem::discriminant,
212
        sync::{Arc, Mutex},
213
    };
214

            
215
    use azul_core::{
216
        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, IdOrClass, NodeId, NodeType},
217
        geom::{LogicalRect, OptionLogicalPosition},
218
        gl::OptionGlContextPtr,
219
        hit_test::ScrollPosition,
220
        refany::OptionRefAny,
221
        resources::RendererResources,
222
        styled_dom::{NodeHierarchyItemId, StyledDom},
223
        window::{MonitorVec, RawWindowHandle},
224
    };
225
    use azul_css::props::basic::{length::SizeMetric, pixel::PixelValue};
226
    use rust_fontconfig::FcFontCache;
227

            
228
    use super::*;
229
    #[cfg(feature = "icu")]
230
    use crate::icu::IcuLocalizerHandle;
231
    use crate::{
232
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
233
        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
234
        window::{DomLayoutResult, LayoutWindow},
235
        window_state::FullWindowState,
236
    };
237

            
238
    // ------------------------------------------------------------------
239
    // Fixtures
240
    // ------------------------------------------------------------------
241

            
242
    /// The swatch is a fixed 14x14 box — the entire geometry of the widget.
243
    const SIDE: f32 = 14.0;
244

            
245
    /// The widget's default title, as promised by `ColorInputStateWrapper::default`.
246
    const DEFAULT_TITLE: &str = "Pick color";
247

            
248
    /// The color a freshly-defaulted `ColorInputState` holds: **opaque white**, which is
249
    /// deliberately *not* `ColorU::default()` (that one is opaque black). A swatch that
250
    /// silently defaulted to black would be indistinguishable from a "real" black pick.
251
    const DEFAULT_COLOR: ColorU = ColorU {
252
        r: 255,
253
        g: 255,
254
        b: 255,
255
        a: 255,
256
    };
257

            
258
    /// Adversarial `ColorU` inputs. `create`/`dom` must move all four channels through
259
    /// verbatim, so the set covers both alpha extremes, the two off-by-one alphas, and
260
    /// `{1,2,3,4}` — four distinct small values that catch any channel reordering (an
261
    /// r/b swap is invisible for greys and for anything symmetric).
262
    const SAMPLE_COLORS: [ColorU; 8] = [
263
        ColorU { r: 0, g: 0, b: 0, a: 0 },
264
        ColorU { r: 0, g: 0, b: 0, a: 255 },
265
        ColorU { r: 255, g: 255, b: 255, a: 255 },
266
        ColorU { r: 255, g: 255, b: 255, a: 0 },
267
        ColorU { r: 255, g: 0, b: 0, a: 1 },
268
        ColorU { r: 0, g: 255, b: 0, a: 254 },
269
        ColorU { r: 1, g: 2, b: 3, a: 4 },
270
        ColorU { r: 128, g: 64, b: 32, a: 16 },
271
    ];
272

            
273
    // ------------------------------------------------------------------
274
    // Style-vec / DOM probes
275
    // ------------------------------------------------------------------
276

            
277
    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
278
        v.as_ref().iter().map(|p| p.property.clone()).collect()
279
    }
280

            
281
    fn find<T>(v: &CssPropertyWithConditionsVec, f: impl Fn(&CssProperty) -> Option<T>) -> Option<T> {
282
        v.as_ref().iter().find_map(|p| f(&p.property))
283
    }
284

            
285
    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length. An `em` or
286
    /// `%` slipping into the swatch geometry would resolve against the parent font/box,
287
    /// so the "14px" swatch could render at any size at all.
288
    fn px(pv: &PixelValue) -> f32 {
289
        assert_eq!(
290
            pv.metric,
291
            SizeMetric::Px,
292
            "color-input geometry must be absolute px, got {:?}",
293
            pv.metric,
294
        );
295
        pv.number.get()
296
    }
297

            
298
    fn width_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
299
        find(v, |p| match p {
300
            CssProperty::Width(w) => match w.get_property() {
301
                Some(LayoutWidth::Px(pv)) => Some(px(pv)),
302
                _ => None,
303
            },
304
            _ => None,
305
        })
306
    }
307

            
308
    fn height_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
309
        find(v, |p| match p {
310
            CssProperty::Height(h) => match h.get_property() {
311
                Some(LayoutHeight::Px(pv)) => Some(px(pv)),
312
                _ => None,
313
            },
314
            _ => None,
315
        })
316
    }
317

            
318
    /// The `background-color` of a style vec (first background layer only).
319
    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
320
        v.as_ref().iter().find_map(|p| match &p.property {
321
            CssProperty::BackgroundContent(b) => match b.get_property()?.as_ref().first()? {
322
                StyleBackgroundContent::Color(c) => Some(*c),
323
                _ => None,
324
            },
325
            _ => None,
326
        })
327
    }
328

            
329
    fn classes(dom: &Dom) -> Vec<String> {
330
        dom.root
331
            .get_ids_and_classes()
332
            .as_ref()
333
            .iter()
334
            .filter_map(|c| match c {
335
                IdOrClass::Class(s) => Some(s.as_str().to_string()),
336
                IdOrClass::Id(_) => None,
337
            })
338
            .collect()
339
    }
340

            
341
    /// The properties of a rendered node's *inline* style, in declaration order.
342
    fn inline_properties(dom: &Dom) -> Vec<CssProperty> {
343
        dom.root
344
            .style
345
            .iter_inline_properties()
346
            .map(|(p, _)| p.clone())
347
            .collect()
348
    }
349

            
350
    /// The `background-color` actually declared on the rendered node.
351
    fn dom_background(dom: &Dom) -> Option<ColorU> {
352
        inline_properties(dom).into_iter().find_map(|p| match p {
353
            CssProperty::BackgroundContent(b) => match b.get_property()?.as_ref().first()? {
354
                StyleBackgroundContent::Color(c) => Some(*c),
355
                _ => None,
356
            },
357
            _ => None,
358
        })
359
    }
360

            
361
    /// The exact property `dom()` is expected to append for `c`.
362
    fn expected_background(c: ColorU) -> CssProperty {
363
        CssProperty::const_background_content(StyleBackgroundContentVec::from_vec(vec![
364
            StyleBackgroundContent::Color(c),
365
        ]))
366
    }
367

            
368
    fn hash_of<T: Hash>(t: &T) -> u64 {
369
        let mut h = DefaultHasher::new();
370
        t.hash(&mut h);
371
        h.finish()
372
    }
373

            
374
    // ------------------------------------------------------------------
375
    // Callback harness
376
    // ------------------------------------------------------------------
377

            
378
    /// A `DomNodeId` in the root DOM pointing at flattened node `idx`.
379
    fn node(idx: usize) -> DomNodeId {
380
        DomNodeId {
381
            dom: DomId::ROOT_ID,
382
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(idx))),
383
        }
384
    }
385

            
386
    /// A `DomNodeId` whose node component is `None` — the "no concrete node was hit" case.
387
    fn node_none() -> DomNodeId {
388
        DomNodeId {
389
            dom: DomId::ROOT_ID,
390
            node: NodeHierarchyItemId::NONE,
391
        }
392
    }
393

            
394
    /// A `DomLayoutResult` carrying only a `styled_dom`. `on_color_input_clicked` never
395
    /// queries the layout at all, so no real layout (and no font) is needed.
396
    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
397
        DomLayoutResult {
398
            styled_dom,
399
            layout_tree: LayoutTree {
400
                nodes: Vec::new(),
401
                warm: Vec::new(),
402
                cold: Vec::new(),
403
                root: 0,
404
                dom_to_layout: BTreeMap::new(),
405
                children_arena: Vec::new(),
406
                children_offsets: Vec::new(),
407
                subtree_needs_intrinsic: Vec::new(),
408
            },
409
            calculated_positions: Vec::new(),
410
            viewport: LogicalRect::zero(),
411
            display_list: Arc::new(DisplayList::default()),
412
            scroll_ids: HashMap::new(),
413
            scroll_id_to_node_id: HashMap::new(),
414
        }
415
    }
416

            
417
    /// Runs `f` with a `CallbackInfo` whose window holds `styled_dom` as the root DOM and
418
    /// whose hit node is `hit`. Returns `f`'s value plus every change the callback pushed
419
    /// onto the transaction log.
420
    fn with_info<R>(
421
        styled_dom: StyledDom,
422
        hit: DomNodeId,
423
        f: impl FnOnce(&mut CallbackInfo) -> R,
424
    ) -> (R, Vec<CallbackChange>) {
425
        let mut layout_window =
426
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
427
        layout_window
428
            .layout_results
429
            .insert(DomId::ROOT_ID, layout_result(styled_dom));
430

            
431
        let renderer_resources = RendererResources::default();
432
        let previous_window_state: Option<FullWindowState> = None;
433
        let current_window_state = FullWindowState::default();
434
        let gl_context = OptionGlContextPtr::None;
435
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
436
            BTreeMap::new();
437
        let window_handle = RawWindowHandle::Unsupported;
438
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
439

            
440
        let ref_data = CallbackInfoRefData {
441
            layout_window: &layout_window,
442
            renderer_resources: &renderer_resources,
443
            previous_window_state: &previous_window_state,
444
            current_window_state: &current_window_state,
445
            gl_context: &gl_context,
446
            current_scroll_manager: &scroll_states,
447
            current_window_handle: &window_handle,
448
            system_callbacks: &system_callbacks,
449
            system_style: Arc::new(system::SystemStyle::default()),
450
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
451
            #[cfg(feature = "icu")]
452
            icu_localizer: IcuLocalizerHandle::default(),
453
            ctx: OptionRefAny::None,
454
        };
455

            
456
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
457

            
458
        let mut info = CallbackInfo::new(
459
            &ref_data,
460
            &changes,
461
            hit,
462
            OptionLogicalPosition::None,
463
            OptionLogicalPosition::None,
464
        );
465

            
466
        let r = f(&mut info);
467
        let pushed = info.take_changes();
468
        (r, pushed)
469
    }
470

            
471
    /// Renders `color_input`, then hands back both the laid-out DOM *and* the very `RefAny`
472
    /// the widget registered on its own mouse-up callback. Driving the handler with these
473
    /// two is the real wiring — nothing is re-created by hand, so a mismatch between what
474
    /// `dom()` stores and what the handler expects cannot hide behind the fixture.
475
    fn laid_out(color_input: ColorInput) -> (StyledDom, RefAny) {
476
        let dom = color_input.dom();
477
        let state = dom.root.callbacks.as_ref()[0].refany.clone();
478
        (StyledDom::create_from_dom(dom), state)
479
    }
480

            
481
    /// One "mouse-up on `hit`" delivered to the widget's own registered handler.
482
    fn click(styled_dom: StyledDom, state: &RefAny, hit: DomNodeId) -> (Update, Vec<CallbackChange>) {
483
        with_info(styled_dom, hit, |info| {
484
            on_color_input_clicked(state.clone(), *info)
485
        })
486
    }
487

            
488
    fn state_color(state: &RefAny) -> ColorU {
489
        let mut state = state.clone();
490
        let wrapper = state
491
            .downcast_ref::<ColorInputStateWrapper>()
492
            .expect("the widget state changed type");
493
        wrapper.inner.color
494
    }
495

            
496
    /// A payload the value-change callback writes into. It arrives as the `data: RefAny`
497
    /// argument — a *shared* clone of what the test still holds — so the test can read back
498
    /// exactly what the widget passed, without any global state.
499
    #[derive(Debug, Clone, PartialEq, Eq)]
500
    struct ColorLog {
501
        seen: Vec<ColorU>,
502
        payload: u32,
503
    }
504

            
505
    extern "C" fn record_value(
506
        mut data: RefAny,
507
        _info: CallbackInfo,
508
        state: ColorInputState,
509
    ) -> Update {
510
        if let Some(mut log) = data.downcast_mut::<ColorLog>() {
511
            log.seen.push(state.color);
512
        }
513
        Update::RefreshDom
514
    }
515

            
516
    extern "C" fn value_do_nothing(
517
        _data: RefAny,
518
        _info: CallbackInfo,
519
        _state: ColorInputState,
520
    ) -> Update {
521
        Update::DoNothing
522
    }
523

            
524
    extern "C" fn value_refresh_all(
525
        _data: RefAny,
526
        _info: CallbackInfo,
527
        _state: ColorInputState,
528
    ) -> Update {
529
        Update::RefreshDomAllWindows
530
    }
531

            
532
    /// A `Callback`-shaped (2-arg) function — the shape FFI bindings hand in, which the
533
    /// `From<Callback>` arm *transmutes* into the 3-arg color-input slot. Never called.
534
    extern "C" fn generic_shaped(_data: RefAny, _info: CallbackInfo) -> Update {
535
        Update::DoNothing
536
    }
537

            
538
    fn log_refany() -> RefAny {
539
        RefAny::new(ColorLog {
540
            seen: Vec::new(),
541
            payload: 0xDEAD_BEEF,
542
        })
543
    }
544

            
545
    fn read_log(probe: &RefAny) -> ColorLog {
546
        let mut probe = probe.clone();
547
        let log = probe
548
            .downcast_ref::<ColorLog>()
549
            .expect("the user payload changed type");
550
        log.clone()
551
    }
552

            
553
    // ==================================================================
554
    // ColorInput::create
555
    // ==================================================================
556

            
557
    #[test]
558
    fn create_stores_every_channel_verbatim() {
559
        // A channel swap (r/b) or a dropped alpha still type-checks and still renders
560
        // *a* color — only an asymmetric fixture catches it.
561
        for c in SAMPLE_COLORS {
562
            let w = ColorInput::create(c);
563
            assert_eq!(
564
                w.color_input_state.inner.color, c,
565
                "create({c:?}) did not store the color it was given",
566
            );
567
        }
568
    }
569

            
570
    #[test]
571
    fn create_installs_no_callback_and_the_default_title() {
572
        for c in SAMPLE_COLORS {
573
            let w = ColorInput::create(c);
574
            assert!(
575
                w.color_input_state.on_value_change.as_ref().is_none(),
576
                "create({c:?}) invented a value-change callback out of nowhere",
577
            );
578
            assert_eq!(
579
                w.color_input_state.title.as_str(),
580
                DEFAULT_TITLE,
581
                "create({c:?}) did not keep the default title",
582
            );
583
        }
584
    }
585

            
586
    #[test]
587
    fn create_is_pure_and_distinguishes_every_sample_color() {
588
        for c in SAMPLE_COLORS {
589
            assert_eq!(
590
                ColorInput::create(c),
591
                ColorInput::create(c),
592
                "create({c:?}) is not deterministic",
593
            );
594
        }
595
        for (i, a) in SAMPLE_COLORS.iter().enumerate() {
596
            for b in &SAMPLE_COLORS[i + 1..] {
597
                assert_ne!(
598
                    ColorInput::create(*a),
599
                    ColorInput::create(*b),
600
                    "the widgets for {a:?} and {b:?} are indistinguishable",
601
                );
602
            }
603
        }
604
    }
605

            
606
    #[test]
607
    fn create_treats_alpha_as_significant() {
608
        // `{255,0,0,0}` and `{255,0,0,255}` differ only in alpha: an invisible swatch and
609
        // an opaque red one. Comparing on rgb alone would fuse the two.
610
        let opaque = ColorU { r: 255, g: 0, b: 0, a: 255 };
611
        let clear = ColorU { r: 255, g: 0, b: 0, a: 0 };
612
        assert_ne!(
613
            ColorInput::create(opaque),
614
            ColorInput::create(clear),
615
            "a transparent swatch compares equal to an opaque one",
616
        );
617
    }
618

            
619
    #[test]
620
    fn create_geometry_is_absolute_14px_for_every_color() {
621
        // `px()` asserts SizeMetric::Px — an em/% here would scale with the parent.
622
        for c in SAMPLE_COLORS {
623
            let w = ColorInput::create(c);
624
            assert_eq!(width_px(&w.style), Some(SIDE), "{c:?}: wrong swatch width");
625
            assert_eq!(height_px(&w.style), Some(SIDE), "{c:?}: wrong swatch height");
626
        }
627
    }
628

            
629
    #[test]
630
    fn create_marks_the_swatch_as_clickable() {
631
        // Without `cursor: pointer` the swatch looks inert even though it is the node
632
        // that carries the mouse-up handler.
633
        let props = properties(&ColorInput::create(DEFAULT_COLOR).style);
634
        assert!(
635
            props.contains(&CssProperty::const_cursor(StyleCursor::Pointer)),
636
            "the color input does not present as clickable: {props:?}",
637
        );
638
    }
639

            
640
    #[test]
641
    fn create_is_a_non_growing_block() {
642
        // A swatch with flex-grow != 0 would stretch to fill its row and stop being a
643
        // 14px square, silently defeating the width/height declarations above.
644
        let props = properties(&ColorInput::create(DEFAULT_COLOR).style);
645
        assert!(
646
            props.contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
647
            "the swatch is allowed to flex-grow: {props:?}",
648
        );
649
        assert!(
650
            props.contains(&CssProperty::const_display(LayoutDisplay::Block)),
651
            "the swatch is not a block box: {props:?}",
652
        );
653
    }
654

            
655
    #[test]
656
    fn create_declares_no_property_twice() {
657
        // A duplicate declaration means the later one silently wins — a latent
658
        // "why is my override ignored" bug that never surfaces as an error.
659
        let props = properties(&ColorInput::create(DEFAULT_COLOR).style);
660
        let mut seen = Vec::new();
661
        for p in &props {
662
            let d = discriminant(p);
663
            assert!(!seen.contains(&d), "the base style declares {p:?} twice");
664
            seen.push(d);
665
        }
666
    }
667

            
668
    #[test]
669
    fn create_keeps_the_color_out_of_the_base_style() {
670
        // The color lives in the *state* and is only turned into a background by `dom()`.
671
        // A background baked into the shared const table would make every swatch on screen
672
        // render the same color (and `dom()` would then declare it twice).
673
        for c in SAMPLE_COLORS {
674
            assert_eq!(
675
                background_color(&ColorInput::create(c).style),
676
                None,
677
                "create({c:?}) leaked the color into the base style",
678
            );
679
        }
680
    }
681

            
682
    #[test]
683
    fn create_style_does_not_depend_on_the_color() {
684
        let reference = properties(&ColorInput::create(SAMPLE_COLORS[0]).style);
685
        for c in SAMPLE_COLORS {
686
            assert_eq!(
687
                properties(&ColorInput::create(c).style),
688
                reference,
689
                "create({c:?}) produced a different style than create({:?})",
690
                SAMPLE_COLORS[0],
691
            );
692
        }
693
    }
694

            
695
    // ==================================================================
696
    // Default state invariants
697
    // ==================================================================
698

            
699
    #[test]
700
    fn the_default_color_is_opaque_white_not_colorus_own_default() {
701
        // `ColorU::default()` is opaque *black*. If `ColorInputState` ever fell back to the
702
        // derived default, every un-set swatch would render black — and a user who really
703
        // picked black would be indistinguishable from one who picked nothing.
704
        assert_eq!(ColorInputState::default().color, DEFAULT_COLOR);
705
        assert_ne!(
706
            ColorInputState::default().color,
707
            ColorU::default(),
708
            "the color input's default silently became ColorU::default()",
709
        );
710
        assert_eq!(ColorInputStateWrapper::default().inner.color, DEFAULT_COLOR);
711
        assert_eq!(
712
            ColorInputStateWrapper::default().title.as_str(),
713
            DEFAULT_TITLE,
714
        );
715
        assert!(ColorInputStateWrapper::default()
716
            .on_value_change
717
            .as_ref()
718
            .is_none());
719
    }
720

            
721
    #[test]
722
    fn color_input_state_ord_and_partial_ord_agree() {
723
        // `ColorInputState` derives both. A hand-written impl drifting from the other would
724
        // make sorted containers of states behave inconsistently with `<`.
725
        for a in SAMPLE_COLORS {
726
            for b in SAMPLE_COLORS {
727
                let (x, y) = (ColorInputState { color: a }, ColorInputState { color: b });
728
                assert_eq!(
729
                    x.partial_cmp(&y),
730
                    Some(x.cmp(&y)),
731
                    "PartialOrd and Ord disagree for {a:?} vs {b:?}",
732
                );
733
                assert_eq!(
734
                    x == y,
735
                    x.cmp(&y) == core::cmp::Ordering::Equal,
736
                    "Eq and Ord disagree for {a:?} vs {b:?}",
737
                );
738
            }
739
        }
740
    }
741

            
742
    #[test]
743
    fn equal_color_input_states_hash_equal() {
744
        // The Hash/Eq contract: `a == b` must imply `hash(a) == hash(b)`, or a
745
        // `HashMap<ColorInputState, _>` loses entries.
746
        for c in SAMPLE_COLORS {
747
            let a = ColorInputState { color: c };
748
            let b = ColorInputState { color: c };
749
            assert_eq!(a, b);
750
            assert_eq!(hash_of(&a), hash_of(&b), "equal states hash differently ({c:?})");
751
        }
752
    }
753

            
754
    #[test]
755
    fn color_input_state_equality_is_channel_exact() {
756
        // One channel bumped by one must break equality — in all four channels.
757
        let base = ColorU { r: 10, g: 20, b: 30, a: 40 };
758
        let variants = [
759
            ColorU { r: 11, ..base },
760
            ColorU { g: 21, ..base },
761
            ColorU { b: 31, ..base },
762
            ColorU { a: 41, ..base },
763
        ];
764
        for v in variants {
765
            assert_ne!(
766
                ColorInputState { color: base },
767
                ColorInputState { color: v },
768
                "a one-channel difference ({base:?} vs {v:?}) was swallowed",
769
            );
770
        }
771
    }
772

            
773
    // ==================================================================
774
    // ColorInput::set_on_value_change / with_on_value_change
775
    // ==================================================================
776

            
777
    #[test]
778
    fn set_on_value_change_stores_the_function_pointer_and_the_payload_verbatim() {
779
        let mut w = ColorInput::create(DEFAULT_COLOR);
780
        w.set_on_value_change(
781
            RefAny::new(0xDEAD_BEEF_u32),
782
            value_do_nothing as ColorInputOnValueChangeCallbackType,
783
        );
784

            
785
        let t = w
786
            .color_input_state
787
            .on_value_change
788
            .as_ref()
789
            .expect("set_on_value_change did not store anything");
790
        assert_eq!(
791
            t.callback.cb as *const () as usize,
792
            value_do_nothing as ColorInputOnValueChangeCallbackType as *const () as usize,
793
            "the fn pointer was corrupted on the way in",
794
        );
795

            
796
        let mut data = t.refany.clone();
797
        assert_eq!(
798
            *data.downcast_ref::<u32>().expect("the payload changed type"),
799
            0xDEAD_BEEF,
800
            "the payload was corrupted",
801
        );
802
        assert!(
803
            data.downcast_ref::<u64>().is_none(),
804
            "downcasting to the wrong type must fail, not reinterpret the bytes",
805
        );
806
    }
807

            
808
    #[test]
809
    fn set_on_value_change_replaces_rather_than_accumulates() {
810
        // `OptionColorInputOnValueChange` is a single slot; setting twice must leave the
811
        // *second* callback installed (and must not leak or free the first one's RefAny).
812
        let first = log_refany();
813
        let mut w = ColorInput::create(DEFAULT_COLOR);
814
        w.set_on_value_change(
815
            first.clone(),
816
            value_do_nothing as ColorInputOnValueChangeCallbackType,
817
        );
818
        w.set_on_value_change(
819
            RefAny::new(1u8),
820
            value_refresh_all as ColorInputOnValueChangeCallbackType,
821
        );
822

            
823
        let t = w
824
            .color_input_state
825
            .on_value_change
826
            .as_ref()
827
            .expect("the callback vanished");
828
        assert_eq!(
829
            t.callback.cb as *const () as usize,
830
            value_refresh_all as ColorInputOnValueChangeCallbackType as *const () as usize,
831
            "the second set_on_value_change did not win",
832
        );
833
        // The displaced payload is still a valid, readable RefAny (not freed twice).
834
        assert_eq!(read_log(&first).payload, 0xDEAD_BEEF);
835
    }
836

            
837
    #[test]
838
    fn set_on_value_change_does_not_disturb_the_color_or_the_style() {
839
        for c in SAMPLE_COLORS {
840
            let pristine = ColorInput::create(c);
841
            let mut w = ColorInput::create(c);
842
            w.set_on_value_change(
843
                RefAny::new(0u8),
844
                value_do_nothing as ColorInputOnValueChangeCallbackType,
845
            );
846

            
847
            assert_eq!(
848
                w.color_input_state.inner.color, c,
849
                "installing a callback rewrote the color",
850
            );
851
            assert_eq!(
852
                properties(&w.style),
853
                properties(&pristine.style),
854
                "installing a callback rewrote the style",
855
            );
856
            assert_eq!(
857
                w.color_input_state.title.as_str(),
858
                pristine.color_input_state.title.as_str(),
859
                "installing a callback rewrote the title",
860
            );
861
        }
862
    }
863

            
864
    #[test]
865
    fn with_on_value_change_is_exactly_set_on_value_change_in_builder_form() {
866
        let by_builder = ColorInput::create(SAMPLE_COLORS[6]).with_on_value_change(
867
            RefAny::new(7u32),
868
            value_do_nothing as ColorInputOnValueChangeCallbackType,
869
        );
870

            
871
        let mut by_setter = ColorInput::create(SAMPLE_COLORS[6]);
872
        by_setter.set_on_value_change(
873
            RefAny::new(7u32),
874
            value_do_nothing as ColorInputOnValueChangeCallbackType,
875
        );
876

            
877
        assert_eq!(by_builder.color_input_state.inner, by_setter.color_input_state.inner);
878
        assert_eq!(properties(&by_builder.style), properties(&by_setter.style));
879

            
880
        let a = by_builder
881
            .color_input_state
882
            .on_value_change
883
            .as_ref()
884
            .expect("builder lost the callback");
885
        let b = by_setter
886
            .color_input_state
887
            .on_value_change
888
            .as_ref()
889
            .expect("setter lost the callback");
890
        assert_eq!(
891
            a.callback.cb as *const () as usize,
892
            b.callback.cb as *const () as usize,
893
        );
894

            
895
        let (mut a, mut b) = (a.refany.clone(), b.refany.clone());
896
        assert_eq!(
897
            *a.downcast_ref::<u32>().expect("builder payload changed type"),
898
            *b.downcast_ref::<u32>().expect("setter payload changed type"),
899
        );
900
    }
901

            
902
    #[test]
903
    fn with_on_value_change_accepts_a_generic_callback_without_mangling_the_pointer() {
904
        // The `From<Callback>` arm *transmutes* a 2-arg fn pointer into the 3-arg
905
        // color-input slot — this is the FFI (Python/C) path. The pointer must come out
906
        // bit-identical; a mangled one would be called as a wild jump on the first click.
907
        let generic = Callback {
908
            cb: generic_shaped,
909
            ctx: OptionRefAny::None,
910
        };
911
        let expected = generic_shaped as *const () as usize;
912

            
913
        let w = ColorInput::create(DEFAULT_COLOR).with_on_value_change(RefAny::new(0u8), generic);
914
        let t = w
915
            .color_input_state
916
            .on_value_change
917
            .as_ref()
918
            .expect("the generic callback was dropped");
919
        assert_eq!(
920
            t.callback.cb as *const () as usize,
921
            expected,
922
            "the Callback -> ColorInputOnValueChangeCallback transmute mangled the pointer",
923
        );
924
    }
925

            
926
    // ==================================================================
927
    // ColorInput::swap_with_default
928
    // ==================================================================
929

            
930
    #[test]
931
    fn swap_with_default_returns_the_old_widget_and_leaves_a_default_behind() {
932
        for c in SAMPLE_COLORS {
933
            let mut w = ColorInput::create(c);
934
            let old = w.swap_with_default();
935

            
936
            assert_eq!(old, ColorInput::create(c), "{c:?}: the old widget was not returned intact");
937
            assert_eq!(w, ColorInput::default(), "{c:?}: what was left behind is not a default widget");
938
        }
939
    }
940

            
941
    #[test]
942
    fn swap_with_default_leaves_an_unstyled_widget_behind() {
943
        // `ColorInput::default()` is *derived*, so its `style` is an empty vec — unlike
944
        // `create()`, which installs the 14x14 + cursor table. The two therefore differ
945
        // even though their state is identical. Documented here so a change in either
946
        // direction is loud rather than silent.
947
        assert_eq!(
948
            ColorInput::default().color_input_state,
949
            ColorInput::create(DEFAULT_COLOR).color_input_state,
950
            "default() and create(white) no longer agree on the state",
951
        );
952
        assert!(
953
            ColorInput::default().style.as_ref().is_empty(),
954
            "ColorInput::default() gained a style",
955
        );
956
        assert_ne!(
957
            ColorInput::default(),
958
            ColorInput::create(DEFAULT_COLOR),
959
            "default() and create(white) became interchangeable",
960
        );
961

            
962
        let mut w = ColorInput::create(SAMPLE_COLORS[6]);
963
        let _ = w.swap_with_default();
964
        assert_eq!(width_px(&w.style), None, "the swapped-in widget unexpectedly has a width");
965
        assert_eq!(height_px(&w.style), None, "the swapped-in widget unexpectedly has a height");
966
    }
967

            
968
    #[test]
969
    fn swap_with_default_moves_the_callback_out_rather_than_copying_or_dropping_it() {
970
        let probe = log_refany();
971
        let mut w = ColorInput::create(SAMPLE_COLORS[4]).with_on_value_change(
972
            probe.clone(),
973
            record_value as ColorInputOnValueChangeCallbackType,
974
        );
975

            
976
        let old = w.swap_with_default();
977

            
978
        // The callback (and its payload) left with the returned value ...
979
        let moved = old
980
            .color_input_state
981
            .on_value_change
982
            .as_ref()
983
            .expect("the value-change callback vanished during the swap");
984
        assert_eq!(
985
            moved.callback.cb as *const () as usize,
986
            record_value as ColorInputOnValueChangeCallbackType as *const () as usize,
987
            "the fn pointer was mangled by the swap",
988
        );
989

            
990
        // ... and did NOT stay behind: a duplicated callback would fire twice, and a
991
        // duplicated RefAny would double-free its payload.
992
        assert!(
993
            w.color_input_state.on_value_change.as_ref().is_none(),
994
            "the callback was copied instead of moved",
995
        );
996

            
997
        // The payload is still alive and unchanged after the move.
998
        assert_eq!(read_log(&probe).payload, 0xDEAD_BEEF);
999
    }
    #[test]
    fn swapping_twice_round_trips_the_original_widget() {
        let mut a = ColorInput::create(SAMPLE_COLORS[6]);
        let mut b = a.swap_with_default(); // a = default, b = the original
        let c = b.swap_with_default(); // b = default, c = the original
        assert_eq!(c, ColorInput::create(SAMPLE_COLORS[6]));
        assert_eq!(a, ColorInput::default());
        assert_eq!(b, ColorInput::default());
    }
    // ==================================================================
    // ColorInput::dom
    // ==================================================================
    #[test]
    fn dom_is_a_single_childless_div_with_the_native_class() {
        for c in SAMPLE_COLORS {
            let dom = ColorInput::create(c).dom();
            assert!(
                matches!(dom.root.get_node_type(), NodeType::Div),
                "{c:?}: the color input is not a div",
            );
            assert_eq!(
                classes(&dom),
                vec!["__azul_native_color_input".to_string()],
                "{c:?}: wrong class list",
            );
            assert!(dom.children.as_ref().is_empty(), "{c:?}: the swatch grew children");
        }
    }
    #[test]
    fn dom_appends_the_color_as_the_last_background_and_keeps_the_base_style() {
        // The round trip: the color goes in through `create` and must come back out of the
        // rendered node's background, byte-identical, with the base style untouched and the
        // background appended *after* it (so a user override earlier in the table can't win).
        for c in SAMPLE_COLORS {
            let base = properties(&ColorInput::create(c).style);
            let rendered = inline_properties(&ColorInput::create(c).dom());
            assert_eq!(
                rendered.len(),
                base.len() + 1,
                "{c:?}: dom() added {} properties instead of exactly one",
                rendered.len() as i64 - base.len() as i64,
            );
            assert_eq!(&rendered[..base.len()], &base[..], "{c:?}: dom() rewrote the base style");
            assert_eq!(
                rendered[base.len()],
                expected_background(c),
                "{c:?}: the appended background is not this widget's color",
            );
        }
    }
    #[test]
    fn dom_round_trips_every_channel_of_every_sample_color() {
        for c in SAMPLE_COLORS {
            assert_eq!(
                dom_background(&ColorInput::create(c).dom()),
                Some(c),
                "create({c:?}).dom() does not paint {c:?}",
            );
        }
    }
    #[test]
    fn dom_declares_exactly_one_background_and_no_property_twice() {
        for c in SAMPLE_COLORS {
            let props = inline_properties(&ColorInput::create(c).dom());
            let backgrounds = props
                .iter()
                .filter(|p| matches!(p, CssProperty::BackgroundContent(_)))
                .count();
            assert_eq!(backgrounds, 1, "{c:?}: expected exactly one background declaration");
            let mut seen = Vec::new();
            for p in &props {
                let d = discriminant(p);
                assert!(!seen.contains(&d), "{c:?}: the rendered node declares {p:?} twice");
                seen.push(d);
            }
        }
    }
    #[test]
    fn dom_preserves_the_swatch_geometry() {
        // The geometry has to survive the const-slice -> owned-vec -> vec round trip that
        // `dom()` performs; losing it would leave a background-only, zero-sized node.
        for c in SAMPLE_COLORS {
            let rendered: CssPropertyWithConditionsVec = inline_properties(&ColorInput::create(c).dom())
                .into_iter()
                .map(CssPropertyWithConditions::simple)
                .collect();
            assert_eq!(width_px(&rendered), Some(SIDE), "{c:?}: the rendered swatch lost its width");
            assert_eq!(height_px(&rendered), Some(SIDE), "{c:?}: the rendered swatch lost its height");
        }
    }
    #[test]
    fn dom_registers_exactly_one_mouse_up_handler_and_it_is_the_widgets_own() {
        for c in SAMPLE_COLORS {
            let dom = ColorInput::create(c).dom();
            let callbacks = dom.root.callbacks.as_ref();
            assert_eq!(callbacks.len(), 1, "{c:?}: expected exactly one callback");
            assert_eq!(
                callbacks[0].event,
                EventFilter::Hover(HoverEventFilter::MouseUp),
                "{c:?}: the color input must fire on mouse-up",
            );
            assert_eq!(
                callbacks[0].callback.cb,
                on_color_input_clicked as usize,
                "{c:?}: the registered handler is not on_color_input_clicked",
            );
            assert_eq!(
                callbacks[0].callback.ctx,
                OptionRefAny::None,
                "{c:?}: a native handler must not carry an FFI context",
            );
        }
    }
    #[test]
    fn dom_hands_the_widget_state_to_the_handler_not_the_user_payload() {
        // `dom()` moves `color_input_state` (state + on_value_change + user RefAny) into the
        // callback's RefAny. If it stored the *user's* payload instead, the handler's
        // `downcast_mut::<ColorInputStateWrapper>()` would fail and every click would be a
        // silent no-op.
        for c in SAMPLE_COLORS {
            let dom = ColorInput::create(c)
                .with_on_value_change(
                    RefAny::new(9u32),
                    value_do_nothing as ColorInputOnValueChangeCallbackType,
                )
                .dom();
            let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
            let wrapper = state
                .downcast_ref::<ColorInputStateWrapper>()
                .expect("the handler's RefAny is not a ColorInputStateWrapper");
            assert_eq!(wrapper.inner.color, c, "the color was lost on the way into the DOM");
            assert_eq!(wrapper.title.as_str(), DEFAULT_TITLE, "the title was lost");
            assert!(
                wrapper.on_value_change.as_ref().is_some(),
                "the user's value-change callback was lost on the way into the DOM",
            );
        }
    }
    #[test]
    fn dom_of_a_callback_less_color_input_still_registers_the_click_handler() {
        // The handler must always be installed: without it, adding an `on_value_change`
        // later via the state would never be reachable.
        let dom = ColorInput::create(DEFAULT_COLOR).dom();
        assert_eq!(dom.root.callbacks.as_ref().len(), 1);
        let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
        let wrapper = state
            .downcast_ref::<ColorInputStateWrapper>()
            .expect("wrong RefAny type");
        assert!(wrapper.on_value_change.as_ref().is_none());
    }
    #[test]
    fn dom_of_an_unstyled_default_widget_still_carries_its_background() {
        // `ColorInput::default()` has an empty style vec — pushing onto it must still work
        // and must produce exactly the one background property.
        let dom = ColorInput::default().dom();
        assert_eq!(
            inline_properties(&dom),
            vec![expected_background(DEFAULT_COLOR)],
            "a default color input did not render its background alone",
        );
    }
    #[test]
    fn the_rendered_dom_flattens_to_exactly_one_node() {
        // `Dom::estimated_total_children` is a *cached* count; if it under-reports, the
        // flatten under-allocates its arenas.
        let styled = StyledDom::create_from_dom(ColorInput::create(SAMPLE_COLORS[6]).dom());
        assert_eq!(
            styled.node_data.as_ref().len(),
            1,
            "the color input no longer flattens to a single node",
        );
    }
    // ==================================================================
    // on_color_input_clicked
    // ==================================================================
    #[test]
    fn clicking_without_a_callback_is_a_no_op() {
        for c in SAMPLE_COLORS {
            let (styled, state) = laid_out(ColorInput::create(c));
            let (update, changes) = click(styled, &state, node(0));
            assert_eq!(update, Update::DoNothing, "{c:?}: a callback-less click asked for a redraw");
            assert!(changes.is_empty(), "{c:?}: a callback-less click wrote to the DOM");
            assert_eq!(state_color(&state), c, "{c:?}: the click changed the stored color");
        }
    }
    #[test]
    fn clicking_with_a_refany_of_the_wrong_type_is_a_silent_no_op() {
        // The handler downcasts blind; a foreign RefAny must bail out, not reinterpret the
        // bytes as a ColorInputStateWrapper.
        let (styled, _) = laid_out(ColorInput::create(DEFAULT_COLOR));
        let foreign = RefAny::new(0xDEAD_BEEF_u32);
        let (update, changes) = click(styled, &foreign, node(0));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "the handler wrote to the DOM through a foreign RefAny");
        let mut foreign = foreign;
        assert_eq!(
            *foreign
                .downcast_ref::<u32>()
                .expect("the foreign payload was reinterpreted"),
            0xDEAD_BEEF,
            "the handler corrupted a RefAny it did not understand",
        );
    }
    #[test]
    fn clicking_forwards_the_user_callbacks_verdict_verbatim() {
        // The handler is a pure relay: whatever the user callback decides is what the event
        // loop must see. Swallowing a `RefreshDom` would freeze the UI after a color pick.
        let cases: [(ColorInputOnValueChangeCallbackType, Update); 3] = [
            (value_do_nothing, Update::DoNothing),
            (record_value, Update::RefreshDom),
            (value_refresh_all, Update::RefreshDomAllWindows),
        ];
        for (cb, expected) in cases {
            let (styled, state) = laid_out(
                ColorInput::create(SAMPLE_COLORS[6]).with_on_value_change(log_refany(), cb),
            );
            let (update, _) = click(styled, &state, node(0));
            assert_eq!(update, expected, "the handler did not forward {expected:?}");
        }
    }
    #[test]
    fn the_callback_sees_this_widgets_color_not_the_default() {
        // The handler reads `color_input.inner` and passes it on. Passing
        // `ColorInputState::default()` (opaque white) instead would type-check and would
        // look right for exactly one of the sample colors.
        for c in SAMPLE_COLORS {
            let probe = log_refany();
            let (styled, state) = laid_out(
                ColorInput::create(c).with_on_value_change(
                    probe.clone(),
                    record_value as ColorInputOnValueChangeCallbackType,
                ),
            );
            let (update, _) = click(styled, &state, node(0));
            assert_eq!(update, Update::RefreshDom);
            assert_eq!(
                read_log(&probe).seen,
                vec![c],
                "the callback was told the wrong color for {c:?}",
            );
        }
    }
    #[test]
    fn the_callback_receives_the_user_payload_not_the_widget_state() {
        let probe = log_refany();
        let (styled, state) = laid_out(
            ColorInput::create(SAMPLE_COLORS[6]).with_on_value_change(
                probe.clone(),
                record_value as ColorInputOnValueChangeCallbackType,
            ),
        );
        click(styled, &state, node(0));
        // It wrote into the ColorLog, so it got the user's payload ...
        assert_eq!(read_log(&probe).seen.len(), 1);
        assert_eq!(read_log(&probe).payload, 0xDEAD_BEEF);
        // ... and that payload is emphatically not the widget state.
        let mut probe = probe;
        assert!(
            probe.downcast_ref::<ColorInputStateWrapper>().is_none(),
            "the user payload and the widget state got confused",
        );
    }
    #[test]
    fn clicking_never_mutates_the_stored_color() {
        // There is no built-in picker dialog: the handler only *reports* the current color.
        // If it ever started writing back, this is where an unreviewed mutation shows up.
        let probe = log_refany();
        let c = SAMPLE_COLORS[6];
        let (_, state) = laid_out(
            ColorInput::create(c).with_on_value_change(
                probe.clone(),
                record_value as ColorInputOnValueChangeCallbackType,
            ),
        );
        for i in 0..8 {
            let (styled, _) = laid_out(ColorInput::create(c));
            let (_, changes) = click(styled, &state, node(0));
            assert!(changes.is_empty(), "click {i} pushed a DOM change");
            assert_eq!(state_color(&state), c, "click {i} altered the stored color");
        }
        assert_eq!(
            read_log(&probe).seen,
            vec![c; 8],
            "the callback did not see the same color on every click",
        );
    }
    #[test]
    fn clicking_a_stale_or_missing_hit_node_does_not_panic() {
        // Stale hit ids reach callbacks after a DOM mutation, and `node_none()` is the
        // "nothing concrete was hit" case. This handler never queries the layout, so all
        // three must sail through and still report the color rather than panicking.
        // usize::MAX is unencodable by NodeId's 1-based scheme and would overflow while
        // building the fixture; usize::MAX - 1 is the repo's MAX_ENCODABLE_NODE.
        let c = SAMPLE_COLORS[4];
        for hit in [node(0), node(99), node(usize::MAX - 1), node_none()] {
            let probe = log_refany();
            let (styled, state) = laid_out(
                ColorInput::create(c).with_on_value_change(
                    probe.clone(),
                    record_value as ColorInputOnValueChangeCallbackType,
                ),
            );
            let (update, changes) = click(styled, &state, hit);
            assert_eq!(update, Update::RefreshDom, "{hit:?}: wrong verdict");
            assert!(changes.is_empty(), "{hit:?}: a DOM change was pushed");
            assert_eq!(read_log(&probe).seen, vec![c], "{hit:?}: wrong color reported");
        }
    }
    #[test]
    fn two_widgets_built_from_the_same_color_do_not_share_state() {
        // `dom()` allocates a fresh `RefAny` per widget. If two swatches aliased one state,
        // clicking one would report through the other's callback as well.
        let a_probe = log_refany();
        let b_probe = log_refany();
        let (a_styled, a_state) = laid_out(ColorInput::create(SAMPLE_COLORS[1]).with_on_value_change(
            a_probe.clone(),
            record_value as ColorInputOnValueChangeCallbackType,
        ));
        let (_b_styled, _b_state) = laid_out(ColorInput::create(SAMPLE_COLORS[1]).with_on_value_change(
            b_probe.clone(),
            record_value as ColorInputOnValueChangeCallbackType,
        ));
        click(a_styled, &a_state, node(0));
        assert_eq!(read_log(&a_probe).seen.len(), 1, "the clicked widget did not report");
        assert!(
            read_log(&b_probe).seen.is_empty(),
            "clicking one color input fired another one's callback",
        );
    }
}