Skip to main content

fission_core/ui/widgets/
button.rs

1use crate::internal::InternalLower;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use crate::motion::{
4    hover_press, ripple_effect, scalar, MotionExpr, MotionPredicate, MotionPropertyId,
5    MotionStartValue, MotionTrack, MotionTransition, RippleFx,
6};
7use crate::ui::Widget;
8use crate::{ActionEnvelope, Env, InteractionStateMap};
9use fission_ir::{
10    op::{BoxShadow, Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke},
11    ActionEntry, ActionSet, Role, Semantics, WidgetId,
12};
13use fission_theme::{ButtonHierarchy, ComponentSize, ComponentState};
14use serde::{Deserialize, Serialize};
15use std::ops::Add;
16
17/// Visual style variant for a [`Button`].
18///
19/// - `Filled` -- solid background with the primary colour (default).
20/// - `Outline` -- transparent background with a border stroke.
21/// - `Ghost` -- no background or border; just text/icon.
22///
23/// # Example
24///
25/// ```rust,ignore
26/// Button {
27///     variant: ButtonVariant::Outline,
28///     child: Some(Text::new("Cancel").into()),
29///     ..Default::default()
30/// }
31/// ```
32#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub enum ButtonVariant {
34    /// Solid primary-colour background.
35    #[default]
36    Filled,
37    /// Transparent background with a border.
38    Outline,
39    /// No background, no border.
40    Ghost,
41    /// DSP primary hierarchy.
42    Primary,
43    /// DSP secondary colour hierarchy.
44    SecondaryColor,
45    /// DSP secondary gray hierarchy.
46    SecondaryGray,
47    /// DSP tertiary colour hierarchy.
48    TertiaryColor,
49    /// DSP tertiary gray hierarchy.
50    TertiaryGray,
51    /// DSP link colour hierarchy.
52    LinkColor,
53    /// DSP link gray hierarchy.
54    LinkGray,
55    /// DSP destructive hierarchy.
56    Destructive,
57}
58
59impl ButtonVariant {
60    fn hierarchy(self) -> ButtonHierarchy {
61        match self {
62            ButtonVariant::Filled | ButtonVariant::Primary => ButtonHierarchy::Primary,
63            ButtonVariant::Outline | ButtonVariant::SecondaryGray => ButtonHierarchy::SecondaryGray,
64            ButtonVariant::Ghost | ButtonVariant::TertiaryGray => ButtonHierarchy::TertiaryGray,
65            ButtonVariant::SecondaryColor => ButtonHierarchy::SecondaryColor,
66            ButtonVariant::TertiaryColor => ButtonHierarchy::TertiaryColor,
67            ButtonVariant::LinkColor => ButtonHierarchy::LinkColor,
68            ButtonVariant::LinkGray => ButtonHierarchy::LinkGray,
69            ButtonVariant::Destructive => ButtonHierarchy::Destructive,
70        }
71    }
72}
73
74/// Horizontal alignment of a [`Button`]'s child content.
75///
76/// Defaults to `Center`.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
78pub enum ButtonContentAlign {
79    /// Center the child horizontally and vertically (default).
80    #[default]
81    Center,
82    /// Align the child to the leading edge.
83    Start,
84    /// Align the child to the trailing edge.
85    End,
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89/// Optional motion presets owned by [`Button`].
90///
91/// Buttons do not animate by default. Set [`Button::motion`] to `Some(...)` to
92/// opt in to hover, press, and ripple feedback.
93///
94/// ```rust,ignore
95/// use fission::prelude::*;
96///
97/// Button {
98///     id: Some(WidgetId::explicit("save")),
99///     child: Some(Text::new("Save").into()),
100///     motion: Some(ButtonMotion::HoverScale + ButtonMotion::PressScale + ButtonMotion::Ripple),
101///     ..Default::default()
102/// };
103/// ```
104pub enum ButtonMotion {
105    /// Curated default hover/press scale feedback.
106    Default,
107    /// Scale up slightly while hovered.
108    HoverScale,
109    /// Scale down slightly while pressed.
110    PressScale,
111    /// Compound hover plus press scale feedback.
112    HoverPressScale,
113    /// Add deterministic pointer-origin ripples.
114    Ripple,
115    /// Compound hover/press scale plus ripple feedback.
116    HoverPressRipple,
117    /// Ordered composition of button motion atoms.
118    Composition(Vec<ButtonMotion>),
119    /// Caller-provided native interaction tracks and ripple configuration.
120    Custom {
121        /// Interaction tracks for the button root slot.
122        interaction: Option<Vec<MotionTrack>>,
123        /// Optional ripple effect for the ripple slot.
124        ripple: Option<RippleFx>,
125    },
126}
127
128impl ButtonMotion {
129    /// Flattens and normalizes an ordered button-motion composition.
130    pub fn compose(items: impl IntoIterator<Item = Self>) -> Self {
131        let mut out = Vec::new();
132        for item in items {
133            item.flatten_into(&mut out);
134        }
135        match out.len() {
136            0 => Self::Composition(Vec::new()),
137            1 => out.remove(0),
138            _ => Self::Composition(out),
139        }
140    }
141
142    fn flatten_into(self, out: &mut Vec<Self>) {
143        match self {
144            Self::Composition(items) => {
145                for item in items {
146                    item.flatten_into(out);
147                }
148            }
149            item => out.push(item),
150        }
151    }
152
153    /// Lowers this preset into interaction tracks for `id`.
154    pub fn interaction_tracks(&self, id: WidgetId) -> Vec<MotionTrack> {
155        let mut tracks = Vec::new();
156        self.append_interaction_tracks(id, &mut tracks);
157        crate::motion::dedupe_tracks_later_wins(tracks)
158    }
159
160    fn append_interaction_tracks(&self, id: WidgetId, out: &mut Vec<MotionTrack>) {
161        match self {
162            Self::Default | Self::HoverPressScale => out.extend(hover_press(id)),
163            Self::HoverScale => out.push(
164                MotionTrack::composite(
165                    MotionPropertyId::Scale,
166                    MotionStartValue::Current,
167                    MotionExpr::If {
168                        predicate: MotionPredicate::Hovered(id),
169                        then_expr: Box::new(scalar(1.02)),
170                        else_expr: Box::new(scalar(1.0)),
171                    },
172                )
173                .transition(MotionTransition::spring(420.0, 30.0)),
174            ),
175            Self::PressScale => out.push(
176                MotionTrack::composite(
177                    MotionPropertyId::Scale,
178                    MotionStartValue::Current,
179                    MotionExpr::If {
180                        predicate: MotionPredicate::Pressed(id),
181                        then_expr: Box::new(scalar(0.97)),
182                        else_expr: Box::new(scalar(1.0)),
183                    },
184                )
185                .transition(MotionTransition::spring(420.0, 30.0)),
186            ),
187            Self::Ripple => {}
188            Self::HoverPressRipple => out.extend(hover_press(id)),
189            Self::Composition(items) => {
190                for item in items {
191                    item.append_interaction_tracks(id, out);
192                }
193            }
194            Self::Custom { interaction, .. } => out.extend(interaction.clone().unwrap_or_default()),
195        }
196    }
197
198    /// Returns the ripple effect selected by this preset, if any.
199    pub fn ripple(&self) -> Option<RippleFx> {
200        match self {
201            Self::Ripple | Self::HoverPressRipple => Some(ripple_effect()),
202            Self::Composition(items) => items.iter().rev().find_map(Self::ripple),
203            Self::Custom { ripple, .. } => ripple.clone(),
204            _ => None,
205        }
206    }
207}
208
209impl Add for ButtonMotion {
210    type Output = Self;
211
212    fn add(self, rhs: Self) -> Self::Output {
213        Self::compose([self, rhs])
214    }
215}
216
217/// A pressable button widget with built-in theming, hover/press states, and
218/// focus ring.
219///
220/// Buttons come in three visual [`ButtonVariant`]s (Filled, Outline, Ghost)
221/// and support flexible content alignment via [`ButtonContentAlign`].
222///
223/// # Example
224///
225/// ```rust,ignore
226/// let on_press = ctx.bind(Submit, reduce_with!(handle_submit));
227///
228/// Button {
229///     child: Some(Text::new("Submit").into()),
230///     on_press: Some(on_press),
231///     variant: ButtonVariant::Filled,
232///     content_align: ButtonContentAlign::Center,
233///     ..Default::default()
234/// }
235/// ```
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Button {
238    /// Explicit node identity (auto-generated if `None`).
239    pub id: Option<WidgetId>,
240    /// The button's content widget (typically [`crate::ui::Text`] or
241    /// [`crate::ui::Icon`]).
242    pub child: Option<Widget>,
243    /// Action dispatched when the button is pressed.
244    pub on_press: Option<ActionEnvelope>,
245    /// Custom semantics (overrides the default button semantics).
246    pub semantics: Option<Semantics>,
247    /// Fixed width in layout points.
248    pub width: Option<f32>,
249    /// Fixed height in layout points.
250    pub height: Option<f32>,
251    /// Minimum width constraint.
252    pub min_width: Option<f32>,
253    /// Maximum width constraint.
254    pub max_width: Option<f32>,
255    /// Flex grow factor for parent flex layouts.
256    pub flex_grow: f32,
257    /// Flex shrink factor for parent flex layouts.
258    pub flex_shrink: f32,
259    /// Custom padding `[left, right, top, bottom]` (overrides theme defaults).
260    pub padding: Option<[f32; 4]>,
261    /// Style overrides (reserved for future use).
262    pub style: Option<ButtonStyleOverride>,
263    /// Visual variant (Filled, Outline, or Ghost).
264    pub variant: ButtonVariant,
265    /// Design-system size slot.
266    #[serde(default)]
267    pub size: ComponentSize,
268    /// Optional fill override for the button background.
269    pub background_fill: Option<Fill>,
270    /// Optional text color override for direct `Text` children.
271    pub text_color: Option<IrColor>,
272    /// Horizontal alignment of the child content.
273    #[serde(default)]
274    pub content_align: ButtonContentAlign,
275    /// When `true`, the button is greyed out and its `on_press` action is not
276    /// attached.
277    pub disabled: bool,
278    /// Optional explicit motion. `None` emits no button-owned motion declarations.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub motion: Option<ButtonMotion>,
281}
282
283impl Button {
284    pub fn background_fill(mut self, fill: Fill) -> Self {
285        self.background_fill = Some(fill);
286        self
287    }
288
289    pub fn text_color(mut self, color: IrColor) -> Self {
290        self.text_color = Some(color);
291        self
292    }
293
294    pub fn flex_grow(mut self, grow: f32) -> Self {
295        self.flex_grow = grow;
296        self
297    }
298
299    pub fn flex_shrink(mut self, shrink: f32) -> Self {
300        self.flex_shrink = shrink;
301        self
302    }
303
304    pub fn min_width(mut self, width: f32) -> Self {
305        self.min_width = Some(width);
306        self
307    }
308
309    pub fn max_width(mut self, width: f32) -> Self {
310        self.max_width = Some(width);
311        self
312    }
313}
314
315impl Default for Button {
316    fn default() -> Self {
317        Self {
318            id: None,
319            child: None,
320            on_press: None,
321            semantics: None,
322            width: None,
323            height: None,
324            min_width: None,
325            max_width: None,
326            flex_grow: 0.0,
327            flex_shrink: 1.0,
328            padding: None,
329            style: None,
330            variant: ButtonVariant::Filled,
331            size: ComponentSize::Md,
332            background_fill: None,
333            text_color: None,
334            content_align: ButtonContentAlign::Center,
335            disabled: false,
336            motion: None,
337        }
338    }
339}
340
341#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
342pub struct ButtonStyleOverride {}
343
344struct ButtonStyleResolved {
345    background_fill: Option<Fill>,
346    text_color: IrColor,
347    padding_horizontal: f32,
348    padding_vertical: f32,
349    height: f32,
350    corner_radius: f32,
351    shadow: Option<BoxShadow>,
352    shadows: Vec<BoxShadow>,
353    stroke: Option<Stroke>,
354    font_size: f32,
355    font_weight: u16,
356    line_height: Option<f32>,
357}
358
359impl Button {
360    fn resolve_style(
361        &self,
362        env: &Env,
363        interaction: &InteractionStateMap,
364        self_id: WidgetId,
365    ) -> ButtonStyleResolved {
366        let default_style = &env.theme.components.button;
367        let tokens = &env.theme.tokens.colors;
368
369        let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
370        let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
371        let is_focused = interaction.is_focused(self_id) && !self.disabled;
372        let component_state = if self.disabled {
373            ComponentState::Disabled
374        } else if is_pressed {
375            ComponentState::Active
376        } else if is_focused {
377            ComponentState::Focus
378        } else if is_hovered {
379            ComponentState::Hover
380        } else {
381            ComponentState::Default
382        };
383        let component_style =
384            default_style.resolve(self.variant.hierarchy(), self.size, component_state);
385
386        let stroke = component_style
387            .border
388            .clone()
389            .or_else(|| component_style.inset_border())
390            .map(|border| Stroke {
391                fill: border.fill,
392                width: border.width,
393                dash_array: None,
394                line_cap: fission_ir::op::LineCap::Butt,
395                line_join: fission_ir::op::LineJoin::Miter,
396            })
397            .or_else(|| {
398                if is_focused {
399                    default_style.focus_stroke.clone()
400                } else {
401                    None
402                }
403            });
404        let shadows = component_style.outer_shadows();
405        let shadow = shadows.first().copied().or_else(|| {
406            if matches!(self.variant, ButtonVariant::Filled | ButtonVariant::Primary) {
407                if is_pressed {
408                    default_style.elevation_pressed
409                } else if is_hovered {
410                    default_style.elevation_hover
411                } else {
412                    default_style.elevation_rest
413                }
414            } else {
415                None
416            }
417        });
418
419        ButtonStyleResolved {
420            background_fill: self
421                .background_fill
422                .clone()
423                .or_else(|| component_style.background.clone()),
424            text_color: self
425                .text_color
426                .unwrap_or(component_style.text_color.unwrap_or(tokens.primary)),
427            padding_horizontal: component_style
428                .padding_x
429                .unwrap_or(default_style.padding_horizontal),
430            padding_vertical: component_style
431                .padding_y
432                .unwrap_or(default_style.padding_vertical),
433            height: component_style.height.unwrap_or(default_style.height),
434            corner_radius: component_style.radius.unwrap_or(default_style.radius),
435            shadow,
436            shadows,
437            stroke,
438            font_size: component_style.font_size.unwrap_or(default_style.text_size),
439            font_weight: component_style
440                .font_weight
441                .unwrap_or(default_style.font_weight),
442            line_height: component_style.line_height,
443        }
444    }
445
446    fn should_attach_semantics(&self) -> bool {
447        self.semantics.is_some() || self.on_press.is_some()
448    }
449
450    fn build_semantics(&self) -> Option<Semantics> {
451        if !self.should_attach_semantics() {
452            return None;
453        }
454
455        let mut semantics = self
456            .semantics
457            .clone()
458            .unwrap_or_else(default_button_semantics);
459
460        semantics.disabled = self.disabled;
461
462        if let Some(action_envelope) = &self.on_press {
463            if !self.disabled {
464                semantics.actions.entries.push(ActionEntry {
465                    trigger: fission_ir::semantics::ActionTrigger::Default,
466                    action_id: action_envelope.id.as_u128(),
467                    payload_data: Some(action_envelope.payload.clone()),
468                });
469            }
470        }
471
472        Some(semantics)
473    }
474}
475
476impl InternalLower for Button {
477    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
478        let semantics_op = self.build_semantics();
479        let outermost_id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
480
481        let (layout_node_id, final_id) = if let Some(_) = semantics_op {
482            (cx.next_node_id(), outermost_id)
483        } else {
484            (outermost_id, outermost_id)
485        };
486
487        let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
488
489        cx.push_scope(layout_node_id);
490
491        let mut button_builder = InternalIrBuilder::new(
492            layout_node_id,
493            Op::Layout(LayoutOp::Box {
494                width: self.width,
495                height: self.height,
496                min_width: self.min_width,
497                max_width: self.max_width,
498                min_height: if self.height.is_some() {
499                    None
500                } else {
501                    Some(resolved_style.height)
502                },
503                max_height: None,
504                padding: self.padding.unwrap_or([
505                    resolved_style.padding_horizontal,
506                    resolved_style.padding_horizontal,
507                    resolved_style.padding_vertical,
508                    resolved_style.padding_vertical,
509                ]),
510                flex_grow: self.flex_grow,
511                flex_shrink: self.flex_shrink,
512                aspect_ratio: None,
513            }),
514        );
515
516        for shadow in &resolved_style.shadows {
517            let shadow_id = InternalIrBuilder::new(
518                cx.next_node_id(),
519                Op::Paint(PaintOp::DrawRect {
520                    fill: None,
521                    stroke: None,
522                    corner_radius: resolved_style.corner_radius,
523                    shadow: Some(*shadow),
524                }),
525            )
526            .build(cx);
527            button_builder.add_child(shadow_id);
528        }
529
530        let background_id = InternalIrBuilder::new(
531            cx.next_node_id(),
532            Op::Paint(PaintOp::DrawRect {
533                fill: resolved_style.background_fill,
534                stroke: resolved_style.stroke,
535                corner_radius: resolved_style.corner_radius,
536                shadow: if resolved_style.shadows.is_empty() {
537                    resolved_style.shadow
538                } else {
539                    None
540                },
541            }),
542        )
543        .build(cx);
544        button_builder.add_child(background_id);
545
546        if let Some(child_widget) = &self.child {
547            let child_id = if let Ok(mut text_widget) = child_widget.clone().into_text() {
548                text_widget.color = Some(resolved_style.text_color);
549                text_widget.font_size = Some(resolved_style.font_size);
550                text_widget.font_weight = Some(resolved_style.font_weight);
551                text_widget.line_height = resolved_style.line_height;
552                text_widget.lower(cx)
553            } else {
554                child_widget.lower(cx)
555            };
556            let aligned_id = match self.content_align {
557                ButtonContentAlign::Center => {
558                    // Center the content within the button's box (vertically + horizontally).
559                    let mut align_builder =
560                        InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
561                    align_builder.add_child(child_id);
562                    align_builder.build(cx)
563                }
564                ButtonContentAlign::Start | ButtonContentAlign::End => {
565                    let justify = match self.content_align {
566                        ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
567                        ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
568                        ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
569                    };
570                    let mut flex_builder = InternalIrBuilder::new(
571                        cx.next_node_id(),
572                        Op::Layout(LayoutOp::Flex {
573                            direction: fission_ir::FlexDirection::Row,
574                            wrap: fission_ir::FlexWrap::NoWrap,
575                            flex_grow: 1.0,
576                            flex_shrink: 0.0,
577                            padding: [0.0; 4],
578                            gap: None,
579                            align_items: fission_ir::op::AlignItems::Center,
580                            justify_content: justify,
581                        }),
582                    );
583                    flex_builder.add_child(child_id);
584                    flex_builder.build(cx)
585                }
586            };
587            button_builder.add_child(aligned_id);
588        }
589
590        let button_node_id = button_builder.build(cx);
591
592        if let Some(op) = semantics_op {
593            let mut semantics_builder = InternalIrBuilder::new(final_id, Op::Semantics(op));
594            semantics_builder.add_child(button_node_id);
595            let res_id = semantics_builder.build(cx);
596            cx.pop_scope();
597            return res_id;
598        }
599
600        cx.pop_scope();
601        button_node_id
602    }
603}
604
605fn default_button_semantics() -> Semantics {
606    Semantics {
607        role: Role::Button,
608        label: None,
609        identifier: None,
610        value: None,
611        actions: ActionSet::default(),
612        action_scope_id: None,
613        focusable: true,
614        multiline: false,
615        masked: false,
616        input_mask: None,
617        ime_preedit_range: None,
618        checked: None,
619        disabled: false,
620        read_only: false,
621        autofocus: false,
622        draggable: false,
623        scrollable_x: false,
624        scrollable_y: false,
625        min_value: None,
626        max_value: None,
627        current_value: None,
628        is_focus_scope: false,
629        is_focus_barrier: false,
630        drag_payload: None,
631        hero_tag: None,
632        focus_index: None,
633        text_input_type: fission_ir::semantics::TextInputType::Text,
634        text_input_action: fission_ir::semantics::TextInputAction::Done,
635        text_capitalization: fission_ir::semantics::TextCapitalization::None,
636        max_length: None,
637        max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
638        input_formatters: Vec::new(),
639        autocorrect: true,
640        enable_suggestions: true,
641        spell_check: true,
642        smart_dashes: true,
643        smart_quotes: true,
644        autofill_hints: Vec::new(),
645        scroll_padding: None,
646        capture_tab: false,
647        auto_indent: false,
648    }
649}