Skip to main content

azul_layout/widgets/
color_input.rs

1//! Rectangular input that displays a color and invokes a callback when clicked
2
3use azul_core::{
4    callbacks::Update,
5    dom::Dom,
6    refany::RefAny,
7};
8use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
9#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
10use azul_css::{
11    props::{
12        basic::*,
13        layout::*,
14        property::{CssProperty, *},
15        style::*,
16    },
17    *,
18};
19
20use 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)]
25pub 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.
31pub type ColorInputOnValueChangeCallbackType =
32    extern "C" fn(RefAny, CallbackInfo, ColorInputState) -> Update;
33impl_widget_callback!(
34    ColorInputOnValueChange,
35    OptionColorInputOnValueChange,
36    ColorInputOnValueChangeCallback,
37    ColorInputOnValueChangeCallbackType
38);
39
40azul_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)]
56pub struct ColorInputStateWrapper {
57    pub inner: ColorInputState,
58    pub title: AzString,
59    pub on_value_change: OptionColorInputOnValueChange,
60}
61
62impl Default for ColorInputStateWrapper {
63    fn default() -> Self {
64        Self {
65            inner: ColorInputState::default(),
66            title: AzString::from_const_str("Pick color"),
67            on_value_change: None.into(),
68        }
69    }
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)]
75pub struct ColorInputState {
76    pub color: ColorU,
77}
78
79impl Default for ColorInputState {
80    fn default() -> Self {
81        Self {
82            color: ColorU {
83                r: 255,
84                g: 255,
85                b: 255,
86                a: 255,
87            },
88        }
89    }
90}
91
92static 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
100impl ColorInput {
101    /// Creates a new `ColorInput` displaying the given color.
102    #[inline]
103    #[must_use]
104    pub fn create(color: ColorU) -> Self {
105        Self {
106            color_input_state: ColorInputStateWrapper {
107                inner: ColorInputState { color },
108                ..Default::default()
109            },
110            style: CssPropertyWithConditionsVec::from_const_slice(DEFAULT_COLOR_INPUT_STYLE),
111        }
112    }
113
114    /// Sets the callback invoked when the color value changes.
115    #[inline]
116    pub fn set_on_value_change<I: Into<ColorInputOnValueChangeCallback>>(
117        &mut self,
118        data: RefAny,
119        callback: I,
120    ) {
121        self.color_input_state.on_value_change = Some(ColorInputOnValueChange {
122            callback: callback.into(),
123            refany: data,
124        })
125        .into();
126    }
127
128    /// Builder-style method to set the value-change callback.
129    #[inline]
130    #[must_use]
131    pub fn with_on_value_change<C: Into<ColorInputOnValueChangeCallback>>(
132        mut self,
133        data: RefAny,
134        callback: C,
135    ) -> Self {
136        self.set_on_value_change(data, callback);
137        self
138    }
139
140    /// Replaces `self` with a default `ColorInput` and returns the previous value.
141    #[inline]
142    #[must_use]
143    pub fn swap_with_default(&mut self) -> Self {
144        let mut s = Self::default();
145        core::mem::swap(&mut s, self);
146        s
147    }
148
149    /// Converts this `ColorInput` into a styled [`Dom`] node with a click callback.
150    #[inline]
151    #[must_use]
152    pub fn dom(self) -> Dom {
153        use azul_core::{
154            callbacks::{CoreCallback, CoreCallbackData},
155            dom::{EventFilter, HoverEventFilter, IdOrClass::Class},
156        };
157
158        let mut style = self.style.into_library_owned_vec();
159        style.push(CssPropertyWithConditions::simple(
160            CssProperty::const_background_content(
161                vec![StyleBackgroundContent::Color(
162                    self.color_input_state.inner.color,
163                )]
164                .into(),
165            ),
166        ));
167
168        Dom::create_div()
169            .with_ids_and_classes(vec![Class("__azul_native_color_input".into())].into())
170            .with_css_props(style.into())
171            .with_callbacks(
172                vec![CoreCallbackData {
173                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
174                    refany: RefAny::new(self.color_input_state),
175                    callback: CoreCallback {
176                        cb: on_color_input_clicked as usize,
177                        ctx: azul_core::refany::OptionRefAny::None,
178                    },
179                }]
180                .into(),
181            )
182    }
183}
184
185extern "C" fn on_color_input_clicked(mut data: RefAny, mut info: CallbackInfo) -> Update {
186    let Some(mut color_input) = data.downcast_mut::<ColorInputStateWrapper>() else {
187        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    let color_input = &mut *color_input;
193    let onvaluechange = &mut color_input.on_value_change;
194    let inner = color_input.inner;
195
196    match onvaluechange.as_mut() {
197        Some(ColorInputOnValueChange {
198            callback,
199            refany: data,
200        }) => (callback.cb)(data.clone(), info, inner),
201        None => Update::DoNothing,
202    }
203}
204
205#[cfg(all(test, feature = "std"))]
206#[allow(clippy::float_cmp, clippy::too_many_lines)]
207mod 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: 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(azul_css::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: azul_core::refany::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    }
1000
1001    #[test]
1002    fn swapping_twice_round_trips_the_original_widget() {
1003        let mut a = ColorInput::create(SAMPLE_COLORS[6]);
1004        let mut b = a.swap_with_default(); // a = default, b = the original
1005        let c = b.swap_with_default(); // b = default, c = the original
1006
1007        assert_eq!(c, ColorInput::create(SAMPLE_COLORS[6]));
1008        assert_eq!(a, ColorInput::default());
1009        assert_eq!(b, ColorInput::default());
1010    }
1011
1012    // ==================================================================
1013    // ColorInput::dom
1014    // ==================================================================
1015
1016    #[test]
1017    fn dom_is_a_single_childless_div_with_the_native_class() {
1018        for c in SAMPLE_COLORS {
1019            let dom = ColorInput::create(c).dom();
1020            assert!(
1021                matches!(dom.root.get_node_type(), NodeType::Div),
1022                "{c:?}: the color input is not a div",
1023            );
1024            assert_eq!(
1025                classes(&dom),
1026                vec!["__azul_native_color_input".to_string()],
1027                "{c:?}: wrong class list",
1028            );
1029            assert!(dom.children.as_ref().is_empty(), "{c:?}: the swatch grew children");
1030        }
1031    }
1032
1033    #[test]
1034    fn dom_appends_the_color_as_the_last_background_and_keeps_the_base_style() {
1035        // The round trip: the color goes in through `create` and must come back out of the
1036        // rendered node's background, byte-identical, with the base style untouched and the
1037        // background appended *after* it (so a user override earlier in the table can't win).
1038        for c in SAMPLE_COLORS {
1039            let base = properties(&ColorInput::create(c).style);
1040            let rendered = inline_properties(&ColorInput::create(c).dom());
1041
1042            assert_eq!(
1043                rendered.len(),
1044                base.len() + 1,
1045                "{c:?}: dom() added {} properties instead of exactly one",
1046                rendered.len() as i64 - base.len() as i64,
1047            );
1048            assert_eq!(&rendered[..base.len()], &base[..], "{c:?}: dom() rewrote the base style");
1049            assert_eq!(
1050                rendered[base.len()],
1051                expected_background(c),
1052                "{c:?}: the appended background is not this widget's color",
1053            );
1054        }
1055    }
1056
1057    #[test]
1058    fn dom_round_trips_every_channel_of_every_sample_color() {
1059        for c in SAMPLE_COLORS {
1060            assert_eq!(
1061                dom_background(&ColorInput::create(c).dom()),
1062                Some(c),
1063                "create({c:?}).dom() does not paint {c:?}",
1064            );
1065        }
1066    }
1067
1068    #[test]
1069    fn dom_declares_exactly_one_background_and_no_property_twice() {
1070        for c in SAMPLE_COLORS {
1071            let props = inline_properties(&ColorInput::create(c).dom());
1072            let backgrounds = props
1073                .iter()
1074                .filter(|p| matches!(p, CssProperty::BackgroundContent(_)))
1075                .count();
1076            assert_eq!(backgrounds, 1, "{c:?}: expected exactly one background declaration");
1077
1078            let mut seen = Vec::new();
1079            for p in &props {
1080                let d = discriminant(p);
1081                assert!(!seen.contains(&d), "{c:?}: the rendered node declares {p:?} twice");
1082                seen.push(d);
1083            }
1084        }
1085    }
1086
1087    #[test]
1088    fn dom_preserves_the_swatch_geometry() {
1089        // The geometry has to survive the const-slice -> owned-vec -> vec round trip that
1090        // `dom()` performs; losing it would leave a background-only, zero-sized node.
1091        for c in SAMPLE_COLORS {
1092            let rendered: CssPropertyWithConditionsVec = inline_properties(&ColorInput::create(c).dom())
1093                .into_iter()
1094                .map(CssPropertyWithConditions::simple)
1095                .collect();
1096            assert_eq!(width_px(&rendered), Some(SIDE), "{c:?}: the rendered swatch lost its width");
1097            assert_eq!(height_px(&rendered), Some(SIDE), "{c:?}: the rendered swatch lost its height");
1098        }
1099    }
1100
1101    #[test]
1102    fn dom_registers_exactly_one_mouse_up_handler_and_it_is_the_widgets_own() {
1103        for c in SAMPLE_COLORS {
1104            let dom = ColorInput::create(c).dom();
1105            let callbacks = dom.root.callbacks.as_ref();
1106
1107            assert_eq!(callbacks.len(), 1, "{c:?}: expected exactly one callback");
1108            assert_eq!(
1109                callbacks[0].event,
1110                EventFilter::Hover(HoverEventFilter::MouseUp),
1111                "{c:?}: the color input must fire on mouse-up",
1112            );
1113            assert_eq!(
1114                callbacks[0].callback.cb,
1115                on_color_input_clicked as usize,
1116                "{c:?}: the registered handler is not on_color_input_clicked",
1117            );
1118            assert_eq!(
1119                callbacks[0].callback.ctx,
1120                OptionRefAny::None,
1121                "{c:?}: a native handler must not carry an FFI context",
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn dom_hands_the_widget_state_to_the_handler_not_the_user_payload() {
1128        // `dom()` moves `color_input_state` (state + on_value_change + user RefAny) into the
1129        // callback's RefAny. If it stored the *user's* payload instead, the handler's
1130        // `downcast_mut::<ColorInputStateWrapper>()` would fail and every click would be a
1131        // silent no-op.
1132        for c in SAMPLE_COLORS {
1133            let dom = ColorInput::create(c)
1134                .with_on_value_change(
1135                    RefAny::new(9u32),
1136                    value_do_nothing as ColorInputOnValueChangeCallbackType,
1137                )
1138                .dom();
1139
1140            let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
1141            let wrapper = state
1142                .downcast_ref::<ColorInputStateWrapper>()
1143                .expect("the handler's RefAny is not a ColorInputStateWrapper");
1144
1145            assert_eq!(wrapper.inner.color, c, "the color was lost on the way into the DOM");
1146            assert_eq!(wrapper.title.as_str(), DEFAULT_TITLE, "the title was lost");
1147            assert!(
1148                wrapper.on_value_change.as_ref().is_some(),
1149                "the user's value-change callback was lost on the way into the DOM",
1150            );
1151        }
1152    }
1153
1154    #[test]
1155    fn dom_of_a_callback_less_color_input_still_registers_the_click_handler() {
1156        // The handler must always be installed: without it, adding an `on_value_change`
1157        // later via the state would never be reachable.
1158        let dom = ColorInput::create(DEFAULT_COLOR).dom();
1159        assert_eq!(dom.root.callbacks.as_ref().len(), 1);
1160
1161        let mut state = dom.root.callbacks.as_ref()[0].refany.clone();
1162        let wrapper = state
1163            .downcast_ref::<ColorInputStateWrapper>()
1164            .expect("wrong RefAny type");
1165        assert!(wrapper.on_value_change.as_ref().is_none());
1166    }
1167
1168    #[test]
1169    fn dom_of_an_unstyled_default_widget_still_carries_its_background() {
1170        // `ColorInput::default()` has an empty style vec — pushing onto it must still work
1171        // and must produce exactly the one background property.
1172        let dom = ColorInput::default().dom();
1173        assert_eq!(
1174            inline_properties(&dom),
1175            vec![expected_background(DEFAULT_COLOR)],
1176            "a default color input did not render its background alone",
1177        );
1178    }
1179
1180    #[test]
1181    fn the_rendered_dom_flattens_to_exactly_one_node() {
1182        // `Dom::estimated_total_children` is a *cached* count; if it under-reports, the
1183        // flatten under-allocates its arenas.
1184        let styled = StyledDom::create_from_dom(ColorInput::create(SAMPLE_COLORS[6]).dom());
1185        assert_eq!(
1186            styled.node_data.as_ref().len(),
1187            1,
1188            "the color input no longer flattens to a single node",
1189        );
1190    }
1191
1192    // ==================================================================
1193    // on_color_input_clicked
1194    // ==================================================================
1195
1196    #[test]
1197    fn clicking_without_a_callback_is_a_no_op() {
1198        for c in SAMPLE_COLORS {
1199            let (styled, state) = laid_out(ColorInput::create(c));
1200            let (update, changes) = click(styled, &state, node(0));
1201
1202            assert_eq!(update, Update::DoNothing, "{c:?}: a callback-less click asked for a redraw");
1203            assert!(changes.is_empty(), "{c:?}: a callback-less click wrote to the DOM");
1204            assert_eq!(state_color(&state), c, "{c:?}: the click changed the stored color");
1205        }
1206    }
1207
1208    #[test]
1209    fn clicking_with_a_refany_of_the_wrong_type_is_a_silent_no_op() {
1210        // The handler downcasts blind; a foreign RefAny must bail out, not reinterpret the
1211        // bytes as a ColorInputStateWrapper.
1212        let (styled, _) = laid_out(ColorInput::create(DEFAULT_COLOR));
1213        let foreign = RefAny::new(0xDEAD_BEEF_u32);
1214
1215        let (update, changes) = click(styled, &foreign, node(0));
1216
1217        assert_eq!(update, Update::DoNothing);
1218        assert!(changes.is_empty(), "the handler wrote to the DOM through a foreign RefAny");
1219
1220        let mut foreign = foreign;
1221        assert_eq!(
1222            *foreign
1223                .downcast_ref::<u32>()
1224                .expect("the foreign payload was reinterpreted"),
1225            0xDEAD_BEEF,
1226            "the handler corrupted a RefAny it did not understand",
1227        );
1228    }
1229
1230    #[test]
1231    fn clicking_forwards_the_user_callbacks_verdict_verbatim() {
1232        // The handler is a pure relay: whatever the user callback decides is what the event
1233        // loop must see. Swallowing a `RefreshDom` would freeze the UI after a color pick.
1234        let cases: [(ColorInputOnValueChangeCallbackType, Update); 3] = [
1235            (value_do_nothing, Update::DoNothing),
1236            (record_value, Update::RefreshDom),
1237            (value_refresh_all, Update::RefreshDomAllWindows),
1238        ];
1239        for (cb, expected) in cases {
1240            let (styled, state) = laid_out(
1241                ColorInput::create(SAMPLE_COLORS[6]).with_on_value_change(log_refany(), cb),
1242            );
1243            let (update, _) = click(styled, &state, node(0));
1244            assert_eq!(update, expected, "the handler did not forward {expected:?}");
1245        }
1246    }
1247
1248    #[test]
1249    fn the_callback_sees_this_widgets_color_not_the_default() {
1250        // The handler reads `color_input.inner` and passes it on. Passing
1251        // `ColorInputState::default()` (opaque white) instead would type-check and would
1252        // look right for exactly one of the sample colors.
1253        for c in SAMPLE_COLORS {
1254            let probe = log_refany();
1255            let (styled, state) = laid_out(
1256                ColorInput::create(c).with_on_value_change(
1257                    probe.clone(),
1258                    record_value as ColorInputOnValueChangeCallbackType,
1259                ),
1260            );
1261
1262            let (update, _) = click(styled, &state, node(0));
1263            assert_eq!(update, Update::RefreshDom);
1264            assert_eq!(
1265                read_log(&probe).seen,
1266                vec![c],
1267                "the callback was told the wrong color for {c:?}",
1268            );
1269        }
1270    }
1271
1272    #[test]
1273    fn the_callback_receives_the_user_payload_not_the_widget_state() {
1274        let probe = log_refany();
1275        let (styled, state) = laid_out(
1276            ColorInput::create(SAMPLE_COLORS[6]).with_on_value_change(
1277                probe.clone(),
1278                record_value as ColorInputOnValueChangeCallbackType,
1279            ),
1280        );
1281        click(styled, &state, node(0));
1282
1283        // It wrote into the ColorLog, so it got the user's payload ...
1284        assert_eq!(read_log(&probe).seen.len(), 1);
1285        assert_eq!(read_log(&probe).payload, 0xDEAD_BEEF);
1286
1287        // ... and that payload is emphatically not the widget state.
1288        let mut probe = probe;
1289        assert!(
1290            probe.downcast_ref::<ColorInputStateWrapper>().is_none(),
1291            "the user payload and the widget state got confused",
1292        );
1293    }
1294
1295    #[test]
1296    fn clicking_never_mutates_the_stored_color() {
1297        // There is no built-in picker dialog: the handler only *reports* the current color.
1298        // If it ever started writing back, this is where an unreviewed mutation shows up.
1299        let probe = log_refany();
1300        let c = SAMPLE_COLORS[6];
1301        let (_, state) = laid_out(
1302            ColorInput::create(c).with_on_value_change(
1303                probe.clone(),
1304                record_value as ColorInputOnValueChangeCallbackType,
1305            ),
1306        );
1307
1308        for i in 0..8 {
1309            let (styled, _) = laid_out(ColorInput::create(c));
1310            let (_, changes) = click(styled, &state, node(0));
1311            assert!(changes.is_empty(), "click {i} pushed a DOM change");
1312            assert_eq!(state_color(&state), c, "click {i} altered the stored color");
1313        }
1314        assert_eq!(
1315            read_log(&probe).seen,
1316            vec![c; 8],
1317            "the callback did not see the same color on every click",
1318        );
1319    }
1320
1321    #[test]
1322    fn clicking_a_stale_or_missing_hit_node_does_not_panic() {
1323        // Stale hit ids reach callbacks after a DOM mutation, and `node_none()` is the
1324        // "nothing concrete was hit" case. This handler never queries the layout, so all
1325        // three must sail through and still report the color rather than panicking.
1326        // usize::MAX is unencodable by NodeId's 1-based scheme and would overflow while
1327        // building the fixture; usize::MAX - 1 is the repo's MAX_ENCODABLE_NODE.
1328        let c = SAMPLE_COLORS[4];
1329        for hit in [node(0), node(99), node(usize::MAX - 1), node_none()] {
1330            let probe = log_refany();
1331            let (styled, state) = laid_out(
1332                ColorInput::create(c).with_on_value_change(
1333                    probe.clone(),
1334                    record_value as ColorInputOnValueChangeCallbackType,
1335                ),
1336            );
1337            let (update, changes) = click(styled, &state, hit);
1338
1339            assert_eq!(update, Update::RefreshDom, "{hit:?}: wrong verdict");
1340            assert!(changes.is_empty(), "{hit:?}: a DOM change was pushed");
1341            assert_eq!(read_log(&probe).seen, vec![c], "{hit:?}: wrong color reported");
1342        }
1343    }
1344
1345    #[test]
1346    fn two_widgets_built_from_the_same_color_do_not_share_state() {
1347        // `dom()` allocates a fresh `RefAny` per widget. If two swatches aliased one state,
1348        // clicking one would report through the other's callback as well.
1349        let a_probe = log_refany();
1350        let b_probe = log_refany();
1351        let (a_styled, a_state) = laid_out(ColorInput::create(SAMPLE_COLORS[1]).with_on_value_change(
1352            a_probe.clone(),
1353            record_value as ColorInputOnValueChangeCallbackType,
1354        ));
1355        let (_b_styled, _b_state) = laid_out(ColorInput::create(SAMPLE_COLORS[1]).with_on_value_change(
1356            b_probe.clone(),
1357            record_value as ColorInputOnValueChangeCallbackType,
1358        ));
1359
1360        click(a_styled, &a_state, node(0));
1361
1362        assert_eq!(read_log(&a_probe).seen.len(), 1, "the clicked widget did not report");
1363        assert!(
1364            read_log(&b_probe).seen.is_empty(),
1365            "clicking one color input fired another one's callback",
1366        );
1367    }
1368}