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, FocusPolicy, 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    /// How pointer-down should affect focus for this button.
248    ///
249    /// Use [`FocusPolicy::PreserveCurrentOnPointer`] for toolbar/ribbon buttons
250    /// that should activate without stealing focus from an editor.
251    #[serde(default)]
252    pub focus_policy: FocusPolicy,
253    /// Fixed width in layout points.
254    pub width: Option<f32>,
255    /// Fixed height in layout points.
256    pub height: Option<f32>,
257    /// Minimum width constraint.
258    pub min_width: Option<f32>,
259    /// Maximum width constraint.
260    pub max_width: Option<f32>,
261    /// Flex grow factor for parent flex layouts.
262    pub flex_grow: f32,
263    /// Flex shrink factor for parent flex layouts.
264    pub flex_shrink: f32,
265    /// Custom padding `[left, right, top, bottom]` (overrides theme defaults).
266    pub padding: Option<[f32; 4]>,
267    /// Style overrides (reserved for future use).
268    pub style: Option<ButtonStyleOverride>,
269    /// Visual variant (Filled, Outline, or Ghost).
270    pub variant: ButtonVariant,
271    /// Design-system size slot.
272    #[serde(default)]
273    pub size: ComponentSize,
274    /// Optional fill override for the button background.
275    pub background_fill: Option<Fill>,
276    /// Optional text color override for direct `Text` children.
277    pub text_color: Option<IrColor>,
278    /// Horizontal alignment of the child content.
279    #[serde(default)]
280    pub content_align: ButtonContentAlign,
281    /// When `true`, the button is greyed out and its `on_press` action is not
282    /// attached.
283    pub disabled: bool,
284    /// Optional explicit motion. `None` emits no button-owned motion declarations.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub motion: Option<ButtonMotion>,
287}
288
289impl Button {
290    pub fn background_fill(mut self, fill: Fill) -> Self {
291        self.background_fill = Some(fill);
292        self
293    }
294
295    pub fn text_color(mut self, color: IrColor) -> Self {
296        self.text_color = Some(color);
297        self
298    }
299
300    pub fn flex_grow(mut self, grow: f32) -> Self {
301        self.flex_grow = grow;
302        self
303    }
304
305    pub fn flex_shrink(mut self, shrink: f32) -> Self {
306        self.flex_shrink = shrink;
307        self
308    }
309
310    /// Sets how pointer-down should affect focus for this button.
311    pub fn focus_policy(mut self, focus_policy: FocusPolicy) -> Self {
312        self.focus_policy = focus_policy;
313        self
314    }
315
316    pub fn min_width(mut self, width: f32) -> Self {
317        self.min_width = Some(width);
318        self
319    }
320
321    pub fn max_width(mut self, width: f32) -> Self {
322        self.max_width = Some(width);
323        self
324    }
325}
326
327impl Default for Button {
328    fn default() -> Self {
329        Self {
330            id: None,
331            child: None,
332            on_press: None,
333            semantics: None,
334            focus_policy: FocusPolicy::FocusOnPointer,
335            width: None,
336            height: None,
337            min_width: None,
338            max_width: None,
339            flex_grow: 0.0,
340            flex_shrink: 1.0,
341            padding: None,
342            style: None,
343            variant: ButtonVariant::Filled,
344            size: ComponentSize::Md,
345            background_fill: None,
346            text_color: None,
347            content_align: ButtonContentAlign::Center,
348            disabled: false,
349            motion: None,
350        }
351    }
352}
353
354#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
355pub struct ButtonStyleOverride {}
356
357struct ButtonStyleResolved {
358    background_fill: Option<Fill>,
359    text_color: IrColor,
360    padding_horizontal: f32,
361    padding_vertical: f32,
362    height: f32,
363    corner_radius: f32,
364    shadow: Option<BoxShadow>,
365    shadows: Vec<BoxShadow>,
366    stroke: Option<Stroke>,
367    font_size: f32,
368    font_weight: u16,
369    line_height: Option<f32>,
370}
371
372impl Button {
373    fn resolve_style(
374        &self,
375        env: &Env,
376        interaction: &InteractionStateMap,
377        self_id: WidgetId,
378    ) -> ButtonStyleResolved {
379        let default_style = &env.theme.components.button;
380        let tokens = &env.theme.tokens.colors;
381
382        let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
383        let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
384        let is_focused = interaction.is_focused(self_id) && !self.disabled;
385        let component_state = if self.disabled {
386            ComponentState::Disabled
387        } else if is_pressed {
388            ComponentState::Active
389        } else if is_focused {
390            ComponentState::Focus
391        } else if is_hovered {
392            ComponentState::Hover
393        } else {
394            ComponentState::Default
395        };
396        let component_style =
397            default_style.resolve(self.variant.hierarchy(), self.size, component_state);
398
399        let stroke = component_style
400            .border
401            .clone()
402            .or_else(|| component_style.inset_border())
403            .map(|border| Stroke {
404                fill: border.fill,
405                width: border.width,
406                dash_array: None,
407                line_cap: fission_ir::op::LineCap::Butt,
408                line_join: fission_ir::op::LineJoin::Miter,
409            })
410            .or_else(|| {
411                if is_focused {
412                    default_style.focus_stroke.clone()
413                } else {
414                    None
415                }
416            });
417        let shadows = component_style.outer_shadows();
418        let shadow = shadows.first().copied().or_else(|| {
419            if matches!(self.variant, ButtonVariant::Filled | ButtonVariant::Primary) {
420                if is_pressed {
421                    default_style.elevation_pressed
422                } else if is_hovered {
423                    default_style.elevation_hover
424                } else {
425                    default_style.elevation_rest
426                }
427            } else {
428                None
429            }
430        });
431
432        ButtonStyleResolved {
433            background_fill: self
434                .background_fill
435                .clone()
436                .or_else(|| component_style.background.clone()),
437            text_color: self
438                .text_color
439                .unwrap_or(component_style.text_color.unwrap_or(tokens.primary)),
440            padding_horizontal: component_style
441                .padding_x
442                .unwrap_or(default_style.padding_horizontal),
443            padding_vertical: component_style
444                .padding_y
445                .unwrap_or(default_style.padding_vertical),
446            height: component_style.height.unwrap_or(default_style.height),
447            corner_radius: component_style.radius.unwrap_or(default_style.radius),
448            shadow,
449            shadows,
450            stroke,
451            font_size: component_style.font_size.unwrap_or(default_style.text_size),
452            font_weight: component_style
453                .font_weight
454                .unwrap_or(default_style.font_weight),
455            line_height: component_style.line_height,
456        }
457    }
458
459    fn should_attach_semantics(&self) -> bool {
460        self.semantics.is_some() || self.on_press.is_some()
461    }
462
463    fn build_semantics(&self) -> Option<Semantics> {
464        if !self.should_attach_semantics() {
465            return None;
466        }
467
468        let mut semantics = self
469            .semantics
470            .clone()
471            .unwrap_or_else(default_button_semantics);
472
473        semantics.disabled = self.disabled;
474        semantics.focus_policy = self.focus_policy;
475
476        if let Some(action_envelope) = &self.on_press {
477            if !self.disabled {
478                semantics.actions.entries.push(ActionEntry {
479                    trigger: fission_ir::semantics::ActionTrigger::Default,
480                    action_id: action_envelope.id.as_u128(),
481                    payload_data: Some(action_envelope.payload.clone()),
482                });
483            }
484        }
485
486        Some(semantics)
487    }
488}
489
490impl InternalLower for Button {
491    fn lower(&self, cx: &mut InternalLoweringCx) -> WidgetId {
492        let semantics_op = self.build_semantics();
493        let outermost_id = self.id.map(Into::into).unwrap_or_else(|| cx.next_node_id());
494
495        let (layout_node_id, final_id) = if let Some(_) = semantics_op {
496            (cx.next_node_id(), outermost_id)
497        } else {
498            (outermost_id, outermost_id)
499        };
500
501        let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
502
503        cx.push_scope(layout_node_id);
504
505        let mut button_builder = InternalIrBuilder::new(
506            layout_node_id,
507            Op::Layout(LayoutOp::Box {
508                width: self.width,
509                height: self.height,
510                min_width: self.min_width,
511                max_width: self.max_width,
512                min_height: if self.height.is_some() {
513                    None
514                } else {
515                    Some(resolved_style.height)
516                },
517                max_height: None,
518                padding: self.padding.unwrap_or([
519                    resolved_style.padding_horizontal,
520                    resolved_style.padding_horizontal,
521                    resolved_style.padding_vertical,
522                    resolved_style.padding_vertical,
523                ]),
524                flex_grow: self.flex_grow,
525                flex_shrink: self.flex_shrink,
526                aspect_ratio: None,
527            }),
528        );
529
530        for shadow in &resolved_style.shadows {
531            let shadow_id = InternalIrBuilder::new(
532                cx.next_node_id(),
533                Op::Paint(PaintOp::DrawRect {
534                    fill: None,
535                    stroke: None,
536                    corner_radius: resolved_style.corner_radius,
537                    shadow: Some(*shadow),
538                }),
539            )
540            .build(cx);
541            button_builder.add_child(shadow_id);
542        }
543
544        let background_id = InternalIrBuilder::new(
545            cx.next_node_id(),
546            Op::Paint(PaintOp::DrawRect {
547                fill: resolved_style.background_fill,
548                stroke: resolved_style.stroke,
549                corner_radius: resolved_style.corner_radius,
550                shadow: if resolved_style.shadows.is_empty() {
551                    resolved_style.shadow
552                } else {
553                    None
554                },
555            }),
556        )
557        .build(cx);
558        button_builder.add_child(background_id);
559
560        if let Some(child_widget) = &self.child {
561            let child_id = if let Ok(mut text_widget) = child_widget.clone().into_text() {
562                text_widget.color = Some(resolved_style.text_color);
563                text_widget.font_size = Some(resolved_style.font_size);
564                text_widget.font_weight = Some(resolved_style.font_weight);
565                text_widget.line_height = resolved_style.line_height;
566                text_widget.lower(cx)
567            } else {
568                child_widget.lower(cx)
569            };
570            let aligned_id = match self.content_align {
571                ButtonContentAlign::Center => {
572                    // Center the content within the button's box (vertically + horizontally).
573                    let mut align_builder =
574                        InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
575                    align_builder.add_child(child_id);
576                    align_builder.build(cx)
577                }
578                ButtonContentAlign::Start | ButtonContentAlign::End => {
579                    let justify = match self.content_align {
580                        ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
581                        ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
582                        ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
583                    };
584                    let mut flex_builder = InternalIrBuilder::new(
585                        cx.next_node_id(),
586                        Op::Layout(LayoutOp::Flex {
587                            direction: fission_ir::FlexDirection::Row,
588                            wrap: fission_ir::FlexWrap::NoWrap,
589                            flex_grow: 1.0,
590                            flex_shrink: 0.0,
591                            padding: [0.0; 4],
592                            gap: None,
593                            align_items: fission_ir::op::AlignItems::Center,
594                            justify_content: justify,
595                        }),
596                    );
597                    flex_builder.add_child(child_id);
598                    flex_builder.build(cx)
599                }
600            };
601            button_builder.add_child(aligned_id);
602        }
603
604        let button_node_id = button_builder.build(cx);
605
606        if let Some(op) = semantics_op {
607            let mut semantics_builder = InternalIrBuilder::new(final_id, Op::Semantics(op));
608            semantics_builder.add_child(button_node_id);
609            let res_id = semantics_builder.build(cx);
610            cx.pop_scope();
611            return res_id;
612        }
613
614        cx.pop_scope();
615        button_node_id
616    }
617}
618
619fn default_button_semantics() -> Semantics {
620    Semantics {
621        role: Role::Button,
622        label: None,
623        identifier: None,
624        value: None,
625        actions: ActionSet::default(),
626        action_scope_id: None,
627        focusable: true,
628        focus_policy: FocusPolicy::FocusOnPointer,
629        multiline: false,
630        masked: false,
631        input_mask: None,
632        ime_preedit_range: None,
633        ime_preedit_cursor_range: None,
634        text_selection: None,
635        checked: None,
636        disabled: false,
637        read_only: false,
638        autofocus: false,
639        draggable: false,
640        scrollable_x: false,
641        scrollable_y: false,
642        min_value: None,
643        max_value: None,
644        current_value: None,
645        is_focus_scope: false,
646        is_focus_barrier: false,
647        drag_payload: None,
648        hero_tag: None,
649        focus_index: None,
650        text_input_type: fission_ir::semantics::TextInputType::Text,
651        text_input_action: fission_ir::semantics::TextInputAction::Done,
652        text_capitalization: fission_ir::semantics::TextCapitalization::None,
653        max_length: None,
654        max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
655        input_formatters: Vec::new(),
656        autocorrect: true,
657        enable_suggestions: true,
658        spell_check: true,
659        smart_dashes: true,
660        smart_quotes: true,
661        autofill_hints: Vec::new(),
662        scroll_padding: None,
663        capture_tab: false,
664        auto_indent: false,
665    }
666}