Skip to main content

fission_core/ui/widgets/
button.rs

1use crate::internal::InternalLower;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use crate::ui::Widget;
4use crate::{ActionEnvelope, Env, InteractionStateMap};
5use fission_ir::{
6    op::{BoxShadow, Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke},
7    ActionEntry, ActionSet, Role, Semantics, WidgetId,
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(Text::new("Cancel").into()),
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(Text::new("Submit").into()),
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<WidgetId>,
106    /// The button's content widget (typically [`Text`] or [`Icon`]).
107    pub child: Option<Widget>,
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
177impl Default for Button {
178    fn default() -> Self {
179        Self {
180            id: None,
181            child: None,
182            on_press: None,
183            semantics: None,
184            width: None,
185            height: None,
186            min_width: None,
187            max_width: None,
188            flex_grow: 0.0,
189            flex_shrink: 1.0,
190            padding: None,
191            style: None,
192            variant: ButtonVariant::Filled,
193            size: ComponentSize::Md,
194            background_fill: None,
195            text_color: None,
196            content_align: ButtonContentAlign::Center,
197            disabled: false,
198        }
199    }
200}
201
202#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
203pub struct ButtonStyleOverride {}
204
205struct ButtonStyleResolved {
206    background_fill: Option<Fill>,
207    text_color: IrColor,
208    padding_horizontal: f32,
209    padding_vertical: f32,
210    height: f32,
211    corner_radius: f32,
212    shadow: Option<BoxShadow>,
213    shadows: Vec<BoxShadow>,
214    stroke: Option<Stroke>,
215    font_size: f32,
216    font_weight: u16,
217    line_height: Option<f32>,
218}
219
220impl Button {
221    fn resolve_style(
222        &self,
223        env: &Env,
224        interaction: &InteractionStateMap,
225        self_id: WidgetId,
226    ) -> ButtonStyleResolved {
227        let default_style = &env.theme.components.button;
228        let tokens = &env.theme.tokens.colors;
229
230        let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
231        let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
232        let is_focused = interaction.is_focused(self_id) && !self.disabled;
233        let component_state = if self.disabled {
234            ComponentState::Disabled
235        } else if is_pressed {
236            ComponentState::Active
237        } else if is_focused {
238            ComponentState::Focus
239        } else if is_hovered {
240            ComponentState::Hover
241        } else {
242            ComponentState::Default
243        };
244        let component_style =
245            default_style.resolve(self.variant.hierarchy(), self.size, component_state);
246
247        let stroke = component_style
248            .border
249            .clone()
250            .or_else(|| component_style.inset_border())
251            .map(|border| Stroke {
252                fill: border.fill,
253                width: border.width,
254                dash_array: None,
255                line_cap: fission_ir::op::LineCap::Butt,
256                line_join: fission_ir::op::LineJoin::Miter,
257            })
258            .or_else(|| {
259                if is_focused {
260                    default_style.focus_stroke.clone()
261                } else {
262                    None
263                }
264            });
265        let shadows = component_style.outer_shadows();
266        let shadow = shadows.first().copied().or_else(|| {
267            if matches!(self.variant, ButtonVariant::Filled | ButtonVariant::Primary) {
268                if is_pressed {
269                    default_style.elevation_pressed
270                } else if is_hovered {
271                    default_style.elevation_hover
272                } else {
273                    default_style.elevation_rest
274                }
275            } else {
276                None
277            }
278        });
279
280        ButtonStyleResolved {
281            background_fill: self
282                .background_fill
283                .clone()
284                .or_else(|| component_style.background.clone()),
285            text_color: self
286                .text_color
287                .unwrap_or(component_style.text_color.unwrap_or(tokens.primary)),
288            padding_horizontal: component_style
289                .padding_x
290                .unwrap_or(default_style.padding_horizontal),
291            padding_vertical: component_style
292                .padding_y
293                .unwrap_or(default_style.padding_vertical),
294            height: component_style.height.unwrap_or(default_style.height),
295            corner_radius: component_style.radius.unwrap_or(default_style.radius),
296            shadow,
297            shadows,
298            stroke,
299            font_size: component_style.font_size.unwrap_or(default_style.text_size),
300            font_weight: component_style
301                .font_weight
302                .unwrap_or(default_style.font_weight),
303            line_height: component_style.line_height,
304        }
305    }
306
307    fn should_attach_semantics(&self) -> bool {
308        self.semantics.is_some() || self.on_press.is_some()
309    }
310
311    fn build_semantics(&self) -> Option<Semantics> {
312        if !self.should_attach_semantics() {
313            return None;
314        }
315
316        let mut semantics = self
317            .semantics
318            .clone()
319            .unwrap_or_else(default_button_semantics);
320
321        semantics.disabled = self.disabled;
322
323        if let Some(action_envelope) = &self.on_press {
324            if !self.disabled {
325                semantics.actions.entries.push(ActionEntry {
326                    trigger: fission_ir::semantics::ActionTrigger::Default,
327                    action_id: action_envelope.id.as_u128(),
328                    payload_data: Some(action_envelope.payload.clone()),
329                });
330            }
331        }
332
333        Some(semantics)
334    }
335}
336
337impl InternalLower for Button {
338    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
339        let semantics_op = self.build_semantics();
340        let outermost_id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
341
342        let (layout_node_id, final_id) = if let Some(_) = semantics_op {
343            (cx.next_node_id(), outermost_id)
344        } else {
345            (outermost_id, outermost_id)
346        };
347
348        let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
349
350        cx.push_scope(layout_node_id);
351
352        let mut button_builder = InternalIrBuilder::new(
353            layout_node_id,
354            Op::Layout(LayoutOp::Box {
355                width: self.width,
356                height: self.height,
357                min_width: self.min_width,
358                max_width: self.max_width,
359                min_height: if self.height.is_some() {
360                    None
361                } else {
362                    Some(resolved_style.height)
363                },
364                max_height: None,
365                padding: self.padding.unwrap_or([
366                    resolved_style.padding_horizontal,
367                    resolved_style.padding_horizontal,
368                    resolved_style.padding_vertical,
369                    resolved_style.padding_vertical,
370                ]),
371                flex_grow: self.flex_grow,
372                flex_shrink: self.flex_shrink,
373                aspect_ratio: None,
374            }),
375        );
376
377        for shadow in &resolved_style.shadows {
378            let shadow_id = InternalIrBuilder::new(
379                cx.next_node_id(),
380                Op::Paint(PaintOp::DrawRect {
381                    fill: None,
382                    stroke: None,
383                    corner_radius: resolved_style.corner_radius,
384                    shadow: Some(*shadow),
385                }),
386            )
387            .build(cx);
388            button_builder.add_child(shadow_id);
389        }
390
391        let background_id = InternalIrBuilder::new(
392            cx.next_node_id(),
393            Op::Paint(PaintOp::DrawRect {
394                fill: resolved_style.background_fill,
395                stroke: resolved_style.stroke,
396                corner_radius: resolved_style.corner_radius,
397                shadow: if resolved_style.shadows.is_empty() {
398                    resolved_style.shadow
399                } else {
400                    None
401                },
402            }),
403        )
404        .build(cx);
405        button_builder.add_child(background_id);
406
407        if let Some(child_widget) = &self.child {
408            let child_id = if let Ok(mut text_widget) = child_widget.clone().into_text() {
409                text_widget.color = Some(resolved_style.text_color);
410                text_widget.font_size = Some(resolved_style.font_size);
411                text_widget.font_weight = Some(resolved_style.font_weight);
412                text_widget.line_height = resolved_style.line_height;
413                text_widget.lower(cx)
414            } else {
415                child_widget.lower(cx)
416            };
417            let aligned_id = match self.content_align {
418                ButtonContentAlign::Center => {
419                    // Center the content within the button's box (vertically + horizontally).
420                    let mut align_builder =
421                        InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
422                    align_builder.add_child(child_id);
423                    align_builder.build(cx)
424                }
425                ButtonContentAlign::Start | ButtonContentAlign::End => {
426                    let justify = match self.content_align {
427                        ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
428                        ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
429                        ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
430                    };
431                    let mut flex_builder = InternalIrBuilder::new(
432                        cx.next_node_id(),
433                        Op::Layout(LayoutOp::Flex {
434                            direction: fission_ir::FlexDirection::Row,
435                            wrap: fission_ir::FlexWrap::NoWrap,
436                            flex_grow: 1.0,
437                            flex_shrink: 0.0,
438                            padding: [0.0; 4],
439                            gap: None,
440                            align_items: fission_ir::op::AlignItems::Center,
441                            justify_content: justify,
442                        }),
443                    );
444                    flex_builder.add_child(child_id);
445                    flex_builder.build(cx)
446                }
447            };
448            button_builder.add_child(aligned_id);
449        }
450
451        let button_node_id = button_builder.build(cx);
452
453        if let Some(op) = semantics_op {
454            let mut semantics_builder = InternalIrBuilder::new(final_id, Op::Semantics(op));
455            semantics_builder.add_child(button_node_id);
456            let res_id = semantics_builder.build(cx);
457            cx.pop_scope();
458            return res_id;
459        }
460
461        cx.pop_scope();
462        button_node_id
463    }
464}
465
466fn default_button_semantics() -> Semantics {
467    Semantics {
468        role: Role::Button,
469        label: None,
470        identifier: None,
471        value: None,
472        actions: ActionSet::default(),
473        action_scope_id: None,
474        focusable: true,
475        multiline: false,
476        masked: false,
477        input_mask: None,
478        ime_preedit_range: None,
479        checked: None,
480        disabled: false,
481        read_only: false,
482        autofocus: false,
483        draggable: false,
484        scrollable_x: false,
485        scrollable_y: false,
486        min_value: None,
487        max_value: None,
488        current_value: None,
489        is_focus_scope: false,
490        is_focus_barrier: false,
491        drag_payload: None,
492        hero_tag: None,
493        focus_index: None,
494        text_input_type: fission_ir::semantics::TextInputType::Text,
495        text_input_action: fission_ir::semantics::TextInputAction::Done,
496        text_capitalization: fission_ir::semantics::TextCapitalization::None,
497        max_length: None,
498        max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
499        input_formatters: Vec::new(),
500        autocorrect: true,
501        enable_suggestions: true,
502        spell_check: true,
503        smart_dashes: true,
504        smart_quotes: true,
505        autofill_hints: Vec::new(),
506        scroll_padding: None,
507        capture_tab: false,
508        auto_indent: false,
509    }
510}