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#[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 pub width: Option<f32>,
249 pub height: Option<f32>,
251 pub min_width: Option<f32>,
253 pub max_width: Option<f32>,
255 pub flex_grow: f32,
257 pub flex_shrink: f32,
259 pub padding: Option<[f32; 4]>,
261 pub style: Option<ButtonStyleOverride>,
263 pub variant: ButtonVariant,
265 #[serde(default)]
267 pub size: ComponentSize,
268 pub background_fill: Option<Fill>,
270 pub text_color: Option<IrColor>,
272 #[serde(default)]
274 pub content_align: ButtonContentAlign,
275 pub disabled: bool,
278 #[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 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}