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