1use 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
40pub const TOOLBAR_SIZE: f32 = 64.;
42pub const TOOLBAR_MARGIN: f32 = 16.;
44pub const TOOLBAR_FAB_GAP: f32 = 8.;
46
47const 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.;
55const FLOATING_GAP: f32 = 4.;
57const FLOATING_PADDING: f32 = 8.;
58const DOCKED_PADDING: f32 = 16.;
60const DOCKED_GAP: f32 = 8.;
61
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
64pub enum ToolbarColor {
65 #[default]
67 Standard,
68 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum ToolbarConfiguration {
92 #[default]
94 Floating,
95 Docked,
97}
98
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
100pub enum ToolbarOrientation {
101 #[default]
102 Horizontal,
103 Vertical,
105}
106
107pub(crate) const EXIT_OFFSET: f32 = TOOLBAR_SIZE + TOOLBAR_MARGIN;
111
112#[derive(Clone, Copy, Debug, PartialEq)]
114pub(crate) struct ButtonPaint {
115 pub container: Rgba,
116 pub ink: Rgba,
117 pub layer: Rgba,
118}
119
120pub(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
169const 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#[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 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 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 pub fn color(mut self, scheme: ToolbarColor) -> Self {
241 self.scheme = scheme;
242 self
243 }
244 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 pub fn ink(mut self, role: ColorRole) -> Self {
261 self.ink = Some(role);
262 self
263 }
264 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#[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#[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 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 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 pub fn fab(mut self, fab: impl IntoElement) -> Self {
515 self.fab = Some(fab.into_any_element());
516 self
517 }
518 pub fn visible(mut self, visible: bool) -> Self {
521 self.visible = visible;
522 self
523 }
524
525 pub fn elevated(mut self, elevated: bool) -> Self {
528 self.elevated = Some(elevated);
529 self
530 }
531 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 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;