Skip to main content

fission_core/ui/widgets/
switch.rs

1use crate::internal::InternalLower;
2use crate::lowering::{wrap_zstack_child, InternalIrBuilder, InternalLoweringCx};
3use crate::ActionEnvelope;
4use fission_ir::{
5    op::{Color, LayoutOp, Op, PaintOp},
6    WidgetId,
7};
8use serde::{Deserialize, Serialize};
9
10/// A boolean toggle rendered as a sliding thumb on a track.
11///
12/// Visually similar to iOS/Material "switch" controls. The `on_toggle` action
13/// is dispatched when the user taps the switch; the application toggles
14/// `checked` in the reducer.
15///
16/// # Example
17///
18/// ```rust,ignore
19/// Switch {
20///     checked: view.state().dark_mode,
21///     on_toggle: Some(ctx.bind(ToggleDarkMode, reduce_with!(handler))),
22///     ..Default::default()
23/// }
24/// ```
25#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26pub struct Switch {
27    /// Explicit node identity.
28    pub id: Option<WidgetId>,
29    /// Stable identifier exposed on the switch's interactive semantics node.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub semantics_identifier: Option<String>,
32    /// Current on/off state.
33    pub checked: bool,
34    /// Action dispatched when the switch is tapped.
35    pub on_toggle: Option<ActionEnvelope>,
36}
37
38impl Switch {
39    /// Sets the stable identifier exposed to accessibility and test tooling.
40    pub fn semantics_identifier(mut self, identifier: impl Into<String>) -> Self {
41        self.semantics_identifier = Some(identifier.into());
42        self
43    }
44}
45
46impl InternalLower for Switch {
47    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
48        let id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
49        cx.push_scope(id);
50
51        let tokens = &cx.env.theme.tokens;
52        let width = 36.0;
53        let height = 20.0;
54        let thumb_size = 16.0;
55        let padding = 2.0;
56
57        let track_color = if self.checked {
58            tokens.colors.primary
59        } else {
60            tokens.colors.border
61        };
62        let thumb_color = tokens.colors.on_primary;
63
64        // Track
65        let track_paint = Op::Paint(PaintOp::DrawRect {
66            fill: Some(fission_ir::op::Fill::Solid(track_color)),
67            stroke: None,
68            corner_radius: height / 2.0,
69            shadow: None,
70        });
71        let track_node = InternalIrBuilder::new(cx.next_node_id(), track_paint).build(cx);
72
73        // Thumb
74        let thumb_paint = Op::Paint(PaintOp::DrawRect {
75            fill: Some(fission_ir::op::Fill::Solid(thumb_color)),
76            stroke: None,
77            corner_radius: thumb_size / 2.0,
78            shadow: Some(fission_ir::op::BoxShadow {
79                color: Color {
80                    r: 0,
81                    g: 0,
82                    b: 0,
83                    a: 50,
84                },
85                blur_radius: 2.0,
86                offset: (0.0, 1.0),
87            }),
88        });
89        let thumb_paint_node = InternalIrBuilder::new(cx.next_node_id(), thumb_paint).build(cx);
90
91        let left_padding = if self.checked {
92            width - thumb_size - padding
93        } else {
94            padding
95        };
96
97        let mut thumb_wrapper = InternalIrBuilder::new(
98            cx.next_node_id(),
99            Op::Layout(LayoutOp::Box {
100                width: Some(thumb_size),
101                height: Some(thumb_size),
102                min_width: None,
103                max_width: None,
104                min_height: None,
105                max_height: None,
106                padding: [0.0; 4],
107                flex_grow: 0.0,
108                flex_shrink: 0.0,
109                aspect_ratio: None,
110            }),
111        );
112        thumb_wrapper.add_child(thumb_paint_node);
113        let thumb_id = thumb_wrapper.build(cx);
114
115        // ZStack for Track + Content
116        let layout_id = cx.next_node_id();
117        let bg_id = {
118            let mut bg_fill =
119                InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::AbsoluteFill));
120            bg_fill.add_child(track_node);
121            bg_fill.build(cx)
122        };
123
124        let content_id = {
125            let mut thumb_track = InternalIrBuilder::new(
126                cx.next_node_id(),
127                Op::Layout(LayoutOp::Box {
128                    width: Some(width),
129                    height: Some(height),
130                    min_width: None,
131                    max_width: None,
132                    min_height: None,
133                    max_height: None,
134                    padding: [left_padding, 0.0, padding, 0.0],
135                    flex_grow: 0.0,
136                    flex_shrink: 0.0,
137                    aspect_ratio: None,
138                }),
139            );
140            thumb_track.add_child(thumb_id);
141            thumb_track.build(cx)
142        };
143
144        cx.push_scope(layout_id);
145        let bg_wrapped = wrap_zstack_child(cx, bg_id);
146        let content_wrapped = wrap_zstack_child(cx, content_id);
147        cx.pop_scope();
148
149        let mut root = InternalIrBuilder::new(layout_id, Op::Layout(LayoutOp::ZStack));
150        root.add_child(bg_wrapped);
151        root.add_child(content_wrapped);
152        root.build(cx);
153
154        cx.pop_scope();
155
156        let mut semantics = fission_ir::Semantics {
157            role: fission_ir::Role::Switch,
158            label: None,
159            identifier: self.semantics_identifier.clone(),
160            value: Some(if self.checked {
161                "true".into()
162            } else {
163                "false".into()
164            }),
165            actions: Default::default(),
166            action_scope_id: None,
167            focusable: true,
168            focus_policy: fission_ir::FocusPolicy::FocusOnPointer,
169            multiline: false,
170            masked: false,
171            input_mask: None,
172            ime_preedit_range: None,
173            ime_preedit_cursor_range: None,
174            text_selection: None,
175            checked: Some(self.checked),
176            disabled: false,
177            read_only: false,
178            autofocus: false,
179            draggable: false,
180            scrollable_x: false,
181            scrollable_y: false,
182            min_value: None,
183            max_value: None,
184            current_value: None,
185            is_focus_scope: false,
186            is_focus_barrier: false,
187            drag_payload: None,
188            hero_tag: None,
189            focus_index: None,
190            text_input_type: fission_ir::semantics::TextInputType::Text,
191            text_input_action: fission_ir::semantics::TextInputAction::Done,
192            text_capitalization: fission_ir::semantics::TextCapitalization::None,
193            max_length: None,
194            max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
195            input_formatters: Vec::new(),
196            autocorrect: true,
197            enable_suggestions: true,
198            spell_check: true,
199            smart_dashes: true,
200            smart_quotes: true,
201            autofill_hints: Vec::new(),
202            scroll_padding: None,
203            capture_tab: false,
204            auto_indent: false,
205        };
206        if let Some(action) = &self.on_toggle {
207            semantics.actions.entries.push(fission_ir::ActionEntry {
208                trigger: fission_ir::semantics::ActionTrigger::Default,
209                action_id: action.id.as_u128(),
210                payload_data: Some(action.payload.clone()),
211            });
212        }
213
214        let mut sem_node = InternalIrBuilder::new(id, Op::Semantics(semantics));
215        sem_node.add_child(layout_id);
216        sem_node.build(cx)
217    }
218}