1use iced::widget::{self, button::Status, container, row, text};
28use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow, Vector};
29
30use crate::after_touch::after_touch;
31use crate::icon::{Glyph, opacity, themed};
32use crate::natural::natural;
33use crate::overlay::tooltip::{Position, tooltip};
34use crate::primitives::button::{self, Variant};
35use crate::theme::{Tokens, fade, text_size};
36
37pub const SIZE: f32 = 56.0;
39pub const RADIUS: f32 = 16.0;
41pub const ICON: f32 = 24.0;
43pub const PADDING_X: f32 = 16.0;
45
46#[derive(Debug)]
50pub struct Fab<'a, Message> {
51 glyph: Glyph,
52 label: Option<text::Fragment<'a>>,
53 tooltip: Option<text::Fragment<'a>>,
54 id: Option<widget::Id>,
55 on_press: Option<Message>,
56}
57
58pub fn fab<'a, Message>(glyph: Glyph) -> Fab<'a, Message> {
60 Fab {
61 glyph,
62 label: None,
63 tooltip: None,
64 id: None,
65 on_press: None,
66 }
67}
68
69impl<'a, Message> Fab<'a, Message> {
70 pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
72 self.label = Some(label.into_fragment());
73 self
74 }
75
76 pub fn tooltip(mut self, tooltip: impl text::IntoFragment<'a>) -> Self {
80 self.tooltip = Some(tooltip.into_fragment());
81 self
82 }
83
84 pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
86 self.id = Some(id.into());
87 self
88 }
89
90 pub fn on_press(mut self, message: Message) -> Self {
91 self.on_press = Some(message);
92 self
93 }
94
95 pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
96 self.on_press = message;
97 self
98 }
99
100 pub fn is_enabled(&self) -> bool {
101 self.on_press.is_some()
102 }
103
104 pub fn is_extended(&self) -> bool {
105 self.label.is_some()
106 }
107}
108
109impl<'a, Message: Clone + 'a> From<Fab<'a, Message>> for Element<'a, Message> {
110 fn from(fab: Fab<'a, Message>) -> Self {
111 let Fab {
112 glyph,
113 label,
114 tooltip: hint,
115 id,
116 on_press,
117 } = fab;
118 let enabled = on_press.is_some();
119 let resting = if enabled {
120 Status::Active
121 } else {
122 Status::Disabled
123 };
124 let icon = themed(glyph, ICON, opacity(enabled), move |theme| {
125 style(&Tokens::of(theme), resting).text_color
126 });
127
128 let extended = label.is_some();
129 let content: Element<'a, Message> = match label {
130 Some(label) => row![
131 icon,
132 text(label)
133 .size(text_size::SM)
134 .font(crate::theme::semibold())
135 .wrapping(text::Wrapping::None)
136 ]
137 .spacing(12)
138 .align_y(Alignment::Center)
139 .into(),
140 None => icon.into(),
141 };
142
143 let (width, padding) = if extended {
144 (Length::Shrink, Padding::from([0.0, PADDING_X]))
145 } else {
146 (Length::Fixed(SIZE), Padding::from((SIZE - ICON) / 2.0))
147 };
148
149 let button = widget::button(
150 container(content)
151 .height(Length::Fill)
152 .align_y(Alignment::Center)
153 .align_x(Alignment::Center),
154 )
155 .width(width)
156 .height(Length::Fixed(SIZE))
157 .padding(padding)
158 .on_press_maybe(on_press)
159 .style(|theme, status| style(&Tokens::of(theme), status));
160
161 let mut element: Element<'a, Message> = match id {
162 Some(id) => container(after_touch(button)).id(id).into(),
163 None => after_touch(button),
164 };
165 if let (Some(hint), false) = (hint, extended) {
166 element = tooltip(element, hint).position(Position::Left).into();
167 }
168 natural(element)
169 }
170}
171
172pub fn shadow(tokens: &Tokens, status: Status) -> Shadow {
175 let alpha = if tokens.is_dark { 0.5 } else { 0.18 };
176 let (offset, blur) = match status {
177 Status::Hovered => (6.0, 16.0),
178 Status::Pressed => (2.0, 6.0),
179 Status::Active | Status::Disabled => (4.0, 12.0),
180 };
181 Shadow {
182 color: fade(Color::BLACK, alpha),
183 offset: Vector::new(0.0, offset),
184 blur_radius: blur,
185 }
186}
187
188pub fn style(tokens: &Tokens, status: Status) -> widget::button::Style {
191 let colours = button::colours(tokens, Variant::Primary, status);
192 widget::button::Style {
193 background: colours.background.map(Background::Color),
194 text_color: colours.foreground,
195 border: Border {
196 color: Color::TRANSPARENT,
197 width: 0.0,
198 radius: RADIUS.into(),
199 },
200 shadow: if status == Status::Disabled {
201 Shadow::default()
202 } else {
203 shadow(tokens, status)
204 },
205 snap: true,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::theme::{dark, light};
213
214 const STATES: [Status; 4] = [
215 Status::Active,
216 Status::Hovered,
217 Status::Pressed,
218 Status::Disabled,
219 ];
220
221 #[test]
222 fn builder_defaults() {
223 let f: Fab<'_, u8> = fab(crate::lucide!(Plus));
224 assert!(!f.is_enabled());
225 assert!(!f.is_extended());
226 assert!(fab::<u8>(crate::lucide!(Plus)).label("New").is_extended());
227 assert!(fab::<u8>(crate::lucide!(Plus)).on_press(1).is_enabled());
228 assert!(
229 !fab::<u8>(crate::lucide!(Plus))
230 .on_press(1)
231 .on_press_maybe(None)
232 .is_enabled()
233 );
234 }
235
236 #[test]
237 fn colours_are_the_primary_button_in_every_state() {
238 for theme in [light(), dark()] {
239 let tokens = Tokens::of(&theme);
240 for status in STATES {
241 let style = style(&tokens, status);
242 let primary = button::colours(&tokens, Variant::Primary, status);
243 assert_eq!(style.background, primary.background.map(Background::Color));
244 assert_eq!(style.text_color, primary.foreground);
245 assert_eq!(style.border.radius, RADIUS.into());
246 }
247 }
248 }
249
250 #[test]
251 fn hovered_and_pressed_differ_from_rest() {
252 let tokens = Tokens::of(&light());
253 let rest = style(&tokens, Status::Active);
254 let hovered = style(&tokens, Status::Hovered);
255 let pressed = style(&tokens, Status::Pressed);
256 assert_ne!(rest.background, hovered.background);
257 assert_ne!(hovered.background, pressed.background);
258 assert!(hovered.shadow.blur_radius > rest.shadow.blur_radius);
259 assert!(pressed.shadow.blur_radius < rest.shadow.blur_radius);
260 }
261
262 #[test]
263 fn only_a_disabled_fab_is_flat_and_dark_shadows_are_stronger() {
264 let light = Tokens::of(&light());
265 let dark = Tokens::of(&dark());
266 assert_eq!(style(&light, Status::Disabled).shadow, Shadow::default());
267 assert!(style(&light, Status::Active).shadow.color.a > 0.0);
268 assert!(
269 style(&dark, Status::Active).shadow.color.a
270 > style(&light, Status::Active).shadow.color.a
271 );
272 }
273}