1use super::button::{ButtonFocusRing, ButtonRipple};
26use super::navigation::{
27 ClickHandler, ITEM_GROUP, NavBadge, NavItem, destinations, item_click, ripple_corners,
28 type_role,
29};
30use super::roving::{Change, Orientation};
31use super::{InteractionPreview, small_badge};
32use crate::color::ColorRole;
33use crate::icons::Icon;
34use crate::motion::{self, MotionToken};
35use crate::theme::{blend, color};
36use crate::tokens::{
37 elevation, state,
38 typography::{TypeRole, rem},
39};
40use gpui::{prelude::FluentBuilder as _, *};
41use std::rc::Rc;
42
43pub const DRAWER_WIDTH: f32 = 360.;
45const DRAWER_PADDING: f32 = 12.;
47const ITEM_HEIGHT: f32 = 56.;
49const DENSE_ITEM_HEIGHT: f32 = 48.;
51const ITEM_LEADING: f32 = 16.;
52const ITEM_TRAILING: f32 = 24.;
53const ITEM_GAP: f32 = 12.;
54const MODAL_RADIUS: f32 = 16.;
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum DrawerPosition {
60 #[default]
61 Start,
62 End,
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
67pub(crate) struct DrawerPaint {
68 pub icon: Rgba,
69 pub label: Rgba,
70 pub indicator: Rgba,
71 pub hover: Rgba,
72 pub press: Rgba,
73}
74
75pub(crate) fn drawer_paint(selected: bool, fade: f32, disabled: bool) -> DrawerPaint {
77 let fade = fade.clamp(0., 1.);
78 if disabled {
79 let ink = state::disabled(color(ColorRole::ON_SURFACE));
80 return DrawerPaint {
81 icon: ink,
82 label: ink,
83 indicator: color(ColorRole::ON_SURFACE).opacity(state::DISABLED_CONTAINER * fade),
84 hover: Rgba::default(),
85 press: Rgba::default(),
86 };
87 }
88 let rest = color(ColorRole::ON_SURFACE_VARIANT);
89 let active = color(ColorRole::ON_SECONDARY_CONTAINER);
90 let layer = if selected {
91 active
92 } else {
93 color(ColorRole::ON_SURFACE)
94 };
95 DrawerPaint {
96 icon: blend(rest, active, fade),
97 label: blend(rest, active, fade),
98 indicator: color(ColorRole::SECONDARY_CONTAINER).opacity(fade),
99 hover: layer.opacity(state::HOVER),
100 press: layer.opacity(state::PRESS),
101 }
102}
103
104#[cfg(test)]
105thread_local! {
106 pub(crate) static DRAWER_PAINTED: std::cell::RefCell<std::collections::BTreeMap<String, DrawerPaint>> =
107 const { std::cell::RefCell::new(std::collections::BTreeMap::new()) };
108 pub(crate) static DRAWER_OPEN: std::cell::Cell<(f32, f32)> = const { std::cell::Cell::new((0., 0.)) };
110}
111
112#[derive(IntoElement)]
114pub struct NavigationDrawer {
115 id: ElementId,
116 label: SharedString,
117 headline: Option<SharedString>,
118 items: Vec<NavItem>,
119 sections: Vec<(usize, SharedString)>,
120 selected: Option<SharedString>,
121 modal: bool,
122 open: bool,
123 dense: bool,
124 position: DrawerPosition,
125 width: f32,
126 icons: bool,
127 ripple: bool,
128 focus_on_open: bool,
129 on_change: Option<Change>,
130 on_dismiss: Option<ClickHandler>,
131}
132
133impl NavigationDrawer {
134 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
136 Self {
137 id: id.into(),
138 label: label.into(),
139 headline: None,
140 items: Vec::new(),
141 sections: Vec::new(),
142 selected: None,
143 modal: false,
144 open: true,
145 dense: false,
146 position: DrawerPosition::Start,
147 width: DRAWER_WIDTH,
148 icons: true,
149 ripple: true,
150 focus_on_open: true,
151 on_change: None,
152 on_dismiss: None,
153 }
154 }
155 pub fn headline(mut self, headline: impl Into<SharedString>) -> Self {
157 self.headline = Some(headline.into());
158 self
159 }
160 pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
162 self.items = items.into_iter().collect();
163 self
164 }
165 pub fn section(mut self, before: usize, label: impl Into<SharedString>) -> Self {
168 self.sections.push((before, label.into()));
169 self
170 }
171 pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
172 self.selected = Some(value.into());
173 self
174 }
175 pub fn modal(mut self, modal: bool) -> Self {
177 self.modal = modal;
178 self
179 }
180 pub fn open(mut self, open: bool) -> Self {
182 self.open = open;
183 self
184 }
185 pub fn dense(mut self, dense: bool) -> Self {
187 self.dense = dense;
188 self
189 }
190 pub fn position(mut self, position: DrawerPosition) -> Self {
191 self.position = position;
192 self
193 }
194 pub fn width(mut self, width: f32) -> Self {
196 self.width = width.clamp(240., DRAWER_WIDTH);
197 self
198 }
199 pub fn icons(mut self, icons: bool) -> Self {
201 self.icons = icons;
202 self
203 }
204 pub fn ripple(mut self, ripple: bool) -> Self {
206 self.ripple = ripple;
207 self
208 }
209 pub fn focus_on_open(mut self, focus: bool) -> Self {
213 self.focus_on_open = focus;
214 self
215 }
216 pub fn on_change(
217 mut self,
218 handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
219 ) -> Self {
220 self.on_change = Some(Rc::new(handler));
221 self
222 }
223 pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
226 self.on_dismiss = Some(Rc::new(move |_, window, cx| handler(window, cx)));
227 self
228 }
229}
230
231struct DrawerRow {
233 id: ElementId,
234 item: NavItem,
235 selected: bool,
236 height: f32,
237 icon: bool,
238 ripple: bool,
239 focus: FocusHandle,
240 tab_stop: bool,
241 position: (usize, usize),
242 on_click: ClickHandler,
243}
244
245impl DrawerRow {
246 fn render(self, window: &mut Window, cx: &mut App) -> AnyElement {
247 let item = self.item;
248 let preview = item.preview_state_value();
249 let disabled = item.is_disabled_value();
250 let enabled = !disabled;
251 let selected = self.selected;
252 let fade = motion::token_value(
253 (self.id.clone(), "drawer-fade"),
254 if selected { 1. } else { 0. },
255 MotionToken::FastEffects,
256 window,
257 cx,
258 );
259 let paint = drawer_paint(selected, fade, disabled);
260 #[cfg(test)]
261 DRAWER_PAINTED.with(|painted| painted.borrow_mut().insert(item.value().to_string(), paint));
262 let focus = self
263 .focus
264 .tab_stop(self.tab_stop && enabled && preview.is_none());
265 let layer = div()
266 .id("drawer-state-layer")
267 .absolute()
268 .inset_0()
269 .rounded_full()
270 .map(|layer| match preview {
271 _ if disabled => layer,
272 None => layer
273 .group_hover(ITEM_GROUP, move |style| style.bg(paint.hover))
274 .group_active(ITEM_GROUP, move |style| style.bg(paint.press)),
275 Some(InteractionPreview::Hovered) => layer.bg(paint.hover),
276 Some(InteractionPreview::Pressed) => layer.bg(paint.press),
277 Some(_) => layer,
278 });
279 let indicator = (paint.indicator.a > 0.001).then(|| {
280 div()
281 .absolute()
282 .inset_0()
283 .rounded_full()
284 .bg(paint.indicator)
285 });
286 let badge = match item.badge_value() {
287 NavBadge::None => None,
288 NavBadge::Small => Some(small_badge().into_any_element()),
289 NavBadge::Large(text) => Some(
290 div()
291 .flex_shrink_0()
292 .text_color(paint.label)
293 .map(|text| type_role(text, TypeRole::LabelLarge))
294 .child(text.clone())
295 .into_any_element(),
296 ),
297 };
298 let height = rem(self.height);
299 let content = div()
300 .relative()
301 .w_full()
302 .h(height)
303 .flex()
304 .items_center()
305 .gap(rem(ITEM_GAP))
306 .pl(rem(ITEM_LEADING))
307 .pr(rem(ITEM_TRAILING))
308 .rounded_full()
309 .when(cfg!(test), |content| {
310 let value = item.value().clone();
311 content.debug_selector(move || format!("drawer-item-{value}"))
312 })
313 .children(indicator)
314 .child(layer)
315 .when(self.icon, |content| {
316 content.child(
317 Icon::new(item.icon_value().path(selected))
318 .size(rem(24.))
319 .color(paint.icon),
320 )
321 })
322 .child(
323 div()
324 .relative()
325 .flex_1()
326 .min_w_0()
327 .whitespace_nowrap()
328 .text_ellipsis()
329 .text_color(paint.label)
330 .map(|label| type_role(label, TypeRole::LabelLarge))
331 .child(item.label().clone()),
332 )
333 .children(badge);
334 let radius = height.to_pixels(window.rem_size()) / 2.;
335 let ripple = ButtonRipple::new(
336 content,
337 (self.id.clone(), "drawer-ripple").into(),
338 preview.is_none() && self.ripple,
339 enabled,
340 ripple_corners(radius),
341 Edges::all(px(0.).into()),
342 if selected {
343 color(ColorRole::ON_SECONDARY_CONTAINER)
344 } else {
345 color(ColorRole::ON_SURFACE)
346 },
347 window,
348 cx,
349 );
350 let ring =
351 ButtonFocusRing::with_corners(ripple, focus.clone(), enabled, Corners::all(radius))
352 .forced(preview == Some(InteractionPreview::Focused));
353 let click_focus = focus.clone();
354 let on_click = self.on_click;
355 let (index, count) = self.position;
356 gpui_base::Tab::new(self.id)
357 .group(ITEM_GROUP)
358 .selected(selected)
359 .disabled(disabled)
360 .accessibility_label(item.label().clone())
361 .set_position(index + 1, count)
362 .track_focus(&focus)
363 .w_full()
364 .h(height)
365 .flex_shrink_0()
366 .flex()
367 .child(ring)
368 .when(enabled && preview.is_none(), |tab| {
369 tab.cursor_pointer().on_click(move |event, window, cx| {
370 click_focus.focus(window, cx);
371 on_click(event, window, cx)
372 })
373 })
374 .into_any_element()
375 }
376}
377
378fn header(id: (&'static str, usize), text: SharedString, height: f32) -> AnyElement {
379 div()
380 .id(id)
381 .role(Role::Heading)
382 .aria_label(text.clone())
383 .flex_shrink_0()
384 .h(rem(height))
385 .flex()
386 .items_center()
387 .px(rem(16.))
388 .whitespace_nowrap()
389 .text_color(color(ColorRole::ON_SURFACE_VARIANT))
390 .map(|text| type_role(text, TypeRole::TitleSmall))
391 .child(text)
392 .into_any_element()
393}
394
395fn divider(index: usize) -> AnyElement {
397 div()
398 .id(("drawer-divider", index))
399 .flex_shrink_0()
400 .px(rem(16.))
401 .child(div().h(px(1.)).bg(color(ColorRole::OUTLINE_VARIANT)))
402 .into_any_element()
403}
404
405impl RenderOnce for NavigationDrawer {
406 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
407 let open = self.open;
408 let amount = motion::token_value(
409 (self.id.clone(), "drawer-open"),
410 if open { 1. } else { 0. },
411 MotionToken::DefaultSpatial,
412 window,
413 cx,
414 );
415 let visible = amount.clamp(0., 1.);
416 let width = self.width;
417 #[cfg(test)]
418 DRAWER_OPEN.with(|cell| cell.set((visible, width)));
419 let modal = self.modal;
420 let end = self.position == DrawerPosition::End;
421 if !open && visible < 0.001 {
423 return div()
424 .id(self.id)
425 .when(!modal, |gap| gap.h_full().w(px(0.)))
426 .into_any_element();
427 }
428 let list = destinations(
429 &self.id,
430 &self.items,
431 self.selected.as_ref(),
432 Orientation::Column,
433 window,
434 cx,
435 );
436 let was_open =
438 window.use_keyed_state((self.id.clone(), "drawer-was-open"), cx, |_, _| false);
439 let opened = open && !*was_open.read(cx);
440 was_open.update(cx, |was, _| *was = open);
441 if modal
442 && opened
443 && self.focus_on_open
444 && let Some(handle) = list
445 .selected
446 .or(list.entry)
447 .map(|i| list.handles[i].clone())
448 {
449 window.defer(cx, move |window, cx| handle.focus(window, cx));
450 }
451
452 let row_height = if self.dense {
453 DENSE_ITEM_HEIGHT
454 } else {
455 ITEM_HEIGHT
456 };
457 let count = self.items.len();
458 let mut sections = self.sections;
459 sections.sort_by_key(|(before, _)| *before);
460 let mut sections = sections.into_iter().peekable();
461 let mut lane = gpui_base::Tabs::new((self.id.clone(), "drawer-items"))
462 .aria_label(self.label.clone())
463 .aria_orientation(accesskit::Orientation::Vertical)
464 .w_full()
465 .flex()
466 .flex_col();
467 for (index, item) in self.items.into_iter().enumerate() {
468 while let Some((_, text)) = sections.next_if(|(before, _)| *before <= index) {
469 if index > 0 {
470 lane = lane.child(divider(index));
471 }
472 lane = lane.child(header(("drawer-section", index), text, row_height));
473 }
474 let selected = list.selected == Some(index);
475 let on_click = item_click(self.on_change.clone(), item.value().clone(), selected);
476 lane = lane.child(
477 DrawerRow {
478 id: ElementId::NamedChild(self.id.clone().into(), item.value().clone()),
479 item,
480 selected,
481 height: row_height,
482 icon: self.icons,
483 ripple: self.ripple,
484 focus: list.handles[index].clone(),
485 tab_stop: list.entry == Some(index),
486 position: (index, count),
487 on_click,
488 }
489 .render(window, cx),
490 );
491 }
492 let lane = list.roving.install(lane);
493 let on_dismiss = self.on_dismiss.filter(|_| modal);
494 let sheet = div()
495 .id((self.id.clone(), "drawer-sheet"))
496 .when(cfg!(test), |sheet| {
497 sheet.debug_selector(|| "navigation-drawer-sheet".into())
498 })
499 .w(rem(width))
500 .h_full()
501 .flex_shrink_0()
502 .flex()
503 .flex_col()
504 .p(rem(DRAWER_PADDING))
505 .overflow_y_scroll()
506 .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
507 .when(modal, |sheet| {
508 let radius = rem(MODAL_RADIUS);
509 sheet
510 .occlude()
511 .map(|sheet| {
512 if end {
513 sheet.rounded_l(radius)
514 } else {
515 sheet.rounded_r(radius)
516 }
517 })
518 .shadow(elevation::level(1))
519 })
520 .when_some(self.headline, |sheet, text| {
521 sheet.child(header(("drawer-headline", 0), text, row_height))
522 })
523 .child(lane)
524 .when_some(on_dismiss.clone(), |sheet, dismiss| {
525 sheet.on_key_down(move |event: &KeyDownEvent, window, cx| {
526 if event.keystroke.key == "escape" {
527 cx.stop_propagation();
528 dismiss(&ClickEvent::default(), window, cx);
529 }
530 })
531 });
532 let landmark = |element: Stateful<Div>| {
533 element
534 .role(Role::Navigation)
535 .aria_label(self.label.clone())
536 };
537 if modal {
538 let offset = rem((-(1. - amount) * width).min(0.));
539 let scrim = div()
540 .id((self.id.clone(), "drawer-scrim"))
541 .when(cfg!(test), |scrim| {
542 scrim.debug_selector(|| "navigation-drawer-scrim".into())
543 })
544 .absolute()
545 .inset_0()
546 .occlude()
547 .bg(color(ColorRole::SCRIM).opacity(0.32 * visible))
548 .when_some(on_dismiss, |scrim, dismiss| {
549 scrim.on_click(move |event, window, cx| dismiss(event, window, cx))
550 });
551 landmark(div().id(self.id))
552 .absolute()
553 .inset_0()
554 .overflow_hidden()
555 .child(scrim)
556 .child(
557 div()
558 .absolute()
559 .top_0()
560 .bottom_0()
561 .map(|frame| {
562 if end {
563 frame.right(offset)
564 } else {
565 frame.left(offset)
566 }
567 })
568 .child(sheet),
569 )
570 .into_any_element()
571 } else {
572 landmark(div().id(self.id))
573 .h_full()
574 .w(rem(width * visible))
575 .flex_shrink_0()
576 .flex()
577 .when(end, |gap| gap.justify_end())
578 .overflow_hidden()
579 .child(sheet)
580 .into_any_element()
581 }
582 }
583}
584
585#[cfg(test)]
586#[path = "navigation_drawer_tests.rs"]
587mod tests;