Skip to main content

gpui_kit_material/components/
toolbar.rs

1//! M3 Expressive toolbars: docked and floating, standard and vibrant,
2//! horizontal and vertical, optionally paired with a FAB.
3//!
4//! - [`Toolbar`] is the container. A floating toolbar is a 64dp
5//!   full-radius pill on surface-container (standard) or primary-container
6//!   (vibrant) at elevation level 3; a docked toolbar is a 64dp full-width
7//!   band with 16dp side padding and its items spread across it. Items are
8//!   caller elements, usually [`ToolbarButton`]s.
9//! - [`Toolbar::visible`] hides and shows the toolbar on the default spatial
10//!   spring, the M3 scroll behavior: a floating toolbar slides off its edge
11//!   (a paired FAB stays), a docked one collapses its height. Spatial motion
12//!   completes at once under reduced motion.
13//! - [`ToolbarButton`] is the kit's toolbar building block: a 40dp icon or
14//!   label button in a 48dp target, optionally toggleable, painted in the
15//!   toolbar's color scheme. Pressing morphs its corners from full to 8dp on
16//!   the fast spatial spring; selection cross-fades container and ink on the
17//!   fast effects spring and swaps to the filled icon.
18//!
19//! Behavior is GPUI Base's: the container is a Base [`gpui_base::Toolbar`]
20//! (toolbar role, Left/Right roving focus over any focusable item), and each
21//! button is a Base [`gpui_base::Button`] (button role, pointer, Enter and
22//! Space activation, pressed state for toggles).
23//!
24//! A vertical toolbar sets Base's axis, so Up/Down rove focus and assistive
25//! technology hears a vertical orientation.
26use super::InteractionPreview;
27use super::button::{ButtonFocusRing, ButtonRipple};
28use super::navigation::{NavIcon, ripple_corners, type_role};
29use crate::color::ColorRole;
30use crate::icons::Icon;
31use crate::motion::{self, MotionToken};
32use crate::theme::{blend, color};
33use crate::tokens::{
34    elevation, state,
35    typography::{TypeRole, rem},
36};
37use gpui::{prelude::FluentBuilder as _, *};
38use std::rc::Rc;
39
40/// Toolbar container height (horizontal) or width (vertical).
41pub const TOOLBAR_SIZE: f32 = 64.;
42/// Space between a floating toolbar and the edge it hides past.
43pub const TOOLBAR_MARGIN: f32 = 16.;
44/// Space between a floating toolbar and its paired FAB.
45pub const TOOLBAR_FAB_GAP: f32 = 8.;
46
47/// Item target, container (pill) height and pressed corner radius.
48const TARGET: f32 = 48.;
49const PILL: f32 = 40.;
50const PRESSED_RADIUS: f32 = 8.;
51const ICON: f32 = 24.;
52const LABEL_ICON: f32 = 20.;
53const LABEL_PADDING: f32 = 16.;
54const LABEL_GAP: f32 = 8.;
55/// Floating: gap between items and padding inside the pill.
56const FLOATING_GAP: f32 = 4.;
57const FLOATING_PADDING: f32 = 8.;
58/// Docked: side padding and minimum gap between items.
59const DOCKED_PADDING: f32 = 16.;
60const DOCKED_GAP: f32 = 8.;
61
62/// Container color scheme.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum ToolbarColor {
65    /// Surface container with on-surface-variant items.
66    #[default]
67    Standard,
68    /// Primary container with on-primary-container items.
69    Vibrant,
70}
71
72impl ToolbarColor {
73    pub const ALL: [Self; 2] = [Self::Standard, Self::Vibrant];
74    pub const fn name(self) -> &'static str {
75        match self {
76            Self::Standard => "Standard",
77            Self::Vibrant => "Vibrant",
78        }
79    }
80    /// The container role.
81    pub const fn container(self) -> ColorRole {
82        match self {
83            Self::Standard => ColorRole::SURFACE_CONTAINER,
84            Self::Vibrant => ColorRole::PRIMARY_CONTAINER,
85        }
86    }
87}
88
89/// Where the toolbar sits.
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum ToolbarConfiguration {
92    /// A pill floating over content, elevated.
93    #[default]
94    Floating,
95    /// A full-width band at the bottom of the window.
96    Docked,
97}
98
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum ToolbarOrientation {
101    #[default]
102    Horizontal,
103    /// Floating only: a column on the leading or trailing edge.
104    Vertical,
105}
106
107/// How far a hidden floating toolbar travels: its own size and its margin,
108/// down past the bottom edge (horizontal) or across the trailing edge
109/// (vertical).
110pub(crate) const EXIT_OFFSET: f32 = TOOLBAR_SIZE + TOOLBAR_MARGIN;
111
112/// The paint of one toolbar button for the current motion frame.
113#[derive(Clone, Copy, Debug, PartialEq)]
114pub(crate) struct ButtonPaint {
115    pub container: Rgba,
116    pub ink: Rgba,
117    pub layer: Rgba,
118}
119
120/// `fade` is the fast-effects selection amount (0 unselected, 1 selected).
121/// A label button has a tonal container at rest; an icon button none.
122pub(crate) fn button_paint(
123    scheme: ToolbarColor,
124    label: bool,
125    fade: f32,
126    disabled: bool,
127) -> ButtonPaint {
128    let on_surface = color(ColorRole::ON_SURFACE);
129    if disabled {
130        return ButtonPaint {
131            container: on_surface.opacity(DISABLED_CONTAINER),
132            ink: state::disabled(on_surface),
133            layer: Rgba::default(),
134        };
135    }
136    let fade = fade.clamp(0., 1.);
137    let (rest_container, rest_ink, on_container, on_ink) = match scheme {
138        ToolbarColor::Standard => (
139            color(ColorRole::SURFACE_CONTAINER),
140            color(ColorRole::ON_SURFACE_VARIANT),
141            color(ColorRole::SECONDARY_CONTAINER),
142            color(ColorRole::ON_SECONDARY_CONTAINER),
143        ),
144        ToolbarColor::Vibrant => (
145            color(ColorRole::PRIMARY_CONTAINER),
146            color(ColorRole::ON_PRIMARY_CONTAINER),
147            color(ColorRole::SURFACE_CONTAINER),
148            on_surface,
149        ),
150    };
151    let rest_container = if label {
152        rest_container
153    } else {
154        rest_container.opacity(0.)
155    };
156    let container = if label {
157        blend(rest_container, on_container, fade)
158    } else {
159        on_container.opacity(fade)
160    };
161    let ink = blend(rest_ink, on_ink, fade);
162    ButtonPaint {
163        container,
164        ink,
165        layer: ink,
166    }
167}
168
169/// The kit's disabled toolbar-button container: on-surface at 10%.
170const DISABLED_CONTAINER: f32 = 0.10;
171
172type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
173
174#[cfg(test)]
175thread_local! {
176    pub(crate) static PAINTED: std::cell::RefCell<std::collections::BTreeMap<String, (ButtonPaint, f32)>> =
177        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
178}
179
180/// A toolbar's icon or label button.
181#[derive(IntoElement)]
182pub struct ToolbarButton {
183    id: ElementId,
184    icon: Option<NavIcon>,
185    label: Option<SharedString>,
186    accessibility_label: SharedString,
187    scheme: ToolbarColor,
188    toggle: bool,
189    selected: bool,
190    disabled: bool,
191    ink: Option<ColorRole>,
192    preview: Option<InteractionPreview>,
193    on_click: Option<ClickHandler>,
194}
195
196impl ToolbarButton {
197    /// An icon button. `accessibility_label` names it for assistive
198    /// technology; the toolbar shows no text.
199    pub fn icon(
200        id: impl Into<ElementId>,
201        icon: NavIcon,
202        accessibility_label: impl Into<SharedString>,
203    ) -> Self {
204        Self {
205            id: id.into(),
206            icon: Some(icon),
207            label: None,
208            accessibility_label: accessibility_label.into(),
209            scheme: ToolbarColor::Standard,
210            toggle: false,
211            selected: false,
212            disabled: false,
213            ink: None,
214            preview: None,
215            on_click: None,
216        }
217    }
218    /// A label button, optionally with a leading icon ([`Self::leading_icon`]).
219    pub fn label(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
220        let label = label.into();
221        Self {
222            id: id.into(),
223            icon: None,
224            label: Some(label.clone()),
225            accessibility_label: label,
226            scheme: ToolbarColor::Standard,
227            toggle: false,
228            selected: false,
229            disabled: false,
230            ink: None,
231            preview: None,
232            on_click: None,
233        }
234    }
235    pub fn leading_icon(mut self, icon: NavIcon) -> Self {
236        self.icon = Some(icon);
237        self
238    }
239    /// The color scheme of the toolbar holding the button.
240    pub fn color(mut self, scheme: ToolbarColor) -> Self {
241        self.scheme = scheme;
242        self
243    }
244    /// Toggle semantics: the button reports its pressed state and paints
245    /// the selected container while `selected`.
246    pub fn toggle(mut self, toggle: bool) -> Self {
247        self.toggle = toggle;
248        self
249    }
250    pub fn selected(mut self, selected: bool) -> Self {
251        self.selected = selected;
252        self
253    }
254    pub fn disabled(mut self, disabled: bool) -> Self {
255        self.disabled = disabled;
256        self
257    }
258    /// Override the resting ink, e.g. on-surface for an app bar's
259    /// navigation icon.
260    pub fn ink(mut self, role: ColorRole) -> Self {
261        self.ink = Some(role);
262        self
263    }
264    /// Render a static specimen in one forced interaction state. See
265    /// [`InteractionPreview`].
266    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
267        self.preview = Some(state);
268        self
269    }
270    pub fn on_click(
271        mut self,
272        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
273    ) -> Self {
274        self.on_click = Some(Rc::new(handler));
275        self
276    }
277}
278
279/// Whether a button is held, for the pressed shape morph.
280#[derive(Default)]
281struct Held(bool);
282
283impl RenderOnce for ToolbarButton {
284    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
285        let preview = self.preview;
286        let disabled = self.disabled || preview.is_some_and(InteractionPreview::is_disabled);
287        let enabled = !disabled;
288        let live = enabled && preview.is_none();
289        let selected = self.toggle && self.selected;
290        let is_label = self.label.is_some();
291        let fade = motion::token_value(
292            (self.id.clone(), "toolbar-fade"),
293            if selected { 1. } else { 0. },
294            MotionToken::FastEffects,
295            window,
296            cx,
297        );
298        let held =
299            window.use_keyed_state((self.id.clone(), "toolbar-held"), cx, |_, _| Held(false));
300        let pressed = match preview {
301            Some(InteractionPreview::Pressed) => true,
302            Some(_) => false,
303            None => live && held.read(cx).0,
304        };
305        let press = motion::token_value(
306            (self.id.clone(), "toolbar-press"),
307            if pressed { 1. } else { 0. },
308            MotionToken::FastSpatial,
309            window,
310            cx,
311        );
312        let mut paint = button_paint(self.scheme, is_label, fade, disabled);
313        if let (Some(role), false, true) = (self.ink, disabled, fade < 0.001) {
314            paint.ink = color(role);
315            paint.layer = paint.ink;
316        }
317        let radius_px = PILL / 2. + (PRESSED_RADIUS - PILL / 2.) * press.clamp(0., 1.2);
318        #[cfg(test)]
319        PAINTED.with(|painted| {
320            painted
321                .borrow_mut()
322                .insert(format!("{:?}", self.id), (paint, radius_px))
323        });
324        let radius = rem(radius_px);
325        let focus = window
326            .use_keyed_state((self.id.clone(), "toolbar-focus"), cx, |_, cx| {
327                cx.focus_handle()
328            })
329            .read(cx)
330            .clone()
331            .tab_stop(live);
332
333        let (hover, focus_layer, press_layer) = (
334            paint.layer.opacity(state::HOVER),
335            paint.layer.opacity(state::FOCUS),
336            paint.layer.opacity(state::PRESS),
337        );
338        let group: SharedString = format!("toolbar-button-{:?}", self.id).into();
339        let layer = div()
340            .id("toolbar-state-layer")
341            .absolute()
342            .inset_0()
343            .rounded(radius)
344            .map(|layer| match preview {
345                _ if disabled => layer,
346                None => layer
347                    .group_hover(group.clone(), move |style| style.bg(hover))
348                    .group_active(group.clone(), move |style| style.bg(press_layer)),
349                Some(InteractionPreview::Hovered) => layer.bg(hover),
350                Some(InteractionPreview::Focused) => layer.bg(focus_layer),
351                Some(InteractionPreview::Pressed) => layer.bg(press_layer),
352                Some(_) => layer,
353            });
354        let hovered_label = is_label && preview == Some(InteractionPreview::Hovered);
355        let icon = self.icon.as_ref().map(|icon| {
356            let size = if is_label { LABEL_ICON } else { ICON };
357            Icon::new(icon.path(selected))
358                .size(rem(size))
359                .color(paint.ink)
360        });
361        let pill = div()
362            .relative()
363            .flex()
364            .flex_shrink_0()
365            .items_center()
366            .justify_center()
367            .h(rem(PILL))
368            .when(is_label, |pill| {
369                pill.px(rem(LABEL_PADDING))
370                    .gap(rem(LABEL_GAP))
371                    .when(live, |pill| {
372                        let group = group.clone();
373                        pill.group_hover(group, |style| style.shadow(elevation::level(1)))
374                    })
375                    .when(hovered_label, |pill| pill.shadow(elevation::level(1)))
376            })
377            .when(!is_label, |pill| pill.w(rem(PILL)))
378            .rounded(radius)
379            .bg(paint.container)
380            .child(layer)
381            .children(icon)
382            .when_some(self.label.clone(), |pill, label| {
383                pill.child(
384                    div()
385                        .relative()
386                        .whitespace_nowrap()
387                        .text_color(paint.ink)
388                        .map(|text| type_role(text, TypeRole::LabelLarge))
389                        .child(label),
390                )
391            });
392        let corners = window.rem_size() * radius.0;
393        let ripple = ButtonRipple::new(
394            pill,
395            (self.id.clone(), "toolbar-ripple").into(),
396            preview.is_none(),
397            enabled,
398            ripple_corners(corners),
399            Edges::all(px(0.).into()),
400            paint.layer,
401            window,
402            cx,
403        );
404        let ring =
405            ButtonFocusRing::with_corners(ripple, focus.clone(), enabled, Corners::all(corners))
406                .forced(preview == Some(InteractionPreview::Focused));
407
408        let on_click = self.on_click.filter(|_| live);
409        let down = held.clone();
410        let up = held.clone();
411        let out = held;
412        gpui_base::Button::new(self.id)
413            .group(group)
414            .disabled(disabled)
415            .tab_stop(live)
416            .accessibility_label(self.accessibility_label)
417            .when(self.toggle, |button| button.selected(selected))
418            .track_focus(&focus)
419            .flex()
420            .flex_shrink_0()
421            .items_center()
422            .justify_center()
423            .h(rem(TARGET))
424            .min_w(rem(TARGET))
425            .when(live, |button| {
426                button
427                    .cursor_pointer()
428                    .on_mouse_down(MouseButton::Left, move |_, _, cx| {
429                        down.update(cx, |held, cx| {
430                            held.0 = true;
431                            cx.notify();
432                        })
433                    })
434                    .on_mouse_up(MouseButton::Left, move |_, _, cx| {
435                        up.update(cx, |held, cx| {
436                            held.0 = false;
437                            cx.notify();
438                        })
439                    })
440                    .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
441                        out.update(cx, |held, cx| {
442                            held.0 = false;
443                            cx.notify();
444                        })
445                    })
446            })
447            .child(ring)
448            .when_some(on_click, |button, handler| {
449                let focus = focus.clone();
450                button.on_click(move |event, window, cx| {
451                    focus.focus(window, cx);
452                    handler(event, window, cx)
453                })
454            })
455    }
456}
457
458/// A toolbar: caller items in a floating pill or a docked band.
459#[derive(IntoElement)]
460pub struct Toolbar {
461    id: ElementId,
462    label: SharedString,
463    configuration: ToolbarConfiguration,
464    orientation: ToolbarOrientation,
465    scheme: ToolbarColor,
466    items: Vec<AnyElement>,
467    fab: Option<AnyElement>,
468    visible: bool,
469    elevated: Option<bool>,
470    docked_centered: bool,
471}
472
473impl Toolbar {
474    /// `label` names the toolbar for assistive technology.
475    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
476        Self {
477            id: id.into(),
478            label: label.into(),
479            configuration: ToolbarConfiguration::Floating,
480            orientation: ToolbarOrientation::Horizontal,
481            scheme: ToolbarColor::Standard,
482            items: Vec::new(),
483            fab: None,
484            visible: true,
485            elevated: None,
486            docked_centered: false,
487        }
488    }
489    pub fn configuration(mut self, configuration: ToolbarConfiguration) -> Self {
490        self.configuration = configuration;
491        self
492    }
493    /// Vertical applies to floating toolbars; a docked toolbar stays
494    /// horizontal.
495    pub fn orientation(mut self, orientation: ToolbarOrientation) -> Self {
496        self.orientation = orientation;
497        self
498    }
499    pub fn color(mut self, scheme: ToolbarColor) -> Self {
500        self.scheme = scheme;
501        self
502    }
503    pub fn item(mut self, item: impl IntoElement) -> Self {
504        self.items.push(item.into_any_element());
505        self
506    }
507    pub fn items<E: IntoElement>(mut self, items: impl IntoIterator<Item = E>) -> Self {
508        self.items
509            .extend(items.into_iter().map(IntoElement::into_any_element));
510        self
511    }
512    /// A FAB beside (horizontal) or below (vertical) a floating toolbar. It
513    /// stays when the toolbar hides.
514    pub fn fab(mut self, fab: impl IntoElement) -> Self {
515        self.fab = Some(fab.into_any_element());
516        self
517    }
518    /// Show or hide the toolbar, e.g. from the scroll direction: hidden on
519    /// scrolling down, shown on scrolling up.
520    pub fn visible(mut self, visible: bool) -> Self {
521        self.visible = visible;
522        self
523    }
524
525    /// Paint the level 3 shadow. By default a floating toolbar is elevated
526    /// and a docked one is flat.
527    pub fn elevated(mut self, elevated: bool) -> Self {
528        self.elevated = Some(elevated);
529        self
530    }
531    /// Group a docked toolbar's items in its center, 8dp apart (the kit's
532    /// minimum docked gap), instead of spreading them across its width.
533    pub fn docked_centered(mut self, centered: bool) -> Self {
534        self.docked_centered = centered;
535        self
536    }
537
538    fn is_elevated(&self) -> bool {
539        self.elevated
540            .unwrap_or(self.configuration == ToolbarConfiguration::Floating)
541    }
542
543    fn vertical(&self) -> bool {
544        self.configuration == ToolbarConfiguration::Floating
545            && self.orientation == ToolbarOrientation::Vertical
546    }
547}
548
549#[cfg(test)]
550thread_local! {
551    pub(crate) static SHOWN: std::cell::RefCell<std::collections::BTreeMap<String, f32>> =
552        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
553    pub(crate) static ELEVATED: std::cell::RefCell<std::collections::BTreeMap<String, bool>> =
554        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
555}
556
557impl RenderOnce for Toolbar {
558    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
559        let vertical = self.vertical();
560        let docked = self.configuration == ToolbarConfiguration::Docked;
561        let shown = motion::token_value(
562            (self.id.clone(), "toolbar-visible"),
563            if self.visible { 1. } else { 0. },
564            MotionToken::DefaultSpatial,
565            window,
566            cx,
567        );
568        #[cfg(test)]
569        SHOWN.with(|shown_map| {
570            shown_map
571                .borrow_mut()
572                .insert(format!("{:?}", self.id), shown)
573        });
574        let gone = !self.visible && shown <= 0.001;
575        let container = color(self.scheme.container());
576        let shadow = if self.is_elevated() {
577            elevation::level(3)
578        } else {
579            Vec::new()
580        };
581        #[cfg(test)]
582        ELEVATED.with(|elevated| {
583            elevated
584                .borrow_mut()
585                .insert(format!("{:?}", self.id), !shadow.is_empty())
586        });
587        let centered = self.docked_centered;
588
589        let bar = gpui_base::Toolbar::new(self.id.clone())
590            .axis(if vertical {
591                Axis::Vertical
592            } else {
593                Axis::Horizontal
594            })
595            .aria_label(self.label)
596            .when(cfg!(test), |bar| bar.debug_selector(|| "toolbar".into()))
597            .flex()
598            .flex_shrink_0()
599            .items_center()
600            .bg(container)
601            .map(|bar| {
602                if docked {
603                    bar.w_full()
604                        .h(rem(TOOLBAR_SIZE))
605                        .px(rem(DOCKED_PADDING))
606                        .gap(rem(DOCKED_GAP))
607                        .map(|bar| {
608                            if centered {
609                                bar.justify_center()
610                            } else {
611                                bar.justify_between()
612                            }
613                        })
614                        .shadow(shadow)
615                } else if vertical {
616                    bar.flex_col()
617                        .w(rem(TOOLBAR_SIZE))
618                        .py(rem(FLOATING_PADDING))
619                        .gap(rem(FLOATING_GAP))
620                        .rounded_full()
621                        .shadow(shadow)
622                } else {
623                    bar.h(rem(TOOLBAR_SIZE))
624                        .px(rem(FLOATING_PADDING))
625                        .gap(rem(FLOATING_GAP))
626                        .rounded_full()
627                        .shadow(shadow)
628                }
629            })
630            .when(!gone, |bar| bar.children(self.items));
631
632        let bar = div()
633            .flex()
634            .when(docked, |wrapper| wrapper.w_full())
635            .child(bar);
636
637        if docked {
638            // Collapse from the top edge; items ride down with it.
639            return div()
640                .w_full()
641                .flex_shrink_0()
642                .overflow_hidden()
643                .h(rem(TOOLBAR_SIZE * shown.max(0.)))
644                .child(bar)
645                .into_any_element();
646        }
647        let offset = rem(EXIT_OFFSET * (1. - shown));
648        let sliding = div()
649            .relative()
650            .when(gone, |bar| bar.invisible())
651            .map(|bar| {
652                if vertical {
653                    bar.left(offset)
654                } else {
655                    bar.top(offset)
656                }
657            })
658            .child(bar);
659        match self.fab {
660            None => sliding.into_any_element(),
661            Some(fab) => div()
662                .flex()
663                .items_center()
664                .gap(rem(TOOLBAR_FAB_GAP))
665                .when(vertical, |pair| pair.flex_col())
666                .child(sliding)
667                .child(fab)
668                .into_any_element(),
669        }
670    }
671}
672
673#[cfg(test)]
674#[path = "toolbar_tests.rs"]
675mod tests;