1use 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
44pub const TOP_APP_BAR_HEIGHT: f32 = 64.;
46pub const BOTTOM_APP_BAR_HEIGHT: f32 = 80.;
48
49const TARGET: f32 = 48.;
51const EDGE: f32 = 4.;
52const TOP_ROW: f32 = 56.;
54const TITLE_GAP: f32 = 8.;
55const TITLE_BOTTOM: f32 = 12.;
56const TITLE_INSET: f32 = 16.;
57const SEARCH_HEIGHT: f32 = 56.;
59const SEARCH_INSET: f32 = 8.;
60const SEARCH_PADDING: f32 = 20.;
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum TopAppBarVariant {
65 #[default]
67 Small,
68 CenterAligned,
70 MediumFlexible,
73 LargeFlexible,
76 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 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 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 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 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 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
147fn line_height(role: TypeRole) -> f32 {
149 match role.scale().line_height {
150 Rems(value) => value * crate::tokens::typography::BASE_PX,
151 }
152}
153
154pub 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#[derive(Clone, Copy, Debug, PartialEq)]
164pub(crate) struct BarPaint {
165 pub container: Rgba,
166 pub search: Rgba,
167}
168
169#[cfg(test)]
172pub(crate) fn bar_paint(scrolled: f32) -> BarPaint {
173 bar_paint_on(ColorRole::SURFACE, scrolled)
174}
175
176pub(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#[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 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
241 self.subtitle = Some(subtitle.into());
242 self
243 }
244 pub fn title_content(mut self, content: impl IntoElement) -> Self {
247 self.title_content = Some(content.into_any_element());
248 self
249 }
250 pub fn centered(mut self, centered: bool) -> Self {
252 self.centered = centered || self.variant == TopAppBarVariant::CenterAligned;
253 self
254 }
255 pub fn navigation_icon(mut self, icon: impl IntoElement) -> Self {
257 self.navigation = Some(icon.into_any_element());
258 self
259 }
260 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 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 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 pub fn search_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
294 self.search_placeholder = placeholder.into();
295 self
296 }
297 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 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 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 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#[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 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 pub fn fab_centered(mut self, centered: bool) -> Self {
592 self.fab_centered = centered;
593 self
594 }
595 pub fn visible(mut self, visible: bool) -> Self {
597 self.visible = visible;
598 self
599 }
600}
601
602pub(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;