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#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
33pub enum ButtonVariant {
34 #[default]
36 Filled,
37 Outline,
39 Ghost,
41 Primary,
43 SecondaryColor,
45 SecondaryGray,
47 TertiaryColor,
49 TertiaryGray,
51 LinkColor,
53 LinkGray,
55 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#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
78pub enum ButtonContentAlign {
79 #[default]
81 Center,
82 Start,
84 End,
86}
87
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
89pub enum ButtonMotion {
105 Default,
107 HoverScale,
109 PressScale,
111 HoverPressScale,
113 Ripple,
115 HoverPressRipple,
117 Composition(Vec<ButtonMotion>),
119 Custom {
121 interaction: Option<Vec<MotionTrack>>,
123 ripple: Option<RippleFx>,
125 },
126}
127
128impl ButtonMotion {
129 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct Button {
238 pub id: Option<WidgetId>,
240 pub child: Option<Widget>,
243 pub on_press: Option<ActionEnvelope>,
245 pub semantics: Option<Semantics>,
247 #[serde(default)]
252 pub focus_policy: FocusPolicy,
253 pub width: Option<f32>,
255 pub height: Option<f32>,
257 pub min_width: Option<f32>,
259 pub max_width: Option<f32>,
261 pub flex_grow: f32,
263 pub flex_shrink: f32,
265 pub padding: Option<[f32; 4]>,
267 pub style: Option<ButtonStyleOverride>,
269 pub variant: ButtonVariant,
271 #[serde(default)]
273 pub size: ComponentSize,
274 pub background_fill: Option<Fill>,
276 pub text_color: Option<IrColor>,
278 #[serde(default)]
280 pub content_align: ButtonContentAlign,
281 pub disabled: bool,
284 #[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 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 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}