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