1use crate::lowering::{LoweringContext, NodeBuilder};
2use crate::ui::traits::Lower;
3use crate::ui::Node;
4use crate::{ActionEnvelope, Env, InteractionStateMap};
5use fission_ir::{
6 op::{BoxShadow, Color as IrColor, Fill, LayoutOp, Op, PaintOp, Stroke},
7 ActionEntry, ActionSet, NodeId, Role, Semantics,
8};
9use fission_theme::{ButtonHierarchy, ComponentSize, ComponentState};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
28pub enum ButtonVariant {
29 #[default]
31 Filled,
32 Outline,
34 Ghost,
36 Primary,
38 SecondaryColor,
40 SecondaryGray,
42 TertiaryColor,
44 TertiaryGray,
46 LinkColor,
48 LinkGray,
50 Destructive,
52}
53
54impl ButtonVariant {
55 fn hierarchy(self) -> ButtonHierarchy {
56 match self {
57 ButtonVariant::Filled | ButtonVariant::Primary => ButtonHierarchy::Primary,
58 ButtonVariant::Outline | ButtonVariant::SecondaryGray => ButtonHierarchy::SecondaryGray,
59 ButtonVariant::Ghost | ButtonVariant::TertiaryGray => ButtonHierarchy::TertiaryGray,
60 ButtonVariant::SecondaryColor => ButtonHierarchy::SecondaryColor,
61 ButtonVariant::TertiaryColor => ButtonHierarchy::TertiaryColor,
62 ButtonVariant::LinkColor => ButtonHierarchy::LinkColor,
63 ButtonVariant::LinkGray => ButtonHierarchy::LinkGray,
64 ButtonVariant::Destructive => ButtonHierarchy::Destructive,
65 }
66 }
67}
68
69#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize)]
73pub enum ButtonContentAlign {
74 #[default]
76 Center,
77 Start,
79 End,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Button {
104 pub id: Option<NodeId>,
106 pub child: Option<Box<Node>>,
108 pub on_press: Option<ActionEnvelope>,
110 pub semantics: Option<Semantics>,
112 pub width: Option<f32>,
114 pub height: Option<f32>,
116 pub min_width: Option<f32>,
118 pub max_width: Option<f32>,
120 pub flex_grow: f32,
122 pub flex_shrink: f32,
124 pub padding: Option<[f32; 4]>,
126 pub style: Option<ButtonStyleOverride>,
128 pub variant: ButtonVariant,
130 #[serde(default)]
132 pub size: ComponentSize,
133 pub background_fill: Option<Fill>,
135 pub text_color: Option<IrColor>,
137 #[serde(default)]
139 pub content_align: ButtonContentAlign,
140 pub disabled: bool,
143}
144
145impl Button {
146 pub fn background_fill(mut self, fill: Fill) -> Self {
147 self.background_fill = Some(fill);
148 self
149 }
150
151 pub fn text_color(mut self, color: IrColor) -> Self {
152 self.text_color = Some(color);
153 self
154 }
155
156 pub fn flex_grow(mut self, grow: f32) -> Self {
157 self.flex_grow = grow;
158 self
159 }
160
161 pub fn flex_shrink(mut self, shrink: f32) -> Self {
162 self.flex_shrink = shrink;
163 self
164 }
165
166 pub fn min_width(mut self, width: f32) -> Self {
167 self.min_width = Some(width);
168 self
169 }
170
171 pub fn max_width(mut self, width: f32) -> Self {
172 self.max_width = Some(width);
173 self
174 }
175
176 pub fn into_node(self) -> crate::ui::Node {
177 crate::ui::Node::Button(self)
178 }
179}
180
181impl Default for Button {
182 fn default() -> Self {
183 Self {
184 id: None,
185 child: None,
186 on_press: None,
187 semantics: None,
188 width: None,
189 height: None,
190 min_width: None,
191 max_width: None,
192 flex_grow: 0.0,
193 flex_shrink: 1.0,
194 padding: None,
195 style: None,
196 variant: ButtonVariant::Filled,
197 size: ComponentSize::Md,
198 background_fill: None,
199 text_color: None,
200 content_align: ButtonContentAlign::Center,
201 disabled: false,
202 }
203 }
204}
205
206#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
207pub struct ButtonStyleOverride {}
208
209struct ButtonStyleResolved {
210 background_fill: Option<Fill>,
211 text_color: IrColor,
212 padding_horizontal: f32,
213 padding_vertical: f32,
214 height: f32,
215 corner_radius: f32,
216 shadow: Option<BoxShadow>,
217 shadows: Vec<BoxShadow>,
218 stroke: Option<Stroke>,
219 font_size: f32,
220 font_weight: u16,
221 line_height: Option<f32>,
222}
223
224impl Button {
225 fn resolve_style(
226 &self,
227 env: &Env,
228 interaction: &InteractionStateMap,
229 self_id: NodeId,
230 ) -> ButtonStyleResolved {
231 let default_style = &env.theme.components.button;
232 let tokens = &env.theme.tokens.colors;
233
234 let is_hovered = interaction.is_hovered(self_id) && !self.disabled;
235 let is_pressed = interaction.is_pressed(self_id) && !self.disabled;
236 let is_focused = interaction.is_focused(self_id) && !self.disabled;
237 let component_state = if self.disabled {
238 ComponentState::Disabled
239 } else if is_pressed {
240 ComponentState::Active
241 } else if is_focused {
242 ComponentState::Focus
243 } else if is_hovered {
244 ComponentState::Hover
245 } else {
246 ComponentState::Default
247 };
248 let component_style =
249 default_style.resolve(self.variant.hierarchy(), self.size, component_state);
250
251 let stroke = component_style
252 .border
253 .clone()
254 .or_else(|| component_style.inset_border())
255 .map(|border| Stroke {
256 fill: border.fill,
257 width: border.width,
258 dash_array: None,
259 line_cap: fission_ir::op::LineCap::Butt,
260 line_join: fission_ir::op::LineJoin::Miter,
261 })
262 .or_else(|| {
263 if is_focused {
264 default_style.focus_stroke.clone()
265 } else {
266 None
267 }
268 });
269 let shadows = component_style.outer_shadows();
270 let shadow = shadows.first().copied().or_else(|| {
271 if matches!(self.variant, ButtonVariant::Filled | ButtonVariant::Primary) {
272 if is_pressed {
273 default_style.elevation_pressed
274 } else if is_hovered {
275 default_style.elevation_hover
276 } else {
277 default_style.elevation_rest
278 }
279 } else {
280 None
281 }
282 });
283
284 ButtonStyleResolved {
285 background_fill: self
286 .background_fill
287 .clone()
288 .or_else(|| component_style.background.clone()),
289 text_color: self
290 .text_color
291 .unwrap_or(component_style.text_color.unwrap_or(tokens.primary)),
292 padding_horizontal: component_style
293 .padding_x
294 .unwrap_or(default_style.padding_horizontal),
295 padding_vertical: component_style
296 .padding_y
297 .unwrap_or(default_style.padding_vertical),
298 height: component_style.height.unwrap_or(default_style.height),
299 corner_radius: component_style.radius.unwrap_or(default_style.radius),
300 shadow,
301 shadows,
302 stroke,
303 font_size: component_style.font_size.unwrap_or(default_style.text_size),
304 font_weight: component_style
305 .font_weight
306 .unwrap_or(default_style.font_weight),
307 line_height: component_style.line_height,
308 }
309 }
310
311 fn should_attach_semantics(&self) -> bool {
312 self.semantics.is_some() || self.on_press.is_some()
313 }
314
315 fn build_semantics(&self) -> Option<Semantics> {
316 if !self.should_attach_semantics() {
317 return None;
318 }
319
320 let mut semantics = self
321 .semantics
322 .clone()
323 .unwrap_or_else(default_button_semantics);
324
325 semantics.disabled = self.disabled;
326
327 if let Some(action_envelope) = &self.on_press {
328 if !self.disabled {
329 semantics.actions.entries.push(ActionEntry {
330 trigger: fission_ir::semantics::ActionTrigger::Default,
331 action_id: action_envelope.id.as_u128(),
332 payload_data: Some(action_envelope.payload.clone()),
333 });
334 }
335 }
336
337 Some(semantics)
338 }
339}
340
341impl Lower for Button {
342 fn lower(&self, cx: &mut LoweringContext) -> NodeId {
343 let semantics_op = self.build_semantics();
344 let outermost_id = self.id.unwrap_or_else(|| cx.next_node_id());
345
346 let (layout_node_id, final_id) = if let Some(_) = semantics_op {
347 (cx.next_node_id(), outermost_id)
348 } else {
349 (outermost_id, outermost_id)
350 };
351
352 let resolved_style = self.resolve_style(cx.env, &cx.runtime_state.interaction, final_id);
353
354 cx.push_scope(layout_node_id);
355
356 let mut button_builder = NodeBuilder::new(
357 layout_node_id,
358 Op::Layout(LayoutOp::Box {
359 width: self.width,
360 height: self.height,
361 min_width: self.min_width,
362 max_width: self.max_width,
363 min_height: if self.height.is_some() {
364 None
365 } else {
366 Some(resolved_style.height)
367 },
368 max_height: None,
369 padding: self.padding.unwrap_or([
370 resolved_style.padding_horizontal,
371 resolved_style.padding_horizontal,
372 resolved_style.padding_vertical,
373 resolved_style.padding_vertical,
374 ]),
375 flex_grow: self.flex_grow,
376 flex_shrink: self.flex_shrink,
377 aspect_ratio: None,
378 }),
379 );
380
381 for shadow in &resolved_style.shadows {
382 let shadow_id = NodeBuilder::new(
383 cx.next_node_id(),
384 Op::Paint(PaintOp::DrawRect {
385 fill: None,
386 stroke: None,
387 corner_radius: resolved_style.corner_radius,
388 shadow: Some(*shadow),
389 }),
390 )
391 .build(cx);
392 button_builder.add_child(shadow_id);
393 }
394
395 let background_id = NodeBuilder::new(
396 cx.next_node_id(),
397 Op::Paint(PaintOp::DrawRect {
398 fill: resolved_style.background_fill,
399 stroke: resolved_style.stroke,
400 corner_radius: resolved_style.corner_radius,
401 shadow: if resolved_style.shadows.is_empty() {
402 resolved_style.shadow
403 } else {
404 None
405 },
406 }),
407 )
408 .build(cx);
409 button_builder.add_child(background_id);
410
411 if let Some(child_widget) = &self.child {
412 let child_id = if let Node::Text(mut text_widget) = *child_widget.clone() {
413 text_widget.color = Some(resolved_style.text_color);
414 text_widget.font_size = Some(resolved_style.font_size);
415 text_widget.font_weight = Some(resolved_style.font_weight);
416 text_widget.line_height = resolved_style.line_height;
417 text_widget.lower(cx)
418 } else {
419 child_widget.lower(cx)
420 };
421 let aligned_id = match self.content_align {
422 ButtonContentAlign::Center => {
423 let mut align_builder =
425 NodeBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::Align));
426 align_builder.add_child(child_id);
427 align_builder.build(cx)
428 }
429 ButtonContentAlign::Start | ButtonContentAlign::End => {
430 let justify = match self.content_align {
431 ButtonContentAlign::Start => fission_ir::op::JustifyContent::Start,
432 ButtonContentAlign::End => fission_ir::op::JustifyContent::End,
433 ButtonContentAlign::Center => fission_ir::op::JustifyContent::Center,
434 };
435 let mut flex_builder = NodeBuilder::new(
436 cx.next_node_id(),
437 Op::Layout(LayoutOp::Flex {
438 direction: fission_ir::FlexDirection::Row,
439 wrap: fission_ir::FlexWrap::NoWrap,
440 flex_grow: 1.0,
441 flex_shrink: 0.0,
442 padding: [0.0; 4],
443 gap: None,
444 align_items: fission_ir::op::AlignItems::Center,
445 justify_content: justify,
446 }),
447 );
448 flex_builder.add_child(child_id);
449 flex_builder.build(cx)
450 }
451 };
452 button_builder.add_child(aligned_id);
453 }
454
455 let button_node_id = button_builder.build(cx);
456
457 if let Some(op) = semantics_op {
458 let mut semantics_builder = NodeBuilder::new(final_id, Op::Semantics(op));
459 semantics_builder.add_child(button_node_id);
460 let res_id = semantics_builder.build(cx);
461 cx.pop_scope();
462 return res_id;
463 }
464
465 cx.pop_scope();
466 button_node_id
467 }
468}
469
470fn default_button_semantics() -> Semantics {
471 Semantics {
472 role: Role::Button,
473 label: None,
474 identifier: None,
475 value: None,
476 actions: ActionSet::default(),
477 action_scope_id: None,
478 focusable: true,
479 multiline: false,
480 masked: false,
481 input_mask: None,
482 ime_preedit_range: None,
483 checked: None,
484 disabled: false,
485 read_only: false,
486 autofocus: false,
487 draggable: false,
488 scrollable_x: false,
489 scrollable_y: false,
490 min_value: None,
491 max_value: None,
492 current_value: None,
493 is_focus_scope: false,
494 is_focus_barrier: false,
495 drag_payload: None,
496 hero_tag: None,
497 focus_index: None,
498 text_input_type: fission_ir::semantics::TextInputType::Text,
499 text_input_action: fission_ir::semantics::TextInputAction::Done,
500 text_capitalization: fission_ir::semantics::TextCapitalization::None,
501 max_length: None,
502 max_length_enforcement: fission_ir::semantics::MaxLengthEnforcement::Enforced,
503 input_formatters: Vec::new(),
504 autocorrect: true,
505 enable_suggestions: true,
506 spell_check: true,
507 smart_dashes: true,
508 smart_quotes: true,
509 autofill_hints: Vec::new(),
510 scroll_padding: None,
511 capture_tab: false,
512 auto_indent: false,
513 }
514}