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}