Skip to main content

fission_core/ui/widgets/
checkbox.rs

1use crate::internal::InternalLower;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use crate::ActionEnvelope;
4use fission_ir::{
5    op::{LayoutOp, Op, PaintOp},
6    WidgetId,
7};
8use serde::{Deserialize, Serialize};
9
10/// A boolean toggle with a square check indicator and optional label.
11///
12/// When pressed, the `on_toggle` action is dispatched. The application is
13/// responsible for toggling `checked` in the corresponding reducer.
14///
15/// # Example
16///
17/// ```rust,ignore
18/// let on_toggle = ctx.bind(ToggleAgree, reduce_with!(handle_toggle));
19///
20/// Checkbox {
21///     checked: view.state().agreed,
22///     on_toggle: Some(on_toggle),
23///     label: Some("I agree to the terms".into()),
24///     ..Default::default()
25/// }
26/// ```
27#[derive(Debug, Default, Clone, Serialize, Deserialize)]
28pub struct Checkbox {
29    /// Explicit node identity.
30    pub id: Option<WidgetId>,
31    /// Stable identifier exposed on the checkbox's interactive semantics node.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub semantics_identifier: Option<String>,
34    /// Current checked state.
35    pub checked: bool,
36    /// Action dispatched when the checkbox is tapped.
37    pub on_toggle: Option<ActionEnvelope>,
38    /// Optional text label rendered next to the indicator.
39    pub label: Option<String>,
40}
41
42impl Checkbox {
43    /// Sets the stable identifier exposed to accessibility and test tooling.
44    pub fn semantics_identifier(mut self, identifier: impl Into<String>) -> Self {
45        self.semantics_identifier = Some(identifier.into());
46        self
47    }
48}
49
50impl InternalLower for Checkbox {
51    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
52        let id = self.id.map(Into::into).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 radius = tokens.radii.small;
58        let border_color = tokens.colors.text_secondary;
59        let active_color = tokens.colors.primary;
60        let text_color = tokens.colors.text_primary;
61
62        // Square indicator
63        let square_id = cx.next_node_id();
64
65        let bg_paint = if self.checked {
66            Op::Paint(PaintOp::DrawRect {
67                fill: Some(fission_ir::op::Fill::Solid(active_color)),
68                stroke: None,
69                corner_radius: radius,
70                shadow: None,
71            })
72        } else {
73            Op::Paint(PaintOp::DrawRect {
74                fill: None,
75                stroke: Some(fission_ir::op::Stroke {
76                    fill: fission_ir::op::Fill::Solid(border_color),
77                    width: 1.5,
78                    dash_array: None,
79                    line_cap: fission_ir::op::LineCap::Butt,
80                    line_join: fission_ir::op::LineJoin::Miter,
81                }),
82                corner_radius: radius,
83                shadow: None,
84            })
85        };
86        let bg_node = InternalIrBuilder::new(cx.next_node_id(), bg_paint).build(cx);
87
88        // Checkmark
89        let check_node = if self.checked {
90            let check = InternalIrBuilder::new(
91                cx.next_node_id(),
92                Op::Paint(PaintOp::DrawRect {
93                    fill: Some(fission_ir::op::Fill::Solid(tokens.colors.on_primary)),
94                    stroke: None,
95                    corner_radius: 1.0,
96                    shadow: None,
97                }),
98            )
99            .build(cx);
100            let mut check_box = InternalIrBuilder::new(
101                cx.next_node_id(),
102                Op::Layout(LayoutOp::Box {
103                    width: Some(10.0),
104                    height: Some(10.0),
105                    min_width: None,
106                    max_width: None,
107                    min_height: None,
108                    max_height: None,
109                    padding: [0.0; 4],
110                    flex_grow: 0.0,
111                    flex_shrink: 0.0,
112                    aspect_ratio: None,
113                }),
114            );
115            check_box.add_child(check);
116            let check_box_id = check_box.build(cx);
117            let mut align = InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
118            align.add_child(check_box_id);
119            Some(align.build(cx))
120        } else {
121            None
122        };
123
124        let mut square_box = InternalIrBuilder::new(
125            square_id,
126            Op::Layout(LayoutOp::Box {
127                width: Some(size),
128                height: Some(size),
129                min_width: None,
130                max_width: None,
131                min_height: None,
132                max_height: None,
133                padding: [0.0; 4],
134                flex_grow: 0.0,
135                flex_shrink: 0.0,
136                aspect_ratio: None,
137            }),
138        );
139        square_box.add_child(bg_node);
140        if let Some(c) = check_node {
141            square_box.add_child(c);
142        }
143        let square_final = square_box.build(cx);
144
145        // Label
146        let label_id = if let Some(text) = &self.label {
147            let text_id = InternalIrBuilder::new(
148                cx.next_node_id(),
149                Op::Paint(PaintOp::DrawText {
150                    text: text.clone(),
151                    size: tokens.typography.body_medium_size,
152                    color: text_color,
153                    underline: false,
154                    wrap: false,
155                    caret_index: None,
156                    caret_color: None,
157                    caret_width: None,
158                    caret_height: None,
159                    caret_radius: None,
160                    paragraph_style: None,
161                }),
162            )
163            .build(cx);
164            let mut layout = InternalIrBuilder::new(
165                cx.next_node_id(),
166                Op::Layout(LayoutOp::Box {
167                    width: None,
168                    height: None,
169                    min_width: None,
170                    max_width: None,
171                    min_height: None,
172                    max_height: None,
173                    padding: [tokens.spacing.s, 0.0, 0.0, 0.0],
174                    flex_grow: 0.0,
175                    flex_shrink: 0.0,
176                    aspect_ratio: None,
177                }),
178            );
179            layout.add_child(text_id);
180            Some(layout.build(cx))
181        } else {
182            None
183        };
184
185        let layout_id = cx.next_node_id();
186        let mut row = InternalIrBuilder::new(
187            layout_id,
188            Op::Layout(LayoutOp::Flex {
189                direction: fission_ir::FlexDirection::Row,
190                wrap: fission_ir::op::FlexWrap::NoWrap,
191                flex_grow: 0.0,
192                flex_shrink: 1.0,
193                padding: [0.0; 4],
194                gap: Some(8.0),
195                align_items: fission_ir::op::AlignItems::Center,
196                justify_content: fission_ir::op::JustifyContent::Start,
197            }),
198        );
199        row.add_child(square_final);
200        if let Some(l) = label_id {
201            row.add_child(l);
202        }
203        row.build(cx);
204
205        cx.pop_scope();
206
207        let mut semantics = fission_ir::Semantics {
208            role: fission_ir::Role::Checkbox,
209            label: self.label.clone(),
210            identifier: self.semantics_identifier.clone(),
211            value: Some(if self.checked {
212                "true".into()
213            } else {
214                "false".into()
215            }),
216            actions: Default::default(),
217            action_scope_id: None,
218            focusable: true,
219            focus_policy: fission_ir::FocusPolicy::FocusOnPointer,
220            multiline: false,
221            masked: false,
222            input_mask: None,
223            ime_preedit_range: None,
224            ime_preedit_cursor_range: None,
225            text_selection: None,
226            checked: Some(self.checked),
227            disabled: false,
228            read_only: false,
229            autofocus: false,
230            draggable: false,
231            scrollable_x: false,
232            scrollable_y: false,
233            min_value: None,
234            max_value: None,
235            current_value: None,
236            is_focus_scope: false,
237            is_focus_barrier: false,
238            drag_payload: None,
239            hero_tag: None,
240            focus_index: None,
241            text_input_type: fission_ir::semantics::TextInputType::Text,
242            text_input_action: fission_ir::semantics::TextInputAction::Done,
243            text_capitalization: fission_ir::semantics::TextCapitalization::None,
244            max_length: None,
245            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
246            input_formatters: Vec::new(),
247            autocorrect: true,
248            enable_suggestions: true,
249            spell_check: true,
250            smart_dashes: true,
251            smart_quotes: true,
252            autofill_hints: Vec::new(),
253            scroll_padding: None,
254            capture_tab: false,
255            auto_indent: false,
256        };
257        if let Some(action) = &self.on_toggle {
258            semantics.actions.entries.push(fission_ir::ActionEntry {
259                trigger: fission_ir::semantics::ActionTrigger::Default,
260                action_id: action.id.as_u128(),
261                payload_data: Some(action.payload.clone()),
262            });
263        }
264
265        let mut sem_node = InternalIrBuilder::new(id, Op::Semantics(semantics));
266        sem_node.add_child(layout_id);
267        sem_node.build(cx)
268    }
269}