Skip to main content

azul_layout/widgets/
radio_group.rs

1//! Radio-group widget — a vertical (or horizontal) group of mutually-exclusive
2//! options where exactly one is selected. Combines the sibling-navigation +
3//! `selected_index` state of [`crate::widgets::segmented::Segmented`] with the
4//! circular filled/empty indicator visual of
5//! [`crate::widgets::check_box::CheckBox`].
6//!
7//! Each option is a row: a circular indicator (an outer ring containing an inner
8//! dot whose opacity is `100` when selected, `0` otherwise) followed by a text
9//! label. Clicking any row selects it: the internal handler computes the clicked
10//! row's index from its position among its siblings, updates `selected_index`,
11//! invokes the user's `on_change(index)`, and live-restyles every row's dot via
12//! `set_css_property`.
13//!
14//! Key types: [`RadioGroup`], [`RadioGroupState`], [`RadioGroupOnChange`].
15
16use std::vec::Vec;
17
18use azul_core::{
19    callbacks::{CoreCallbackData, Update},
20    dom::{Dom, IdOrClass, IdOrClass::Class, IdOrClassVec, TabIndex},
21    refany::RefAny,
22};
23use azul_css::dynamic_selector::{CssPropertyWithConditions, CssPropertyWithConditionsVec};
24use azul_css::{
25    props::{
26        basic::{color::ColorU, StyleFontSize},
27        layout::{LayoutDisplay, LayoutFlexDirection, LayoutJustifyContent, LayoutAlignItems, LayoutFlexGrow, LayoutWidth, LayoutHeight, LayoutAlignSelf, LayoutMarginRight, LayoutMarginBottom, LayoutMarginLeft},
28        property::{CssProperty, *},
29        style::{StyleBackgroundContent, StyleBackgroundContentVec, LayoutBorderTopWidth, LayoutBorderBottomWidth, LayoutBorderLeftWidth, LayoutBorderRightWidth, StyleBorderTopStyle, BorderStyle, StyleBorderBottomStyle, StyleBorderLeftStyle, StyleBorderRightStyle, StyleBorderTopColor, StyleBorderBottomColor, StyleBorderLeftColor, StyleBorderRightColor, StyleBorderTopLeftRadius, StyleBorderTopRightRadius, StyleBorderBottomLeftRadius, StyleBorderBottomRightRadius, StyleOpacity, StyleCursor, StyleUserSelect},
30    },
31    impl_option_inner, AzString, StringVec,
32};
33
34use crate::callbacks::{Callback, CallbackInfo};
35
36static RADIO_GROUP_CLASS: &[IdOrClass] =
37    &[Class(AzString::from_const_str("__azul-native-radio-group"))];
38static RADIO_GROUP_ROW_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
39    "__azul-native-radio-group-row",
40))];
41static RADIO_GROUP_CIRCLE_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
42    "__azul-native-radio-group-circle",
43))];
44static RADIO_GROUP_DOT_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
45    "__azul-native-radio-group-dot",
46))];
47static RADIO_GROUP_LABEL_CLASS: &[IdOrClass] = &[Class(AzString::from_const_str(
48    "__azul-native-radio-group-label",
49))];
50
51/// Callback function type invoked when the selected option changes.
52pub type RadioGroupOnChangeCallbackType =
53    extern "C" fn(RefAny, CallbackInfo, RadioGroupState) -> Update;
54impl_widget_callback!(
55    RadioGroupOnChange,
56    OptionRadioGroupOnChange,
57    RadioGroupOnChangeCallback,
58    RadioGroupOnChangeCallbackType
59);
60
61azul_core::impl_managed_callback! {
62    wrapper:        RadioGroupOnChangeCallback,
63    info_ty:        CallbackInfo,
64    return_ty:      Update,
65    default_ret:    Update::DoNothing,
66    invoker_static: RADIO_GROUP_ON_CHANGE_INVOKER,
67    invoker_ty:     AzRadioGroupOnChangeCallbackInvoker,
68    thunk_fn:       az_radio_group_on_change_callback_thunk,
69    setter_fn:      AzApp_setRadioGroupOnChangeCallbackInvoker,
70    from_handle_fn: AzRadioGroupOnChangeCallback_createFromHostHandle,
71    extra_args:     [ state: RadioGroupState ],
72}
73
74/// A group of mutually-exclusive radio options with a selection callback.
75#[derive(Debug, Clone, PartialEq, Eq)]
76#[repr(C)]
77pub struct RadioGroup {
78    pub radio_group_state: RadioGroupStateWrapper,
79    /// The label of each option, in order.
80    pub options: StringVec,
81    /// Style for the group container.
82    pub container_style: CssPropertyWithConditionsVec,
83}
84
85#[derive(Debug, Default, Clone, PartialEq, Eq)]
86#[repr(C)]
87pub struct RadioGroupStateWrapper {
88    /// The current selection.
89    pub inner: RadioGroupState,
90    /// `true` lays the options out in a horizontal row, `false` (default) stacks
91    /// them vertically.
92    pub horizontal: bool,
93    /// Optional: function to call when the selection changes.
94    pub on_change: OptionRadioGroupOnChange,
95}
96
97/// State of a [`RadioGroup`]: the index of the currently selected option.
98#[derive(Debug, Copy, Clone, PartialEq, Eq, Default)]
99#[repr(C)]
100pub struct RadioGroupState {
101    /// Zero-based index of the selected option.
102    pub selected_index: usize,
103}
104
105// ---- dimensions (logical px) ----
106const CIRCLE_SIZE: isize = 16;
107const CIRCLE_RADIUS: isize = 8;
108const CIRCLE_BORDER: isize = 1;
109const DOT_SIZE: isize = 8;
110const DOT_RADIUS: isize = 4;
111/// Gap between stacked rows (vertical) / between side-by-side rows (horizontal).
112const ROW_GAP: isize = 6;
113/// Gap between the indicator circle and its label.
114const LABEL_GAP: isize = 8;
115
116// ---- colours ----
117/// Indicator ring colour (#9b9b9b).
118const CIRCLE_BORDER_COLOR: ColorU = ColorU {
119    r: 155,
120    g: 155,
121    b: 155,
122    a: 255,
123};
124/// Selected dot fill (#0d6efd, accent blue).
125const DOT_COLOR: ColorU = ColorU {
126    r: 13,
127    g: 110,
128    b: 253,
129    a: 255,
130};
131
132const DOT_BG_ITEMS: &[StyleBackgroundContent] = &[StyleBackgroundContent::Color(DOT_COLOR)];
133const DOT_BG: StyleBackgroundContentVec = StyleBackgroundContentVec::from_const_slice(DOT_BG_ITEMS);
134
135/// Outer ring of one option's indicator (parameter-independent → const slice).
136/// A flex box that centres its inner dot.
137static RADIO_GROUP_CIRCLE_STYLE: &[CssPropertyWithConditions] = &[
138    CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
139    CssPropertyWithConditions::simple(CssProperty::const_flex_direction(LayoutFlexDirection::Row)),
140    CssPropertyWithConditions::simple(CssProperty::const_justify_content(
141        LayoutJustifyContent::Center,
142    )),
143    CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
144    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
145    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(CIRCLE_SIZE))),
146    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(CIRCLE_SIZE))),
147    CssPropertyWithConditions::simple(CssProperty::const_border_top_width(
148        LayoutBorderTopWidth::const_px(CIRCLE_BORDER),
149    )),
150    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_width(
151        LayoutBorderBottomWidth::const_px(CIRCLE_BORDER),
152    )),
153    CssPropertyWithConditions::simple(CssProperty::const_border_left_width(
154        LayoutBorderLeftWidth::const_px(CIRCLE_BORDER),
155    )),
156    CssPropertyWithConditions::simple(CssProperty::const_border_right_width(
157        LayoutBorderRightWidth::const_px(CIRCLE_BORDER),
158    )),
159    CssPropertyWithConditions::simple(CssProperty::const_border_top_style(StyleBorderTopStyle {
160        inner: BorderStyle::Solid,
161    })),
162    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_style(
163        StyleBorderBottomStyle {
164            inner: BorderStyle::Solid,
165        },
166    )),
167    CssPropertyWithConditions::simple(CssProperty::const_border_left_style(StyleBorderLeftStyle {
168        inner: BorderStyle::Solid,
169    })),
170    CssPropertyWithConditions::simple(CssProperty::const_border_right_style(
171        StyleBorderRightStyle {
172            inner: BorderStyle::Solid,
173        },
174    )),
175    CssPropertyWithConditions::simple(CssProperty::const_border_top_color(StyleBorderTopColor {
176        inner: CIRCLE_BORDER_COLOR,
177    })),
178    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_color(
179        StyleBorderBottomColor {
180            inner: CIRCLE_BORDER_COLOR,
181        },
182    )),
183    CssPropertyWithConditions::simple(CssProperty::const_border_left_color(StyleBorderLeftColor {
184        inner: CIRCLE_BORDER_COLOR,
185    })),
186    CssPropertyWithConditions::simple(CssProperty::const_border_right_color(
187        StyleBorderRightColor {
188            inner: CIRCLE_BORDER_COLOR,
189        },
190    )),
191    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
192        StyleBorderTopLeftRadius::const_px(CIRCLE_RADIUS),
193    )),
194    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
195        StyleBorderTopRightRadius::const_px(CIRCLE_RADIUS),
196    )),
197    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
198        StyleBorderBottomLeftRadius::const_px(CIRCLE_RADIUS),
199    )),
200    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
201        StyleBorderBottomRightRadius::const_px(CIRCLE_RADIUS),
202    )),
203];
204
205/// Inner filled dot when the option is SELECTED (opacity 100).
206static RADIO_GROUP_DOT_STYLE_SELECTED: &[CssPropertyWithConditions] = &[
207    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(DOT_SIZE))),
208    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(DOT_SIZE))),
209    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
210    CssPropertyWithConditions::simple(CssProperty::const_background_content(DOT_BG)),
211    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
212        StyleBorderTopLeftRadius::const_px(DOT_RADIUS),
213    )),
214    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
215        StyleBorderTopRightRadius::const_px(DOT_RADIUS),
216    )),
217    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
218        StyleBorderBottomLeftRadius::const_px(DOT_RADIUS),
219    )),
220    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
221        StyleBorderBottomRightRadius::const_px(DOT_RADIUS),
222    )),
223    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(100))),
224];
225
226/// Inner filled dot when the option is UNSELECTED (opacity 0 — hidden but laid out).
227static RADIO_GROUP_DOT_STYLE_UNSELECTED: &[CssPropertyWithConditions] = &[
228    CssPropertyWithConditions::simple(CssProperty::const_width(LayoutWidth::const_px(DOT_SIZE))),
229    CssPropertyWithConditions::simple(CssProperty::const_height(LayoutHeight::const_px(DOT_SIZE))),
230    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
231    CssPropertyWithConditions::simple(CssProperty::const_background_content(DOT_BG)),
232    CssPropertyWithConditions::simple(CssProperty::const_border_top_left_radius(
233        StyleBorderTopLeftRadius::const_px(DOT_RADIUS),
234    )),
235    CssPropertyWithConditions::simple(CssProperty::const_border_top_right_radius(
236        StyleBorderTopRightRadius::const_px(DOT_RADIUS),
237    )),
238    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_left_radius(
239        StyleBorderBottomLeftRadius::const_px(DOT_RADIUS),
240    )),
241    CssPropertyWithConditions::simple(CssProperty::const_border_bottom_right_radius(
242        StyleBorderBottomRightRadius::const_px(DOT_RADIUS),
243    )),
244    CssPropertyWithConditions::simple(CssProperty::const_opacity(StyleOpacity::const_new(0))),
245];
246
247/// Builds the container style. Orientation (row vs column) is the only
248/// parameter-dependent property, so the style is built at runtime.
249fn build_container_style(horizontal: bool) -> CssPropertyWithConditionsVec {
250    let direction = if horizontal {
251        LayoutFlexDirection::Row
252    } else {
253        LayoutFlexDirection::Column
254    };
255    CssPropertyWithConditionsVec::from_vec(alloc::vec![
256        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
257        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(direction)),
258        CssPropertyWithConditions::simple(CssProperty::align_self(LayoutAlignSelf::Start)),
259        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
260            0,
261        ))),
262    ])
263}
264
265/// Builds one option's row style. The orientation decides whether the inter-row
266/// gap is applied to the bottom (vertical) or the right (horizontal).
267fn build_row_style(horizontal: bool) -> CssPropertyWithConditionsVec {
268    let mut v: Vec<CssPropertyWithConditions> = alloc::vec![
269        CssPropertyWithConditions::simple(CssProperty::const_display(LayoutDisplay::Flex)),
270        CssPropertyWithConditions::simple(CssProperty::const_flex_direction(
271            LayoutFlexDirection::Row,
272        )),
273        CssPropertyWithConditions::simple(CssProperty::const_align_items(LayoutAlignItems::Center)),
274        CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(
275            0,
276        ))),
277        CssPropertyWithConditions::simple(CssProperty::const_cursor(StyleCursor::Pointer)),
278        CssPropertyWithConditions::simple(CssProperty::user_select(StyleUserSelect::None)),
279    ];
280    if horizontal {
281        v.push(CssPropertyWithConditions::simple(
282            CssProperty::const_margin_right(LayoutMarginRight::const_px(ROW_GAP * 2)),
283        ));
284    } else {
285        v.push(CssPropertyWithConditions::simple(
286            CssProperty::const_margin_bottom(LayoutMarginBottom::const_px(ROW_GAP)),
287        ));
288    }
289    CssPropertyWithConditionsVec::from_vec(v)
290}
291
292/// The label-text style: a small left gap from the indicator.
293static RADIO_GROUP_LABEL_STYLE: &[CssPropertyWithConditions] = &[
294    CssPropertyWithConditions::simple(CssProperty::const_flex_grow(LayoutFlexGrow::const_new(0))),
295    CssPropertyWithConditions::simple(CssProperty::const_font_size(StyleFontSize::const_px(13))),
296    CssPropertyWithConditions::simple(CssProperty::const_margin_left(LayoutMarginLeft::const_px(
297        LABEL_GAP,
298    ))),
299];
300
301impl RadioGroup {
302    /// Creates a radio group from the given options, with the first one selected.
303    #[must_use] pub fn create(options: StringVec) -> Self {
304        Self {
305            radio_group_state: RadioGroupStateWrapper {
306                inner: RadioGroupState { selected_index: 0 },
307                horizontal: false,
308                ..Default::default()
309            },
310            options,
311            container_style: build_container_style(false),
312        }
313    }
314
315    /// Sets the currently selected option index.
316    #[inline]
317    pub const fn set_selected_index(&mut self, selected_index: usize) {
318        self.radio_group_state.inner.selected_index = selected_index;
319    }
320
321    /// Builder-style setter for the selected option index.
322    #[inline]
323    #[must_use] pub const fn with_selected_index(mut self, selected_index: usize) -> Self {
324        self.set_selected_index(selected_index);
325        self
326    }
327
328    /// Lays the options out horizontally (default is vertical).
329    #[inline]
330    pub fn set_horizontal(&mut self, horizontal: bool) {
331        self.radio_group_state.horizontal = horizontal;
332        self.container_style = build_container_style(horizontal);
333    }
334
335    /// Builder-style setter for the horizontal layout flag.
336    #[inline]
337    #[must_use] pub fn with_horizontal(mut self, horizontal: bool) -> Self {
338        self.set_horizontal(horizontal);
339        self
340    }
341
342    #[inline]
343    #[must_use] pub fn swap_with_default(&mut self) -> Self {
344        let mut s = Self::create(StringVec::from_const_slice(&[]));
345        core::mem::swap(&mut s, self);
346        s
347    }
348
349    #[inline]
350    pub fn set_on_change<C: Into<RadioGroupOnChangeCallback>>(
351        &mut self,
352        data: RefAny,
353        on_change: C,
354    ) {
355        self.radio_group_state.on_change = Some(RadioGroupOnChange {
356            callback: on_change.into(),
357            refany: data,
358        })
359        .into();
360    }
361
362    #[inline]
363    #[must_use] pub fn with_on_change<C: Into<RadioGroupOnChangeCallback>>(
364        mut self,
365        data: RefAny,
366        on_change: C,
367    ) -> Self {
368        self.set_on_change(data, on_change);
369        self
370    }
371
372    #[must_use] pub fn dom(self) -> Dom {
373        use azul_core::{
374            callbacks::CoreCallback,
375            dom::{EventFilter, HoverEventFilter},
376            refany::OptionRefAny,
377        };
378
379        let selected = self.radio_group_state.inner.selected_index;
380        let horizontal = self.radio_group_state.horizontal;
381        let count = self.options.as_ref().len();
382
383        let row_style = build_row_style(horizontal);
384
385        // One shared RefAny across every row's callback (RefAny::clone shares
386        // the underlying state — same pattern as segmented/tabs/map).
387        let state = RefAny::new(self.radio_group_state);
388
389        let mut children: Vec<Dom> = Vec::with_capacity(count);
390        for (i, label) in self.options.as_ref().iter().enumerate() {
391            let dot_style = if i == selected {
392                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_SELECTED)
393            } else {
394                CssPropertyWithConditionsVec::from_const_slice(RADIO_GROUP_DOT_STYLE_UNSELECTED)
395            };
396
397            let circle = Dom::create_div()
398                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CIRCLE_CLASS))
399                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
400                    RADIO_GROUP_CIRCLE_STYLE,
401                ))
402                .with_children(
403                    vec![Dom::create_div()
404                        .with_ids_and_classes(IdOrClassVec::from_const_slice(
405                            RADIO_GROUP_DOT_CLASS,
406                        ))
407                        .with_css_props(dot_style)]
408                    .into(),
409                );
410
411            let label_node = Dom::create_text(label.clone())
412                .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_LABEL_CLASS))
413                .with_css_props(CssPropertyWithConditionsVec::from_const_slice(
414                    RADIO_GROUP_LABEL_STYLE,
415                ));
416
417            children.push(
418                Dom::create_div()
419                    .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_ROW_CLASS))
420                    .with_css_props(row_style.clone())
421                    .with_callbacks(
422                        vec![CoreCallbackData {
423                            event: EventFilter::Hover(HoverEventFilter::MouseUp),
424                            callback: CoreCallback {
425                                cb: on_radio_row_click as usize,
426                                ctx: OptionRefAny::None,
427                            },
428                            refany: state.clone(),
429                        }]
430                        .into(),
431                    )
432                    .with_tab_index(TabIndex::Auto)
433                    .with_children(vec![circle, label_node].into()),
434            );
435        }
436
437        Dom::create_div()
438            .with_ids_and_classes(IdOrClassVec::from_const_slice(RADIO_GROUP_CLASS))
439            .with_css_props(self.container_style)
440            .with_children(children.into())
441    }
442}
443
444impl Default for RadioGroup {
445    fn default() -> Self {
446        Self::create(StringVec::from_const_slice(&[]))
447    }
448}
449
450/// Click handler shared by all rows. Determines the clicked row's index from its
451/// position among its siblings (the hit node resolves to the row the callback is
452/// registered on — currentTarget semantics — regardless of whether the dot,
453/// circle or label was clicked), updates the selection, invokes the user
454/// callback, and live-restyles every row's indicator dot.
455extern "C" fn on_radio_row_click(mut data: RefAny, mut info: CallbackInfo) -> Update {
456    use azul_core::dom::DomNodeId;
457
458    let clicked = info.get_hit_node();
459    let Some(parent) = info.get_parent(clicked) else {
460        return Update::DoNothing;
461    };
462
463    // Collect the option rows in document order.
464    let mut rows: Vec<DomNodeId> = Vec::new();
465    let mut cur = info.get_first_child(parent);
466    while let Some(node) = cur {
467        rows.push(node);
468        cur = info.get_next_sibling(node);
469    }
470
471    let Some(selected) = rows.iter().position(|n| *n == clicked) else {
472        return Update::DoNothing;
473    };
474
475    let result = {
476        let Some(mut rg) = data.downcast_mut::<RadioGroupStateWrapper>() else {
477            return Update::DoNothing;
478        };
479        rg.inner.selected_index = selected;
480        let inner = rg.inner;
481        let rg = &mut *rg;
482        match rg.on_change.as_mut() {
483            Some(RadioGroupOnChange { callback, refany }) => {
484                (callback.cb)(refany.clone(), info, inner)
485            }
486            None => Update::DoNothing,
487        }
488    };
489
490    // Live-restyle every row's dot: the selected option's dot becomes visible
491    // (opacity 100), the rest are hidden (opacity 0). Each row is
492    // `row → circle (first child) → dot (first child)`.
493    for (i, row) in rows.iter().enumerate() {
494        let Some(circle) = info.get_first_child(*row) else {
495            continue;
496        };
497        let Some(dot) = info.get_first_child(circle) else {
498            continue;
499        };
500        let opacity = if i == selected { 100 } else { 0 };
501        info.set_css_property(dot, CssProperty::const_opacity(StyleOpacity::const_new(opacity)));
502    }
503
504    result
505}
506
507impl From<RadioGroup> for Dom {
508    fn from(r: RadioGroup) -> Self {
509        r.dom()
510    }
511}