Skip to main content

gpui_kit_material/components/
app_bar.rs

1//! M3 Expressive top app bars and the bottom app bar.
2//!
3//! [`TopAppBar`] renders the kit's configurations ([`TopAppBarVariant`]):
4//! small, center-aligned, medium flexible, large flexible and search. Each
5//! holds a caller navigation icon, a title with an optional subtitle (or a
6//! caller element in the title's place, e.g. a logo) and caller trailing
7//! actions. Strings and actions come from the caller; the bar owns layout,
8//! typography, color and motion.
9//!
10//! Scrolling is driven by the caller's content scroll offset
11//! ([`TopAppBar::scroll_offset`], design px):
12//!
13//! - Any offset above zero is the scrolled-under state: the container fades
14//!   from surface to surface-container (and a search bar's field from
15//!   surface-container to surface-container-highest) on the default effects
16//!   spring, which keeps its fade under reduced motion.
17//! - A flexible bar collapses with the offset itself, from its expanded
18//!   height to the 64dp collapsed one. The large title rides up with the
19//!   bar's bottom edge and fades out (alpha `1 - f`) while the collapsed
20//!   title fades in over the second half ([`collapsed_title_alpha`]), the
21//!   two-row bar's title handoff. The collapse follows the scroll, so it
22//!   needs no clock and is identical under reduced motion.
23//!
24//! [`BottomAppBar`] is the 80dp surface-container bar of up to four actions
25//! and a trailing FAB, hidden and shown with [`BottomAppBar::visible`] on the
26//! default spatial spring (at once under reduced motion).
27//!
28//! Semantics: a top app bar is a banner landmark whose title is a heading; a
29//! bottom app bar is a GPUI Base toolbar (toolbar role, Left/Right roving
30//! focus among its actions).
31use super::InteractionPreview;
32use super::focus::{RingPlacement, RingedButton};
33use super::navigation::type_role;
34use crate::color::ColorRole;
35use crate::motion::{self, MotionToken};
36use crate::theme::{blend, color};
37use crate::tokens::{
38    state,
39    typography::{TypeRole, rem},
40};
41use gpui::{prelude::FluentBuilder as _, *};
42use std::rc::Rc;
43
44/// Height of a single-row (and of a collapsed flexible) top app bar.
45pub const TOP_APP_BAR_HEIGHT: f32 = 64.;
46/// Height of the bottom app bar.
47pub const BOTTOM_APP_BAR_HEIGHT: f32 = 80.;
48
49/// Icon target and the bar's edge padding.
50const TARGET: f32 = 48.;
51const EDGE: f32 = 4.;
52/// The flexible bar's top row and the space under its large title.
53const TOP_ROW: f32 = 56.;
54const TITLE_GAP: f32 = 8.;
55const TITLE_BOTTOM: f32 = 12.;
56const TITLE_INSET: f32 = 16.;
57/// Search field height and the space between it and the icons.
58const SEARCH_HEIGHT: f32 = 56.;
59const SEARCH_INSET: f32 = 8.;
60const SEARCH_PADDING: f32 = 20.;
61
62/// The kit's top app bar configurations.
63#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum TopAppBarVariant {
65    /// Title after the navigation icon, 64dp.
66    #[default]
67    Small,
68    /// Title centered across the bar, 64dp.
69    CenterAligned,
70    /// A headline-medium title under the icons that collapses into a small
71    /// bar on scroll; 112dp expanded (136dp with a subtitle).
72    MediumFlexible,
73    /// A display-small title under the icons that collapses into a small
74    /// bar on scroll; 120dp expanded (152dp with a subtitle).
75    LargeFlexible,
76    /// A search field between the navigation icon and the actions, 64dp.
77    Search,
78}
79
80impl TopAppBarVariant {
81    pub const ALL: [Self; 5] = [
82        Self::CenterAligned,
83        Self::Search,
84        Self::Small,
85        Self::MediumFlexible,
86        Self::LargeFlexible,
87    ];
88    pub const fn name(self) -> &'static str {
89        match self {
90            Self::Small => "Small",
91            Self::CenterAligned => "Small-centered",
92            Self::MediumFlexible => "Medium flexible",
93            Self::LargeFlexible => "Large flexible",
94            Self::Search => "Search",
95        }
96    }
97    pub const fn is_flexible(self) -> bool {
98        matches!(self, Self::MediumFlexible | Self::LargeFlexible)
99    }
100    /// The title and subtitle roles of the bar's (expanded) title.
101    pub const fn title_roles(self) -> (TypeRole, TypeRole) {
102        match self {
103            Self::MediumFlexible => (TypeRole::HeadlineMedium, TypeRole::LabelLarge),
104            Self::LargeFlexible => (TypeRole::DisplaySmall, TypeRole::TitleMedium),
105            Self::Small | Self::CenterAligned | Self::Search => {
106                (TypeRole::TitleLarge, TypeRole::LabelMedium)
107            }
108        }
109    }
110    /// Space between the title and its subtitle.
111    pub const fn subtitle_gap(self) -> f32 {
112        match self {
113            Self::MediumFlexible => 4.,
114            Self::LargeFlexible => 8.,
115            Self::Small | Self::CenterAligned | Self::Search => 0.,
116        }
117    }
118    /// Expanded height in design px.
119    pub fn expanded_height(self, subtitle: bool) -> f32 {
120        if !self.is_flexible() {
121            return TOP_APP_BAR_HEIGHT;
122        }
123        let (title, sub) = self.title_roles();
124        let mut text = line_height(title);
125        if subtitle {
126            text += line_height(sub) + self.subtitle_gap();
127        }
128        TOP_ROW + TITLE_GAP + text + TITLE_BOTTOM
129    }
130    /// How far a flexible bar has collapsed at `offset` (0 expanded, 1
131    /// collapsed); single-row bars never collapse.
132    pub fn collapse_fraction(self, subtitle: bool, offset: f32) -> f32 {
133        let range = self.expanded_height(subtitle) - TOP_APP_BAR_HEIGHT;
134        if range <= 0. {
135            0.
136        } else {
137            (offset / range).clamp(0., 1.)
138        }
139    }
140    /// The bar's height at `offset`.
141    pub fn height(self, subtitle: bool, offset: f32) -> f32 {
142        let expanded = self.expanded_height(subtitle);
143        expanded - (expanded - TOP_APP_BAR_HEIGHT) * self.collapse_fraction(subtitle, offset)
144    }
145}
146
147/// A role's line height in design px at the base text scale.
148fn line_height(role: TypeRole) -> f32 {
149    match role.scale().line_height {
150        Rems(value) => value * crate::tokens::typography::BASE_PX,
151    }
152}
153
154/// The collapsed title's alpha at collapse fraction `f`: hidden over the
155/// first half, then fading in on an ease-in-out so it arrives as the large
156/// title leaves.
157pub fn collapsed_title_alpha(f: f32) -> f32 {
158    let t = ((f - 0.5) * 2.).clamp(0., 1.);
159    t * t * (3. - 2. * t)
160}
161
162/// Container, search-field and scrolled amounts for one frame.
163#[derive(Clone, Copy, Debug, PartialEq)]
164pub(crate) struct BarPaint {
165    pub container: Rgba,
166    pub search: Rgba,
167}
168
169/// `scrolled` is the effects amount, 0 flat and 1 scrolled-under, for a bar
170/// on the default surface.
171#[cfg(test)]
172pub(crate) fn bar_paint(scrolled: f32) -> BarPaint {
173    bar_paint_on(ColorRole::SURFACE, scrolled)
174}
175
176/// The bar paint for a bar whose flat container is `flat`.
177pub(crate) fn bar_paint_on(flat: ColorRole, scrolled: f32) -> BarPaint {
178    let scrolled = scrolled.clamp(0., 1.);
179    BarPaint {
180        container: blend(color(flat), color(ColorRole::SURFACE_CONTAINER), scrolled),
181        search: blend(
182            color(ColorRole::SURFACE_CONTAINER),
183            color(ColorRole::SURFACE_CONTAINER_HIGHEST),
184            scrolled,
185        ),
186    }
187}
188
189#[cfg(test)]
190thread_local! {
191    pub(crate) static PAINTED: std::cell::RefCell<std::collections::BTreeMap<String, (BarPaint, f32)>> =
192        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
193}
194
195type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
196
197/// An M3 Expressive top app bar.
198#[derive(IntoElement)]
199pub struct TopAppBar {
200    id: ElementId,
201    variant: TopAppBarVariant,
202    title: Option<SharedString>,
203    subtitle: Option<SharedString>,
204    title_content: Option<AnyElement>,
205    centered: bool,
206    navigation: Option<AnyElement>,
207    actions: Vec<AnyElement>,
208    scroll_offset: f32,
209    scrolled_under: Option<bool>,
210    container: ColorRole,
211    search_placeholder: SharedString,
212    on_search: Option<ClickHandler>,
213    preview: Option<InteractionPreview>,
214}
215
216impl TopAppBar {
217    pub fn new(id: impl Into<ElementId>, variant: TopAppBarVariant) -> Self {
218        Self {
219            id: id.into(),
220            variant,
221            title: None,
222            subtitle: None,
223            title_content: None,
224            centered: variant == TopAppBarVariant::CenterAligned,
225            navigation: None,
226            actions: Vec::new(),
227            scroll_offset: 0.,
228            scrolled_under: None,
229            container: ColorRole::SURFACE,
230            search_placeholder: SharedString::default(),
231            on_search: None,
232            preview: None,
233        }
234    }
235    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
236        self.title = Some(title.into());
237        self
238    }
239    /// Supporting text under the title.
240    pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
241        self.subtitle = Some(subtitle.into());
242        self
243    }
244    /// A caller element in the title's place, e.g. a logo or image. A
245    /// text title, if also set, stays the bar's accessible heading.
246    pub fn title_content(mut self, content: impl IntoElement) -> Self {
247        self.title_content = Some(content.into_any_element());
248        self
249    }
250    /// Center a flexible bar's titles (center-aligned bars always are).
251    pub fn centered(mut self, centered: bool) -> Self {
252        self.centered = centered || self.variant == TopAppBarVariant::CenterAligned;
253        self
254    }
255    /// The leading navigation control, e.g. a menu or back icon button.
256    pub fn navigation_icon(mut self, icon: impl IntoElement) -> Self {
257        self.navigation = Some(icon.into_any_element());
258        self
259    }
260    /// A trailing action, e.g. an icon button or avatar.
261    pub fn action(mut self, action: impl IntoElement) -> Self {
262        self.actions.push(action.into_any_element());
263        self
264    }
265    pub fn actions<E: IntoElement>(mut self, actions: impl IntoIterator<Item = E>) -> Self {
266        self.actions
267            .extend(actions.into_iter().map(IntoElement::into_any_element));
268        self
269    }
270    /// How far the content under the bar has scrolled, in design px.
271    /// Override the scrolled-under state the offset implies, e.g. for a bar
272    /// over content the caller knows is scrolled, or a static specimen. A
273    /// flexible bar still collapses with the offset alone.
274    pub fn scrolled_under(mut self, scrolled: bool) -> Self {
275        self.scrolled_under = Some(scrolled);
276        self
277    }
278    fn is_scrolled_under(&self) -> bool {
279        self.scrolled_under.unwrap_or(self.scroll_offset > 0.)
280    }
281    /// The flat container role (surface by default), e.g. the
282    /// surface-container-lowest of the content pane a bar heads. Scrolled
283    /// under, the bar still fades to surface-container.
284    pub fn container(mut self, role: ColorRole) -> Self {
285        self.container = role;
286        self
287    }
288    pub fn scroll_offset(mut self, offset: f32) -> Self {
289        self.scroll_offset = offset.max(0.);
290        self
291    }
292    /// The search field's placeholder (search bars).
293    pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
294        self.search_placeholder = placeholder.into();
295        self
296    }
297    /// Activating the search field, e.g. to open a search view.
298    pub fn on_search(
299        mut self,
300        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
301    ) -> Self {
302        self.on_search = Some(Rc::new(handler));
303        self
304    }
305    /// Render the search field as a static specimen in one forced
306    /// interaction state. See [`InteractionPreview`].
307    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
308        self.preview = Some(state);
309        self
310    }
311
312    fn title_block(
313        &self,
314        id: &'static str,
315        roles: (TypeRole, TypeRole),
316        gap: f32,
317        heading: bool,
318    ) -> Option<Div> {
319        let title = self.title.clone()?;
320        let centered = self.centered;
321        let text = |text: SharedString, role: TypeRole, ink: ColorRole| {
322            div()
323                .w_full()
324                .min_w_0()
325                .overflow_hidden()
326                .whitespace_nowrap()
327                .text_ellipsis()
328                .when(centered, |line| line.text_center())
329                .text_color(color(ink))
330                .map(|line| type_role(line, role))
331                .child(text)
332        };
333        let block = div()
334            .id(id)
335            .min_w_0()
336            .flex()
337            .flex_col()
338            .gap(rem(gap))
339            .when(centered, |block| block.items_center())
340            .when(heading, |block| block.role(Role::Heading).aria_label(title))
341            .when_some(self.title.clone(), |block, title| {
342                block.child(text(title, roles.0, ColorRole::ON_SURFACE))
343            })
344            .when_some(self.subtitle.clone(), |block, subtitle| {
345                block.child(text(subtitle, roles.1, ColorRole::ON_SURFACE_VARIANT))
346            });
347        Some(div().min_w_0().child(block))
348    }
349}
350
351impl RenderOnce for TopAppBar {
352    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
353        let variant = self.variant;
354        let subtitle = self.subtitle.is_some();
355        let scrolled = motion::token_value(
356            (self.id.clone(), "app-bar-scrolled"),
357            if self.is_scrolled_under() { 1. } else { 0. },
358            MotionToken::DefaultEffects,
359            window,
360            cx,
361        );
362        let paint = bar_paint_on(self.container, scrolled);
363        let f = variant.collapse_fraction(subtitle, self.scroll_offset);
364        #[cfg(test)]
365        PAINTED.with(|painted| {
366            painted
367                .borrow_mut()
368                .insert(format!("{:?}", self.id), (paint, f))
369        });
370        let height = variant.height(subtitle, self.scroll_offset);
371        let leading = self.navigation.take();
372        let has_leading = leading.is_some();
373        let actions = std::mem::take(&mut self.actions);
374        let action_count = actions.len();
375        let trailing = div()
376            .flex()
377            .flex_shrink_0()
378            .items_center()
379            .justify_end()
380            .children(actions);
381        let leading = leading.map(|icon| div().flex_shrink_0().child(icon));
382        let content = self.title_content.take();
383        let small_roles = TopAppBarVariant::Small.title_roles();
384        // Space from the bar's edge to a small title without a navigation
385        // icon: the 16dp title inset.
386        let lead_gap = if has_leading {
387            EDGE
388        } else {
389            TITLE_INSET - EDGE
390        };
391
392        let bar = div()
393            .id(self.id.clone())
394            .role(Role::Banner)
395            .when(cfg!(test), |bar| {
396                bar.debug_selector(|| "top-app-bar".into())
397            })
398            .relative()
399            .w_full()
400            .flex_shrink_0()
401            .overflow_hidden()
402            .h(rem(height))
403            .bg(paint.container);
404
405        match variant {
406            TopAppBarVariant::Small | TopAppBarVariant::Search => {
407                let middle = if variant == TopAppBarVariant::Search {
408                    let preview = self.preview;
409                    let ink = color(ColorRole::ON_SURFACE_VARIANT);
410                    let on_search = self.on_search.clone();
411                    let scale = TypeRole::BodyLarge.scale();
412                    div().flex_1().min_w_0().mx(rem(SEARCH_INSET)).child(
413                        RingedButton::new(
414                            (self.id.clone(), "search"),
415                            RingPlacement::Outer(Corners::all(
416                                window.rem_size() * rem(SEARCH_HEIGHT / 2.).0,
417                            )),
418                        )
419                        .accessibility_label(self.search_placeholder.clone())
420                        .map(|field| {
421                            field
422                                .w_full()
423                                .h(rem(SEARCH_HEIGHT))
424                                .px(rem(SEARCH_PADDING))
425                                .flex()
426                                .items_center()
427                                .justify_start()
428                                .rounded_full()
429                                .bg(paint.search)
430                                .text_color(ink)
431                                .text_size(scale.font_size)
432                                .line_height(scale.line_height)
433                                .whitespace_nowrap()
434                                .overflow_hidden()
435                                .child(self.search_placeholder.clone())
436                                .when(on_search.is_some(), |field| field.cursor_pointer())
437                                .when_some(on_search, |field, handler| {
438                                    field.on_click(move |event, window, cx| {
439                                        handler(event, window, cx)
440                                    })
441                                })
442                        })
443                        .state_layers(
444                            state::hovered(paint.search, ink),
445                            state::pressed(paint.search, ink),
446                        )
447                        .when_some(preview, |field, preview| field.preview_state(preview)),
448                    )
449                } else {
450                    div()
451                        .flex_1()
452                        .min_w_0()
453                        .flex()
454                        .items_center()
455                        .ml(rem(lead_gap))
456                        .mr(rem(EDGE))
457                        .children(content)
458                        .children(self.title_block("app-bar-title", small_roles, 0., true))
459                };
460                bar.flex()
461                    .items_center()
462                    .px(rem(EDGE))
463                    .children(leading)
464                    .child(middle)
465                    .child(trailing)
466                    .into_any_element()
467            }
468            TopAppBarVariant::CenterAligned => {
469                // The title centers on the bar, clear of the wider side.
470                let side = EDGE + TARGET * (usize::from(has_leading).max(action_count)) as f32;
471                bar.flex()
472                    .items_center()
473                    .px(rem(EDGE))
474                    .child(
475                        div()
476                            .absolute()
477                            .inset_0()
478                            .flex()
479                            .items_center()
480                            .justify_center()
481                            .px(rem(side.max(TITLE_INSET)))
482                            .children(content)
483                            .children(self.title_block("app-bar-title", small_roles, 0., true)),
484                    )
485                    .children(leading)
486                    .child(div().flex_1())
487                    .child(trailing)
488                    .into_any_element()
489            }
490            TopAppBarVariant::MediumFlexible | TopAppBarVariant::LargeFlexible => {
491                let top_alpha = collapsed_title_alpha(f);
492                let top_title = (top_alpha > 0.001)
493                    .then(|| self.title_block("app-bar-collapsed-title", small_roles, 0., false))
494                    .flatten()
495                    .map(|title| title.opacity(top_alpha));
496                let expanded_title = self
497                    .title_block(
498                        "app-bar-title",
499                        variant.title_roles(),
500                        variant.subtitle_gap(),
501                        true,
502                    )
503                    .map(|title| title.w_full().opacity(1. - f));
504                let top_row = div()
505                    .relative()
506                    .flex()
507                    .flex_shrink_0()
508                    .items_start()
509                    .h(rem(TOP_ROW))
510                    .pt(rem(EDGE * 2.))
511                    .px(rem(EDGE))
512                    .children(leading)
513                    .child(
514                        div()
515                            .flex_1()
516                            .min_w_0()
517                            .h(rem(TARGET))
518                            .flex()
519                            .items_center()
520                            .when(self.centered, |middle| middle.justify_center())
521                            .ml(rem(lead_gap))
522                            .mr(rem(EDGE))
523                            .children(top_title),
524                    )
525                    .child(trailing);
526                let area = div()
527                    .flex_1()
528                    .min_h_0()
529                    .overflow_hidden()
530                    .flex()
531                    .flex_col()
532                    .justify_end()
533                    .px(rem(TITLE_INSET))
534                    .pb(rem(TITLE_BOTTOM))
535                    .children(content.map(|content| div().opacity(1. - f).child(content)))
536                    .children(expanded_title);
537                bar.flex()
538                    .flex_col()
539                    .child(top_row)
540                    .child(area)
541                    .into_any_element()
542            }
543        }
544    }
545}
546
547#[cfg(test)]
548thread_local! {
549    pub(crate) static BOTTOM_SHOWN: std::cell::RefCell<std::collections::BTreeMap<String, f32>> =
550        const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
551}
552
553/// The M3 bottom app bar: actions and an optional trailing FAB.
554#[derive(IntoElement)]
555pub struct BottomAppBar {
556    id: ElementId,
557    label: SharedString,
558    actions: Vec<AnyElement>,
559    fab: Option<AnyElement>,
560    fab_centered: bool,
561    visible: bool,
562}
563
564impl BottomAppBar {
565    /// `label` names the bar's toolbar for assistive technology.
566    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
567        Self {
568            id: id.into(),
569            label: label.into(),
570            actions: Vec::new(),
571            fab: None,
572            fab_centered: false,
573            visible: true,
574        }
575    }
576    pub fn action(mut self, action: impl IntoElement) -> Self {
577        self.actions.push(action.into_any_element());
578        self
579    }
580    pub fn actions<E: IntoElement>(mut self, actions: impl IntoIterator<Item = E>) -> Self {
581        self.actions
582            .extend(actions.into_iter().map(IntoElement::into_any_element));
583        self
584    }
585    pub fn fab(mut self, fab: impl IntoElement) -> Self {
586        self.fab = Some(fab.into_any_element());
587        self
588    }
589    /// Center the FAB in the bar, with the actions split around it (the
590    /// first half before it), instead of trailing them.
591    pub fn fab_centered(mut self, centered: bool) -> Self {
592        self.fab_centered = centered;
593        self
594    }
595    /// Show or hide the bar, e.g. hidden while scrolling down.
596    pub fn visible(mut self, visible: bool) -> Self {
597        self.visible = visible;
598        self
599    }
600}
601
602/// How many of `count` actions sit before a centered FAB: the first half,
603/// rounded up.
604pub(crate) fn actions_before_center(count: usize) -> usize {
605    count.div_ceil(2)
606}
607
608impl RenderOnce for BottomAppBar {
609    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
610        let shown = motion::token_value(
611            (self.id.clone(), "bottom-app-bar-visible"),
612            if self.visible { 1. } else { 0. },
613            MotionToken::DefaultSpatial,
614            window,
615            cx,
616        );
617        #[cfg(test)]
618        BOTTOM_SHOWN.with(|map| map.borrow_mut().insert(format!("{:?}", self.id), shown));
619        let gone = !self.visible && shown <= 0.001;
620        let row = |actions: Vec<AnyElement>| {
621            div()
622                .flex_1()
623                .flex()
624                .items_center()
625                .gap(rem(TITLE_GAP))
626                .children(actions)
627        };
628        let content: Vec<AnyElement> = match self.fab {
629            Some(fab) if self.fab_centered => {
630                let mut before = self.actions;
631                let after = before.split_off(actions_before_center(before.len()));
632                vec![
633                    row(before).into_any_element(),
634                    fab,
635                    row(after).justify_end().into_any_element(),
636                ]
637            }
638            fab => std::iter::once(row(self.actions).into_any_element())
639                .chain(fab)
640                .collect(),
641        };
642        div()
643            .when(cfg!(test), |bar| {
644                bar.debug_selector(|| "bottom-app-bar".into())
645            })
646            .w_full()
647            .flex_shrink_0()
648            .overflow_hidden()
649            .h(rem(BOTTOM_APP_BAR_HEIGHT * shown.max(0.)))
650            .child(
651                gpui_base::Toolbar::new(self.id)
652                    .aria_label(self.label)
653                    .w_full()
654                    .h(rem(BOTTOM_APP_BAR_HEIGHT))
655                    .flex()
656                    .items_center()
657                    .pl(rem(EDGE))
658                    .pr(rem(TITLE_INSET))
659                    .bg(color(ColorRole::SURFACE_CONTAINER))
660                    .when(!gone, |bar| bar.children(content)),
661            )
662    }
663}
664
665#[cfg(test)]
666#[path = "app_bar_tests.rs"]
667mod tests;