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 fission_theme::{ButtonHierarchy, ComponentSize, ComponentState};
10use serde::{Deserialize, Serialize};
11
12/// Visual style variant for a [`Button`].
13///
14/// - `Filled` -- solid background with the primary colour (default).
15/// - `Outline` -- transparent background with a border stroke.
16/// - `Ghost` -- no background or border; just text/icon.
17///
18/// # Example
19///
20/// ```rust,ignore
21/// Button {
22///     variant: ButtonVariant::Outline,
23///     child: Some(Box::new(Text::new("Cancel").into_node())),
24///     ..Default::default()
25/// }
26/// ```
27#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub enum ButtonVariant {
29    /// Solid primary-colour background.
30    #[default]
31    Filled,
32    /// Transparent background with a border.
33    Outline,
34    /// No background, no border.
35    Ghost,
36    /// DSP primary hierarchy.
37    Primary,
38    /// DSP secondary colour hierarchy.
39    SecondaryColor,
40    /// DSP secondary gray hierarchy.
41    SecondaryGray,
42    /// DSP tertiary colour hierarchy.
43    TertiaryColor,
44    /// DSP tertiary gray hierarchy.
45    TertiaryGray,
46    /// DSP link colour hierarchy.
47    LinkColor,
48    /// DSP link gray hierarchy.
49    LinkGray,
50    /// DSP destructive hierarchy.
51    Destructive,
52}
53
54impl ButtonVariant {
55    fn hierarchy(self) -> ButtonHierarchy {
56        match self {
57            ButtonVariant::Filled | ButtonVariant::Primary => ButtonHierarchy::Primary,
58            ButtonVariant::Outline | ButtonVariant::SecondaryGray => ButtonHierarchy::SecondaryGray,
59            ButtonVariant::Ghost | ButtonVariant::TertiaryGray => ButtonHierarchy::TertiaryGray,
60            ButtonVariant::SecondaryColor => ButtonHierarchy::SecondaryColor,
61            ButtonVariant::TertiaryColor => ButtonHierarchy::TertiaryColor,
62            ButtonVariant::LinkColor => ButtonHierarchy::LinkColor,
63            ButtonVariant::LinkGray => ButtonHierarchy::LinkGray,
64            ButtonVariant::Destructive => ButtonHierarchy::Destructive,
65        }
66    }
67}
68
69/// Horizontal alignment of a [`Button`]'s child content.
70///
71/// Defaults to `Center`.
72#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
73pub enum ButtonContentAlign {
74    /// Center the child horizontally and vertically (default).
75    #[default]
76    Center,
77    /// Align the child to the leading edge.
78    Start,
79    /// Align the child to the trailing edge.
80    End,
81}
82
83/// A pressable button widget with built-in theming, hover/press states, and
84/// focus ring.
85///
86/// Buttons come in three visual [`ButtonVariant`]s (Filled, Outline, Ghost)
87/// and support flexible content alignment via [`ButtonContentAlign`].
88///
89/// # Example
90///
91/// ```rust,ignore
92/// let on_press = ctx.bind(Submit, reduce_with!(handle_submit));
93///
94/// Button {
95///     child: Some(Box::new(Text::new("Submit").into_node())),
96///     on_press: Some(on_press),
97///     variant: ButtonVariant::Filled,
98///     content_align: ButtonContentAlign::Center,
99///     ..Default::default()
100/// }
101/// ```
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Button {
104    /// Explicit node identity (auto-generated if `None`).
105    pub id: Option<NodeId>,
106    /// The button's content widget (typically [`Text`] or [`Icon`]).
107    pub child: Option<Box<Node>>,
108    /// Action dispatched when the button is pressed.
109    pub on_press: Option<ActionEnvelope>,
110    /// Custom semantics (overrides the default button semantics).
111    pub semantics: Option<Semantics>,
112    /// Fixed width in layout points.
113    pub width: Option<f32>,
114    /// Fixed height in layout points.
115    pub height: Option<f32>,
116    /// Minimum width constraint.
117    pub min_width: Option<f32>,
118    /// Maximum width constraint.
119    pub max_width: Option<f32>,
120    /// Flex grow factor for parent flex layouts.
121    pub flex_grow: f32,
122    /// Flex shrink factor for parent flex layouts.
123    pub flex_shrink: f32,
124    /// Custom padding `[left, right, top, bottom]` (overrides theme defaults).
125    pub padding: Option<[f32; 4]>,
126    /// Style overrides (reserved for future use).
127    pub style: Option<ButtonStyleOverride>,
128    /// Visual variant (Filled, Outline, or Ghost).
129    pub variant: ButtonVariant,
130    /// Design-system size slot.
131    #[serde(default)]
132    pub size: ComponentSize,
133    /// Optional fill override for the button background.
134    pub background_fill: Option<Fill>,
135    /// Optional text color override for direct `Text` children.
136    pub text_color: Option<IrColor>,
137    /// Horizontal alignment of the child content.
138    #[serde(default)]
139    pub content_align: ButtonContentAlign,
140    /// When `true`, the button is greyed out and its `on_press` action is not
141    /// attached.
142    pub disabled: bool,
143}
144
145impl Button {
146    pub fn background_fill(mut self, fill: Fill) -> Self {
147        self.background_fill = Some(fill);
148        self
149    }
150
151    pub fn text_color(mut self, color: IrColor) -> Self {
152        self.text_color = Some(color);
153        self
154    }
155
156    pub fn flex_grow(mut self, grow: f32) -> Self {
157        self.flex_grow = grow;
158        self
159    }
160
161    pub fn flex_shrink(mut self, shrink: f32) -> Self {
162        self.flex_shrink = shrink;
163        self
164    }
165
166    pub fn min_width(mut self, width: f32) -> Self {
167        self.min_width = Some(width);
168        self
169    }
170
171    pub fn max_width(mut self, width: f32) -> Self {
172        self.max_width = Some(width);
173        self
174    }
175
176    pub fn into_node(self) -> crate::ui::Node {
177        crate::ui::Node::Button(self)
178    }
179}
180
181impl Default for Button {
182    fn default() -> Self {
183        Self {
184            id: None,
185            child: None,
186            on_press: None,
187            semantics: None,
188            width: None,
189            height: None,
190            min_width: None,
191            max_width: None,
192            flex_grow: 0.0,
193            flex_shrink: 1.0,
194            padding: None,
195            style: None,
196            variant: ButtonVariant::Filled,
197            size: ComponentSize::Md,
198            background_fill: None,
199            text_color: None,
200            content_align: ButtonContentAlign::Center,
201            disabled: false,
202        }
203    }
204}
205
206#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
207pub struct ButtonStyleOverride {}
208
209struct ButtonStyleResolved {
210    background_fill: Option<Fill>,
211    text_color: IrColor,
212    padding_horizontal: f32,
213    padding_vertical: f32,
214    height: f32,
215    corner_radius: f32,
216    shadow: Option<BoxShadow>,
217    shadows: Vec<BoxShadow>,
218    stroke: Option<Stroke>,
219    font_size: f32,
220    font_weight: u16,
221    line_height: Option<f32>,
222}
223
224impl Button {
225    fn resolve_style(
226        &self,
227        env: &Env,
228        interaction: &InteractionStateMap,
229        self_id: NodeId,
230    ) -> ButtonStyleResolved {
231        let default_style = &env.theme.components.button;
232        let tokens = &env.theme.tokens.colors;
233
234        let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
235        let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
236        let is_focused = interaction.is_focused(self_id) && !self.disabled;
237        let component_state = if self.disabled {
238            ComponentState::Disabled
239        } else if is_pressed {
240            ComponentState::Active
241        } else if is_focused {
242            ComponentState::Focus
243        } else if is_hovered {
244            ComponentState::Hover
245        } else {
246            ComponentState::Default
247        };
248        let component_style =
249            default_style.resolve(self.variant.hierarchy(), self.size, component_state);
250
251        let stroke = component_style
252            .border
253            .clone()
254            .or_else(|| component_style.inset_border())
255            .map(|border| Stroke {
256                fill: border.fill,
257                width: border.width,
258                dash_array: None,
259                line_cap: fission_ir::op::LineCap::Butt,
260                line_join: fission_ir::op::LineJoin::Miter,
261            })
262            .or_else(|| {
263                if is_focused {
264                    default_style.focus_stroke.clone()
265                } else {
266                    None
267                }
268            });
269        let shadows = component_style.outer_shadows();
270        let shadow = shadows.first().copied().or_else(|| {
271            if matches!(self.variant, ButtonVariant::Filled | ButtonVariant::Primary) {
272                if is_pressed {
273                    default_style.elevation_pressed
274                } else if is_hovered {
275                    default_style.elevation_hover
276                } else {
277                    default_style.elevation_rest
278                }
279            } else {
280                None
281            }
282        });
283
284        ButtonStyleResolved {
285            background_fill: self
286                .background_fill
287                .clone()
288                .or_else(|| component_style.background.clone()),
289            text_color: self
290                .text_color
291                .unwrap_or(component_style.text_color.unwrap_or(tokens.primary)),
292            padding_horizontal: component_style
293                .padding_x
294                .unwrap_or(default_style.padding_horizontal),
295            padding_vertical: component_style
296                .padding_y
297                .unwrap_or(default_style.padding_vertical),
298            height: component_style.height.unwrap_or(default_style.height),
299            corner_radius: component_style.radius.unwrap_or(default_style.radius),
300            shadow,
301            shadows,
302            stroke,
303            font_size: component_style.font_size.unwrap_or(default_style.text_size),
304            font_weight: component_style
305                .font_weight
306                .unwrap_or(default_style.font_weight),
307            line_height: component_style.line_height,
308        }
309    }
310
311    fn should_attach_semantics(&self) -> bool {
312        self.semantics.is_some() || self.on_press.is_some()
313    }
314
315    fn build_semantics(&self) -> Option<Semantics> {
316        if !self.should_attach_semantics() {
317            return None;
318        }
319
320        let mut semantics = self
321            .semantics
322            .clone()
323            .unwrap_or_else(default_button_semantics);
324
325        semantics.disabled = self.disabled;
326
327        if let Some(action_envelope) = &self.on_press {
328            if !self.disabled {
329                semantics.actions.entries.push(ActionEntry {
330                    trigger: fission_ir::semantics::ActionTrigger::Default,
331                    action_id: action_envelope.id.as_u128(),
332                    payload_data: Some(action_envelope.payload.clone()),
333                });
334            }
335        }
336
337        Some(semantics)
338    }
339}
340
341impl Lower for Button {
342    fn lower(&self, cx: &mut LoweringContext) -> NodeId {
343        let semantics_op = self.build_semantics();
344        let outermost_id = self.id.unwrap_or_else(|| cx.next_node_id());
345
346        let (layout_node_id, final_id) = if let Some(_) = semantics_op {
347            (cx.next_node_id(), outermost_id)
348        } else {
349            (outermost_id, outermost_id)
350        };
351
352        let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
353
354        cx.push_scope(layout_node_id);
355
356        let mut button_builder = NodeBuilder::new(
357            layout_node_id,
358            Op::Layout(LayoutOp::Box {
359                width: self.width,
360                height: self.height,
361                min_width: self.min_width,
362                max_width: self.max_width,
363                min_height: if self.height.is_some() {
364                    None
365                } else {
366                    Some(resolved_style.height)
367                },
368                max_height: None,
369                padding: self.padding.unwrap_or([
370                    resolved_style.padding_horizontal,
371                    resolved_style.padding_horizontal,
372                    resolved_style.padding_vertical,
373                    resolved_style.padding_vertical,
374                ]),
375                flex_grow: self.flex_grow,
376                flex_shrink: self.flex_shrink,
377                aspect_ratio: None,
378            }),
379        );
380
381        for shadow in &resolved_style.shadows {
382            let shadow_id = NodeBuilder::new(
383                cx.next_node_id(),
384                Op::Paint(PaintOp::DrawRect {
385                    fill: None,
386                    stroke: None,
387                    corner_radius: resolved_style.corner_radius,
388                    shadow: Some(*shadow),
389                }),
390            )
391            .build(cx);
392            button_builder.add_child(shadow_id);
393        }
394
395        let background_id = NodeBuilder::new(
396            cx.next_node_id(),
397            Op::Paint(PaintOp::DrawRect {
398                fill: resolved_style.background_fill,
399                stroke: resolved_style.stroke,
400                corner_radius: resolved_style.corner_radius,
401                shadow: if resolved_style.shadows.is_empty() {
402                    resolved_style.shadow
403                } else {
404                    None
405                },
406            }),
407        )
408        .build(cx);
409        button_builder.add_child(background_id);
410
411        if let Some(child_widget) = &self.child {
412            let child_id = if let Node::Text(mut text_widget) = *child_widget.clone() {
413                text_widget.color = Some(resolved_style.text_color);
414                text_widget.font_size = Some(resolved_style.font_size);
415                text_widget.font_weight = Some(resolved_style.font_weight);
416                text_widget.line_height = resolved_style.line_height;
417                text_widget.lower(cx)
418            } else {
419                child_widget.lower(cx)
420            };
421            let aligned_id = match self.content_align {
422                ButtonContentAlign::Center => {
423                    // Center the content within the button's box (vertically + horizontally).
424                    let mut align_builder =
425                        NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
426                    align_builder.add_child(child_id);
427                    align_builder.build(cx)
428                }
429                ButtonContentAlign::Start | ButtonContentAlign::End => {
430                    let justify = match self.content_align {
431                        ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
432                        ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
433                        ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
434                    };
435                    let mut flex_builder = NodeBuilder::new(
436                        cx.next_node_id(),
437                        Op::Layout(LayoutOp::Flex {
438                            direction: fission_ir::FlexDirection::Row,
439                            wrap: fission_ir::FlexWrap::NoWrap,
440                            flex_grow: 1.0,
441                            flex_shrink: 0.0,
442                            padding: [0.0; 4],
443                            gap: None,
444                            align_items: fission_ir::op::AlignItems::Center,
445                            justify_content: justify,
446                        }),
447                    );
448                    flex_builder.add_child(child_id);
449                    flex_builder.build(cx)
450                }
451            };
452            button_builder.add_child(aligned_id);
453        }
454
455        let button_node_id = button_builder.build(cx);
456
457        if let Some(op) = semantics_op {
458            let mut semantics_builder = NodeBuilder::new(final_id, Op::Semantics(op));
459            semantics_builder.add_child(button_node_id);
460            let res_id = semantics_builder.build(cx);
461            cx.pop_scope();
462            return res_id;
463        }
464
465        cx.pop_scope();
466        button_node_id
467    }
468}
469
470fn default_button_semantics() -> Semantics {
471    Semantics {
472        role: Role::Button,
473        label: None,
474        identifier: None,
475        value: None,
476        actions: ActionSet::default(),
477        action_scope_id: None,
478        focusable: true,
479        multiline: false,
480        masked: false,
481        input_mask: None,
482        ime_preedit_range: None,
483        checked: None,
484        disabled: false,
485        read_only: false,
486        autofocus: false,
487        draggable: false,
488        scrollable_x: false,
489        scrollable_y: false,
490        min_value: None,
491        max_value: None,
492        current_value: None,
493        is_focus_scope: false,
494        is_focus_barrier: false,
495        drag_payload: None,
496        hero_tag: None,
497        focus_index: None,
498        text_input_type: fission_ir::semantics::TextInputType::Text,
499        text_input_action: fission_ir::semantics::TextInputAction::Done,
500        text_capitalization: fission_ir::semantics::TextCapitalization::None,
501        max_length: None,
502        max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
503        input_formatters: Vec::new(),
504        autocorrect: true,
505        enable_suggestions: true,
506        spell_check: true,
507        smart_dashes: true,
508        smart_quotes: true,
509        autofill_hints: Vec::new(),
510        scroll_padding: None,
511        capture_tab: false,
512        auto_indent: false,
513    }
514}