1use frus_core::{BorderRadius, Color, Point, Rect, Scene, ShapeBorder, TextStyle};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::{disabled_container, disabled_content};
7use crate::icons::IconData;
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
19pub enum FabSize {
20 #[default]
22 Regular,
23 Small,
26 Large,
28 Extended,
31}
32
33impl FabSize {
34 pub const fn extent(self) -> f32 {
36 match self {
37 FabSize::Small => 40.0,
38 FabSize::Large => 96.0,
39 FabSize::Regular | FabSize::Extended => 56.0,
40 }
41 }
42
43 pub const fn radius(self) -> f32 {
45 match self {
46 FabSize::Small => 12.0,
47 FabSize::Large => 28.0,
48 FabSize::Regular | FabSize::Extended => 16.0,
49 }
50 }
51
52 pub const fn icon_size(self) -> f32 {
54 match self {
55 FabSize::Large => 36.0,
56 _ => 24.0,
57 }
58 }
59}
60
61const EXTENDED_PAD: f32 = 20.0;
65const EXTENDED_PAD_WITH_ICON: f32 = 16.0;
66const EXTENDED_GAP: f32 = 8.0;
68
69const ELEVATION: f32 = 6.0;
72const HOVER_ELEVATION: f32 = 8.0;
73
74pub struct FloatingActionButton<Msg = crate::callback::Callback> {
90 icon: Option<IconData>,
91 label: Option<String>,
92 size: FabSize,
93 enabled: bool,
94 on_press: Option<Msg>,
95 background: Option<Color>,
96 foreground: Option<Color>,
97 elevation: Option<f32>,
98 hover_elevation: Option<f32>,
99 shape: Option<ShapeBorder>,
100 icon_size: Option<f32>,
101 label_style: Option<TextStyle>,
102}
103
104impl<Msg: Clone> FloatingActionButton<Msg> {
105 pub fn new(icon: IconData) -> Self {
107 Self {
108 icon: Some(icon),
109 label: None,
110 size: FabSize::Regular,
111 enabled: true,
112 on_press: None,
113 background: None,
114 foreground: None,
115 elevation: None,
116 hover_elevation: None,
117 shape: None,
118 icon_size: None,
119 label_style: None,
120 }
121 }
122
123 pub fn glyph(text: impl Into<String>) -> Self {
131 Self {
132 label: Some(text.into()),
133 ..Self::extended("")
134 }
135 .size(FabSize::Regular)
136 }
137
138 pub fn extended(label: impl Into<String>) -> Self {
141 Self {
142 icon: None,
143 label: Some(label.into()),
144 size: FabSize::Extended,
145 enabled: true,
146 on_press: None,
147 background: None,
148 foreground: None,
149 elevation: None,
150 hover_elevation: None,
151 shape: None,
152 icon_size: None,
153 label_style: None,
154 }
155 }
156
157 #[must_use]
160 pub fn icon(mut self, icon: IconData) -> Self {
161 self.icon = Some(icon);
162 self
163 }
164
165 #[must_use]
167 pub fn small(mut self) -> Self {
168 self.size = FabSize::Small;
169 self
170 }
171
172 #[must_use]
174 pub fn large(mut self) -> Self {
175 self.size = FabSize::Large;
176 self
177 }
178
179 #[must_use]
181 pub fn size(mut self, size: FabSize) -> Self {
182 self.size = size;
183 self
184 }
185
186 #[must_use]
189 pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
190 self.on_press = Some(message.into());
191 self
192 }
193
194 #[must_use]
199 pub fn enabled(mut self, enabled: bool) -> Self {
200 self.enabled = enabled;
201 self
202 }
203
204 #[must_use]
206 pub fn background(mut self, color: Color) -> Self {
207 self.background = Some(color);
208 self
209 }
210
211 #[must_use]
213 pub fn foreground(mut self, color: Color) -> Self {
214 self.foreground = Some(color);
215 self
216 }
217
218 #[must_use]
224 pub fn elevation(mut self, elevation: f32) -> Self {
225 self.elevation = Some(elevation);
226 self
227 }
228
229 #[must_use]
232 pub fn hover_elevation(mut self, elevation: f32) -> Self {
233 self.hover_elevation = Some(elevation);
234 self
235 }
236
237 #[must_use]
239 pub fn shape(mut self, shape: ShapeBorder) -> Self {
240 self.shape = Some(shape);
241 self
242 }
243
244 #[must_use]
247 pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
248 self.shape(ShapeBorder::rounded(radius.into()))
249 }
250
251 #[must_use]
253 pub fn icon_size(mut self, size: f32) -> Self {
254 self.icon_size = Some(size);
255 self
256 }
257
258 #[must_use]
261 pub fn label_style(mut self, style: TextStyle) -> Self {
262 self.label_style = Some(style);
263 self
264 }
265
266 pub fn shape_of(&self, theme: &Theme) -> ShapeBorder {
277 crate::resolve_shape(
278 self.shape,
279 theme.widgets.fab.shape,
280 theme.widgets.fab.radius.map(BorderRadius::uniform),
281 ShapeBorder::rounded(self.size.radius()),
282 )
283 }
284
285 fn glyph_size(&self, theme: &Theme) -> f32 {
286 self.icon_size
287 .or(theme.widgets.fab.icon_size)
288 .unwrap_or(self.size.icon_size())
289 }
290
291 fn text_style(&self, theme: &Theme) -> frus_core::ResolvedTextStyle {
292 self.label_style
293 .or(theme.widgets.fab.label_style)
294 .unwrap_or(theme.text.label_large)
295 .resolved()
296 }
297
298 fn lead_pad(&self, theme: &Theme) -> f32 {
301 theme.widgets.fab.extended_padding.unwrap_or(
302 if self.icon.is_some() && self.size == FabSize::Extended {
303 EXTENDED_PAD_WITH_ICON
304 } else {
305 EXTENDED_PAD
306 },
307 )
308 }
309
310 fn width(&self, theme: &Theme) -> f32 {
313 if self.size != FabSize::Extended {
314 return self.size.extent();
315 }
316 let lead = self.lead_pad(theme);
317 let glyph = match self.icon {
318 Some(_) => {
319 self.glyph_size(theme) + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP)
320 }
321 None => 0.0,
322 };
323 let words = match &self.label {
324 Some(label) => frus_text::measure_resolved(label, &self.text_style(theme)).width,
325 None => 0.0,
326 };
327 lead + glyph + words + EXTENDED_PAD
328 }
329
330 fn depth(&self, status: &Status, theme: &Theme) -> f32 {
339 if !self.enabled || self.on_press.is_none() {
340 return 0.0;
341 }
342 let rest = self
343 .elevation
344 .or(theme.widgets.fab.elevation)
345 .unwrap_or(ELEVATION);
346 let hovered = self
347 .hover_elevation
348 .or(theme.widgets.fab.hover_elevation)
349 .unwrap_or(HOVER_ELEVATION);
350 rest + (hovered - rest) * status.hover_progress.clamp(0.0, 1.0)
351 }
352}
353
354impl<Msg: Clone> Widget<Msg> for FloatingActionButton<Msg> {
355 fn style(&self) -> Style {
356 Widget::<Msg>::style_themed(self, &Theme::default())
357 }
358
359 fn style_themed(&self, theme: &Theme) -> Style {
360 Style {
361 width: Dimension::Length(self.width(theme)),
362 height: Dimension::Length(self.size.extent()),
363 ..Default::default()
364 }
365 }
366
367 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
368 &[]
369 }
370
371 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
372 let o = status.opacity;
373 let shape = self.shape_of(theme);
374 let radius = shape
375 .as_rounded(bounds)
376 .map(|(_, r)| r)
377 .unwrap_or(BorderRadius::ZERO);
378
379 let (fill, ink) = if self.enabled {
380 (
381 self.background
382 .or(theme.widgets.fab.background)
383 .unwrap_or(theme.scheme.primary_container),
384 self.foreground
385 .or(theme.widgets.fab.foreground)
386 .unwrap_or(theme.scheme.on_primary_container),
387 )
388 } else {
389 (disabled_container(theme), disabled_content(theme))
393 };
394
395 let depth = self.depth(&status, theme);
396 if depth > 0.0 {
397 frus_core::paint_elevation(scene, bounds, radius, depth, theme.scheme.shadow.fade(o));
399 }
400 let surface = theme.state_layer(fill, ink, &status);
403 scene.draw_shape(bounds, shape, surface.fade(o));
404
405 let glyph = self.glyph_size(theme);
406 let cy = bounds.y + (bounds.height - glyph) * 0.5;
407 match (&self.label, self.icon) {
408 (Some(label), icon) if self.size == FabSize::Extended => {
410 let mut x = bounds.x + self.lead_pad(theme);
411 if let Some(name) = icon {
412 let path = name.placed(glyph, x, cy, theme.direction);
413 scene.fill_path(&path, ink.fade(o));
414 x += glyph + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP);
415 }
416 let style = self.text_style(theme);
417 let measured = frus_text::measure_resolved(label, &style);
418 scene.text(
419 Point::new(x, bounds.y + (bounds.height - measured.height) * 0.5),
420 label.clone(),
421 &style,
422 ink.fade(o),
423 );
424 }
425 (_, Some(name)) => {
428 let path = name.placed(
429 glyph,
430 bounds.x + (bounds.width - glyph) * 0.5,
431 cy,
432 theme.direction,
433 );
434 scene.fill_path(&path, ink.fade(o));
435 }
436 (Some(text), None) => {
437 let style = self.text_style(theme);
438 let measured = frus_text::measure_resolved(text, &style);
439 scene.text(
440 Point::new(
441 bounds.x + (bounds.width - measured.width) * 0.5,
442 bounds.y + (bounds.height - measured.height) * 0.5,
443 ),
444 text.clone(),
445 &style,
446 ink.fade(o),
447 );
448 }
449 (None, None) => {}
450 }
451 }
452
453 fn on_click(&self) -> Option<Msg> {
454 self.enabled.then(|| self.on_press.clone()).flatten()
455 }
456
457 fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
458 if !self.enabled {
461 return None;
462 }
463 self.on_press.as_ref()?;
464 let radius = self
465 .shape_of(theme)
466 .as_rounded(Rect::new(0.0, 0.0, self.width(theme), self.size.extent()))
467 .map(|(_, r)| r)
468 .unwrap_or(BorderRadius::ZERO);
469 Some(crate::InkStyle::of(theme).radius(radius))
470 }
471
472 fn focusable(&self) -> bool {
473 self.enabled && self.on_press.is_some()
474 }
475
476 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
481 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
482 let semantics = match &self.label {
483 Some(label) => semantics.label(label.clone()),
484 None => semantics,
485 };
486 Some(if self.enabled && self.on_press.is_some() {
487 semantics.clickable()
488 } else {
489 semantics.disabled(!self.enabled)
490 })
491 }
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use crate::icons::Icons;
498 use frus_core::Primitive;
499
500 #[derive(Clone, Debug, PartialEq)]
501 enum Msg {
502 Add,
503 }
504
505 fn painted(
506 fab: &FloatingActionButton<Msg>,
507 theme: &Theme,
508 status: Status,
509 ) -> (Vec<(Rect, Color, BorderRadius)>, usize) {
510 let mut scene = Scene::new();
511 let style = Widget::<Msg>::style_themed(fab, theme);
512 let (Dimension::Length(w), Dimension::Length(h)) = (style.width, style.height) else {
513 panic!("a measured box");
514 };
515 Widget::<Msg>::paint(fab, Rect::new(0.0, 0.0, w, h), status, theme, &mut scene);
516 let rects = scene
517 .primitives()
518 .iter()
519 .filter_map(|p| match p {
520 Primitive::Rect {
521 rect,
522 color,
523 radius,
524 ..
525 } => Some((*rect, *color, *radius)),
526 _ => None,
527 })
528 .collect();
529 let paths = scene
530 .primitives()
531 .iter()
532 .filter(|p| matches!(p, Primitive::Path { .. }))
533 .count();
534 (rects, paths)
535 }
536
537 #[test]
543 fn a_button_takes_the_container_roles_and_not_the_accent() {
544 let theme = Theme::default();
545 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
546 let (rects, paths) = painted(&fab, &theme, Status::default());
547 assert_eq!(
548 rects.last().expect("a surface").1,
549 theme.scheme.primary_container,
550 "the container, not the accent"
551 );
552 assert_ne!(
553 theme.scheme.primary_container, theme.primary,
554 "and they differ"
555 );
556 assert_eq!(paths, 1, "the glyph is drawn");
557 }
558
559 #[test]
564 fn the_four_sizes_carry_their_own_box_corner_and_glyph() {
565 let theme = Theme::default();
566 for (size, extent, radius, icon) in [
567 (FabSize::Small, 40.0, 12.0, 24.0),
568 (FabSize::Regular, 56.0, 16.0, 24.0),
569 (FabSize::Large, 96.0, 28.0, 36.0),
570 ] {
571 assert_eq!(size.extent(), extent);
572 assert_eq!(size.radius(), radius);
573 assert_eq!(size.icon_size(), icon);
574
575 let fab = FloatingActionButton::<Msg>::new(Icons::ADD).size(size);
576 let style = Widget::<Msg>::style_themed(&fab, &theme);
577 assert_eq!(style.width, Dimension::Length(extent));
578 assert_eq!(style.height, Dimension::Length(extent));
579 let (rects, _) = painted(&fab, &theme, Status::default());
580 assert_eq!(
581 rects.last().expect("a surface").2,
582 BorderRadius::uniform(radius),
583 "{size:?} takes its own corner"
584 );
585 }
586 }
587
588 #[test]
594 fn it_is_a_rounded_square_and_a_stadium_when_asked() {
595 let theme = Theme::default();
596 let plain = FloatingActionButton::<Msg>::new(Icons::ADD);
597 assert_eq!(
598 painted(&plain, &theme, Status::default())
599 .0
600 .last()
601 .unwrap()
602 .2,
603 BorderRadius::uniform(16.0)
604 );
605 let docked = FloatingActionButton::<Msg>::new(Icons::ADD).shape(ShapeBorder::stadium());
606 assert_eq!(
607 painted(&docked, &theme, Status::default())
608 .0
609 .last()
610 .unwrap()
611 .2,
612 BorderRadius::uniform(28.0),
613 "half the short side"
614 );
615 }
616
617 #[test]
621 fn an_extended_button_is_as_wide_as_what_it_carries() {
622 let theme = Theme::default();
623 let words = FloatingActionButton::<Msg>::extended("New list");
624 let with_icon = FloatingActionButton::<Msg>::extended("New list").icon(Icons::ADD);
625
626 let width = |fab: &FloatingActionButton<Msg>| match Widget::<Msg>::style_themed(fab, &theme)
627 .width
628 {
629 Dimension::Length(w) => w,
630 other => panic!("a measured width, not {other:?}"),
631 };
632 let text =
633 frus_text::measure_resolved("New list", &theme.text.label_large.resolved()).width;
634 assert_eq!(width(&words), EXTENDED_PAD + text + EXTENDED_PAD);
635 assert_eq!(
636 width(&with_icon),
637 EXTENDED_PAD_WITH_ICON + 24.0 + EXTENDED_GAP + text + EXTENDED_PAD,
638 "a glyph shortens the leading room, as the reference's does"
639 );
640 assert_eq!(
641 Widget::<Msg>::style_themed(&words, &theme).height,
642 Dimension::Length(56.0),
643 "and it is still a regular button's height"
644 );
645
646 let (_, paths) = painted(&with_icon, &theme, Status::default());
647 assert_eq!(paths, 1, "the glyph before the words");
648 assert_eq!(painted(&words, &theme, Status::default()).1, 0);
649 }
650
651 #[test]
656 fn a_disabled_button_stops_floating() {
657 let theme = Theme::default();
658 let live = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
659 let (rects, _) = painted(&live, &theme, Status::default());
660 assert_eq!(
661 rects.len(),
662 4,
663 "three shadows under the surface: {rects:#?}"
664 );
665
666 let dead = FloatingActionButton::new(Icons::ADD)
667 .on_press(Msg::Add)
668 .enabled(false);
669 let (rects, _) = painted(&dead, &theme, Status::default());
670 assert_eq!(rects.len(), 1, "the surface alone");
671 assert_eq!(rects[0].1, disabled_container(&theme));
672 assert_eq!(Widget::<Msg>::on_click(&dead), None);
673 assert!(!Widget::<Msg>::focusable(&dead));
674 assert!(Widget::<Msg>::ink(&dead, &theme).is_none());
675
676 let inert = FloatingActionButton::<Msg>::new(Icons::ADD);
679 let (rects, _) = painted(&inert, &theme, Status::default());
680 assert_eq!(rects.len(), 1);
681 assert_eq!(rects[0].1, theme.scheme.primary_container);
682 }
683
684 #[test]
687 fn it_rises_under_a_pointer() {
688 let theme = Theme::default();
689 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
690 let shadow_of = |hover: f32| {
691 let status = Status {
692 hover_progress: hover,
693 ..Default::default()
694 };
695 painted(&fab, &theme, status).0[1].0.width
698 };
699 assert!(
700 shadow_of(1.0) > shadow_of(0.0),
701 "a wider shadow is a higher button"
702 );
703 }
704
705 #[test]
707 fn a_theme_answers_for_every_button() {
708 let mut theme = Theme::default();
709 theme.widgets.fab.background = Some(Color::rgb(0.2, 0.4, 0.6));
710 theme.widgets.fab.foreground = Some(Color::rgb(0.9, 0.9, 0.9));
711 theme.widgets.fab.radius = Some(3.0);
712 theme.widgets.fab.icon_size = Some(11.0);
713
714 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
715 let (rects, _) = painted(&fab, &theme, Status::default());
716 let surface = rects.last().unwrap();
717 assert_eq!(surface.1, Color::rgb(0.2, 0.4, 0.6));
718 assert_eq!(surface.2, BorderRadius::uniform(3.0));
719
720 let told = FloatingActionButton::new(Icons::ADD)
721 .on_press(Msg::Add)
722 .background(Color::rgb(0.1, 0.1, 0.1))
723 .radius(9.0);
724 let (rects, _) = painted(&told, &theme, Status::default());
725 let surface = rects.last().unwrap();
726 assert_eq!(surface.1, Color::rgb(0.1, 0.1, 0.1));
727 assert_eq!(surface.2, BorderRadius::uniform(9.0));
728 }
729
730 #[test]
735 fn a_character_sits_in_the_square_like_a_glyph() {
736 let theme = Theme::default();
737 let plus = FloatingActionButton::<Msg>::glyph("+");
738 let style = Widget::<Msg>::style_themed(&plus, &theme);
739 assert_eq!(
740 style.width,
741 Dimension::Length(56.0),
742 "a square, not a lozenge"
743 );
744 assert_eq!(style.height, Dimension::Length(56.0));
745
746 let docked = crate::fab_button::<Msg>("+", Msg::Add);
749 let (rects, _) = painted(&docked, &theme, Status::default());
750 assert_eq!(
751 rects.last().unwrap().2,
752 BorderRadius::uniform(28.0),
753 "round, for a round notch"
754 );
755 assert_eq!(
756 rects.last().unwrap().1,
757 theme.scheme.primary_container,
758 "and it takes the button's colours now, not a filled button's"
759 );
760 }
761
762 #[test]
768 fn an_extended_button_has_something_to_read_out() {
769 let words = FloatingActionButton::extended("New list").on_press(Msg::Add);
770 let semantics = Widget::<Msg>::semantics(&words).expect("a button");
771 assert_eq!(semantics.label.as_deref(), Some("New list"));
772 assert!(semantics.clickable);
773
774 let glyph = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
775 assert_eq!(Widget::<Msg>::semantics(&glyph).unwrap().label, None);
776 }
777
778 #[test]
785 fn the_hovered_height_is_resolved_on_its_own() {
786 let theme = Theme::default();
787 let hovered = Status {
788 hover_progress: 1.0,
789 ..Default::default()
790 };
791 let shadow = |fab: FloatingActionButton<Msg>, theme: &Theme, status: Status| {
794 let (rects, _) = painted(&fab.on_press(Msg::Add), theme, status);
795 (rects.len() == 4).then(|| rects[2].0.width)
796 };
797 let fab = || FloatingActionButton::new(Icons::ADD);
798 let eight = shadow(fab(), &theme, hovered).expect("a hovered button floats");
799
800 assert_eq!(
801 shadow(fab().elevation(0.0), &theme, Status::default()),
802 None
803 );
804 assert_eq!(
805 shadow(fab().elevation(0.0), &theme, hovered),
806 Some(eight),
807 "flat at rest still rises to eight, as the reference's does"
808 );
809 assert_eq!(
810 shadow(fab().elevation(0.0).hover_elevation(0.0), &theme, hovered),
811 None,
812 "and one told both stays down"
813 );
814 let twelve = shadow(fab().elevation(12.0), &theme, Status::default()).unwrap();
815 assert_eq!(
816 shadow(fab().elevation(12.0), &theme, hovered),
817 Some(eight),
818 "a hovered height is not the resting one's floor"
819 );
820 assert!(twelve > eight);
821
822 let mut themed = Theme::default();
823 themed.widgets.fab.hover_elevation = Some(0.0);
824 assert_eq!(shadow(fab(), &themed, hovered), None, "the theme's word");
825 assert_eq!(
826 shadow(fab().hover_elevation(8.0), &themed, hovered),
827 Some(eight),
828 "and the caller's over it"
829 );
830 }
831}