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 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 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 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}