Skip to main content

rosace_widgets/tree/
fab.rs

1//! `FloatingActionButton` (D115/Phase 32 Step 1) — the circular primary
2//! action button, made for `Scaffold::fab` (which already positions its
3//! slot bottom-trailing above the bottom bar).
4//!
5//! Fully themeable per the Phase 32 customization sweep: color, shape
6//! (circle by default, any radius via `.radius()`), size, elevation
7//! shadow — all D094 builders with live-theme defaults.
8
9use std::sync::Arc;
10
11use rosace_core::types::{Point, Rect, Size};
12use rosace_layout::Constraints;
13use rosace_render::Color;
14
15use super::button::lighten;
16use super::container::draw_rounded_rect_pub;
17use super::{LayoutCtx, PaintCtx, Widget};
18
19/// A floating action button. Content is an icon widget, a text label, or
20/// the default "+" glyph.
21pub struct FloatingActionButton {
22    icon: Option<super::BoxedWidget>,
23    label: Option<String>,
24    size: f32,
25    background: Option<Color>,
26    foreground: Option<Color>,
27    /// `None` = a perfect circle (`size / 2`); any explicit value makes a
28    /// rounded square (Material's "large FAB" look at ~16).
29    radius: Option<f32>,
30    /// Shadow strength; `0.0` disables it.
31    elevation: f32,
32    disabled: bool,
33    on_press: Option<Arc<dyn Fn() + Send + Sync>>,
34}
35
36impl FloatingActionButton {
37    pub fn new() -> Self {
38        Self {
39            icon: None,
40            label: None,
41            size: 56.0,
42            background: None,
43            foreground: None,
44            radius: None,
45            elevation: 1.0,
46            disabled: false,
47            on_press: None,
48        }
49    }
50    /// Icon widget centered in the button (usually [`super::Icon`]).
51    pub fn icon(mut self, w: impl Widget + 'static) -> Self {
52        self.icon = Some(Box::new(w));
53        self
54    }
55    /// Text content instead of an icon (e.g. "+" or a short label).
56    pub fn label(mut self, s: impl Into<String>) -> Self {
57        self.label = Some(s.into());
58        self
59    }
60    pub fn size(mut self, s: f32) -> Self { self.size = s; self }
61    /// Fill — defaults to the theme's `primary`.
62    pub fn background(mut self, c: Color) -> Self { self.background = Some(c); self }
63    /// Content tint — defaults to the theme's `on_primary`.
64    pub fn color(mut self, c: Color) -> Self { self.foreground = Some(c); self }
65    /// Rounded-square shape instead of the default circle.
66    pub fn radius(mut self, r: f32) -> Self { self.radius = Some(r); self }
67    pub fn elevation(mut self, e: f32) -> Self { self.elevation = e; self }
68    pub fn disabled(mut self, d: bool) -> Self { self.disabled = d; self }
69    pub fn on_press(mut self, f: impl Fn() + Send + Sync + 'static) -> Self {
70        self.on_press = Some(Arc::new(f));
71        self
72    }
73}
74
75impl Default for FloatingActionButton {
76    fn default() -> Self { Self::new() }
77}
78
79impl Widget for FloatingActionButton {
80    fn layout(&self, _ctx: &LayoutCtx) -> Size {
81        Size { width: self.size, height: self.size }
82    }
83
84    fn paint(&self, ctx: &mut PaintCtx) {
85        // Hoisted theme reads (the borrow must end before mutable painting).
86        let (bg, fg, shadow) = {
87            let t = &ctx.theme.colors;
88            (
89                self.background.unwrap_or_else(|| ctx.tc(t.primary)),
90                self.foreground.unwrap_or_else(|| ctx.tc(t.on_primary)),
91                ctx.tc(t.shadow),
92            )
93        };
94        let radius = self.radius.unwrap_or(self.size / 2.0);
95        let r = ctx.rect;
96
97        let sem_label = self.label.clone().unwrap_or_else(|| "action".to_string());
98        ctx.semantics(super::Semantics::new(rosace_core::Role::Button).label(&sem_label));
99
100        // Real blurred drop shadow (`ctx.fill_shadow_rrect`, same Gaussian
101        // primitive `Container`'s `.elevation()`/`.shadow()` use) — this
102        // used to be a single flat, hard-edged rect offset below the
103        // button, which reads as a solid gray blob rather than a soft
104        // shadow, especially at the FAB's small 40px default size.
105        if self.elevation > 0.0 && !self.disabled {
106            ctx.fill_shadow_rrect(
107                r,
108                radius,
109                Color::rgba(shadow.r, shadow.g, shadow.b, 90),
110                3.0 * self.elevation,
111            );
112        }
113
114        // Hover/press lift, the Button convention (D108 Step 1).
115        let target = if self.disabled { 0.0 } else if ctx.pressed() { 1.0 } else if ctx.hovered() { 0.5 } else { 0.0 };
116        let emphasis = ctx.animate_to(target, 0.0);
117        let bg = if self.disabled {
118            Color::rgba(bg.r, bg.g, bg.b, 110)
119        } else if emphasis > 0.0 {
120            lighten(bg, (0.12 * emphasis * 2.0).min(1.0))
121        } else {
122            bg
123        };
124        draw_rounded_rect_pub(ctx, r, bg, radius);
125
126        if let Some(icon) = &self.icon {
127            let inner = self.size * 0.45;
128            let is = icon.layout(&ctx.layout_ctx(Constraints::loose(inner, inner)));
129            icon.paint(&mut ctx.child(Rect {
130                origin: Point {
131                    x: r.origin.x + (r.size.width - is.width) / 2.0,
132                    y: r.origin.y + (r.size.height - is.height) / 2.0,
133                },
134                size: is,
135            }));
136        } else {
137            let text = self.label.as_deref().unwrap_or("+");
138            let px = self.size * 0.4;
139            let tw = ctx.font.measure_text(text, px);
140            let lh = ctx.font.line_height(px);
141            ctx.draw_text_at(
142                text,
143                Point {
144                    x: r.origin.x + (r.size.width - tw) / 2.0,
145                    y: r.origin.y + (r.size.height - lh) / 2.0,
146                },
147                fg,
148                px,
149            );
150        }
151
152        if let Some(cb) = &self.on_press {
153            if !self.disabled {
154                ctx.register_hit(Arc::clone(cb));
155            }
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn fab_is_square_at_its_configured_size() {
166        let fab = FloatingActionButton::new().size(64.0);
167        let font = rosace_render::FontCache::embedded();
168        let theme = rosace_theme::built_in::dark_theme();
169        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
170        let size = fab.layout(&ctx);
171        assert_eq!((size.width, size.height), (64.0, 64.0));
172    }
173
174    #[test]
175    fn default_size_is_the_material_convention() {
176        let fab = FloatingActionButton::new();
177        let font = rosace_render::FontCache::embedded();
178        let theme = rosace_theme::built_in::dark_theme();
179        let ctx = LayoutCtx::new(Constraints::loose(400.0, 400.0), &font, &theme);
180        assert_eq!(fab.layout(&ctx).width, 56.0);
181    }
182}