Skip to main content

azul_layout/widgets/
chip.rs

1//! Chip / tag widget — a compact rounded "pill" holding a short label plus an
2//! optional removable "x" affordance. A blend of
3//! [`crate::widgets::badge::Badge`] (the coloured pill visual + [`ChipKind`]
4//! colour variants) and [`crate::widgets::alert::Alert`] (the dismiss pattern:
5//! a stateful close affordance that hides the widget on click).
6//!
7//! When made removable (`with_removable(true)` or `set_on_remove`), the chip
8//! mirrors the stateful pattern of [`crate::widgets::alert::Alert`]: it carries a
9//! [`ChipStateWrapper`] (`{ visible } + on_remove`) in a [`RefAny`] attached to
10//! the "x" node. Clicking "x" flips `visible` to `false`, invokes the optional
11//! user `on_remove`, and hides the whole chip by setting `display: none` on the
12//! container via `set_css_property` (mirroring alert's live restyle). A
13//! non-removable chip renders no "x" and carries no live callback — it is then
14//! just a stateless styled pill (a near-clone of [`Badge`]).
15//!
16//! Key types: [`Chip`], [`ChipKind`], [`ChipState`], [`ChipOnRemove`],
17//! [`ChipOnClick`].
18
19use azul_core::{
20    callbacks::{CoreCallbackData, Update},
21    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
22    refany::RefAny,
23};
24use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
25use azul_css::{
26    props::{
27        basic::{color::ColorU, font::{StyleFontFamily, StyleFontFamilyVec}, StyleFontSize},
28        layout::{LayoutDisplay, LayoutFlexDirection, LayoutAlignItems, LayoutAlignSelf, LayoutFlexGrow, LayoutPaddingTop, LayoutPaddingBottom, LayoutPaddingLeft, LayoutPaddingRight, LayoutMarginLeft},
29        property::{CssProperty, *},
30        style::{StyleBackgroundContentVec, StyleBackgroundContent, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleTextColor, StyleTextAlign, StyleUserSelect, StyleCursor},
31    },
32    impl_option_inner, AzString,
33};
34
35use crate::callbacks::{Callback, CallbackInfo};
36
37static CHIP_CONTAINER_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str("__azul-native-chip"))];
38static CHIP_LABEL_CLASS: &[IdOrClass] =
39    &[Class(AzString::from_const_str("__azul-native-chip-label"))];
40static CHIP_REMOVE_CLASS: &[IdOrClass] =
41    &[Class(AzString::from_const_str("__azul-native-chip-remove"))];
42
43const SYSTEM_UI_STR: AzString = AzString::from_const_str("system:ui");
44const SYSTEM_UI_FAMILIES: &[StyleFontFamily] = &[StyleFontFamily::System(SYSTEM_UI_STR)];
45const SYSTEM_UI_FAMILY: StyleFontFamilyVec =
46    StyleFontFamilyVec::from_const_slice(SYSTEM_UI_FAMILIES);
47
48/// Callback function type invoked when a removable chip's "x" is clicked.
49pub type ChipOnRemoveCallbackType = extern "C" fn(RefAny, CallbackInfo, ChipState) -> Update;
50impl_widget_callback!(
51    ChipOnRemove,
52    OptionChipOnRemove,
53    ChipOnRemoveCallback,
54    ChipOnRemoveCallbackType
55);
56
57azul_core::impl_managed_callback! {
58    wrapper:        ChipOnRemoveCallback,
59    info_ty:        CallbackInfo,
60    return_ty:      Update,
61    default_ret:    Update::DoNothing,
62    invoker_static: CHIP_ON_REMOVE_INVOKER,
63    invoker_ty:     AzChipOnRemoveCallbackInvoker,
64    thunk_fn:       az_chip_on_remove_callback_thunk,
65    setter_fn:      AzApp_setChipOnRemoveCallbackInvoker,
66    from_handle_fn: AzChipOnRemoveCallback_createFromHostHandle,
67    extra_args:     [ state: ChipState ],
68}
69
70/// Callback function type invoked when the chip's label area is clicked.
71pub type ChipOnClickCallbackType = extern "C" fn(RefAny, CallbackInfo, ChipState) -> Update;
72impl_widget_callback!(
73    ChipOnClick,
74    OptionChipOnClick,
75    ChipOnClickCallback,
76    ChipOnClickCallbackType
77);
78
79azul_core::impl_managed_callback! {
80    wrapper:        ChipOnClickCallback,
81    info_ty:        CallbackInfo,
82    return_ty:      Update,
83    default_ret:    Update::DoNothing,
84    invoker_static: CHIP_ON_CLICK_INVOKER,
85    invoker_ty:     AzChipOnClickCallbackInvoker,
86    thunk_fn:       az_chip_on_click_callback_thunk,
87    setter_fn:      AzApp_setChipOnClickCallbackInvoker,
88    from_handle_fn: AzChipOnClickCallback_createFromHostHandle,
89    extra_args:     [ state: ChipState ],
90}
91
92/// The semantic colour variant of a [`Chip`] (mirrors `badge::BadgeKind`).
93#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
94#[repr(C)]
95pub enum ChipKind {
96    /// Neutral light-grey chip — the default.
97    #[default]
98    Default,
99    /// Blue "primary" chip.
100    Primary,
101    /// Green "success" chip.
102    Success,
103    /// Red "danger" chip.
104    Danger,
105    /// Yellow "warning" chip (uses dark text).
106    Warning,
107    /// Cyan "info" chip (uses dark text).
108    Info,
109}
110
111impl ChipKind {
112    /// Returns the `(background, text)` colours for this chip kind.
113    #[allow(clippy::trivially_copy_pass_by_ref)] // <=8B Copy param kept by-ref intentionally (hot pixel/coord path or to avoid churning call sites for a perf-neutral change)
114    const fn colors(&self) -> (ColorU, ColorU) {
115        const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
116        const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
117        match self {
118            // The default chip is a light neutral pill with dark text (the
119            // common "tag" look), unlike Badge's solid grey.
120            Self::Default => (ColorU { r: 233, g: 236, b: 239, a: 255 }, DARK),
121            Self::Primary => (ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
122            Self::Success => (ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
123            Self::Danger => (ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
124            Self::Warning => (ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
125            Self::Info => (ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
126        }
127    }
128
129    /// CSS class name for this chip kind (mirrors `BadgeKind::class_name`).
130    #[must_use] pub const fn class_name(&self) -> &'static str {
131        match self {
132            Self::Default => "__azul-chip-default",
133            Self::Primary => "__azul-chip-primary",
134            Self::Success => "__azul-chip-success",
135            Self::Danger => "__azul-chip-danger",
136            Self::Warning => "__azul-chip-warning",
137            Self::Info => "__azul-chip-info",
138        }
139    }
140}
141
142/// A compact rounded pill holding a label plus an optional removable "x".
143#[derive(Debug, Clone, PartialEq, Eq)]
144#[repr(C)]
145pub struct Chip {
146    /// Runtime state (`visible`) plus the optional remove callback.
147    pub chip_state: ChipStateWrapper,
148    /// The text shown inside the pill.
149    pub label: AzString,
150    /// The colour variant.
151    pub kind: ChipKind,
152    /// Whether to render the "x" remove affordance (hides the chip on click).
153    pub removable: bool,
154    /// The computed inline style for the pill container.
155    pub container_style: CssPropertyWithConditionsVec,
156}
157
158#[derive(Debug, Default, Clone, PartialEq, Eq)]
159#[repr(C)]
160pub struct ChipStateWrapper {
161    /// Whether the chip is currently visible.
162    pub inner: ChipState,
163    /// Optional: function to call when the chip is removed.
164    pub on_remove: OptionChipOnRemove,
165    /// Optional: function to call when the chip's label area is clicked.
166    pub on_click: OptionChipOnClick,
167}
168
169/// The visible/hidden state of a [`Chip`].
170#[derive(Debug, Copy, Clone, PartialEq, Eq)]
171#[repr(C)]
172pub struct ChipState {
173    /// `true` (default) = shown, `false` = removed/hidden.
174    pub visible: bool,
175}
176
177impl Default for ChipState {
178    fn default() -> Self {
179        Self { visible: true }
180    }
181}
182
183/// Builds the pill container style for a given [`ChipKind`]. The colours are the
184/// only kind-dependent properties, so the style is built at runtime per the
185/// recipe's "runtime vec when param-dependent" path (see `badge::build_badge_style`).
186fn build_chip_style(kind: ChipKind) -> CssPropertyWithConditionsVec {
187    let (bg, text) = kind.colors();
188    let bg_vec =
189        StyleBackgroundContentVec::from_vec(alloc::vec![StyleBackgroundContent::Color(bg)]);
190    CssPropertyWithConditionsVec::from_vec(alloc::vec![
191        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
192        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
193            LayoutFlexDirection::Row,
194        )),
195        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
196        // Hug the content rather than stretch across a flex parent's cross axis.
197        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
198        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
199            0,
200        ))),
201        // padding: 4px 10px
202        CssPropertyWithConditions::simple(CssProperty::const_padding_top(LayoutPaddingTop::const_px(
203            4,
204        ))),
205        CssPropertyWithConditions::simple(CssProperty::const_padding_bottom(
206            LayoutPaddingBottom::const_px(4),
207        )),
208        CssPropertyWithConditions::simple(CssProperty::const_padding_left(
209            LayoutPaddingLeft::const_px(10),
210        )),
211        CssPropertyWithConditions::simple(CssProperty::const_padding_right(
212            LayoutPaddingRight::const_px(10),
213        )),
214        // border-radius: 12px (pill)
215        CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
216            StyleBorderTopLeftRadius::const_px(12),
217        )),
218        CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
219            StyleBorderTopRightRadius::const_px(12),
220        )),
221        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
222            StyleBorderBottomLeftRadius::const_px(12),
223        )),
224        CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
225            StyleBorderBottomRightRadius::const_px(12),
226        )),
227        CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
228        CssPropertyWithConditions::simple(CssProperty::const_font_family(SYSTEM_UI_FAMILY)),
229        // Text colour is inherited by the label + "x" children.
230        CssPropertyWithConditions::simple(CssProperty::const_text_color(StyleTextColor {
231            inner: text,
232        })),
233        CssPropertyWithConditions::simple(CssProperty::const_background_content(bg_vec)),
234    ])
235}
236
237/// Label style: left-aligned, hugs its content.
238static CHIP_LABEL_STYLE: &[CssPropertyWithConditions] = &[
239    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
240    CssPropertyWithConditions::simple(CssProperty::const_text_align(StyleTextAlign::Left)),
241    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
242];
243
244/// "x" remove-affordance style: a small pointer-cursor box on the right.
245static CHIP_REMOVE_STYLE: &[CssPropertyWithConditions] = &[
246    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
247    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(14))),
248    CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
249    CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
250    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(
251        6,
252    ))),
253];
254
255impl Chip {
256    /// Creates a new chip with the given label and the default (light-grey) kind.
257    #[inline]
258    #[must_use] pub fn create(label: AzString) -> Self {
259        Self::with_kind(label, ChipKind::Default)
260    }
261
262    /// Creates a new chip with the given label and colour variant.
263    #[inline]
264    #[must_use] pub fn with_kind(label: AzString, kind: ChipKind) -> Self {
265        Self {
266            chip_state: ChipStateWrapper::default(),
267            label,
268            kind,
269            removable: false,
270            container_style: build_chip_style(kind),
271        }
272    }
273
274    /// Sets the colour variant, recomputing the container style.
275    #[inline]
276    pub fn set_kind(&mut self, kind: ChipKind) {
277        self.kind = kind;
278        self.container_style = build_chip_style(kind);
279    }
280
281    /// Builder-style setter for the colour variant.
282    #[inline]
283    #[must_use] pub fn with_chip_kind(mut self, kind: ChipKind) -> Self {
284        self.set_kind(kind);
285        self
286    }
287
288    /// Sets whether the chip shows a "x" remove affordance.
289    #[inline]
290    pub const fn set_removable(&mut self, removable: bool) {
291        self.removable = removable;
292    }
293
294    /// Builder-style setter for the removable flag.
295    #[inline]
296    #[must_use] pub const fn with_removable(mut self, removable: bool) -> Self {
297        self.set_removable(removable);
298        self
299    }
300
301    /// Sets the remove callback. Implies `removable = true` so the "x" is rendered.
302    #[inline]
303    pub fn set_on_remove<C: Into<ChipOnRemoveCallback>>(&mut self, data: RefAny, on_remove: C) {
304        self.removable = true;
305        self.chip_state.on_remove = Some(ChipOnRemove {
306            callback: on_remove.into(),
307            refany: data,
308        })
309        .into();
310    }
311
312    /// Builder-style setter for the remove callback (implies removable).
313    #[inline]
314    #[must_use] pub fn with_on_remove<C: Into<ChipOnRemoveCallback>>(
315        mut self,
316        data: RefAny,
317        on_remove: C,
318    ) -> Self {
319        self.set_on_remove(data, on_remove);
320        self
321    }
322
323    /// Sets the click callback, invoked when the chip's label area is clicked.
324    #[inline]
325    pub fn set_on_click<C: Into<ChipOnClickCallback>>(&mut self, data: RefAny, on_click: C) {
326        self.chip_state.on_click = Some(ChipOnClick {
327            callback: on_click.into(),
328            refany: data,
329        })
330        .into();
331    }
332
333    /// Builder-style setter for the click callback.
334    #[inline]
335    #[must_use] pub fn with_on_click<C: Into<ChipOnClickCallback>>(
336        mut self,
337        data: RefAny,
338        on_click: C,
339    ) -> Self {
340        self.set_on_click(data, on_click);
341        self
342    }
343
344    /// Replaces `self` with an empty default chip and returns the original.
345    #[inline]
346    #[must_use] pub fn swap_with_default(&mut self) -> Self {
347        let mut s = Self::create(AzString::from_const_str(""));
348        core::mem::swap(&mut s, self);
349        s
350    }
351
352    /// Converts this chip into a DOM subtree with the `__azul-native-chip` class.
353    #[inline]
354    #[must_use] pub fn dom(self) -> Dom {
355        use azul_core::{
356            callbacks::CoreCallback,
357            dom::{EventFilter, HoverEventFilter},
358            refany::OptionRefAny,
359        };
360
361        let has_on_click = matches!(self.chip_state.on_click, OptionChipOnClick::Some(_));
362
363        // The remove ("x") and the label-click callbacks share the same state
364        // RefAny so both handlers observe the same ChipState.
365        let state_ref = RefAny::new(self.chip_state);
366
367        let mut label = Dom::create_text(self.label)
368            .with_ids_and_classes(IdOrClassVec::from_const_slice(CHIP_LABEL_CLASS))
369            .with_css_props(CssPropertyWithConditionsVec::from_const_slice(CHIP_LABEL_STYLE));
370
371        // The click callback is attached to the LABEL node rather than the
372        // pill container: a container-level MouseUp would also fire when the
373        // remove "x" (a child of the container) is clicked, double-firing
374        // alongside on_remove. Attaching per-child sidesteps that (same
375        // wiring as list_view's row/column callbacks); clicks on the pill's
376        // padding therefore do not trigger on_click.
377        if has_on_click {
378            label = label.with_tab_index(TabIndex::Auto).with_callbacks(
379                alloc::vec![CoreCallbackData {
380                    event: EventFilter::Hover(HoverEventFilter::MouseUp),
381                    callback: CoreCallback {
382                        cb: default_on_chip_click as usize,
383                        ctx: OptionRefAny::None,
384                    },
385                    refany: state_ref.clone(),
386                }]
387                .into(),
388            );
389        }
390
391        let mut children = alloc::vec![label];
392
393        if self.removable {
394            let remove = Dom::create_text(AzString::from_const_str("\u{00D7}"))
395                .with_ids_and_classes(IdOrClassVec::from_const_slice(CHIP_REMOVE_CLASS))
396                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(CHIP_REMOVE_STYLE))
397                .with_tab_index(TabIndex::Auto)
398                .with_callbacks(
399                    alloc::vec![CoreCallbackData {
400                        event: EventFilter::Hover(HoverEventFilter::MouseUp),
401                        callback: CoreCallback {
402                            cb: default_on_chip_remove as usize,
403                            ctx: OptionRefAny::None,
404                        },
405                        refany: state_ref,
406                    }]
407                    .into(),
408                );
409            children.push(remove);
410        }
411
412        Dom::create_div()
413            .with_ids_and_classes(IdOrClassVec::from_const_slice(CHIP_CONTAINER_CLASS))
414            .with_css_props(self.container_style)
415            .with_children(children.into())
416    }
417}
418
419impl Default for Chip {
420    fn default() -> Self {
421        Self::create(AzString::from_const_str(""))
422    }
423}
424
425/// "x" click handler. The hit node is the "x" (the callback-bearing node, per
426/// `currentTarget` semantics — see `radio_group`); its parent is the chip
427/// container. Flips `visible` to `false`, invokes the optional user callback,
428/// then hides the whole chip via `display: none`.
429extern "C" fn default_on_chip_remove(mut data: RefAny, mut info: CallbackInfo) -> Update {
430    let remove_node = info.get_hit_node();
431    let Some(container) = info.get_parent(remove_node) else {
432        return Update::DoNothing;
433    };
434
435    let result = {
436        let Some(mut chip) = data.downcast_mut::<ChipStateWrapper>() else {
437            return Update::DoNothing;
438        };
439        chip.inner.visible = false;
440        let inner = chip.inner;
441        let chip = &mut *chip;
442        match chip.on_remove.as_mut() {
443            Some(ChipOnRemove { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
444            None => Update::DoNothing,
445        }
446    };
447
448    // TODO2: hides the chip by toggling `display: none` via set_css_property.
449    // This follows the proven live-restyle pattern of alert/check_box/radio_group
450    // (which toggle display/opacity/background); the display:none relayout itself
451    // is not GUI-verified in this build.
452    info.set_css_property(container, CssProperty::const_display(LayoutDisplay::None));
453
454    result
455}
456
457/// Label click handler. Invokes the optional user `on_click` with the current
458/// [`ChipState`] (mirrors `default_on_chip_remove`, minus the state flip/hide).
459extern "C" fn default_on_chip_click(mut data: RefAny, info: CallbackInfo) -> Update {
460    let Some(mut chip) = data.downcast_mut::<ChipStateWrapper>() else {
461        return Update::DoNothing;
462    };
463    let inner = chip.inner;
464    let chip = &mut *chip;
465    match chip.on_click.as_mut() {
466        Some(ChipOnClick { callback, refany }) => (callback.cb)(refany.clone(), info, inner),
467        None => Update::DoNothing,
468    }
469}
470
471impl From<Chip> for Dom {
472    fn from(c: Chip) -> Self {
473        c.dom()
474    }
475}
476
477#[cfg(test)]
478mod autotest_generated {
479    use std::{
480        collections::{BTreeMap, HashMap, HashSet},
481        sync::{Arc, Mutex},
482    };
483
484    use azul_core::{
485        dom::{DomId, DomNodeId, EventFilter, HoverEventFilter, NodeId, NodeType},
486        geom::{LogicalRect, OptionLogicalPosition},
487        gl::OptionGlContextPtr,
488        hit_test::ScrollPosition,
489        refany::OptionRefAny,
490        resources::RendererResources,
491        styled_dom::{NodeHierarchyItemId, StyledDom},
492        window::{MonitorVec, RawWindowHandle},
493    };
494    use azul_css::{
495        props::basic::{length::SizeMetric, pixel::PixelValue},
496        system::SystemStyle,
497    };
498    use rust_fontconfig::FcFontCache;
499
500    use super::*;
501    #[cfg(feature = "icu")]
502    use crate::icu::IcuLocalizerHandle;
503    use crate::{
504        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
505        solver3::{display_list::DisplayList, layout_tree::LayoutTree},
506        window::{DomLayoutResult, LayoutWindow},
507        window_state::FullWindowState,
508    };
509
510    // ------------------------------------------------------------------
511    // Helpers
512    // ------------------------------------------------------------------
513
514    /// Every variant of `ChipKind` — the complete input domain of `colors`,
515    /// `class_name` and `build_chip_style`.
516    const ALL_KINDS: [ChipKind; 6] = [
517        ChipKind::Default,
518        ChipKind::Primary,
519        ChipKind::Success,
520        ChipKind::Danger,
521        ChipKind::Warning,
522        ChipKind::Info,
523    ];
524
525    const WHITE: ColorU = ColorU { r: 255, g: 255, b: 255, a: 255 };
526    const DARK: ColorU = ColorU { r: 33, g: 37, b: 41, a: 255 };
527
528    /// The declared properties of a style vec, in declaration order.
529    fn properties(v: &CssPropertyWithConditionsVec) -> Vec<CssProperty> {
530        v.as_ref().iter().map(|p| p.property.clone()).collect()
531    }
532
533    /// The *kind* of every declared property, in order (ignores the values).
534    fn property_types(
535        v: &CssPropertyWithConditionsVec,
536    ) -> Vec<core::mem::Discriminant<CssProperty>> {
537        v.as_ref().iter().map(|p| core::mem::discriminant(&p.property)).collect()
538    }
539
540    /// The `f32` of a `PixelValue`, asserting it is an absolute `px` length — an
541    /// `em`/`%` slipping into the pill geometry would resolve against the parent
542    /// font/box instead of the intended fixed padding or radius.
543    fn px(pv: &PixelValue) -> f32 {
544        assert_eq!(pv.metric, SizeMetric::Px, "chip geometry must be absolute px, got {:?}", pv.metric);
545        pv.number.get()
546    }
547
548    /// The four paddings in `(top, bottom, left, right)` order.
549    fn padding_px(
550        v: &CssPropertyWithConditionsVec,
551    ) -> (Option<f32>, Option<f32>, Option<f32>, Option<f32>) {
552        let find = |f: &dyn Fn(&CssProperty) -> Option<f32>| v.as_ref().iter().find_map(|p| f(&p.property));
553        (
554            find(&|p| match p {
555                CssProperty::PaddingTop(x) => x.get_property().map(|x| px(&x.inner)),
556                _ => None,
557            }),
558            find(&|p| match p {
559                CssProperty::PaddingBottom(x) => x.get_property().map(|x| px(&x.inner)),
560                _ => None,
561            }),
562            find(&|p| match p {
563                CssProperty::PaddingLeft(x) => x.get_property().map(|x| px(&x.inner)),
564                _ => None,
565            }),
566            find(&|p| match p {
567                CssProperty::PaddingRight(x) => x.get_property().map(|x| px(&x.inner)),
568                _ => None,
569            }),
570        )
571    }
572
573    /// The four corner radii, in declaration order.
574    fn radii_px(v: &CssPropertyWithConditionsVec) -> Vec<f32> {
575        v.as_ref()
576            .iter()
577            .filter_map(|p| match &p.property {
578                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
579                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
580                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| px(&r.inner)),
581                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| px(&r.inner)),
582                _ => None,
583            })
584            .collect()
585    }
586
587    fn font_size_px(v: &CssPropertyWithConditionsVec) -> Option<f32> {
588        v.as_ref().iter().find_map(|p| match &p.property {
589            CssProperty::FontSize(f) => f.get_property().map(|f| px(&f.inner)),
590            _ => None,
591        })
592    }
593
594    fn text_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
595        v.as_ref().iter().find_map(|p| match &p.property {
596            CssProperty::TextColor(c) => c.get_property().map(|c| c.inner),
597            _ => None,
598        })
599    }
600
601    /// The single background layer of a style vec, asserting there is exactly one
602    /// and that it is a flat colour (a gradient would not be a `Color`).
603    fn background_color(v: &CssPropertyWithConditionsVec) -> Option<ColorU> {
604        let bg = v.as_ref().iter().find_map(|p| match &p.property {
605            CssProperty::BackgroundContent(b) => b.get_property(),
606            _ => None,
607        })?;
608        assert_eq!(bg.as_ref().len(), 1, "a chip must declare exactly one background layer");
609        match &bg.as_ref()[0] {
610            StyleBackgroundContent::Color(c) => Some(*c),
611            other => panic!("chip background is not a flat colour: {other:?}"),
612        }
613    }
614
615    /// Every `PixelValue` a style vec mentions (paddings, radii, font size).
616    fn all_pixel_values(v: &CssPropertyWithConditionsVec) -> Vec<PixelValue> {
617        v.as_ref()
618            .iter()
619            .filter_map(|p| match &p.property {
620                CssProperty::PaddingTop(x) => x.get_property().map(|x| x.inner),
621                CssProperty::PaddingBottom(x) => x.get_property().map(|x| x.inner),
622                CssProperty::PaddingLeft(x) => x.get_property().map(|x| x.inner),
623                CssProperty::PaddingRight(x) => x.get_property().map(|x| x.inner),
624                CssProperty::BorderTopLeftRadius(r) => r.get_property().map(|r| r.inner),
625                CssProperty::BorderTopRightRadius(r) => r.get_property().map(|r| r.inner),
626                CssProperty::BorderBottomLeftRadius(r) => r.get_property().map(|r| r.inner),
627                CssProperty::BorderBottomRightRadius(r) => r.get_property().map(|r| r.inner),
628                CssProperty::FontSize(f) => f.get_property().map(|f| f.inner),
629                _ => None,
630            })
631            .collect()
632    }
633
634    /// Perceived brightness (0..=255) of an sRGB colour, Rec.709 weights. Kept to
635    /// plain `+`/`*` (no gamma expansion) so the readability assertions below stay
636    /// exact and toolchain-independent.
637    fn luma(c: ColorU) -> f32 {
638        0.2126 * f32::from(c.r) + 0.7152 * f32::from(c.g) + 0.0722 * f32::from(c.b)
639    }
640
641    /// The text of a `NodeType::Text` node (`None` for any other node type).
642    fn text_of(node: &Dom) -> Option<&str> {
643        match node.root.get_node_type() {
644            NodeType::Text(s) => Some(s.as_ref().as_str()),
645            _ => None,
646        }
647    }
648
649    /// The properties of a rendered node's *inline* style, in declaration order.
650    fn inline_properties(node: &Dom) -> Vec<CssProperty> {
651        node.root.style.iter_inline_properties().map(|(p, _)| p.clone()).collect()
652    }
653
654    /// Adversarial chip labels: empty, whitespace, combining marks, ZWJ emoji,
655    /// RTL, embedded NULs (`AzString` is length-based, so a NUL must not
656    /// truncate), the remove glyph itself, and a string far longer than any
657    /// plausible tag.
658    fn adversarial_strings() -> Vec<String> {
659        let mut v: Vec<String> = [
660            "",
661            "tag",
662            " ",
663            "e\u{0301}",                                   // e + combining acute
664            "\u{1F469}\u{200D}\u{1F469}\u{200D}\u{1F467}", // ZWJ family emoji
665            "\u{5E9}\u{5DC}\u{5D5}\u{5DD}",                // RTL Hebrew
666            "\0",                                          // a single NUL
667            "a\0b",                                        // embedded NUL
668            "\u{FFFD}\u{202E}\u{200B}",                    // replacement char, RTL override, ZWSP
669            "\u{00D7}",                                    // the remove glyph as a label
670            "line\nbreak\ttab",                            // control characters
671            "-9223372036854775808",                        // i64::MIN as a "count"
672        ]
673        .iter()
674        .map(|s| (*s).to_string())
675        .collect();
676        v.push("x".repeat(100_000));
677        v
678    }
679
680    fn remove_cb(f: ChipOnRemoveCallbackType) -> ChipOnRemoveCallback {
681        f.into()
682    }
683
684    fn click_cb(f: ChipOnClickCallbackType) -> ChipOnClickCallback {
685        f.into()
686    }
687
688    /// A `RefAny` payload recording every `ChipState` a user callback observes.
689    struct StateLog {
690        calls: Vec<bool>,
691    }
692
693    extern "C" fn record_remove(mut data: RefAny, _: CallbackInfo, state: ChipState) -> Update {
694        if let Some(mut log) = data.downcast_mut::<StateLog>() {
695            log.calls.push(state.visible);
696        }
697        Update::RefreshDom
698    }
699
700    extern "C" fn record_click(mut data: RefAny, _: CallbackInfo, state: ChipState) -> Update {
701        if let Some(mut log) = data.downcast_mut::<StateLog>() {
702            log.calls.push(state.visible);
703            log.calls.push(state.visible); // keeps this body distinct from record_remove
704        }
705        Update::RefreshDom
706    }
707
708    extern "C" fn remove_do_nothing(_: RefAny, _: CallbackInfo, _: ChipState) -> Update {
709        Update::DoNothing
710    }
711
712    extern "C" fn click_do_nothing(_: RefAny, _: CallbackInfo, state: ChipState) -> Update {
713        // `state.visible` is read (and discarded) purely so this body cannot be
714        // identical-code-folded onto `remove_do_nothing`; the tests below compare
715        // callback function pointers for inequality.
716        let _ = state.visible;
717        Update::DoNothing
718    }
719
720    /// A payload whose callback tries to read the *same* `ChipStateWrapper`
721    /// `RefAny` that the handler is currently holding a mutable borrow on.
722    struct ReentrantProbe {
723        /// A clone of the state `RefAny` the handler was invoked with.
724        state: RefAny,
725        /// `Some(visible)` if the re-entrant read succeeded, `None` if it was
726        /// refused. Starts as `Some(true)` so "never ran" is distinguishable.
727        saw_state: Option<bool>,
728        calls: usize,
729    }
730
731    extern "C" fn probe_state_reentrantly(
732        mut data: RefAny,
733        _: CallbackInfo,
734        _: ChipState,
735    ) -> Update {
736        if let Some(mut probe) = data.downcast_mut::<ReentrantProbe>() {
737            probe.calls += 1;
738            let mut state = probe.state.clone();
739            probe.saw_state = state.downcast_ref::<ChipStateWrapper>().map(|w| w.inner.visible);
740        }
741        Update::DoNothing
742    }
743
744    /// `visible` of a `ChipStateWrapper` payload.
745    fn wrapper_visible(data: &mut RefAny) -> bool {
746        data.downcast_ref::<ChipStateWrapper>()
747            .expect("payload must still be a ChipStateWrapper")
748            .inner
749            .visible
750    }
751
752    /// The `visible` flags recorded by a `StateLog` payload.
753    fn log_calls(data: &mut RefAny) -> Vec<bool> {
754        data.downcast_ref::<StateLog>()
755            .expect("payload must still be a StateLog")
756            .calls
757            .clone()
758    }
759
760    /// A `DomLayoutResult` with an *empty* layout tree: the chip handlers only
761    /// walk `styled_dom.node_hierarchy`, so no real layout (and no font) is needed.
762    fn layout_result(styled_dom: StyledDom) -> DomLayoutResult {
763        DomLayoutResult {
764            styled_dom,
765            layout_tree: LayoutTree {
766                nodes: Vec::new(),
767                warm: Vec::new(),
768                cold: Vec::new(),
769                root: 0,
770                dom_to_layout: BTreeMap::new(),
771                children_arena: Vec::new(),
772                children_offsets: Vec::new(),
773                subtree_needs_intrinsic: Vec::new(),
774            },
775            calculated_positions: Vec::new(),
776            viewport: LogicalRect::zero(),
777            display_list: DisplayList::default(),
778            scroll_ids: HashMap::new(),
779            scroll_id_to_node_id: HashMap::new(),
780        }
781    }
782
783    /// The flattened DOM of a removable chip: `container(0)`, `label(1)`,
784    /// `remove(2)` — i.e. exactly the hierarchy `default_on_chip_remove` walks
785    /// (hit node -> parent).
786    fn removable_styled_dom() -> StyledDom {
787        let chip = Chip::create(AzString::from("tag")).with_removable(true);
788        let styled = StyledDom::create_from_dom(chip.dom());
789        assert_eq!(
790            styled.node_hierarchy.as_ref().len(),
791            3,
792            "fixture must flatten to exactly container/label/remove"
793        );
794        styled
795    }
796
797    /// Builds a `CallbackInfo` pointing at node `hit` of `styled` (or at a window
798    /// with no layout result at all when `styled` is `None`), hands it to `f`, and
799    /// returns `f`'s result plus every recorded `CallbackChange`.
800    fn with_callback_info<R>(
801        styled: Option<StyledDom>,
802        hit: usize,
803        f: impl FnOnce(CallbackInfo) -> R,
804    ) -> (R, Vec<CallbackChange>) {
805        let mut layout_window =
806            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
807        if let Some(sd) = styled {
808            layout_window.layout_results.insert(DomId::ROOT_ID, layout_result(sd));
809        }
810
811        let renderer_resources = RendererResources::default();
812        let previous_window_state: Option<FullWindowState> = None;
813        let current_window_state = FullWindowState::default();
814        let gl_context = OptionGlContextPtr::None;
815        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
816            BTreeMap::new();
817        let window_handle = RawWindowHandle::Unsupported;
818        let system_callbacks = ExternalSystemCallbacks::rust_internal();
819
820        let ref_data = CallbackInfoRefData {
821            layout_window: &layout_window,
822            renderer_resources: &renderer_resources,
823            previous_window_state: &previous_window_state,
824            current_window_state: &current_window_state,
825            gl_context: &gl_context,
826            current_scroll_manager: &scroll_states,
827            current_window_handle: &window_handle,
828            system_callbacks: &system_callbacks,
829            system_style: Arc::new(SystemStyle::default()),
830            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
831            #[cfg(feature = "icu")]
832            icu_localizer: IcuLocalizerHandle::default(),
833            ctx: OptionRefAny::None,
834        };
835
836        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
837
838        let info = CallbackInfo::new(
839            &ref_data,
840            &changes,
841            DomNodeId {
842                dom: DomId::ROOT_ID,
843                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(hit))),
844            },
845            OptionLogicalPosition::None,
846            OptionLogicalPosition::None,
847        );
848
849        let out = f(info);
850        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
851        (out, recorded)
852    }
853
854    fn run_remove(
855        styled: Option<StyledDom>,
856        hit: usize,
857        data: RefAny,
858    ) -> (Update, Vec<CallbackChange>) {
859        with_callback_info(styled, hit, move |info| default_on_chip_remove(data, info))
860    }
861
862    fn run_click(
863        styled: Option<StyledDom>,
864        hit: usize,
865        data: RefAny,
866    ) -> (Update, Vec<CallbackChange>) {
867        with_callback_info(styled, hit, move |info| default_on_chip_click(data, info))
868    }
869
870    /// Every `display` write recorded in the change log, as `(node index, display)`.
871    fn display_writes(changes: &[CallbackChange]) -> Vec<(usize, LayoutDisplay)> {
872        let mut out = Vec::new();
873        for change in changes {
874            if let CallbackChange::ChangeNodeCssProperties { node_id, properties, .. } = change {
875                for p in properties.as_ref() {
876                    if let CssProperty::Display(v) = p {
877                        if let Some(d) = v.get_property() {
878                            out.push((node_id.index(), *d));
879                        }
880                    }
881                }
882            }
883        }
884        out
885    }
886
887    /// A `ChipStateWrapper` wired to `record_remove`, plus the log it writes to.
888    fn state_with_remove_log() -> (RefAny, RefAny) {
889        let log = RefAny::new(StateLog { calls: Vec::new() });
890        let state = RefAny::new(ChipStateWrapper {
891            inner: ChipState { visible: true },
892            on_remove: Some(ChipOnRemove {
893                callback: remove_cb(record_remove),
894                refany: log.clone(),
895            })
896            .into(),
897            on_click: OptionChipOnClick::None,
898        });
899        (state, log)
900    }
901
902    /// A `ChipStateWrapper` wired to `record_click`, plus the log it writes to.
903    fn state_with_click_log(visible: bool) -> (RefAny, RefAny) {
904        let log = RefAny::new(StateLog { calls: Vec::new() });
905        let state = RefAny::new(ChipStateWrapper {
906            inner: ChipState { visible },
907            on_remove: OptionChipOnRemove::None,
908            on_click: Some(ChipOnClick { callback: click_cb(record_click), refany: log.clone() })
909                .into(),
910        });
911        (state, log)
912    }
913
914    // ------------------------------------------------------------------
915    // ChipKind::colors  (getter)
916    // ------------------------------------------------------------------
917
918    #[test]
919    fn colors_returns_the_documented_constants_for_every_kind() {
920        let expected = [
921            // NOTE: unlike `BadgeKind::Default` (solid grey + white text), the
922            // default *chip* is the light neutral "tag" pill with dark text.
923            (ChipKind::Default, ColorU { r: 233, g: 236, b: 239, a: 255 }, DARK),
924            (ChipKind::Primary, ColorU { r: 13, g: 110, b: 253, a: 255 }, WHITE),
925            (ChipKind::Success, ColorU { r: 25, g: 135, b: 84, a: 255 }, WHITE),
926            (ChipKind::Danger, ColorU { r: 220, g: 53, b: 69, a: 255 }, WHITE),
927            (ChipKind::Warning, ColorU { r: 255, g: 193, b: 7, a: 255 }, DARK),
928            (ChipKind::Info, ColorU { r: 13, g: 202, b: 240, a: 255 }, DARK),
929        ];
930        for (kind, bg, text) in expected {
931            assert_eq!(kind.colors(), (bg, text), "{kind:?}: wrong (background, text) pair");
932        }
933        // The doc comments promise Default/Warning/Info are the dark-text kinds and
934        // no others: a fourth dark-text kind sneaking in here is a regression.
935        for kind in ALL_KINDS {
936            let (_, text) = kind.colors();
937            let dark_text = matches!(kind, ChipKind::Default | ChipKind::Warning | ChipKind::Info);
938            assert_eq!(
939                text == DARK,
940                dark_text,
941                "{kind:?}: text colour contradicts the documented variant"
942            );
943        }
944    }
945
946    #[test]
947    fn colors_only_ever_returns_one_of_the_two_documented_text_colours() {
948        for kind in ALL_KINDS {
949            let (_, text) = kind.colors();
950            assert!(
951                text == WHITE || text == DARK,
952                "{kind:?}: text colour {text:?} is neither the documented WHITE nor DARK"
953            );
954        }
955    }
956
957    #[test]
958    fn colors_are_fully_opaque_on_every_kind() {
959        // A non-opaque pill would let the page background bleed through and
960        // silently destroy the contrast the kind was chosen for.
961        for kind in ALL_KINDS {
962            let (bg, text) = kind.colors();
963            assert_eq!(bg.a, 255, "{kind:?}: translucent background {bg:?}");
964            assert_eq!(text.a, 255, "{kind:?}: translucent text colour {text:?}");
965            assert_ne!(bg, text, "{kind:?}: an invisible label is not a chip");
966        }
967    }
968
969    #[test]
970    fn colors_give_every_kind_a_distinguishable_background() {
971        // Two kinds that render identically make the semantic variant useless.
972        let mut seen = HashSet::new();
973        for kind in ALL_KINDS {
974            let (bg, _) = kind.colors();
975            assert!(
976                seen.insert((bg.r, bg.g, bg.b, bg.a)),
977                "{kind:?}: duplicate background colour {bg:?}"
978            );
979        }
980        assert_eq!(seen.len(), ALL_KINDS.len());
981    }
982
983    #[test]
984    fn colors_pick_the_more_readable_of_the_two_text_colours() {
985        // The only real invariant of `colors()`: the text must be legible on the
986        // pill. For each kind the chosen text colour must be further from the
987        // background (in perceived brightness) than the rejected alternative,
988        // and light backgrounds must take the dark text.
989        for kind in ALL_KINDS {
990            let (bg, text) = kind.colors();
991            let other = if text == WHITE { DARK } else { WHITE };
992
993            let chosen = (luma(bg) - luma(text)).abs();
994            let rejected = (luma(bg) - luma(other)).abs();
995            assert!(
996                chosen > rejected,
997                "{kind:?}: text {text:?} (delta luma {chosen:.1}) is less readable on {bg:?} than \
998                 {other:?} (delta luma {rejected:.1})"
999            );
1000            assert!(chosen >= 60.0, "{kind:?}: text/background brightness gap {chosen:.1} is too low to read");
1001
1002            // Mid-grey split: a light pill must not carry white text.
1003            let light_bg = luma(bg) >= 128.0;
1004            assert_eq!(text == DARK, light_bg, "{kind:?}: bg luma {:.1} but text is {text:?}", luma(bg));
1005        }
1006    }
1007
1008    #[test]
1009    fn colors_is_pure_and_the_default_kind_is_the_neutral_tag() {
1010        assert_eq!(ChipKind::default(), ChipKind::Default);
1011        assert_eq!(ChipKind::default().colors(), ChipKind::Default.colors());
1012        // `colors()` takes `&self` on a `Copy` enum: repeated calls, and calls
1013        // through a copy, must be side-effect free and identical.
1014        for kind in ALL_KINDS {
1015            let copy = kind;
1016            assert_eq!(kind.colors(), kind.colors(), "{kind:?}: colors() is not pure");
1017            assert_eq!(kind.colors(), copy.colors(), "{kind:?}: a copy disagrees with the original");
1018        }
1019    }
1020
1021    #[test]
1022    fn colors_is_const_evaluable() {
1023        const DEFAULT: (ColorU, ColorU) = ChipKind::Default.colors();
1024        assert_eq!(DEFAULT.0, ColorU { r: 233, g: 236, b: 239, a: 255 });
1025        assert_eq!(DEFAULT.1, DARK);
1026    }
1027
1028    // ------------------------------------------------------------------
1029    // ChipKind::class_name  (getter)
1030    // ------------------------------------------------------------------
1031
1032    #[test]
1033    fn class_name_returns_the_documented_string_for_every_kind() {
1034        assert_eq!(ChipKind::Default.class_name(), "__azul-chip-default");
1035        assert_eq!(ChipKind::Primary.class_name(), "__azul-chip-primary");
1036        assert_eq!(ChipKind::Success.class_name(), "__azul-chip-success");
1037        assert_eq!(ChipKind::Danger.class_name(), "__azul-chip-danger");
1038        assert_eq!(ChipKind::Warning.class_name(), "__azul-chip-warning");
1039        assert_eq!(ChipKind::Info.class_name(), "__azul-chip-info");
1040        assert_eq!(ChipKind::default().class_name(), "__azul-chip-default");
1041    }
1042
1043    #[test]
1044    fn class_name_is_unique_per_kind_and_a_usable_css_identifier() {
1045        let mut seen = HashSet::new();
1046        for kind in ALL_KINDS {
1047            let name = kind.class_name();
1048            assert!(seen.insert(name), "{kind:?}: class name {name:?} collides with another kind");
1049            assert!(!name.is_empty(), "{kind:?}: empty class name");
1050            assert!(name.starts_with("__azul-chip-"), "{kind:?}: unnamespaced class {name:?}");
1051            assert!(name.is_ascii(), "{kind:?}: non-ASCII class name {name:?}");
1052            // A space, a dot or a `#` would silently split/re-target the selector.
1053            assert!(
1054                name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
1055                "{kind:?}: class name {name:?} contains a CSS-significant character"
1056            );
1057            // The returned `&'static str` must be stable across calls.
1058            assert_eq!(
1059                name.as_ptr(),
1060                kind.class_name().as_ptr(),
1061                "{kind:?}: class_name() is not a stable constant"
1062            );
1063        }
1064        assert_eq!(seen.len(), ALL_KINDS.len());
1065    }
1066
1067    #[test]
1068    fn class_name_does_not_collide_with_the_widget_element_classes() {
1069        // `__azul-native-chip` is a *prefix* of `__azul-native-chip-label` and
1070        // `__azul-native-chip-remove`; the kind classes live in their own
1071        // `__azul-chip-` namespace and must not alias any of the three.
1072        let element_classes =
1073            ["__azul-native-chip", "__azul-native-chip-label", "__azul-native-chip-remove"];
1074        for kind in ALL_KINDS {
1075            let name = kind.class_name();
1076            for element in element_classes {
1077                assert_ne!(name, element, "{kind:?}: kind class shadows the element class {element:?}");
1078            }
1079        }
1080    }
1081
1082    #[test]
1083    fn class_name_is_const_evaluable() {
1084        const DANGER: &str = ChipKind::Danger.class_name();
1085        assert_eq!(DANGER, "__azul-chip-danger");
1086    }
1087
1088    // ------------------------------------------------------------------
1089    // build_chip_style
1090    // ------------------------------------------------------------------
1091
1092    #[test]
1093    fn build_chip_style_emits_the_documented_pill_geometry() {
1094        for kind in ALL_KINDS {
1095            let style = build_chip_style(kind);
1096            assert_eq!(
1097                padding_px(&style),
1098                (Some(4.0), Some(4.0), Some(10.0), Some(10.0)),
1099                "{kind:?}: padding is not 4px 10px"
1100            );
1101            assert_eq!(
1102                radii_px(&style),
1103                vec![12.0, 12.0, 12.0, 12.0],
1104                "{kind:?}: all four corners must carry a 12px radius"
1105            );
1106            assert_eq!(font_size_px(&style), Some(13.0), "{kind:?}: wrong font size");
1107        }
1108    }
1109
1110    #[test]
1111    fn build_chip_style_radius_actually_rounds_the_chip_to_a_pill() {
1112        // The widget's premise: the corner radius must reach at least half the
1113        // content height (font + vertical padding), otherwise it renders as a
1114        // rounded rectangle rather than a pill.
1115        for kind in ALL_KINDS {
1116            let style = build_chip_style(kind);
1117            let (top, bottom, ..) = padding_px(&style);
1118            let height = font_size_px(&style).expect("a font size must be declared")
1119                + top.expect("padding-top")
1120                + bottom.expect("padding-bottom");
1121            for r in radii_px(&style) {
1122                assert!(
1123                    r * 2.0 >= height,
1124                    "{kind:?}: radius {r} does not reach half of the {height}px pill height"
1125                );
1126            }
1127        }
1128    }
1129
1130    #[test]
1131    fn build_chip_style_is_a_row_flexbox_that_hugs_its_content() {
1132        for kind in ALL_KINDS {
1133            let props = properties(&build_chip_style(kind));
1134            let has = |p: &CssProperty| props.contains(p);
1135
1136            assert!(has(&CssProperty::const_display(LayoutDisplay::Flex)), "{kind:?}: not a flex box");
1137            assert!(
1138                has(&CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
1139                "{kind:?}: the label and the x must sit side by side"
1140            );
1141            assert!(
1142                has(&CssProperty::const_align_items(LayoutAlignItems::Center)),
1143                "{kind:?}: label and x not vertically centred"
1144            );
1145            // align-self: start + flex-grow: 0 — without both, the pill stretches
1146            // across a flex parent instead of hugging its label.
1147            assert!(
1148                has(&CssProperty::align_self(LayoutAlignSelf::Start)),
1149                "{kind:?}: chip stretches on the cross axis"
1150            );
1151            assert!(
1152                has(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
1153                "{kind:?}: chip grows on the main axis"
1154            );
1155        }
1156    }
1157
1158    #[test]
1159    fn build_chip_style_leaves_text_alignment_to_the_label() {
1160        // Unlike `badge`, the chip container declares neither `text-align` nor
1161        // `justify-content` — the label child owns `text-align: left`. A
1162        // container-level declaration here would fight the label's own style.
1163        for kind in ALL_KINDS {
1164            for p in build_chip_style(kind).as_ref() {
1165                assert!(
1166                    !matches!(p.property, CssProperty::TextAlign(_)),
1167                    "{kind:?}: the container must not declare text-align"
1168                );
1169                assert!(
1170                    !matches!(p.property, CssProperty::JustifyContent(_)),
1171                    "{kind:?}: the container must not declare justify-content"
1172                );
1173            }
1174        }
1175    }
1176
1177    #[test]
1178    fn build_chip_style_declares_the_inheritable_text_style_once() {
1179        // The label and the "x" carry no colour/family of their own — both are
1180        // inherited from the container, so the container must declare them.
1181        for kind in ALL_KINDS {
1182            let style = build_chip_style(kind);
1183            let families: Vec<_> = style
1184                .as_ref()
1185                .iter()
1186                .filter_map(|p| match &p.property {
1187                    CssProperty::FontFamily(f) => f.get_property(),
1188                    _ => None,
1189                })
1190                .collect();
1191            assert_eq!(families.len(), 1, "{kind:?}: exactly one font-family declaration");
1192            let fams = families[0].as_ref();
1193            assert_eq!(fams.len(), 1, "{kind:?}: expected a single system-ui family");
1194            match &fams[0] {
1195                StyleFontFamily::System(name) => {
1196                    assert_eq!(name.as_str(), "system:ui", "{kind:?}: wrong system font family")
1197                }
1198                other => panic!("{kind:?}: chip must use the system UI font, got {other:?}"),
1199            }
1200            assert!(text_color(&style).is_some(), "{kind:?}: no inheritable text colour declared");
1201        }
1202    }
1203
1204    #[test]
1205    fn build_chip_style_colours_track_the_kind() {
1206        for kind in ALL_KINDS {
1207            let style = build_chip_style(kind);
1208            let (bg, text) = kind.colors();
1209            assert_eq!(background_color(&style), Some(bg), "{kind:?}: emitted background != colors().0");
1210            assert_eq!(text_color(&style), Some(text), "{kind:?}: emitted text colour != colors().1");
1211        }
1212    }
1213
1214    #[test]
1215    fn build_chip_style_declares_every_property_at_most_once() {
1216        // A duplicated declaration is a last-one-wins ambiguity: two backgrounds
1217        // would make one of them silently dead.
1218        for kind in ALL_KINDS {
1219            let types = property_types(&build_chip_style(kind));
1220            let mut seen = HashSet::new();
1221            for t in &types {
1222                assert!(seen.insert(*t), "{kind:?}: the container style declares the same property twice");
1223            }
1224            assert_eq!(seen.len(), types.len());
1225        }
1226    }
1227
1228    #[test]
1229    fn build_chip_style_properties_are_all_unconditional() {
1230        // The chip container is stateless — a declaration gated on
1231        // `:hover`/`:active` would simply never paint.
1232        for kind in ALL_KINDS {
1233            for p in build_chip_style(kind).as_ref() {
1234                assert!(
1235                    p.apply_if.as_ref().is_empty(),
1236                    "{kind:?}: {:?} is conditional on a stateless container",
1237                    p.property
1238                );
1239            }
1240        }
1241    }
1242
1243    #[test]
1244    fn build_chip_style_is_deterministic_and_kind_dependent() {
1245        let baseline = property_types(&build_chip_style(ChipKind::Default));
1246        assert!(!baseline.is_empty(), "the container style must not be empty");
1247
1248        for kind in ALL_KINDS {
1249            assert_eq!(
1250                properties(&build_chip_style(kind)),
1251                properties(&build_chip_style(kind)),
1252                "{kind:?}: two builds of the same kind disagree"
1253            );
1254            assert_eq!(
1255                property_types(&build_chip_style(kind)),
1256                baseline,
1257                "{kind:?}: declares different properties, or in a different order, than Default"
1258            );
1259        }
1260        // No two kinds may collapse onto the same style, or the variant is a no-op.
1261        for (i, a) in ALL_KINDS.iter().enumerate() {
1262            for b in &ALL_KINDS[i + 1..] {
1263                assert_ne!(
1264                    properties(&build_chip_style(*a)),
1265                    properties(&build_chip_style(*b)),
1266                    "{a:?} and {b:?} produce an identical style"
1267                );
1268            }
1269        }
1270    }
1271
1272    #[test]
1273    fn build_chip_style_differs_only_in_the_colours() {
1274        // Exactly two declarations may depend on the kind: the background and the
1275        // text colour. Kinds that share a text colour (Default/Warning/Info are
1276        // all dark-on-light) must therefore differ in the background *alone* —
1277        // any third differing declaration means geometry leaked into the palette.
1278        for (i, a) in ALL_KINDS.iter().enumerate() {
1279            for b in &ALL_KINDS[i + 1..] {
1280                let (style_a, style_b) = (build_chip_style(*a), build_chip_style(*b));
1281                let differing: Vec<_> = style_a
1282                    .as_ref()
1283                    .iter()
1284                    .zip(style_b.as_ref().iter())
1285                    .filter(|(x, y)| x != y)
1286                    .map(|(x, _)| core::mem::discriminant(&x.property))
1287                    .collect();
1288
1289                let same_text = a.colors().1 == b.colors().1;
1290                let expected = if same_text { 1 } else { 2 };
1291                assert_eq!(
1292                    differing.len(),
1293                    expected,
1294                    "{a:?} vs {b:?}: only the background{} may depend on the kind",
1295                    if same_text { "" } else { " and the text colour" }
1296                );
1297                let bg_differs = style_a
1298                    .as_ref()
1299                    .iter()
1300                    .zip(style_b.as_ref().iter())
1301                    .any(|(x, y)| x != y && matches!(x.property, CssProperty::BackgroundContent(_)));
1302                assert!(
1303                    bg_differs,
1304                    "{a:?} vs {b:?}: the background must be one of the differing declarations"
1305                );
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn build_chip_style_emits_only_finite_non_negative_px_lengths() {
1312        // Guard the numeric conversions in this file (`isize` -> `PixelValue`):
1313        // a NaN/inf/negative length must never reach the layout solver.
1314        for kind in ALL_KINDS {
1315            let values = all_pixel_values(&build_chip_style(kind));
1316            assert_eq!(values.len(), 9, "{kind:?}: expected 4 paddings + 4 radii + 1 font size");
1317            for pv in values {
1318                let n = px(&pv); // also asserts SizeMetric::Px
1319                assert!(n.is_finite(), "{kind:?}: non-finite length {n}");
1320                assert!(n >= 0.0, "{kind:?}: negative length {n}");
1321                assert!(n <= 128.0, "{kind:?}: implausibly large length {n} for a chip");
1322            }
1323        }
1324    }
1325
1326    #[test]
1327    fn label_and_remove_static_styles_are_finite_unconditional_and_non_growing() {
1328        for (name, style) in [("label", CHIP_LABEL_STYLE), ("remove", CHIP_REMOVE_STYLE)] {
1329            let vec = CssPropertyWithConditionsVec::from_const_slice(style);
1330            for p in vec.as_ref() {
1331                assert!(p.apply_if.as_ref().is_empty(), "{name}: {:?} must be unconditional", p.property);
1332            }
1333            // Both children must hug their content, or the "x" is pushed off the pill.
1334            assert!(
1335                properties(&vec).contains(&CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
1336                "{name}: child must not grow inside the pill"
1337            );
1338            for pv in all_pixel_values(&vec) {
1339                let n = px(&pv);
1340                assert!(n.is_finite(), "{name}: non-finite length {n}");
1341                assert!((0.0..=64.0).contains(&n), "{name}: implausible length {n}");
1342            }
1343            let types = property_types(&vec);
1344            let mut seen = HashSet::new();
1345            for t in &types {
1346                assert!(seen.insert(*t), "{name}: declares the same property twice");
1347            }
1348        }
1349    }
1350
1351    #[test]
1352    fn the_remove_affordance_is_styled_as_a_clickable_target() {
1353        let remove = CssPropertyWithConditionsVec::from_const_slice(CHIP_REMOVE_STYLE);
1354        let props = properties(&remove);
1355        assert!(
1356            props.contains(&CssProperty::const_cursor(StyleCursor::Pointer)),
1357            "the x must advertise itself as clickable"
1358        );
1359        assert!(
1360            props.contains(&CssProperty::user_select(StyleUserSelect::None)),
1361            "dragging across the x must not select the glyph"
1362        );
1363        assert!(
1364            props.contains(&CssProperty::const_margin_left(LayoutMarginLeft::const_px(6))),
1365            "the x needs breathing room from the label"
1366        );
1367
1368        let label = CssPropertyWithConditionsVec::from_const_slice(CHIP_LABEL_STYLE);
1369        let label_props = properties(&label);
1370        assert!(
1371            label_props.contains(&CssProperty::const_text_align(StyleTextAlign::Left)),
1372            "the label owns text-align, not the container"
1373        );
1374        assert!(
1375            label_props.contains(&CssProperty::user_select(StyleUserSelect::None)),
1376            "the label must not be selectable (it is a click target)"
1377        );
1378        assert!(
1379            !label_props.iter().any(|p| matches!(p, CssProperty::Cursor(_))),
1380            "only the x declares a pointer cursor"
1381        );
1382    }
1383
1384    // ------------------------------------------------------------------
1385    // Chip::create / Chip::with_kind  (constructors)
1386    // ------------------------------------------------------------------
1387
1388    #[test]
1389    fn create_defaults_to_the_neutral_tag_and_keeps_the_label_verbatim() {
1390        for s in adversarial_strings() {
1391            let c = Chip::create(AzString::from(s.clone()));
1392            assert_eq!(c.label.as_str(), s.as_str(), "the label was not preserved verbatim");
1393            assert_eq!(c.label.len(), s.len(), "byte length changed (NUL truncation?)");
1394            assert_eq!(c.kind, ChipKind::Default, "create() must use the neutral default kind");
1395            assert!(!c.removable, "a fresh chip renders no x");
1396            assert!(c.chip_state.inner.visible, "a fresh chip is visible");
1397            assert!(c.chip_state.on_remove.is_none(), "a fresh chip carries no remove callback");
1398            assert!(c.chip_state.on_click.is_none(), "a fresh chip carries no click callback");
1399            assert_eq!(properties(&c.container_style), properties(&build_chip_style(ChipKind::Default)));
1400        }
1401    }
1402
1403    #[test]
1404    fn with_kind_stores_both_arguments_and_the_matching_style() {
1405        for kind in ALL_KINDS {
1406            for s in adversarial_strings() {
1407                let c = Chip::with_kind(AzString::from(s.clone()), kind);
1408                assert_eq!(c.label.as_str(), s.as_str(), "{kind:?}: label not preserved");
1409                assert_eq!(c.label.len(), s.len(), "{kind:?}: byte length changed");
1410                assert_eq!(c.kind, kind, "{kind:?}: kind field does not match the argument");
1411                assert!(!c.removable, "{kind:?}: with_kind must not turn on the x");
1412                // The invariant that makes `container_style` a cache and not a lie.
1413                assert_eq!(properties(&c.container_style), properties(&build_chip_style(kind)));
1414                assert_eq!(background_color(&c.container_style), Some(kind.colors().0));
1415            }
1416        }
1417    }
1418
1419    #[test]
1420    fn create_is_with_kind_default() {
1421        for s in ["", "tag", "\u{1F600}", "\u{00D7}"] {
1422            assert_eq!(
1423                Chip::create(AzString::from_const_str(s)),
1424                Chip::with_kind(AzString::from_const_str(s), ChipKind::Default)
1425            );
1426        }
1427    }
1428
1429    #[test]
1430    fn default_chip_is_an_empty_neutral_chip_and_equality_sees_every_field() {
1431        let d = Chip::default();
1432        assert_eq!(d, Chip::create(AzString::from_const_str("")));
1433        assert_eq!(d.label.as_str(), "");
1434        assert_eq!(d.kind, ChipKind::Default);
1435        assert!(!d.removable);
1436        assert_eq!(d.clone(), d, "Clone must preserve equality");
1437
1438        assert_ne!(d, Chip::create(AzString::from_const_str("tag")), "the label must affect equality");
1439        assert_ne!(
1440            Chip::with_kind(AzString::from_const_str("t"), ChipKind::Danger),
1441            Chip::with_kind(AzString::from_const_str("t"), ChipKind::Success),
1442            "chips of different kinds must not compare equal"
1443        );
1444        assert_ne!(
1445            Chip::default(),
1446            Chip::default().with_removable(true),
1447            "the removable flag must affect equality"
1448        );
1449    }
1450
1451    #[test]
1452    fn chips_wired_to_separate_payloads_are_not_equal() {
1453        // `RefAny` equality is "same data", not "same value": two independently
1454        // allocated payloads holding the same `u8` are distinct, so the chips
1455        // that carry them must not compare equal either.
1456        let shared = RefAny::new(0u8);
1457        let a = Chip::create(AzString::from("t")).with_on_click(shared.clone(), click_cb(click_do_nothing));
1458        let b = Chip::create(AzString::from("t")).with_on_click(shared.clone(), click_cb(click_do_nothing));
1459        assert_eq!(a, b, "chips sharing one payload and one callback must be equal");
1460
1461        let c = Chip::create(AzString::from("t"))
1462            .with_on_click(RefAny::new(0u8), click_cb(click_do_nothing));
1463        assert_ne!(a, c, "a separately allocated payload is a different chip");
1464    }
1465
1466    // ------------------------------------------------------------------
1467    // Chip::set_kind / with_chip_kind
1468    // ------------------------------------------------------------------
1469
1470    #[test]
1471    fn set_kind_recomputes_the_style_without_growing_it() {
1472        // A push-instead-of-replace bug would grow the style vec on every call and
1473        // leave stale (earlier-kind) colour declarations behind, which then win or
1474        // lose the cascade by accident.
1475        let mut c = Chip::create(AzString::from_const_str("tag"));
1476        let expected_len = build_chip_style(ChipKind::Default).as_ref().len();
1477
1478        for round in 0..50 {
1479            let kind = ALL_KINDS[round % ALL_KINDS.len()];
1480            c.set_kind(kind);
1481
1482            assert_eq!(c.kind, kind, "round {round}: kind field not updated");
1483            assert_eq!(
1484                c.container_style.as_ref().len(),
1485                expected_len,
1486                "round {round}: style vec changed length — stale declarations?"
1487            );
1488            assert_eq!(
1489                properties(&c.container_style),
1490                properties(&build_chip_style(kind)),
1491                "round {round}: style does not match a freshly built one"
1492            );
1493            assert_eq!(
1494                background_color(&c.container_style),
1495                Some(kind.colors().0),
1496                "round {round}: stale background"
1497            );
1498            assert_eq!(c.label.as_str(), "tag", "round {round}: set_kind ate the label");
1499        }
1500    }
1501
1502    #[test]
1503    fn set_kind_leaves_label_removable_and_callbacks_alone() {
1504        let mut c = Chip::create(AzString::from("keep me"));
1505        c.set_on_remove(RefAny::new(StateLog { calls: Vec::new() }), remove_cb(remove_do_nothing));
1506        c.set_on_click(RefAny::new(0u8), click_cb(click_do_nothing));
1507        c.chip_state.inner.visible = false;
1508
1509        c.set_kind(ChipKind::Warning);
1510
1511        assert_eq!(c.label.as_str(), "keep me");
1512        assert!(c.removable, "set_kind must not clear the x");
1513        assert!(c.chip_state.on_remove.is_some(), "set_kind must not drop the remove callback");
1514        assert!(c.chip_state.on_click.is_some(), "set_kind must not drop the click callback");
1515        assert!(!c.chip_state.inner.visible, "set_kind must not resurrect a removed chip");
1516    }
1517
1518    #[test]
1519    fn with_chip_kind_agrees_with_set_kind_and_is_last_call_wins() {
1520        let chained = Chip::create(AzString::from_const_str("t"))
1521            .with_chip_kind(ChipKind::Danger)
1522            .with_chip_kind(ChipKind::Warning)
1523            .with_chip_kind(ChipKind::Info);
1524
1525        let mut mutated = Chip::create(AzString::from_const_str("t"));
1526        mutated.set_kind(ChipKind::Danger);
1527        mutated.set_kind(ChipKind::Warning);
1528        mutated.set_kind(ChipKind::Info);
1529
1530        assert_eq!(chained, mutated, "the builder and the mutator must agree");
1531        assert_eq!(chained.kind, ChipKind::Info);
1532        assert_eq!(chained.label.as_str(), "t");
1533        assert_eq!(properties(&chained.container_style), properties(&build_chip_style(ChipKind::Info)));
1534        // In particular the Danger red must be completely gone.
1535        assert_eq!(background_color(&chained.container_style), Some(ChipKind::Info.colors().0));
1536    }
1537
1538    #[test]
1539    fn setting_the_same_kind_twice_is_idempotent() {
1540        for kind in ALL_KINDS {
1541            let once = Chip::with_kind(AzString::from_const_str("x"), kind);
1542            let twice = once.clone().with_chip_kind(kind);
1543            assert_eq!(once, twice, "{kind:?}: re-setting the same kind changed the chip");
1544        }
1545        // A full cycle through every kind and back must not accumulate state.
1546        let original = Chip::create(AzString::from("t"));
1547        let mut cycled = original.clone();
1548        for kind in ALL_KINDS {
1549            cycled.set_kind(kind);
1550        }
1551        cycled.set_kind(ChipKind::Default);
1552        assert_eq!(cycled, original, "kind cycling must not accumulate state");
1553    }
1554
1555    // ------------------------------------------------------------------
1556    // Chip::set_removable / with_removable
1557    // ------------------------------------------------------------------
1558
1559    #[test]
1560    fn set_removable_last_write_wins_and_touches_nothing_else() {
1561        let mut c = Chip::with_kind(AzString::from("t"), ChipKind::Warning);
1562        let style_before = c.container_style.clone();
1563
1564        for flag in [true, true, false, true, false, false] {
1565            c.set_removable(flag);
1566            assert_eq!(c.removable, flag);
1567        }
1568
1569        assert_eq!(c.kind, ChipKind::Warning);
1570        assert_eq!(c.label.as_str(), "t");
1571        assert_eq!(c.container_style, style_before, "toggling must not restyle the pill");
1572        assert!(c.chip_state.on_remove.is_none(), "toggling must not invent a callback");
1573        assert!(c.chip_state.inner.visible, "toggling must not hide the chip");
1574    }
1575
1576    #[test]
1577    fn with_removable_toggle_sequence_ends_on_the_last_value() {
1578        assert!(Chip::default().with_removable(true).removable);
1579        assert!(!Chip::default().with_removable(false).removable);
1580        assert!(!Chip::default().with_removable(true).with_removable(false).removable);
1581        assert!(Chip::default().with_removable(false).with_removable(true).removable);
1582
1583        // builder == setter
1584        let mut mutated = Chip::default();
1585        mutated.set_removable(true);
1586        assert_eq!(Chip::default().with_removable(true), mutated);
1587    }
1588
1589    #[test]
1590    fn removable_only_changes_the_child_count_not_the_pill() {
1591        let plain = Chip::create(AzString::from("t"));
1592        let style = plain.container_style.clone();
1593        let removable = plain.clone().with_removable(true);
1594
1595        assert_eq!(removable.container_style, style, "the x must not restyle the container");
1596        assert_eq!(plain.dom().children.as_ref().len(), 1);
1597        assert_eq!(removable.dom().children.as_ref().len(), 2);
1598    }
1599
1600    // ------------------------------------------------------------------
1601    // Chip::set_on_remove / with_on_remove
1602    // ------------------------------------------------------------------
1603
1604    #[test]
1605    fn set_on_remove_implies_removable() {
1606        let mut c = Chip::create(AzString::from("t"));
1607        assert!(!c.removable);
1608
1609        c.set_on_remove(RefAny::new(1u8), remove_cb(remove_do_nothing));
1610
1611        assert!(c.removable, "a remove callback must render an x");
1612        assert!(c.chip_state.on_remove.is_some());
1613        assert!(c.chip_state.on_click.is_none(), "wiring on_remove must not invent an on_click");
1614        assert!(c.chip_state.inner.visible, "wiring a callback must not hide the chip");
1615        assert_eq!(c.dom().children.as_ref().len(), 2, "the x must actually be rendered");
1616    }
1617
1618    #[test]
1619    fn set_on_remove_replaces_rather_than_appends() {
1620        let mut c = Chip::create(AzString::from("t"));
1621
1622        c.set_on_remove(RefAny::new(1u8), remove_cb(remove_do_nothing));
1623        let first = c.chip_state.on_remove.as_ref().expect("first callback").refany.get_type_id();
1624        assert_eq!(first, RefAny::new(1u8).get_type_id());
1625
1626        // a second call must *replace* the payload + function, not stack another one
1627        c.set_on_remove(RefAny::new(9i64), remove_cb(record_remove));
1628        let second = c.chip_state.on_remove.as_ref().expect("second callback");
1629        assert_eq!(second.refany.get_type_id(), RefAny::new(9i64).get_type_id());
1630        assert_eq!(second.callback, remove_cb(record_remove));
1631        assert_ne!(second.callback, remove_cb(remove_do_nothing));
1632
1633        // still exactly one x in the DOM
1634        assert_eq!(c.dom().children.as_ref().len(), 2);
1635    }
1636
1637    #[test]
1638    fn with_on_remove_keeps_the_label_kind_and_style() {
1639        let c = Chip::with_kind(AzString::from("boom"), ChipKind::Danger)
1640            .with_on_remove(RefAny::new(0u8), remove_cb(remove_do_nothing));
1641
1642        assert_eq!(c.label.as_str(), "boom");
1643        assert_eq!(c.kind, ChipKind::Danger);
1644        assert_eq!(c.container_style, build_chip_style(ChipKind::Danger));
1645        assert!(c.removable);
1646        assert!(c.chip_state.on_remove.is_some());
1647    }
1648
1649    #[test]
1650    fn set_removable_false_after_set_on_remove_silently_drops_the_x() {
1651        // Footgun, pinned as the *current* behaviour: `set_on_remove` implies
1652        // `removable = true`, but a later `set_removable(false)` wins and the
1653        // wired-up callback becomes unreachable (no x is rendered).
1654        let mut c = Chip::create(AzString::from("t"));
1655        c.set_on_remove(RefAny::new(0u8), remove_cb(record_remove));
1656        c.set_removable(false);
1657
1658        assert!(c.chip_state.on_remove.is_some(), "the callback is still stored");
1659        let dom = c.dom();
1660        assert_eq!(
1661            dom.children.as_ref().len(),
1662            1,
1663            "no x is rendered, so the remove callback can never fire"
1664        );
1665    }
1666
1667    // ------------------------------------------------------------------
1668    // Chip::set_on_click / with_on_click
1669    // ------------------------------------------------------------------
1670
1671    #[test]
1672    fn set_on_click_does_not_imply_removable() {
1673        // Deliberate asymmetry with `set_on_remove`: a clickable chip is not
1674        // automatically a removable one.
1675        let mut c = Chip::create(AzString::from("t"));
1676        c.set_on_click(RefAny::new(1u8), click_cb(click_do_nothing));
1677
1678        assert!(!c.removable, "on_click must not turn on the x");
1679        assert!(c.chip_state.on_click.is_some());
1680        assert!(c.chip_state.on_remove.is_none());
1681        assert_eq!(c.dom().children.as_ref().len(), 1, "still just the label");
1682    }
1683
1684    #[test]
1685    fn set_on_click_replaces_rather_than_appends() {
1686        let mut c = Chip::create(AzString::from("t"));
1687
1688        c.set_on_click(RefAny::new(1u8), click_cb(click_do_nothing));
1689        c.set_on_click(RefAny::new(9i64), click_cb(record_click));
1690
1691        let second = c.chip_state.on_click.as_ref().expect("second callback");
1692        assert_eq!(second.refany.get_type_id(), RefAny::new(9i64).get_type_id());
1693        assert_eq!(second.callback, click_cb(record_click));
1694        assert_ne!(second.callback, click_cb(click_do_nothing));
1695
1696        // exactly one handler reaches the label
1697        let dom = c.dom();
1698        assert_eq!(dom.children.as_ref()[0].root.get_callbacks().as_ref().len(), 1);
1699    }
1700
1701    #[test]
1702    fn with_on_click_keeps_the_label_kind_and_style() {
1703        let c = Chip::with_kind(AzString::from("click me"), ChipKind::Primary)
1704            .with_on_click(RefAny::new(0u8), click_cb(click_do_nothing));
1705
1706        assert_eq!(c.label.as_str(), "click me");
1707        assert_eq!(c.kind, ChipKind::Primary);
1708        assert_eq!(c.container_style, build_chip_style(ChipKind::Primary));
1709        assert!(c.chip_state.on_click.is_some());
1710    }
1711
1712    #[test]
1713    fn both_callbacks_can_be_wired_at_once() {
1714        let c = Chip::create(AzString::from("t"))
1715            .with_on_click(RefAny::new(1u8), click_cb(record_click))
1716            .with_on_remove(RefAny::new(2u8), remove_cb(record_remove));
1717
1718        assert!(c.removable, "on_remove still implies removable when on_click is set");
1719        assert!(c.chip_state.on_click.is_some(), "on_remove must not clobber on_click");
1720        assert!(c.chip_state.on_remove.is_some());
1721        assert_eq!(c.chip_state.on_click.as_ref().expect("on_click").callback, click_cb(record_click));
1722        assert_eq!(c.chip_state.on_remove.as_ref().expect("on_remove").callback, remove_cb(record_remove));
1723
1724        // Setting them in the opposite order must produce the same wiring.
1725        let reversed = Chip::create(AzString::from("t"))
1726            .with_on_remove(RefAny::new(2u8), remove_cb(record_remove))
1727            .with_on_click(RefAny::new(1u8), click_cb(record_click));
1728        assert!(reversed.removable);
1729        assert!(reversed.chip_state.on_click.is_some());
1730        assert!(reversed.chip_state.on_remove.is_some());
1731    }
1732
1733    // ------------------------------------------------------------------
1734    // Chip::swap_with_default
1735    // ------------------------------------------------------------------
1736
1737    #[test]
1738    fn swap_with_default_returns_the_original_and_leaves_a_default_behind() {
1739        let mut c = Chip::with_kind(AzString::from_const_str("tag"), ChipKind::Danger)
1740            .with_removable(true);
1741        let taken = c.swap_with_default();
1742
1743        // The returned value is the *original*, intact.
1744        assert_eq!(taken.label.as_str(), "tag");
1745        assert_eq!(taken.kind, ChipKind::Danger);
1746        assert!(taken.removable);
1747        assert_eq!(properties(&taken.container_style), properties(&build_chip_style(ChipKind::Danger)));
1748
1749        // What is left behind is a *default* chip — in particular its style must
1750        // be the neutral one and not a stale Danger red.
1751        assert_eq!(c, Chip::default());
1752        assert_eq!(c.label.as_str(), "");
1753        assert_eq!(c.kind, ChipKind::Default);
1754        assert!(!c.removable, "the x must not survive the swap");
1755        assert_eq!(
1756            background_color(&c.container_style),
1757            Some(ChipKind::Default.colors().0),
1758            "the red survived the swap"
1759        );
1760    }
1761
1762    #[test]
1763    fn swap_with_default_is_idempotent_on_an_already_default_chip() {
1764        let mut c = Chip::default();
1765        for _ in 0..3 {
1766            let taken = c.swap_with_default();
1767            assert_eq!(taken, Chip::default());
1768            assert_eq!(c, Chip::default());
1769        }
1770    }
1771
1772    #[test]
1773    fn swap_with_default_moves_the_callbacks_out_of_self() {
1774        let mut c = Chip::create(AzString::from("t"))
1775            .with_on_remove(RefAny::new(7u32), remove_cb(record_remove))
1776            .with_on_click(RefAny::new(8u32), click_cb(record_click));
1777
1778        let taken = c.swap_with_default();
1779
1780        assert!(taken.chip_state.on_remove.is_some(), "the remove callback moves out");
1781        assert!(taken.chip_state.on_click.is_some(), "the click callback moves out");
1782        assert!(
1783            c.chip_state.on_remove.is_none(),
1784            "the reset chip must not keep a reference to the old callback"
1785        );
1786        assert!(c.chip_state.on_click.is_none());
1787        assert!(!c.removable);
1788    }
1789
1790    #[test]
1791    fn swap_with_default_survives_a_huge_label_and_repeated_swaps() {
1792        let long = "x".repeat(100_000);
1793        let mut c = Chip::with_kind(AzString::from(long.clone()), ChipKind::Success);
1794        for round in 0..10 {
1795            let taken = c.swap_with_default();
1796            if round == 0 {
1797                assert_eq!(taken.label.len(), long.len(), "the long label was truncated");
1798                assert_eq!(taken.kind, ChipKind::Success);
1799            } else {
1800                assert_eq!(taken, Chip::default(), "round {round}: the emptied chip is not a default");
1801            }
1802            assert_eq!(c, Chip::default(), "round {round}: what was left behind is not a default");
1803        }
1804    }
1805
1806    #[test]
1807    fn swap_with_default_preserves_a_hidden_state_on_the_returned_chip() {
1808        let mut c = Chip::create(AzString::from("t")).with_removable(true);
1809        c.chip_state.inner.visible = false;
1810
1811        let taken = c.swap_with_default();
1812        assert!(!taken.chip_state.inner.visible, "the removed state travels with the original");
1813        assert!(c.chip_state.inner.visible, "the fresh chip left behind must be visible");
1814    }
1815
1816    // ------------------------------------------------------------------
1817    // Chip::dom
1818    // ------------------------------------------------------------------
1819
1820    #[test]
1821    fn dom_of_a_plain_chip_is_a_container_with_one_inert_label() {
1822        for kind in ALL_KINDS {
1823            let chip = Chip::with_kind(AzString::from("tag"), kind);
1824            let expected = properties(&chip.container_style);
1825            let dom = chip.dom();
1826
1827            assert!(dom.root.has_class("__azul-native-chip"), "{kind:?}: missing the widget class");
1828            assert!(
1829                dom.root.get_callbacks().as_ref().is_empty(),
1830                "{kind:?}: a stateless chip must carry no container callback"
1831            );
1832            assert_eq!(inline_properties(&dom), expected, "{kind:?}: the pill lost its computed style");
1833
1834            let children = dom.children.as_ref();
1835            assert_eq!(children.len(), 1, "{kind:?}: no x without `removable`");
1836            let label = &children[0];
1837            assert!(label.root.has_class("__azul-native-chip-label"));
1838            assert_eq!(text_of(label), Some("tag"), "{kind:?}: the label was mangled");
1839            assert!(
1840                label.root.get_callbacks().as_ref().is_empty(),
1841                "{kind:?}: no on_click means no handler on the label"
1842            );
1843            assert!(
1844                label.root.get_tab_index().is_none(),
1845                "{kind:?}: an inert label must not be keyboard-focusable"
1846            );
1847            assert!(label.children.as_ref().is_empty(), "{kind:?}: the label is a leaf text node");
1848        }
1849    }
1850
1851    #[test]
1852    fn dom_of_a_removable_chip_appends_a_focusable_x() {
1853        let dom = Chip::create(AzString::from("tag")).with_removable(true).dom();
1854
1855        let children = dom.children.as_ref();
1856        assert_eq!(children.len(), 2, "[label, remove]");
1857
1858        let remove = &children[1];
1859        assert!(remove.root.has_class("__azul-native-chip-remove"));
1860        assert_eq!(
1861            text_of(remove),
1862            Some("\u{00D7}"),
1863            "the remove glyph is U+00D7 MULTIPLICATION SIGN, not an ASCII 'x'"
1864        );
1865        assert!(
1866            matches!(remove.root.get_tab_index(), Some(TabIndex::Auto)),
1867            "the x must be keyboard-reachable"
1868        );
1869
1870        let callbacks = remove.root.get_callbacks();
1871        assert_eq!(callbacks.as_ref().len(), 1, "exactly one remove handler");
1872        let cb = &callbacks.as_ref()[0];
1873        assert!(matches!(&cb.event, EventFilter::Hover(HoverEventFilter::MouseUp)));
1874        assert_eq!(cb.callback.cb, default_on_chip_remove as usize);
1875        assert!(matches!(&cb.callback.ctx, OptionRefAny::None));
1876
1877        // The container itself must stay handler-free, or a click on the x would
1878        // bubble and double-fire.
1879        assert!(dom.root.get_callbacks().as_ref().is_empty());
1880    }
1881
1882    #[test]
1883    fn dom_attaches_the_click_handler_to_the_label_not_the_container() {
1884        // Documented rationale: a container-level MouseUp would also fire when the
1885        // x (a child) is clicked, double-firing alongside on_remove.
1886        let dom = Chip::create(AzString::from("tag"))
1887            .with_on_click(RefAny::new(0u8), click_cb(record_click))
1888            .dom();
1889
1890        assert!(
1891            dom.root.get_callbacks().as_ref().is_empty(),
1892            "the pill container must never carry the click handler"
1893        );
1894
1895        let label = &dom.children.as_ref()[0];
1896        assert!(
1897            matches!(label.root.get_tab_index(), Some(TabIndex::Auto)),
1898            "a clickable label must be keyboard-reachable"
1899        );
1900        let callbacks = label.root.get_callbacks();
1901        assert_eq!(callbacks.as_ref().len(), 1, "exactly one click handler");
1902        let cb = &callbacks.as_ref()[0];
1903        assert!(matches!(&cb.event, EventFilter::Hover(HoverEventFilter::MouseUp)));
1904        assert_eq!(cb.callback.cb, default_on_chip_click as usize);
1905        assert!(matches!(&cb.callback.ctx, OptionRefAny::None));
1906    }
1907
1908    #[test]
1909    fn dom_gives_the_label_and_the_x_one_shared_state() {
1910        // The doc promises both handlers observe the same `ChipState`.
1911        let dom = Chip::create(AzString::from("tag"))
1912            .with_removable(true)
1913            .with_on_click(RefAny::new(0u8), click_cb(record_click))
1914            .dom();
1915
1916        let children = dom.children.as_ref();
1917        assert_eq!(children.len(), 2);
1918        let mut label_state = children[0].root.get_callbacks().as_ref()[0].refany.clone();
1919        let mut remove_state = children[1].root.get_callbacks().as_ref()[0].refany.clone();
1920
1921        assert_eq!(label_state, remove_state, "the two handlers must share one state RefAny");
1922
1923        // ...and prove it is genuinely shared, not merely equal.
1924        {
1925            let mut w = label_state
1926                .downcast_mut::<ChipStateWrapper>()
1927                .expect("the label payload must be a ChipStateWrapper");
1928            w.inner.visible = false;
1929        }
1930        assert!(
1931            !wrapper_visible(&mut remove_state),
1932            "a write through the label's handle must be visible through the x's handle"
1933        );
1934    }
1935
1936    #[test]
1937    fn dom_of_a_removable_chip_without_on_click_leaves_the_label_inert() {
1938        let dom = Chip::create(AzString::from("tag")).with_removable(true).dom();
1939        let label = &dom.children.as_ref()[0];
1940        assert!(
1941            label.root.get_callbacks().as_ref().is_empty(),
1942            "removable alone must not make the label clickable"
1943        );
1944        assert!(label.root.get_tab_index().is_none());
1945    }
1946
1947    #[test]
1948    fn dom_preserves_adversarial_labels_verbatim() {
1949        for s in adversarial_strings() {
1950            let dom = Chip::create(AzString::from(s.clone())).with_removable(true).dom();
1951            let label = &dom.children.as_ref()[0];
1952            let t = text_of(label).expect("the label must be a text node");
1953            assert_eq!(t, s.as_str(), "the label changed on its way into the DOM");
1954            assert_eq!(t.len(), s.len(), "byte length changed (NUL truncation?)");
1955            assert!(dom.root.has_class("__azul-native-chip"));
1956
1957            // A label that *is* the remove glyph must not be confusable with the x:
1958            // the two are told apart by class, never by text.
1959            let remove = &dom.children.as_ref()[1];
1960            assert!(label.root.has_class("__azul-native-chip-label"));
1961            assert!(remove.root.has_class("__azul-native-chip-remove"));
1962            assert!(!label.root.has_class("__azul-native-chip-remove"));
1963        }
1964    }
1965
1966    #[test]
1967    fn dom_does_not_emit_the_kind_class() {
1968        // Current behaviour, pinned: `ChipKind::class_name()` is never applied to
1969        // the DOM — the container only carries the generic container class, and
1970        // the kind travels as inline style instead.
1971        for kind in ALL_KINDS {
1972            let dom = Chip::with_kind(AzString::from("t"), kind).with_removable(true).dom();
1973            assert!(dom.root.has_class("__azul-native-chip"));
1974            assert!(
1975                !dom.root.has_class(kind.class_name()),
1976                "{kind:?}: the kind class is not emitted (kind is carried inline)"
1977            );
1978        }
1979    }
1980
1981    #[test]
1982    fn dom_renders_the_kind_the_chip_was_last_set_to() {
1983        // `dom()` consumes the *cached* style, so a `set_kind` that forgot to
1984        // recompute would paint the previous colour here and nowhere else.
1985        for kind in ALL_KINDS {
1986            let mut chip = Chip::create(AzString::from_const_str("t"));
1987            chip.set_kind(ChipKind::Danger);
1988            chip.set_kind(kind);
1989            let expected = properties(&build_chip_style(kind));
1990            assert_eq!(
1991                inline_properties(&chip.dom()),
1992                expected,
1993                "{kind:?}: the DOM does not show the current kind"
1994            );
1995        }
1996    }
1997
1998    #[test]
1999    fn from_chip_for_dom_is_exactly_dom() {
2000        for kind in ALL_KINDS {
2001            let chip = Chip::with_kind(AzString::from_const_str("ok"), kind).with_removable(true);
2002            let via_into: Dom = chip.clone().into();
2003            let via_dom = chip.dom();
2004            assert_eq!(
2005                inline_properties(&via_into),
2006                inline_properties(&via_dom),
2007                "{kind:?}: `From` diverges from `dom()`"
2008            );
2009            assert_eq!(
2010                via_into.root.get_node_type(),
2011                via_dom.root.get_node_type(),
2012                "{kind:?}: `From` built a different node"
2013            );
2014            assert_eq!(via_into.children.as_ref().len(), via_dom.children.as_ref().len());
2015        }
2016    }
2017
2018    #[test]
2019    fn dom_flattens_to_the_hierarchy_the_remove_handler_expects() {
2020        // `default_on_chip_remove` hard-codes "parent of the hit node is the
2021        // container". That only holds while the x is a *direct* child.
2022        let styled = removable_styled_dom();
2023        let hierarchy = styled.node_hierarchy.as_ref();
2024        assert_eq!(hierarchy.len(), 3, "container(0), label(1), remove(2)");
2025        assert_eq!(
2026            hierarchy[2].parent_id(),
2027            Some(NodeId::new(0)),
2028            "the x's parent must be the pill container"
2029        );
2030        assert_eq!(hierarchy[0].parent_id(), None, "the container is the root");
2031    }
2032
2033    // ------------------------------------------------------------------
2034    // default_on_chip_remove
2035    // ------------------------------------------------------------------
2036
2037    #[test]
2038    fn remove_hides_the_container_and_flips_visible() {
2039        let mut data = RefAny::new(ChipStateWrapper::default());
2040
2041        // node 2 == the x, its parent (node 0) is the container
2042        let (update, changes) = run_remove(Some(removable_styled_dom()), 2, data.clone());
2043
2044        assert_eq!(update, Update::DoNothing, "no user callback -> DoNothing");
2045        assert_eq!(
2046            display_writes(&changes),
2047            alloc::vec![(0usize, LayoutDisplay::None)],
2048            "the *container* (not the x) must be hidden"
2049        );
2050        assert_eq!(changes.len(), 1, "exactly one restyle per click");
2051        assert!(!wrapper_visible(&mut data), "state must flip to hidden");
2052    }
2053
2054    #[test]
2055    fn remove_invokes_the_user_callback_with_the_already_flipped_state() {
2056        let (mut data, mut log) = state_with_remove_log();
2057
2058        let (update, changes) = run_remove(Some(removable_styled_dom()), 2, data.clone());
2059
2060        assert_eq!(update, Update::RefreshDom, "the user callback's Update is returned");
2061        assert_eq!(
2062            log_calls(&mut log),
2063            alloc::vec![false],
2064            "the callback must see `visible == false` (already removed)"
2065        );
2066        assert!(!wrapper_visible(&mut data));
2067        assert_eq!(
2068            display_writes(&changes),
2069            alloc::vec![(0usize, LayoutDisplay::None)],
2070            "the container is hidden even after a user callback ran"
2071        );
2072    }
2073
2074    #[test]
2075    fn remove_twice_is_idempotent() {
2076        let (mut data, mut log) = state_with_remove_log();
2077
2078        for _ in 0..2 {
2079            let (update, changes) = run_remove(Some(removable_styled_dom()), 2, data.clone());
2080            assert_eq!(update, Update::RefreshDom);
2081            assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
2082        }
2083
2084        assert!(!wrapper_visible(&mut data), "a second remove must not un-hide");
2085        assert_eq!(
2086            log_calls(&mut log),
2087            alloc::vec![false, false],
2088            "each click fires the callback exactly once, always with visible == false"
2089        );
2090    }
2091
2092    #[test]
2093    fn remove_on_a_root_hit_node_is_a_noop() {
2094        // node 0 has no parent -> there is no container to hide, and the early
2095        // return happens *before* the state is touched
2096        let mut data = RefAny::new(ChipStateWrapper::default());
2097
2098        let (update, changes) = run_remove(Some(removable_styled_dom()), 0, data.clone());
2099
2100        assert_eq!(update, Update::DoNothing);
2101        assert!(changes.is_empty(), "nothing may be restyled without a parent");
2102        assert!(wrapper_visible(&mut data), "state must not flip");
2103    }
2104
2105    #[test]
2106    fn remove_with_a_stale_hit_node_is_a_noop() {
2107        // node 999 does not exist in the 3-node fixture
2108        let mut data = RefAny::new(ChipStateWrapper::default());
2109
2110        let (update, changes) = run_remove(Some(removable_styled_dom()), 999, data.clone());
2111
2112        assert_eq!(update, Update::DoNothing);
2113        assert!(changes.is_empty());
2114        assert!(wrapper_visible(&mut data));
2115    }
2116
2117    #[test]
2118    fn remove_without_any_layout_result_is_a_noop() {
2119        let mut data = RefAny::new(ChipStateWrapper::default());
2120
2121        let (update, changes) = run_remove(None, 2, data.clone());
2122
2123        assert_eq!(update, Update::DoNothing);
2124        assert!(changes.is_empty());
2125        assert!(wrapper_visible(&mut data), "state must not flip");
2126    }
2127
2128    #[test]
2129    fn remove_with_a_foreign_payload_is_a_noop() {
2130        // the callback-bearing node carries a RefAny of the *wrong* type
2131        let data = RefAny::new(0xdead_beef_u64);
2132
2133        let (update, changes) = run_remove(Some(removable_styled_dom()), 2, data.clone());
2134
2135        assert_eq!(update, Update::DoNothing);
2136        assert!(changes.is_empty(), "a foreign payload must not hide the container");
2137    }
2138
2139    #[test]
2140    fn remove_fired_from_the_label_still_hides_the_container() {
2141        // Current behaviour, pinned: the handler trusts its wiring — it hides
2142        // whatever the hit node's parent is and never checks that the hit node is
2143        // actually the x. Firing it from node 1 (the label) therefore hides the
2144        // container just the same.
2145        let mut data = RefAny::new(ChipStateWrapper::default());
2146
2147        let (update, changes) = run_remove(Some(removable_styled_dom()), 1, data.clone());
2148
2149        assert_eq!(update, Update::DoNothing);
2150        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
2151        assert!(!wrapper_visible(&mut data));
2152    }
2153
2154    #[test]
2155    fn remove_holds_the_state_borrow_across_the_user_callback() {
2156        // The handler invokes the user callback while its own `downcast_mut` on
2157        // the state is still live. A user callback that re-enters the *same*
2158        // state `RefAny` is therefore refused — it must get `None` back rather
2159        // than a second aliasing borrow (or a panic).
2160        //
2161        // NOTE: probe <-> state form a RefAny reference cycle, so this fixture
2162        // leaks. That is deliberate and harmless for a single test.
2163        let mut probe = RefAny::new(ReentrantProbe {
2164            state: RefAny::new(0u8),
2165            saw_state: Some(true),
2166            calls: 0,
2167        });
2168        let state = RefAny::new(ChipStateWrapper {
2169            inner: ChipState { visible: true },
2170            on_remove: Some(ChipOnRemove {
2171                callback: remove_cb(probe_state_reentrantly),
2172                refany: probe.clone(),
2173            })
2174            .into(),
2175            on_click: OptionChipOnClick::None,
2176        });
2177        {
2178            let mut p = probe.downcast_mut::<ReentrantProbe>().expect("ReentrantProbe");
2179            p.state = state.clone();
2180        }
2181
2182        let (update, changes) = run_remove(Some(removable_styled_dom()), 2, state.clone());
2183
2184        assert_eq!(update, Update::DoNothing);
2185        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
2186
2187        let p = probe.downcast_ref::<ReentrantProbe>().expect("ReentrantProbe");
2188        assert_eq!(p.calls, 1, "the user callback must have run exactly once");
2189        assert_eq!(
2190            p.saw_state, None,
2191            "a re-entrant read of the state must be refused, not aliased"
2192        );
2193    }
2194
2195    #[test]
2196    fn remove_end_to_end_through_the_real_dom_payload() {
2197        // Take the *actual* RefAny the widget wired into its x and drive the
2198        // *actual* handler the widget registered against it.
2199        let chip = Chip::create(AzString::from("bye")).with_removable(true);
2200        let dom = chip.dom();
2201        let remove = &dom.children.as_ref()[1];
2202        let entry = &remove.root.get_callbacks().as_ref()[0];
2203        assert_eq!(entry.callback.cb, default_on_chip_remove as usize);
2204        let mut payload = entry.refany.clone();
2205
2206        let styled = StyledDom::create_from_dom(dom);
2207        let (update, changes) = run_remove(Some(styled), 2, payload.clone());
2208
2209        assert_eq!(update, Update::DoNothing);
2210        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
2211        assert!(
2212            !wrapper_visible(&mut payload),
2213            "the state living in the DOM must be flipped to hidden"
2214        );
2215    }
2216
2217    // ------------------------------------------------------------------
2218    // default_on_chip_click
2219    // ------------------------------------------------------------------
2220
2221    #[test]
2222    fn click_without_a_user_callback_is_a_noop() {
2223        let mut data = RefAny::new(ChipStateWrapper::default());
2224
2225        let (update, changes) = run_click(Some(removable_styled_dom()), 1, data.clone());
2226
2227        assert_eq!(update, Update::DoNothing);
2228        assert!(changes.is_empty(), "a click must never restyle anything");
2229        assert!(wrapper_visible(&mut data), "a click must not hide the chip");
2230    }
2231
2232    #[test]
2233    fn click_invokes_the_user_callback_with_the_current_state() {
2234        let (mut data, mut log) = state_with_click_log(true);
2235
2236        let (update, changes) = run_click(Some(removable_styled_dom()), 1, data.clone());
2237
2238        assert_eq!(update, Update::RefreshDom, "the user callback's Update is returned");
2239        assert_eq!(
2240            log_calls(&mut log),
2241            alloc::vec![true, true],
2242            "the callback must see the *unmodified* state (still visible)"
2243        );
2244        assert!(wrapper_visible(&mut data), "on_click must not flip `visible`");
2245        assert!(changes.is_empty(), "on_click must not write any CSS property");
2246    }
2247
2248    #[test]
2249    fn click_reports_a_hidden_chip_as_hidden() {
2250        // The label and the x share one state, so a click after a remove must
2251        // observe `visible == false`.
2252        let (data, mut log) = state_with_click_log(false);
2253
2254        let (update, _) = run_click(Some(removable_styled_dom()), 1, data);
2255
2256        assert_eq!(update, Update::RefreshDom);
2257        assert_eq!(log_calls(&mut log), alloc::vec![false, false]);
2258    }
2259
2260    #[test]
2261    fn click_does_not_need_the_node_hierarchy() {
2262        // Unlike the remove handler, `default_on_chip_click` never walks the tree:
2263        // it must still fire with no layout result at all, and from the root node.
2264        let (data, mut log) = state_with_click_log(true);
2265
2266        let (update, changes) = run_click(None, 0, data);
2267
2268        assert_eq!(update, Update::RefreshDom, "a missing layout result must not suppress on_click");
2269        assert!(changes.is_empty());
2270        assert_eq!(log_calls(&mut log), alloc::vec![true, true]);
2271    }
2272
2273    #[test]
2274    fn click_with_a_foreign_payload_is_a_noop() {
2275        let data = RefAny::new(0xdead_beef_u64);
2276
2277        let (update, changes) = run_click(Some(removable_styled_dom()), 1, data);
2278
2279        assert_eq!(update, Update::DoNothing);
2280        assert!(changes.is_empty());
2281    }
2282
2283    #[test]
2284    fn click_twice_is_stable_and_never_mutates_the_state() {
2285        let (mut data, mut log) = state_with_click_log(true);
2286
2287        for _ in 0..3 {
2288            let (update, changes) = run_click(Some(removable_styled_dom()), 1, data.clone());
2289            assert_eq!(update, Update::RefreshDom);
2290            assert!(changes.is_empty());
2291        }
2292
2293        assert!(wrapper_visible(&mut data), "repeated clicks must not hide the chip");
2294        assert_eq!(log_calls(&mut log).len(), 6, "each click fires the callback exactly once");
2295    }
2296
2297    #[test]
2298    fn click_end_to_end_through_the_real_dom_payload() {
2299        let log = RefAny::new(StateLog { calls: Vec::new() });
2300        let mut log_handle = log.clone();
2301        let chip = Chip::create(AzString::from("tag"))
2302            .with_removable(true)
2303            .with_on_click(log, click_cb(record_click));
2304        let dom = chip.dom();
2305
2306        let label = &dom.children.as_ref()[0];
2307        let entry = &label.root.get_callbacks().as_ref()[0];
2308        assert_eq!(entry.callback.cb, default_on_chip_click as usize);
2309        let mut payload = entry.refany.clone();
2310
2311        let styled = StyledDom::create_from_dom(dom);
2312        let (update, changes) = run_click(Some(styled), 1, payload.clone());
2313
2314        assert_eq!(update, Update::RefreshDom);
2315        assert!(changes.is_empty());
2316        assert_eq!(log_calls(&mut log_handle), alloc::vec![true, true]);
2317        assert!(wrapper_visible(&mut payload), "the shared state must be untouched by a click");
2318    }
2319
2320    #[test]
2321    fn remove_then_click_through_the_shared_dom_state_sees_the_hidden_chip() {
2322        // The end-to-end consequence of the shared `RefAny`: once the x has been
2323        // clicked, the label's handler observes `visible == false`.
2324        let log = RefAny::new(StateLog { calls: Vec::new() });
2325        let mut log_handle = log.clone();
2326        let chip = Chip::create(AzString::from("tag"))
2327            .with_removable(true)
2328            .with_on_click(log, click_cb(record_click));
2329        let dom = chip.dom();
2330
2331        let click_payload = dom.children.as_ref()[0].root.get_callbacks().as_ref()[0].refany.clone();
2332        let remove_payload = dom.children.as_ref()[1].root.get_callbacks().as_ref()[0].refany.clone();
2333
2334        let styled = StyledDom::create_from_dom(dom);
2335        let (_, changes) = run_remove(Some(styled), 2, remove_payload);
2336        assert_eq!(display_writes(&changes), alloc::vec![(0usize, LayoutDisplay::None)]);
2337
2338        let (update, _) = run_click(Some(removable_styled_dom()), 1, click_payload);
2339        assert_eq!(update, Update::RefreshDom);
2340        assert_eq!(
2341            log_calls(&mut log_handle),
2342            alloc::vec![false, false],
2343            "after the x was clicked, the label handler must see a hidden chip"
2344        );
2345    }
2346}