Skip to main content

fission_core/ui/widgets/
checkbox.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 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<NodeId>,
31    /// Current checked state.
32    pub checked: bool,
33    /// Action dispatched when the checkbox is tapped.
34    pub on_toggle: Option<ActionEnvelope>,
35    /// Optional text label rendered next to the indicator.
36    pub label: Option<String>,
37}
38
39impl Checkbox {
40    pub fn into_node(self) -> crate::ui::Node {
41        crate::ui::Node::Checkbox(self)
42    }
43}
44
45impl Lower for Checkbox {
46    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
47        let id = self.id.unwrap_or_else(|| cx.next_node_id());
48        cx.push_scope(id);
49
50        let tokens = &cx.env.theme.tokens;
51        let size = 18.0;
52        let radius = tokens.radii.small;
53        let border_color = tokens.colors.text_secondary;
54        let active_color = tokens.colors.primary;
55        let text_color = tokens.colors.text_primary;
56
57        // Square indicator
58        let square_id = cx.next_node_id();
59
60        let bg_paint = if self.checked {
61            Op::Paint(PaintOp::DrawRect {
62                fill: Some(fission_ir::op::Fill::Solid(active_color)),
63                stroke: None,
64                corner_radius: radius,
65                shadow: None,
66            })
67        } else {
68            Op::Paint(PaintOp::DrawRect {
69                fill: None,
70                stroke: Some(fission_ir::op::Stroke {
71                    fill: fission_ir::op::Fill::Solid(border_color),
72                    width: 1.5,
73                    dash_array: None,
74                    line_cap: fission_ir::op::LineCap::Butt,
75                    line_join: fission_ir::op::LineJoin::Miter,
76                }),
77                corner_radius: radius,
78                shadow: None,
79            })
80        };
81        let bg_node = NodeBuilder::new(cx.next_node_id(), bg_paint).build(cx);
82
83        // Checkmark
84        let check_node = if self.checked {
85            let check = NodeBuilder::new(
86                cx.next_node_id(),
87                Op::Paint(PaintOp::DrawRect {
88                    fill: Some(fission_ir::op::Fill::Solid(tokens.colors.on_primary)),
89                    stroke: None,
90                    corner_radius: 1.0,
91                    shadow: None,
92                }),
93            )
94            .build(cx);
95            let mut check_box = NodeBuilder::new(
96                cx.next_node_id(),
97                Op::Layout(LayoutOp::Box {
98                    width: Some(10.0),
99                    height: Some(10.0),
100                    min_width: None,
101                    max_width: None,
102                    min_height: None,
103                    max_height: None,
104                    padding: [0.0; 4],
105                    flex_grow: 0.0,
106                    flex_shrink: 0.0,
107                    aspect_ratio: None,
108                }),
109            );
110            check_box.add_child(check);
111            let check_box_id = check_box.build(cx);
112            let mut align = NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
113            align.add_child(check_box_id);
114            Some(align.build(cx))
115        } else {
116            None
117        };
118
119        let mut square_box = NodeBuilder::new(
120            square_id,
121            Op::Layout(LayoutOp::Box {
122                width: Some(size),
123                height: Some(size),
124                min_width: None,
125                max_width: None,
126                min_height: None,
127                max_height: None,
128                padding: [0.0; 4],
129                flex_grow: 0.0,
130                flex_shrink: 0.0,
131                aspect_ratio: None,
132            }),
133        );
134        square_box.add_child(bg_node);
135        if let Some(c) = check_node {
136            square_box.add_child(c);
137        }
138        let square_final = square_box.build(cx);
139
140        // Label
141        let label_id = if let Some(text) = &self.label {
142            let text_id = NodeBuilder::new(
143                cx.next_node_id(),
144                Op::Paint(PaintOp::DrawText {
145                    text: text.clone(),
146                    size: tokens.typography.body_medium_size,
147                    color: text_color,
148                    underline: false,
149                    wrap: false,
150                    caret_index: None,
151                    caret_color: None,
152                    caret_width: None,
153                    caret_height: None,
154                    caret_radius: None,
155                    paragraph_style: None,
156                }),
157            )
158            .build(cx);
159            let mut layout = NodeBuilder::new(
160                cx.next_node_id(),
161                Op::Layout(LayoutOp::Box {
162                    width: None,
163                    height: None,
164                    min_width: None,
165                    max_width: None,
166                    min_height: None,
167                    max_height: None,
168                    padding: [tokens.spacing.s, 0.0, 0.0, 0.0],
169                    flex_grow: 0.0,
170                    flex_shrink: 0.0,
171                    aspect_ratio: None,
172                }),
173            );
174            layout.add_child(text_id);
175            Some(layout.build(cx))
176        } else {
177            None
178        };
179
180        let layout_id = cx.next_node_id();
181        let mut row = NodeBuilder::new(
182            layout_id,
183            Op::Layout(LayoutOp::Flex {
184                direction: fission_ir::FlexDirection::Row,
185                wrap: fission_ir::op::FlexWrap::NoWrap,
186                flex_grow: 0.0,
187                flex_shrink: 1.0,
188                padding: [0.0; 4],
189                gap: Some(8.0),
190                align_items: fission_ir::op::AlignItems::Center,
191                justify_content: fission_ir::op::JustifyContent::Start,
192            }),
193        );
194        row.add_child(square_final);
195        if let Some(l) = label_id {
196            row.add_child(l);
197        }
198        row.build(cx);
199
200        cx.pop_scope();
201
202        let mut semantics = fission_ir::Semantics {
203            role: fission_ir::Role::Checkbox,
204            label: self.label.clone(),
205            identifier: None,
206            value: Some(if self.checked {
207                "true".into()
208            } else {
209                "false".into()
210            }),
211            actions: Default::default(),
212            action_scope_id: None,
213            focusable: true,
214            multiline: false,
215            masked: false,
216            input_mask: None,
217            ime_preedit_range: None,
218            checked: Some(self.checked),
219            disabled: false,
220            read_only: false,
221            autofocus: false,
222            draggable: false,
223            scrollable_x: false,
224            scrollable_y: false,
225            min_value: None,
226            max_value: None,
227            current_value: None,
228            is_focus_scope: false,
229            is_focus_barrier: false,
230            drag_payload: None,
231            hero_tag: None,
232            focus_index: None,
233            text_input_type: fission_ir::semantics::TextInputType::Text,
234            text_input_action: fission_ir::semantics::TextInputAction::Done,
235            text_capitalization: fission_ir::semantics::TextCapitalization::None,
236            max_length: None,
237            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
238            input_formatters: Vec::new(),
239            autocorrect: true,
240            enable_suggestions: true,
241            spell_check: true,
242            smart_dashes: true,
243            smart_quotes: true,
244            autofill_hints: Vec::new(),
245            scroll_padding: None,
246            capture_tab: false,
247            auto_indent: false,
248        };
249        if let Some(action) = &self.on_toggle {
250            semantics.actions.entries.push(fission_ir::ActionEntry {
251                trigger: fission_ir::semantics::ActionTrigger::Default,
252                action_id: action.id.as_u128(),
253                payload_data: Some(action.payload.clone()),
254            });
255        }
256
257        let mut sem_node = NodeBuilder::new(id, Op::Semantics(semantics));
258        sem_node.add_child(layout_id);
259        sem_node.build(cx)
260    }
261}