Skip to main content

fission_core/ui/widgets/
button.rs

1use crate::lowering::{LoweringContext, NodeBuilder};
2use crate::ui::traits::Lower;
3use crate::ui::Node;
4use crate::{ActionEnvelope, Env, InteractionStateMap};
5use fission_ir::{
6    op::{BoxShadow, Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke},
7    ActionEntry, ActionSet, NodeId, Role, Semantics,
8};
9use serde::{Deserialize, Serialize};
10
11/// Visual style variant for a [`Button`].
12///
13/// - `Filled` -- solid background with the primary colour (default).
14/// - `Outline` -- transparent background with a border stroke.
15/// - `Ghost` -- no background or border; just text/icon.
16///
17/// # Example
18///
19/// ```rust,ignore
20/// Button {
21///     variant: ButtonVariant::Outline,
22///     child: Some(Box::new(Text::new("Cancel").into_node())),
23///     ..Default::default()
24/// }
25/// ```
26#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
27pub enum ButtonVariant {
28    /// Solid primary-colour background.
29    #[default]
30    Filled,
31    /// Transparent background with a border.
32    Outline,
33    /// No background, no border.
34    Ghost,
35}
36
37/// Horizontal alignment of a [`Button`]'s child content.
38///
39/// Defaults to `Center`.
40#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
41pub enum ButtonContentAlign {
42    /// Center the child horizontally and vertically (default).
43    #[default]
44    Center,
45    /// Align the child to the leading edge.
46    Start,
47    /// Align the child to the trailing edge.
48    End,
49}
50
51/// A pressable button widget with built-in theming, hover/press states, and
52/// focus ring.
53///
54/// Buttons come in three visual [`ButtonVariant`]s (Filled, Outline, Ghost)
55/// and support flexible content alignment via [`ButtonContentAlign`].
56///
57/// # Example
58///
59/// ```rust,ignore
60/// let on_press = ctx.bind(Submit, handle_submit as fn(&mut S, Submit));
61///
62/// Button {
63///     child: Some(Box::new(Text::new("Submit").into_node())),
64///     on_press: Some(on_press),
65///     variant: ButtonVariant::Filled,
66///     content_align: ButtonContentAlign::Center,
67///     ..Default::default()
68/// }
69/// ```
70#[derive(Debug, Default, Clone, Serialize, Deserialize)]
71pub struct Button {
72    /// Explicit node identity (auto-generated if `None`).
73    pub id: Option<NodeId>,
74    /// The button's content widget (typically [`Text`] or [`Icon`]).
75    pub child: Option<Box<Node>>,
76    /// Action dispatched when the button is pressed.
77    pub on_press: Option<ActionEnvelope>,
78    /// Custom semantics (overrides the default button semantics).
79    pub semantics: Option<Semantics>,
80    /// Fixed width in layout points.
81    pub width: Option<f32>,
82    /// Fixed height in layout points.
83    pub height: Option<f32>,
84    /// Custom padding `[left, right, top, bottom]` (overrides theme defaults).
85    pub padding: Option<[f32; 4]>,
86    /// Style overrides (reserved for future use).
87    pub style: Option<ButtonStyleOverride>,
88    /// Visual variant (Filled, Outline, or Ghost).
89    pub variant: ButtonVariant,
90    /// Horizontal alignment of the child content.
91    #[serde(default)]
92    pub content_align: ButtonContentAlign,
93    /// When `true`, the button is greyed out and its `on_press` action is not
94    /// attached.
95    pub disabled: bool,
96}
97
98impl Button {
99    pub fn into_node(self) -> crate::ui::Node {
100        crate::ui::Node::Button(self)
101    }
102}
103
104#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
105pub struct ButtonStyleOverride {}
106
107struct ButtonStyleResolved {
108    background_color: Option<IrColor>,
109    text_color: IrColor,
110    padding_horizontal: f32,
111    padding_vertical: f32,
112    height: f32,
113    corner_radius: f32,
114    shadow: Option<BoxShadow>,
115    stroke: Option<Stroke>,
116}
117
118impl Button {
119    fn resolve_style(
120        &self,
121        env: &Env,
122        interaction: &InteractionStateMap,
123        self_id: NodeId,
124    ) -> ButtonStyleResolved {
125        let default_style = &env.theme.components.button;
126        let tokens = &env.theme.tokens.colors;
127
128        let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
129        let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
130        let is_focused = interaction.is_focused(self_id) && !self.disabled;
131
132        let (bg_color, text_color, border_stroke) = if self.disabled {
133            (
134                if self.variant == ButtonVariant::Filled { Some(tokens.border) } else { None }, // Grey bg or transparent
135                tokens.text_secondary, // Grey text
136                if self.variant == ButtonVariant::Outline { Some(Stroke { color: tokens.border, width: 1.0 }) } else { None },
137            )
138        } else {
139            match self.variant {
140                ButtonVariant::Filled => (
141                    Some(tokens.primary),
142                    tokens.on_primary,
143                    if is_focused { default_style.focus_stroke } else { None },
144                ),
145                ButtonVariant::Outline => (
146                    if is_hovered { Some(tokens.surface) } else { None },
147                    tokens.primary,
148                    Some(Stroke { color: tokens.border, width: 1.0 }),
149                ),
150                ButtonVariant::Ghost => (
151                    if is_hovered { Some(tokens.surface) } else { None },
152                    tokens.primary,
153                    None,
154                ),
155            }
156        };
157
158        let shadow = if self.variant == ButtonVariant::Filled {
159            if is_pressed {
160                default_style.elevation_pressed
161            } else if is_hovered {
162                default_style.elevation_hover
163            } else {
164                default_style.elevation_rest
165            }
166        } else {
167            None
168        };
169
170        ButtonStyleResolved {
171            background_color: bg_color,
172            text_color,
173            padding_horizontal: default_style.padding_horizontal,
174            padding_vertical: default_style.padding_vertical,
175            height: default_style.height,
176            corner_radius: default_style.radius,
177            shadow,
178            stroke: border_stroke,
179        }
180    }
181
182    fn should_attach_semantics(&self) -> bool {
183        self.semantics.is_some() || self.on_press.is_some()
184    }
185
186    fn build_semantics(&self) -> Option<Semantics> {
187        if !self.should_attach_semantics() {
188            return None;
189        }
190
191        let mut semantics = self
192            .semantics
193            .clone()
194            .unwrap_or_else(default_button_semantics);
195            
196        semantics.disabled = self.disabled;
197
198        if let Some(action_envelope) = &self.on_press {
199            if !self.disabled {
200                semantics.actions.entries.push(ActionEntry {
201                    trigger: fission_ir::semantics::ActionTrigger::Default,
202                    action_id: action_envelope.id.as_u128(),
203                    payload_data: Some(action_envelope.payload.clone()),
204                });
205            }
206        }
207
208        Some(semantics)
209    }
210}
211
212impl Lower for Button {
213    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
214        let semantics_op = self.build_semantics();
215        let outermost_id = self.id.unwrap_or_else(|| cx.next_node_id());
216        
217        let (layout_node_id, final_id) = if let Some(_) = semantics_op {
218            (cx.next_node_id(), outermost_id)
219        } else {
220            (outermost_id, outermost_id)
221        };
222
223        let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
224        
225        cx.push_scope(layout_node_id);
226
227        let background_id = NodeBuilder::new(
228            cx.next_node_id(),
229            Op::Paint(PaintOp::DrawRect {
230                fill: resolved_style.background_color.map(|c| Fill { color: c }),
231                stroke: resolved_style.stroke,
232                corner_radius: resolved_style.corner_radius,
233                shadow: resolved_style.shadow,
234            }),
235        )
236        .build(cx);
237
238        let mut button_builder = NodeBuilder::new(
239            layout_node_id,
240            Op::Layout(LayoutOp::Box {
241                width: self.width,
242                height: self.height,
243                min_width: None,
244                max_width: None,
245                min_height: if self.height.is_some() { None } else { Some(resolved_style.height) },
246                max_height: None,
247                padding: self.padding.unwrap_or([
248                    resolved_style.padding_horizontal,
249                    resolved_style.padding_horizontal,
250                    resolved_style.padding_vertical,
251                    resolved_style.padding_vertical,
252                ]),
253                flex_grow: 0.0,
254                flex_shrink: 0.0,
255                aspect_ratio: None,
256            }),
257        );
258        button_builder.add_child(background_id);
259
260        if let Some(child_widget) = &self.child {
261            let child_id = if let Node::Text(mut text_widget) = *child_widget.clone() {
262                text_widget.color = Some(resolved_style.text_color);
263                text_widget.lower(cx)
264            } else {
265                child_widget.lower(cx)
266            };
267            let aligned_id = match self.content_align {
268                ButtonContentAlign::Center => {
269                    // Center the content within the button's box (vertically + horizontally).
270                    let mut align_builder =
271                        NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
272                    align_builder.add_child(child_id);
273                    align_builder.build(cx)
274                }
275                ButtonContentAlign::Start | ButtonContentAlign::End => {
276                    let justify = match self.content_align {
277                        ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
278                        ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
279                        ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
280                    };
281                    let mut flex_builder = NodeBuilder::new(
282                        cx.next_node_id(),
283                        Op::Layout(LayoutOp::Flex {
284                            direction: fission_ir::FlexDirection::Row,
285                            wrap: fission_ir::FlexWrap::NoWrap,
286                            flex_grow: 1.0,
287                            flex_shrink: 0.0,
288                            padding: [0.0; 4],
289                            gap: None,
290                            align_items: fission_ir::op::AlignItems::Center,
291                            justify_content: justify,
292                        }),
293                    );
294                    flex_builder.add_child(child_id);
295                    flex_builder.build(cx)
296                }
297            };
298            button_builder.add_child(aligned_id);
299        }
300        
301        let button_node_id = button_builder.build(cx);
302
303        if let Some(op) = semantics_op {
304            let mut semantics_builder =
305                NodeBuilder::new(final_id, Op::Semantics(op));
306            semantics_builder.add_child(button_node_id);
307            let res_id = semantics_builder.build(cx);
308            cx.pop_scope();
309            return res_id;
310        }
311
312        cx.pop_scope();
313        button_node_id
314    }
315}
316
317fn default_button_semantics() -> Semantics {
318    Semantics {
319        role: Role::Button,
320        label: None,
321        value: None,
322        actions: ActionSet::default(),
323        focusable: true,
324        multiline: false,
325        masked: false,
326        input_mask: None,
327        ime_preedit_range: None,
328        checked: None,
329        disabled: false,
330        draggable: false,
331        scrollable_x: false,
332        scrollable_y: false,
333        min_value: None,
334        max_value: None,
335        current_value: None,
336        is_focus_scope: false,
337        is_focus_barrier: false,
338        drag_payload: None,
339        hero_tag: None,
340        focus_index: None, capture_tab: false, auto_indent: false,
341    }
342}