1use super::{
9 ButtonIconPosition, FabSize, InteractionPreview, MaterialButton,
10 button::{Form, Palette, Roles},
11};
12use crate::{
13 color::ColorRole,
14 icons::Icon,
15 motion::{self, MotionToken},
16 tokens::typography::{BASE_PX, TypeRole, rem},
17};
18use gpui::{prelude::*, *};
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
22pub enum FabColor {
23 Surface,
25 #[default]
26 PrimaryContainer,
27 SecondaryContainer,
28 TertiaryContainer,
29 Primary,
30 Secondary,
31 Tertiary,
32}
33
34impl FabColor {
35 pub const ALL: [Self; 7] = [
36 Self::Surface,
37 Self::PrimaryContainer,
38 Self::SecondaryContainer,
39 Self::TertiaryContainer,
40 Self::Primary,
41 Self::Secondary,
42 Self::Tertiary,
43 ];
44 pub const fn name(self) -> &'static str {
45 match self {
46 Self::Surface => "surface",
47 Self::PrimaryContainer => "primary container",
48 Self::SecondaryContainer => "secondary container",
49 Self::TertiaryContainer => "tertiary container",
50 Self::Primary => "primary",
51 Self::Secondary => "secondary",
52 Self::Tertiary => "tertiary",
53 }
54 }
55 pub const fn roles(self) -> (ColorRole, ColorRole) {
57 use ColorRole as C;
58 match self {
59 Self::Surface => (C::SURFACE_CONTAINER_HIGH, C::PRIMARY),
60 Self::PrimaryContainer => (C::PRIMARY_CONTAINER, C::ON_PRIMARY_CONTAINER),
61 Self::SecondaryContainer => (C::SECONDARY_CONTAINER, C::ON_SECONDARY_CONTAINER),
62 Self::TertiaryContainer => (C::TERTIARY_CONTAINER, C::ON_TERTIARY_CONTAINER),
63 Self::Primary => (C::PRIMARY, C::ON_PRIMARY),
64 Self::Secondary => (C::SECONDARY, C::ON_SECONDARY),
65 Self::Tertiary => (C::TERTIARY, C::ON_TERTIARY),
66 }
67 }
68 pub(crate) const fn palette(self, elevation: (u8, u8)) -> Palette {
69 let (container, content) = self.roles();
70 Palette {
71 rest: Roles {
72 container: Some(container),
73 content,
74 },
75 selected: None,
76 disabled_container: true,
77 elevation,
78 }
79 }
80}
81
82pub(crate) const FAB_ELEVATION: (u8, u8) = (3, 4);
84pub(crate) const LOWERED_ELEVATION: (u8, u8) = (1, 2);
86
87pub(crate) const fn fab_elevation(lowered: bool) -> (u8, u8) {
89 if lowered {
90 LOWERED_ELEVATION
91 } else {
92 FAB_ELEVATION
93 }
94}
95
96type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
97
98macro_rules! fab_builders {
99 () => {
100 pub fn size(mut self, size: FabSize) -> Self {
101 self.size = size;
102 self
103 }
104 pub fn color(mut self, color: FabColor) -> Self {
105 self.color = color;
106 self
107 }
108 pub fn lowered(mut self, lowered: bool) -> Self {
111 self.lowered = lowered;
112 self
113 }
114 pub fn disabled(mut self, disabled: bool) -> Self {
117 self.disabled = disabled;
118 self
119 }
120 pub fn preview_state(mut self, state: InteractionPreview) -> Self {
122 self.preview = Some(state);
123 self
124 }
125 pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
126 self.focus = Some(focus.clone());
127 self
128 }
129 pub fn on_click(
130 mut self,
131 callback: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
132 ) -> Self {
133 self.on_click = Some(Box::new(callback));
134 self
135 }
136 };
137}
138
139#[derive(gpui::IntoElement)]
141pub struct Fab {
142 id: ElementId,
143 icon: Icon,
144 label: SharedString,
145 size: FabSize,
146 color: FabColor,
147 lowered: bool,
148 disabled: bool,
149 preview: Option<InteractionPreview>,
150 focus: Option<FocusHandle>,
151 on_click: Option<ClickHandler>,
152}
153
154impl Fab {
155 pub fn new(
156 id: impl Into<ElementId>,
157 icon: impl Into<Icon>,
158 label: impl Into<SharedString>,
159 ) -> Self {
160 Self {
161 id: id.into(),
162 icon: icon.into(),
163 label: label.into(),
164 size: FabSize::Regular,
165 color: FabColor::PrimaryContainer,
166 lowered: false,
167 disabled: false,
168 preview: None,
169 focus: None,
170 on_click: None,
171 }
172 }
173 fab_builders!();
174}
175
176fn finish(
177 mut button: MaterialButton,
178 preview: Option<InteractionPreview>,
179 focus: Option<FocusHandle>,
180 on_click: Option<ClickHandler>,
181) -> MaterialButton {
182 if let Some(preview) = preview {
183 button = button.preview_state(preview);
184 }
185 if let Some(focus) = &focus {
186 button = button.track_focus(focus);
187 }
188 if let Some(callback) = on_click {
189 button = button.on_click(callback);
190 }
191 button
192}
193
194impl RenderOnce for Fab {
195 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
196 finish(
197 MaterialButton::new(self.id)
198 .form(Form::Fab(self.size))
199 .palette(self.color.palette(fab_elevation(self.lowered)))
200 .disabled(self.disabled)
201 .icon(self.icon)
202 .accessibility_label(self.label),
203 self.preview,
204 self.focus,
205 self.on_click,
206 )
207 }
208}
209
210#[derive(gpui::IntoElement)]
216pub struct ExtendedFab {
217 id: ElementId,
218 icon: Option<Icon>,
219 icon_position: ButtonIconPosition,
220 label: SharedString,
221 size: FabSize,
222 color: FabColor,
223 lowered: bool,
224 disabled: bool,
225 fluid: bool,
226 expanded: bool,
227 preview: Option<InteractionPreview>,
228 focus: Option<FocusHandle>,
229 on_click: Option<ClickHandler>,
230}
231
232impl ExtendedFab {
233 pub fn new(
234 id: impl Into<ElementId>,
235 icon: impl Into<Icon>,
236 label: impl Into<SharedString>,
237 ) -> Self {
238 Self {
239 icon: Some(icon.into()),
240 ..Self::label_only(id, label)
241 }
242 }
243 pub fn label_only(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
245 Self {
246 id: id.into(),
247 icon: None,
248 icon_position: ButtonIconPosition::Leading,
249 label: label.into(),
250 size: FabSize::Regular,
251 color: FabColor::PrimaryContainer,
252 lowered: false,
253 disabled: false,
254 fluid: false,
255 expanded: true,
256 preview: None,
257 focus: None,
258 on_click: None,
259 }
260 }
261 pub fn expanded(mut self, expanded: bool) -> Self {
262 self.expanded = expanded;
263 self
264 }
265 pub fn icon_position(mut self, position: ButtonIconPosition) -> Self {
267 self.icon_position = position;
268 self
269 }
270 pub fn fluid(mut self, fluid: bool) -> Self {
273 self.fluid = fluid;
274 self
275 }
276 fab_builders!();
277
278 fn target_expansion(&self) -> f32 {
280 if self.expanded || self.icon.is_none() {
281 1.
282 } else {
283 0.
284 }
285 }
286}
287
288fn label_width(label: &SharedString, role: TypeRole, window: &Window) -> f32 {
290 let scale = role.scale();
291 let font_size = scale.font_size.to_pixels(window.rem_size());
292 let run = TextRun {
293 len: label.len(),
294 font: Font {
295 weight: FontWeight(scale.font_weight as f32),
296 ..font(scale.font_family)
297 },
298 color: Hsla::default(),
299 background_color: None,
300 underline: None,
301 strikethrough: None,
302 };
303 let width = window
304 .text_system()
305 .shape_line(label.clone(), font_size, &[run], None)
306 .width;
307 f32::from(width) * BASE_PX / f32::from(window.rem_size())
308}
309
310pub(super) fn revealed_label(
313 label: &SharedString,
314 role: TypeRole,
315 expansion: f32,
316 window: &Window,
317) -> Option<AnyElement> {
318 (expansion > 0.).then(|| {
319 let text = div().whitespace_nowrap().child(label.clone());
320 if expansion >= 1. {
321 text.into_any_element()
322 } else {
323 let width = label_width(label, role, window) * expansion;
324 div()
325 .overflow_hidden()
326 .flex_shrink_0()
327 .w(rem(width))
328 .h(role.scale().line_height)
329 .opacity(expansion.clamp(0., 1.))
330 .child(text)
331 .into_any_element()
332 }
333 })
334}
335
336impl RenderOnce for ExtendedFab {
337 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
338 let expansion = motion::token_value(
339 (self.id.clone(), "fab-expansion"),
340 self.target_expansion(),
341 MotionToken::DefaultSpatial,
342 window,
343 cx,
344 );
345 let (_, _, _, role) = self.size.extended_size().extended();
346 let label = revealed_label(&self.label, role, expansion, window);
347 finish(
348 MaterialButton::new(self.id)
349 .form(Form::ExtendedFab {
350 size: self.size,
351 expansion,
352 })
353 .palette(self.color.palette(fab_elevation(self.lowered)))
354 .disabled(self.disabled)
355 .when_some(self.icon, |button, icon| button.icon(icon))
356 .icon_position(self.icon_position)
357 .accessibility_label(self.label)
358 .children(label)
359 .when(self.fluid, |button| button.w_full()),
360 self.preview,
361 self.focus,
362 self.on_click,
363 )
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::{ExtendedFab, FAB_ELEVATION, Fab, FabColor, FabSize, fab_elevation};
370 use crate::components::ButtonIconPosition;
371 use crate::{color::ColorRole, icons::IconName, motion};
372 use gpui::{
373 Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Styled as _,
374 TestAppContext, VisualTestContext, Window, div, px, size,
375 };
376 use std::time::Duration;
377
378 struct Harness {
379 expanded: bool,
380 }
381 impl Render for Harness {
382 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
383 window.set_rem_size(px(14.));
384 div()
385 .flex()
386 .flex_col()
387 .items_start()
388 .p(px(40.))
389 .gap(px(20.))
390 .child(
391 div()
392 .flex()
393 .debug_selector(|| "fab".into())
394 .child(Fab::new("fab", IconName::Check, "Compose").size(FabSize::Large)),
395 )
396 .child(
397 div().flex().debug_selector(|| "extended".into()).child(
398 ExtendedFab::new("extended", IconName::Check, "Compose")
399 .size(FabSize::Medium)
400 .expanded(self.expanded),
401 ),
402 )
403 }
404 }
405
406 fn draw(cx: &mut VisualTestContext) {
407 cx.update(|window, cx| {
408 window.refresh();
409 window.draw(cx).clear(cx);
410 });
411 }
412
413 #[gpui::test]
414 fn extended_fab_collapses_to_a_square_through_intermediate_widths(cx: &mut TestAppContext) {
415 cx.update(|cx| {
416 gpui_base::init(cx);
417 cx.set_reduce_motion(false);
418 motion::set_motion_scheme(motion::MotionScheme::Expressive);
419 });
420 let (view, cx) = cx.add_window_view(|_, _| Harness { expanded: true });
421 draw(cx);
422 let fab = cx.debug_bounds("fab").unwrap();
423 assert_eq!(fab.size, size(px(96.), px(96.)));
424 let extended = cx.debug_bounds("extended").unwrap();
425 assert_eq!(extended.size.height, px(80.));
426 assert!(extended.size.width > px(140.), "{extended:?}");
427 view.update(cx, |view, cx| {
428 view.expanded = false;
429 cx.notify();
430 });
431 draw(cx);
432 cx.executor().advance_clock(Duration::from_millis(80));
433 draw(cx);
434 let moving = cx.debug_bounds("extended").unwrap().size.width;
435 assert!(
436 moving < extended.size.width && moving > px(80.),
437 "{moving:?}"
438 );
439 cx.executor().advance_clock(Duration::from_secs(2));
440 draw(cx);
441 draw(cx);
442 let collapsed = cx.debug_bounds("extended").unwrap().size;
443 assert_eq!(collapsed, size(px(80.), px(80.)));
444 }
445
446 #[core::prelude::v1::test]
447 fn color_styles_pair_containers_with_their_content() {
448 assert_eq!(
449 FabColor::TertiaryContainer.roles(),
450 (
451 ColorRole::TERTIARY_CONTAINER,
452 ColorRole::ON_TERTIARY_CONTAINER
453 )
454 );
455 assert_eq!(
456 FabColor::Surface.roles(),
457 (ColorRole::SURFACE_CONTAINER_HIGH, ColorRole::PRIMARY)
458 );
459 assert_eq!(FabColor::Primary.palette(FAB_ELEVATION).elevation, (3, 4));
460 }
461
462 #[core::prelude::v1::test]
463 fn lowered_fabs_float_two_levels_lower() {
464 assert_eq!(fab_elevation(false), (3, 4));
465 assert_eq!(fab_elevation(true), (1, 2));
466 let fab = Fab::new("fab", IconName::Check, "Add").lowered(true);
467 assert!(fab.lowered);
468 }
469
470 #[core::prelude::v1::test]
471 fn only_a_fab_with_an_icon_collapses() {
472 let collapsed = ExtendedFab::new("fab", IconName::Check, "Compose").expanded(false);
473 assert_eq!(collapsed.target_expansion(), 0.);
474 let label_only = ExtendedFab::label_only("fab", "Compose").expanded(false);
475 assert_eq!(label_only.target_expansion(), 1.);
476 }
477
478 struct Variants {
479 clicks: std::rc::Rc<std::cell::Cell<usize>>,
480 }
481 impl Render for Variants {
482 fn render(&mut self, window: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
483 window.set_rem_size(px(14.));
484 let clicks = self.clicks.clone();
485 div()
486 .w(px(400.))
487 .flex()
488 .flex_col()
489 .items_start()
490 .gap(px(20.))
491 .child(
492 div().flex().debug_selector(|| "end".into()).child(
493 ExtendedFab::new("end", IconName::Check, "Compose")
494 .icon_position(ButtonIconPosition::Trailing),
495 ),
496 )
497 .child(
498 div()
499 .flex()
500 .debug_selector(|| "label-only".into())
501 .child(ExtendedFab::label_only("label-only", "Compose").expanded(false)),
502 )
503 .child(
504 div()
505 .w_full()
506 .flex()
507 .debug_selector(|| "fluid".into())
508 .child(ExtendedFab::new("fluid", IconName::Check, "Compose").fluid(true)),
509 )
510 .child(
511 div().flex().debug_selector(|| "disabled".into()).child(
512 Fab::new("disabled", IconName::Check, "Add")
513 .disabled(true)
514 .on_click(move |_, _, _| clicks.set(clicks.get() + 1)),
515 ),
516 )
517 }
518 }
519
520 #[gpui::test]
521 fn extended_fab_options_lay_out_and_disabled_fabs_ignore_clicks(cx: &mut TestAppContext) {
522 cx.update(|cx| {
523 gpui_base::init(cx);
524 cx.set_reduce_motion(true);
525 });
526 let clicks = std::rc::Rc::new(std::cell::Cell::new(0));
527 let (_, cx) = cx.add_window_view({
528 let clicks = clicks.clone();
529 move |_, _| Variants { clicks }
530 });
531 draw(cx);
532 draw(cx);
533 let label_only = cx.debug_bounds("label-only").unwrap().size;
534 assert_eq!(label_only.height, px(56.));
535 assert!(
536 label_only.width > px(56.),
537 "a label-only FAB stays extended"
538 );
539 assert_eq!(cx.debug_bounds("fluid").unwrap().size.width, px(400.));
540 let end = cx.debug_bounds("end").unwrap().size;
541 assert!(end.width > px(100.));
542 let disabled = cx.debug_bounds("disabled").unwrap();
543 cx.simulate_click(disabled.center(), gpui::Modifiers::none());
544 draw(cx);
545 assert_eq!(clicks.get(), 0);
546 }
547}