Skip to main content

fission_core/ui/widgets/
radio.rs

1use crate::lowering::{LoweringContext, NodeBuilder};
2use crate::ui::traits::Lower;
3use crate::ActionEnvelope;
4use fission_ir::{
5    op::{LayoutOp, Op, PaintOp},
6    NodeId,
7};
8use serde::{Deserialize, Serialize};
9
10/// A single-select radio button with a circular indicator and optional label.
11///
12/// Radio buttons are typically used in a group where exactly one is selected
13/// at a time. The `on_select` action is dispatched when the user taps the
14/// button; the application updates which option is selected in the reducer.
15///
16/// # Example
17///
18/// ```rust,ignore
19/// for (i, option) in options.iter().enumerate() {
20///     let on_select = ctx.bind(
21///         SelectOption { index: i },
22///         reduce_with!(handle_select),
23///     );
24///     children.push(Radio {
25///         checked: view.state.selected == i,
26///         on_select: Some(on_select),
27///         label: Some(option.clone()),
28///         ..Default::default()
29///     }.into_node().into());
30/// }
31/// ```
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
33pub struct Radio {
34    /// Explicit node identity.
35    pub id: Option<NodeId>,
36    /// Whether this radio button is currently selected.
37    pub checked: bool,
38    /// Action dispatched when this radio button is tapped.
39    pub on_select: Option<ActionEnvelope>,
40    /// Optional text label rendered next to the indicator.
41    pub label: Option<String>,
42}
43
44impl Radio {
45    pub fn into_node(self) -> crate::ui::Node {
46        crate::ui::Node::Radio(self)
47    }
48}
49
50impl Lower for Radio {
51    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
52        let id = self.id.unwrap_or_else(|| cx.next_node_id());
53        cx.push_scope(id);
54
55        let tokens = &cx.env.theme.tokens;
56        let size = 18.0;
57        let dot_size = size * 0.5;
58        let _radius = size / 2.0;
59        let border_color = tokens.colors.text_secondary;
60        let active_color = tokens.colors.primary;
61        let text_color = tokens.colors.text_primary;
62
63        // Outer Circle
64        let bg_paint = if self.checked {
65            Op::Paint(PaintOp::DrawRect {
66                fill: None,
67                stroke: Some(fission_ir::op::Stroke {
68                    fill: fission_ir::op::Fill::Solid(active_color),
69                    width: 2.0,
70                    dash_array: None,
71                    line_cap: fission_ir::op::LineCap::Butt,
72                    line_join: fission_ir::op::LineJoin::Miter,
73                }),
74                corner_radius: size / 2.0,
75                shadow: None,
76            })
77        } else {
78            Op::Paint(PaintOp::DrawRect {
79                fill: None,
80                stroke: Some(fission_ir::op::Stroke {
81                    fill: fission_ir::op::Fill::Solid(border_color),
82                    width: 1.5,
83                    dash_array: None,
84                    line_cap: fission_ir::op::LineCap::Butt,
85                    line_join: fission_ir::op::LineJoin::Miter,
86                }),
87                corner_radius: size / 2.0,
88                shadow: None,
89            })
90        };
91        let outer_node = NodeBuilder::new(cx.next_node_id(), bg_paint).build(cx);
92
93        // Dot
94        let dot_node = if self.checked {
95            let dot = NodeBuilder::new(
96                cx.next_node_id(),
97                Op::Paint(PaintOp::DrawRect {
98                    fill: Some(fission_ir::op::Fill::Solid(active_color)),
99                    stroke: None,
100                    corner_radius: dot_size / 2.0,
101                    shadow: None,
102                }),
103            )
104            .build(cx);
105            let mut dot_box = NodeBuilder::new(
106                cx.next_node_id(),
107                Op::Layout(LayoutOp::Box {
108                    width: Some(dot_size),
109                    height: Some(dot_size),
110                    min_width: None,
111                    max_width: None,
112                    min_height: None,
113                    max_height: None,
114                    padding: [0.0; 4],
115                    flex_grow: 0.0,
116                    flex_shrink: 0.0,
117                    aspect_ratio: None,
118                }),
119            );
120            dot_box.add_child(dot);
121            let dot_box_id = dot_box.build(cx);
122            let mut dot_align = NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
123            dot_align.add_child(dot_box_id);
124            let dot_align_id = dot_align.build(cx);
125            let mut dot_container = NodeBuilder::new(
126                cx.next_node_id(),
127                Op::Layout(LayoutOp::Box {
128                    width: Some(size),
129                    height: Some(size),
130                    min_width: None,
131                    max_width: None,
132                    min_height: None,
133                    max_height: None,
134                    padding: [0.0; 4],
135                    flex_grow: 0.0,
136                    flex_shrink: 0.0,
137                    aspect_ratio: None,
138                }),
139            );
140            dot_container.add_child(dot_align_id);
141            Some(dot_container.build(cx))
142        } else {
143            None
144        };
145
146        let mut radio_box = NodeBuilder::new(
147            cx.next_node_id(),
148            Op::Layout(LayoutOp::Box {
149                width: Some(size),
150                height: Some(size),
151                min_width: None,
152                max_width: None,
153                min_height: None,
154                max_height: None,
155                padding: [0.0; 4],
156                flex_grow: 0.0,
157                flex_shrink: 0.0,
158                aspect_ratio: None,
159            }),
160        );
161        radio_box.add_child(outer_node);
162        if let Some(d) = dot_node {
163            radio_box.add_child(d);
164        }
165        let radio_final = radio_box.build(cx);
166
167        // Label
168        let label_id = if let Some(text) = &self.label {
169            let text_id = NodeBuilder::new(
170                cx.next_node_id(),
171                Op::Paint(PaintOp::DrawText {
172                    text: text.clone(),
173                    size: tokens.typography.body_medium_size,
174                    color: text_color,
175                    underline: false,
176                    wrap: false,
177                    caret_index: None,
178                    caret_color: None,
179                    caret_width: None,
180                    caret_height: None,
181                    caret_radius: None,
182                    paragraph_style: None,
183                }),
184            )
185            .build(cx);
186            let mut layout = NodeBuilder::new(
187                cx.next_node_id(),
188                Op::Layout(LayoutOp::Box {
189                    width: None,
190                    height: None,
191                    min_width: None,
192                    max_width: None,
193                    min_height: None,
194                    max_height: None,
195                    padding: [tokens.spacing.s, 0.0, 0.0, 0.0],
196                    flex_grow: 0.0,
197                    flex_shrink: 0.0,
198                    aspect_ratio: None,
199                }),
200            );
201            layout.add_child(text_id);
202            Some(layout.build(cx))
203        } else {
204            None
205        };
206
207        let layout_id = cx.next_node_id();
208        let mut row = NodeBuilder::new(
209            layout_id,
210            Op::Layout(LayoutOp::Flex {
211                direction: fission_ir::FlexDirection::Row,
212                wrap: fission_ir::op::FlexWrap::NoWrap,
213                flex_grow: 0.0,
214                flex_shrink: 1.0,
215                padding: [0.0; 4],
216                gap: Some(8.0),
217                align_items: fission_ir::op::AlignItems::Center,
218                justify_content: fission_ir::op::JustifyContent::Start,
219            }),
220        );
221        row.add_child(radio_final);
222        if let Some(l) = label_id {
223            row.add_child(l);
224        }
225        row.build(cx);
226
227        cx.pop_scope();
228
229        let mut semantics = fission_ir::Semantics {
230            role: fission_ir::Role::Checkbox, // Reuse Checkbox for Radio behavior?
231            label: self.label.clone(),
232            identifier: None,
233            value: Some(if self.checked {
234                "true".into()
235            } else {
236                "false".into()
237            }),
238            actions: Default::default(),
239            action_scope_id: None,
240            focusable: true,
241            multiline: false,
242            masked: false,
243            input_mask: None,
244            ime_preedit_range: None,
245            checked: Some(self.checked),
246            disabled: false,
247            read_only: false,
248            autofocus: false,
249            draggable: false,
250            scrollable_x: false,
251            scrollable_y: false,
252            min_value: None,
253            max_value: None,
254            current_value: None,
255            is_focus_scope: false,
256            is_focus_barrier: false,
257            drag_payload: None,
258            hero_tag: None,
259            focus_index: None,
260            text_input_type: fission_ir::semantics::TextInputType::Text,
261            text_input_action: fission_ir::semantics::TextInputAction::Done,
262            text_capitalization: fission_ir::semantics::TextCapitalization::None,
263            max_length: None,
264            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
265            input_formatters: Vec::new(),
266            autocorrect: true,
267            enable_suggestions: true,
268            spell_check: true,
269            smart_dashes: true,
270            smart_quotes: true,
271            autofill_hints: Vec::new(),
272            scroll_padding: None,
273            capture_tab: false,
274            auto_indent: false,
275        };
276        if let Some(action) = &self.on_select {
277            semantics.actions.entries.push(fission_ir::ActionEntry {
278                trigger: fission_ir::semantics::ActionTrigger::Default,
279                action_id: action.id.as_u128(),
280                payload_data: Some(action.payload.clone()),
281            });
282        }
283
284        let mut sem_node = NodeBuilder::new(id, Op::Semantics(semantics));
285        sem_node.add_child(layout_id);
286        sem_node.build(cx)
287    }
288}