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 let blur = depth * 4.0 + 8.0;
398 scene.shadow(
399 Rect::new(
400 bounds.x - blur,
401 bounds.y + depth * 2.0 - blur,
402 bounds.width + 2.0 * blur,
403 bounds.height + 2.0 * blur,
404 ),
405 theme.scheme.shadow.with_alpha(0.30).fade(o),
406 radius.inflate(blur),
407 blur,
408 );
409 }
410 let surface = theme.state_layer(fill, ink, &status);
413 scene.draw_shape(bounds, shape, surface.fade(o));
414
415 let glyph = self.glyph_size(theme);
416 let cy = bounds.y + (bounds.height - glyph) * 0.5;
417 match (&self.label, self.icon) {
418 (Some(label), icon) if self.size == FabSize::Extended => {
420 let mut x = bounds.x + self.lead_pad(theme);
421 if let Some(name) = icon {
422 let path = name.placed(glyph, x, cy, theme.direction);
423 scene.fill_path(&path, ink.fade(o));
424 x += glyph + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP);
425 }
426 let style = self.text_style(theme);
427 let measured = frus_text::measure_resolved(label, &style);
428 scene.text(
429 Point::new(x, bounds.y + (bounds.height - measured.height) * 0.5),
430 label.clone(),
431 &style,
432 ink.fade(o),
433 );
434 }
435 (_, Some(name)) => {
438 let path = name.placed(
439 glyph,
440 bounds.x + (bounds.width - glyph) * 0.5,
441 cy,
442 theme.direction,
443 );
444 scene.fill_path(&path, ink.fade(o));
445 }
446 (Some(text), None) => {
447 let style = self.text_style(theme);
448 let measured = frus_text::measure_resolved(text, &style);
449 scene.text(
450 Point::new(
451 bounds.x + (bounds.width - measured.width) * 0.5,
452 bounds.y + (bounds.height - measured.height) * 0.5,
453 ),
454 text.clone(),
455 &style,
456 ink.fade(o),
457 );
458 }
459 (None, None) => {}
460 }
461 }
462
463 fn on_click(&self) -> Option<Msg> {
464 self.enabled.then(|| self.on_press.clone()).flatten()
465 }
466
467 fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
468 if !self.enabled {
471 return None;
472 }
473 self.on_press.as_ref()?;
474 let radius = self
475 .shape_of(theme)
476 .as_rounded(Rect::new(0.0, 0.0, self.width(theme), self.size.extent()))
477 .map(|(_, r)| r)
478 .unwrap_or(BorderRadius::ZERO);
479 Some(crate::InkStyle::of(theme).radius(radius))
480 }
481
482 fn focusable(&self) -> bool {
483 self.enabled && self.on_press.is_some()
484 }
485
486 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
491 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
492 let semantics = match &self.label {
493 Some(label) => semantics.label(label.clone()),
494 None => semantics,
495 };
496 Some(if self.enabled && self.on_press.is_some() {
497 semantics.clickable()
498 } else {
499 semantics.disabled(!self.enabled)
500 })
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::icons::Icons;
508 use frus_core::Primitive;
509
510 #[derive(Clone, Debug, PartialEq)]
511 enum Msg {
512 Add,
513 }
514
515 fn painted(
516 fab: &FloatingActionButton<Msg>,
517 theme: &Theme,
518 status: Status,
519 ) -> (Vec<(Rect, Color, BorderRadius)>, usize) {
520 let mut scene = Scene::new();
521 let style = Widget::<Msg>::style_themed(fab, theme);
522 let (Dimension::Length(w), Dimension::Length(h)) = (style.width, style.height) else {
523 panic!("a measured box");
524 };
525 Widget::<Msg>::paint(fab, Rect::new(0.0, 0.0, w, h), status, theme, &mut scene);
526 let rects = scene
527 .primitives()
528 .iter()
529 .filter_map(|p| match p {
530 Primitive::Rect {
531 rect,
532 color,
533 radius,
534 ..
535 } => Some((*rect, *color, *radius)),
536 _ => None,
537 })
538 .collect();
539 let paths = scene
540 .primitives()
541 .iter()
542 .filter(|p| matches!(p, Primitive::Path { .. }))
543 .count();
544 (rects, paths)
545 }
546
547 #[test]
553 fn a_button_takes_the_container_roles_and_not_the_accent() {
554 let theme = Theme::default();
555 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
556 let (rects, paths) = painted(&fab, &theme, Status::default());
557 assert_eq!(
558 rects.last().expect("a surface").1,
559 theme.scheme.primary_container,
560 "the container, not the accent"
561 );
562 assert_ne!(
563 theme.scheme.primary_container, theme.primary,
564 "and they differ"
565 );
566 assert_eq!(paths, 1, "the glyph is drawn");
567 }
568
569 #[test]
574 fn the_four_sizes_carry_their_own_box_corner_and_glyph() {
575 let theme = Theme::default();
576 for (size, extent, radius, icon) in [
577 (FabSize::Small, 40.0, 12.0, 24.0),
578 (FabSize::Regular, 56.0, 16.0, 24.0),
579 (FabSize::Large, 96.0, 28.0, 36.0),
580 ] {
581 assert_eq!(size.extent(), extent);
582 assert_eq!(size.radius(), radius);
583 assert_eq!(size.icon_size(), icon);
584
585 let fab = FloatingActionButton::<Msg>::new(Icons::ADD).size(size);
586 let style = Widget::<Msg>::style_themed(&fab, &theme);
587 assert_eq!(style.width, Dimension::Length(extent));
588 assert_eq!(style.height, Dimension::Length(extent));
589 let (rects, _) = painted(&fab, &theme, Status::default());
590 assert_eq!(
591 rects.last().expect("a surface").2,
592 BorderRadius::uniform(radius),
593 "{size:?} takes its own corner"
594 );
595 }
596 }
597
598 #[test]
604 fn it_is_a_rounded_square_and_a_stadium_when_asked() {
605 let theme = Theme::default();
606 let plain = FloatingActionButton::<Msg>::new(Icons::ADD);
607 assert_eq!(
608 painted(&plain, &theme, Status::default())
609 .0
610 .last()
611 .unwrap()
612 .2,
613 BorderRadius::uniform(16.0)
614 );
615 let docked = FloatingActionButton::<Msg>::new(Icons::ADD).shape(ShapeBorder::stadium());
616 assert_eq!(
617 painted(&docked, &theme, Status::default())
618 .0
619 .last()
620 .unwrap()
621 .2,
622 BorderRadius::uniform(28.0),
623 "half the short side"
624 );
625 }
626
627 #[test]
631 fn an_extended_button_is_as_wide_as_what_it_carries() {
632 let theme = Theme::default();
633 let words = FloatingActionButton::<Msg>::extended("New list");
634 let with_icon = FloatingActionButton::<Msg>::extended("New list").icon(Icons::ADD);
635
636 let width = |fab: &FloatingActionButton<Msg>| match Widget::<Msg>::style_themed(fab, &theme)
637 .width
638 {
639 Dimension::Length(w) => w,
640 other => panic!("a measured width, not {other:?}"),
641 };
642 let text =
643 frus_text::measure_resolved("New list", &theme.text.label_large.resolved()).width;
644 assert_eq!(width(&words), EXTENDED_PAD + text + EXTENDED_PAD);
645 assert_eq!(
646 width(&with_icon),
647 EXTENDED_PAD_WITH_ICON + 24.0 + EXTENDED_GAP + text + EXTENDED_PAD,
648 "a glyph shortens the leading room, as the reference's does"
649 );
650 assert_eq!(
651 Widget::<Msg>::style_themed(&words, &theme).height,
652 Dimension::Length(56.0),
653 "and it is still a regular button's height"
654 );
655
656 let (_, paths) = painted(&with_icon, &theme, Status::default());
657 assert_eq!(paths, 1, "the glyph before the words");
658 assert_eq!(painted(&words, &theme, Status::default()).1, 0);
659 }
660
661 #[test]
666 fn a_disabled_button_stops_floating() {
667 let theme = Theme::default();
668 let live = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
669 let (rects, _) = painted(&live, &theme, Status::default());
670 assert_eq!(rects.len(), 2, "a shadow under the surface: {rects:#?}");
671
672 let dead = FloatingActionButton::new(Icons::ADD)
673 .on_press(Msg::Add)
674 .enabled(false);
675 let (rects, _) = painted(&dead, &theme, Status::default());
676 assert_eq!(rects.len(), 1, "the surface alone");
677 assert_eq!(rects[0].1, disabled_container(&theme));
678 assert_eq!(Widget::<Msg>::on_click(&dead), None);
679 assert!(!Widget::<Msg>::focusable(&dead));
680 assert!(Widget::<Msg>::ink(&dead, &theme).is_none());
681
682 let inert = FloatingActionButton::<Msg>::new(Icons::ADD);
685 let (rects, _) = painted(&inert, &theme, Status::default());
686 assert_eq!(rects.len(), 1);
687 assert_eq!(rects[0].1, theme.scheme.primary_container);
688 }
689
690 #[test]
693 fn it_rises_under_a_pointer() {
694 let theme = Theme::default();
695 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
696 let shadow_of = |hover: f32| {
697 let status = Status {
698 hover_progress: hover,
699 ..Default::default()
700 };
701 painted(&fab, &theme, status).0[0].0.width
702 };
703 assert!(
704 shadow_of(1.0) > shadow_of(0.0),
705 "a wider shadow is a higher button"
706 );
707 }
708
709 #[test]
711 fn a_theme_answers_for_every_button() {
712 let mut theme = Theme::default();
713 theme.widgets.fab.background = Some(Color::rgb(0.2, 0.4, 0.6));
714 theme.widgets.fab.foreground = Some(Color::rgb(0.9, 0.9, 0.9));
715 theme.widgets.fab.radius = Some(3.0);
716 theme.widgets.fab.icon_size = Some(11.0);
717
718 let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
719 let (rects, _) = painted(&fab, &theme, Status::default());
720 let surface = rects.last().unwrap();
721 assert_eq!(surface.1, Color::rgb(0.2, 0.4, 0.6));
722 assert_eq!(surface.2, BorderRadius::uniform(3.0));
723
724 let told = FloatingActionButton::new(Icons::ADD)
725 .on_press(Msg::Add)
726 .background(Color::rgb(0.1, 0.1, 0.1))
727 .radius(9.0);
728 let (rects, _) = painted(&told, &theme, Status::default());
729 let surface = rects.last().unwrap();
730 assert_eq!(surface.1, Color::rgb(0.1, 0.1, 0.1));
731 assert_eq!(surface.2, BorderRadius::uniform(9.0));
732 }
733
734 #[test]
739 fn a_character_sits_in_the_square_like_a_glyph() {
740 let theme = Theme::default();
741 let plus = FloatingActionButton::<Msg>::glyph("+");
742 let style = Widget::<Msg>::style_themed(&plus, &theme);
743 assert_eq!(
744 style.width,
745 Dimension::Length(56.0),
746 "a square, not a lozenge"
747 );
748 assert_eq!(style.height, Dimension::Length(56.0));
749
750 let docked = crate::fab_button::<Msg>("+", Msg::Add);
753 let (rects, _) = painted(&docked, &theme, Status::default());
754 assert_eq!(
755 rects.last().unwrap().2,
756 BorderRadius::uniform(28.0),
757 "round, for a round notch"
758 );
759 assert_eq!(
760 rects.last().unwrap().1,
761 theme.scheme.primary_container,
762 "and it takes the button's colours now, not a filled button's"
763 );
764 }
765
766 #[test]
772 fn an_extended_button_has_something_to_read_out() {
773 let words = FloatingActionButton::extended("New list").on_press(Msg::Add);
774 let semantics = Widget::<Msg>::semantics(&words).expect("a button");
775 assert_eq!(semantics.label.as_deref(), Some("New list"));
776 assert!(semantics.clickable);
777
778 let glyph = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
779 assert_eq!(Widget::<Msg>::semantics(&glyph).unwrap().label, None);
780 }
781
782 #[test]
789 fn the_hovered_height_is_resolved_on_its_own() {
790 let theme = Theme::default();
791 let hovered = Status {
792 hover_progress: 1.0,
793 ..Default::default()
794 };
795 let shadow = |fab: FloatingActionButton<Msg>, theme: &Theme, status: Status| {
798 let (rects, _) = painted(&fab.on_press(Msg::Add), theme, status);
799 (rects.len() == 2).then(|| rects[0].0.width)
800 };
801 let fab = || FloatingActionButton::new(Icons::ADD);
802 let eight = shadow(fab(), &theme, hovered).expect("a hovered button floats");
803
804 assert_eq!(
805 shadow(fab().elevation(0.0), &theme, Status::default()),
806 None
807 );
808 assert_eq!(
809 shadow(fab().elevation(0.0), &theme, hovered),
810 Some(eight),
811 "flat at rest still rises to eight, as the reference's does"
812 );
813 assert_eq!(
814 shadow(fab().elevation(0.0).hover_elevation(0.0), &theme, hovered),
815 None,
816 "and one told both stays down"
817 );
818 let twelve = shadow(fab().elevation(12.0), &theme, Status::default()).unwrap();
819 assert_eq!(
820 shadow(fab().elevation(12.0), &theme, hovered),
821 Some(eight),
822 "a hovered height is not the resting one's floor"
823 );
824 assert!(twelve > eight);
825
826 let mut themed = Theme::default();
827 themed.widgets.fab.hover_elevation = Some(0.0);
828 assert_eq!(shadow(fab(), &themed, hovered), None, "the theme's word");
829 assert_eq!(
830 shadow(fab().hover_elevation(8.0), &themed, hovered),
831 Some(eight),
832 "and the caller's over it"
833 );
834 }
835}