1use super::adaptive::{WindowHeightClass, WindowSizeClass, WindowWidthClass};
18use super::navigation::{
19 NavItem, NavItemLayout, NavigationBar, NavigationRail, RAIL_COLLAPSED_WIDTH,
20 RAIL_EXPANDED_WIDTH,
21};
22use super::navigation_drawer::{DRAWER_WIDTH, NavigationDrawer};
23use crate::{
24 color::ColorRole,
25 motion::{self, MotionToken},
26 theme::color,
27 tokens::typography::rem,
28};
29use gpui::{prelude::*, *};
30use std::rc::Rc;
31
32pub const SUITE_PRIMARY_ACTION_PADDING: f32 = 16.;
35const DRAWER_ACTION_PADDING: f32 = 12.;
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
39pub enum NavigationSuiteType {
40 BarCompact,
42 BarMedium,
44 RailCollapsed,
46 RailExpanded,
48 Drawer,
50 None,
52}
53
54impl NavigationSuiteType {
55 pub const fn is_bar(self) -> bool {
57 matches!(self, Self::BarCompact | Self::BarMedium)
58 }
59}
60
61pub fn navigation_suite_type(
80 size: WindowSizeClass,
81 expanded_from: Option<WindowWidthClass>,
82 expanded_type: NavigationSuiteType,
83) -> NavigationSuiteType {
84 if size.width == WindowWidthClass::Compact {
85 NavigationSuiteType::BarCompact
86 } else if size.height == WindowHeightClass::Compact {
87 NavigationSuiteType::BarMedium
88 } else if expanded_from.is_some_and(|from| size.width >= from) {
89 expanded_type
90 } else {
91 NavigationSuiteType::RailCollapsed
92 }
93}
94
95type Change = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
96type TypeChange = Rc<dyn Fn(&NavigationSuiteType, &mut Window, &mut App)>;
97
98#[derive(gpui::IntoElement)]
118pub struct NavigationSuite {
119 id: ElementId,
120 label: SharedString,
121 items: Vec<NavItem>,
122 selected: Option<SharedString>,
123 layout_type: Option<NavigationSuiteType>,
124 size_class: Option<WindowSizeClass>,
125 expanded_from: Option<WindowWidthClass>,
126 expanded_type: NavigationSuiteType,
127 hidden: bool,
128 primary_action: Option<AnyElement>,
129 content: Vec<AnyElement>,
130 on_change: Option<Change>,
131 on_type_change: Option<TypeChange>,
132}
133
134impl NavigationSuite {
135 pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
137 Self {
138 id: id.into(),
139 label: label.into(),
140 items: Vec::new(),
141 selected: None,
142 layout_type: None,
143 size_class: None,
144 expanded_from: Some(WindowWidthClass::Large),
145 expanded_type: NavigationSuiteType::Drawer,
146 hidden: false,
147 primary_action: None,
148 content: Vec::new(),
149 on_change: None,
150 on_type_change: None,
151 }
152 }
153
154 pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
156 self.items = items.into_iter().collect();
157 self
158 }
159
160 pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
162 self.selected = Some(value.into());
163 self
164 }
165
166 pub fn layout_type(mut self, layout_type: Option<NavigationSuiteType>) -> Self {
168 self.layout_type = layout_type;
169 self
170 }
171
172 pub fn size_class(mut self, size_class: Option<WindowSizeClass>) -> Self {
175 self.size_class = size_class;
176 self
177 }
178
179 pub fn expanded_from(mut self, from: Option<WindowWidthClass>) -> Self {
182 self.expanded_from = from;
183 self
184 }
185
186 pub fn expanded_type(mut self, expanded_type: NavigationSuiteType) -> Self {
189 self.expanded_type = expanded_type;
190 self
191 }
192
193 pub fn navigation_hidden(mut self, hidden: bool) -> Self {
196 self.hidden = hidden;
197 self
198 }
199
200 pub fn primary_action(mut self, action: impl IntoElement) -> Self {
203 self.primary_action = Some(action.into_any_element());
204 self
205 }
206
207 pub fn child(mut self, child: impl IntoElement) -> Self {
209 self.content.push(child.into_any_element());
210 self
211 }
212
213 pub fn on_change(
215 mut self,
216 handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
217 ) -> Self {
218 self.on_change = Some(Rc::new(handler));
219 self
220 }
221
222 pub fn on_type_change(
224 mut self,
225 handler: impl Fn(&NavigationSuiteType, &mut Window, &mut App) + 'static,
226 ) -> Self {
227 self.on_type_change = Some(Rc::new(handler));
228 self
229 }
230
231 fn resolved_type(&self, window: &Window) -> NavigationSuiteType {
233 self.layout_type.unwrap_or_else(|| {
234 navigation_suite_type(
235 self.size_class
236 .unwrap_or_else(|| WindowSizeClass::of_window(window)),
237 self.expanded_from,
238 self.expanded_type,
239 )
240 })
241 }
242}
243
244fn navigation_extent(suite_type: NavigationSuiteType) -> f32 {
246 match suite_type {
247 NavigationSuiteType::BarCompact => NavItemLayout::Vertical.geometry().item_height,
248 NavigationSuiteType::BarMedium => NavItemLayout::Horizontal.geometry().item_height,
249 NavigationSuiteType::RailCollapsed => RAIL_COLLAPSED_WIDTH,
250 NavigationSuiteType::RailExpanded => RAIL_EXPANDED_WIDTH,
251 NavigationSuiteType::Drawer => DRAWER_WIDTH,
252 NavigationSuiteType::None => 0.,
253 }
254}
255
256fn forward(handler: &Option<Change>) -> impl Fn(&SharedString, &mut Window, &mut App) + 'static {
258 let handler = handler.clone();
259 move |value, window, cx| {
260 if let Some(handler) = &handler {
261 handler(value, window, cx);
262 }
263 }
264}
265
266impl RenderOnce for NavigationSuite {
267 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
268 let suite_type = self.resolved_type(window);
269 let shown = motion::token_value(
270 (self.id.clone(), "suite-visibility"),
271 if self.hidden { 0. } else { 1. },
272 MotionToken::DefaultSpatial,
273 window,
274 cx,
275 )
276 .clamp(0., 1.);
277
278 let previous =
279 window.use_keyed_state((self.id.clone(), "suite-type"), cx, |_, _| suite_type);
280 if *previous.read(cx) != suite_type {
281 previous.update(cx, |previous, _| *previous = suite_type);
282 if let Some(handler) = self.on_type_change.clone() {
283 window.defer(cx, move |window, cx| handler(&suite_type, window, cx));
284 }
285 }
286
287 let bar = suite_type.is_bar();
288 let offset = rem(-navigation_extent(suite_type) * (1. - shown));
289 let present = !(self.hidden && shown <= 0.) && suite_type != NavigationSuiteType::None;
290 let on_change = self.on_change;
291 let mut action = self.primary_action;
292 let selected = self.selected.unwrap_or_default();
293 let navigation: Option<AnyElement> = present.then(|| match suite_type {
294 NavigationSuiteType::RailCollapsed | NavigationSuiteType::RailExpanded => {
295 let rail = NavigationRail::new(self.id.clone(), self.label.clone())
296 .items(self.items)
297 .selected(selected)
298 .expanded(suite_type == NavigationSuiteType::RailExpanded)
299 .on_change(forward(&on_change));
300 match action.take() {
301 Some(action) => rail.fab(action).into_any_element(),
302 None => rail.into_any_element(),
303 }
304 }
305 NavigationSuiteType::Drawer => {
306 let drawer = NavigationDrawer::new(self.id.clone(), self.label.clone())
307 .items(self.items)
308 .selected(selected)
309 .open(true)
310 .on_change(forward(&on_change));
311 match action.take() {
312 Some(action) => gpui_base::v_flex()
313 .h_full()
314 .flex_shrink_0()
315 .bg(color(ColorRole::SURFACE_CONTAINER_LOW))
316 .child(div().p(rem(DRAWER_ACTION_PADDING)).child(action))
317 .child(div().flex_1().min_h_0().child(drawer))
318 .into_any_element(),
319 None => drawer.into_any_element(),
320 }
321 }
322 _ => NavigationBar::new(self.id.clone(), self.label.clone())
324 .items(self.items)
325 .selected(selected)
326 .layout(if suite_type == NavigationSuiteType::BarMedium {
327 NavItemLayout::Horizontal
328 } else {
329 NavItemLayout::Vertical
330 })
331 .on_change(forward(&on_change))
332 .into_any_element(),
333 });
334 let floating = action.filter(|_| bar).map(|action| {
337 div()
338 .absolute()
339 .bottom(rem(SUITE_PRIMARY_ACTION_PADDING))
340 .right(rem(SUITE_PRIMARY_ACTION_PADDING))
341 .child(action)
342 });
343 let content = div()
344 .relative()
345 .flex_1()
346 .min_w_0()
347 .min_h_0()
348 .debug_selector(|| "navigation-suite-content".into())
349 .children(self.content)
350 .children(floating);
351 let navigation = navigation.map(|navigation| {
352 div()
353 .flex_shrink_0()
354 .debug_selector(|| "navigation-suite-navigation".into())
355 .map(|frame| {
356 if bar {
357 frame.w_full().mb(offset)
358 } else {
359 frame.h_full().ml(offset)
360 }
361 })
362 .child(navigation)
363 });
364 div()
365 .id((self.id, "suite"))
366 .size_full()
367 .flex()
368 .overflow_hidden()
369 .bg(color(ColorRole::SURFACE))
370 .text_color(color(ColorRole::ON_SURFACE))
371 .map(|root| {
372 if bar {
373 root.flex_col().child(content).children(navigation)
374 } else {
375 root.flex_row().children(navigation).child(content)
376 }
377 })
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::components::NavIcon;
385 use core::prelude::v1::test;
386
387 fn class(width: f32, height: f32) -> WindowSizeClass {
388 WindowSizeClass::of(width, height)
389 }
390
391 #[test]
392 fn the_rule_moves_between_bar_rail_and_drawer_by_width() {
393 use NavigationSuiteType::*;
394 let large = Some(WindowWidthClass::Large);
395 for (width, height, expected) in [
396 (360., 800., BarCompact),
397 (599., 800., BarCompact),
398 (600., 800., RailCollapsed),
399 (1199., 800., RailCollapsed),
400 (1200., 800., Drawer),
401 (1800., 800., Drawer),
402 (900., 479., BarMedium),
404 (1400., 479., BarMedium),
405 (360., 479., BarCompact),
406 ] {
407 assert_eq!(
408 navigation_suite_type(class(width, height), large, Drawer),
409 expected,
410 "{width}x{height}"
411 );
412 }
413 }
414
415 #[test]
416 fn the_expanded_form_and_its_breakpoint_are_options() {
417 use NavigationSuiteType::*;
418 let wide = class(1300., 800.);
419 assert_eq!(
420 navigation_suite_type(wide, Some(WindowWidthClass::Large), RailExpanded),
421 RailExpanded,
422 );
423 assert_eq!(
424 navigation_suite_type(wide, Option::None, Drawer),
425 RailCollapsed
426 );
427 assert_eq!(
428 navigation_suite_type(class(900., 800.), Some(WindowWidthClass::Expanded), Drawer),
429 Drawer,
430 );
431 }
432
433 struct Shell {
434 layout_type: Option<NavigationSuiteType>,
435 size_class: WindowSizeClass,
436 selected: SharedString,
437 hidden: bool,
438 changes: Vec<NavigationSuiteType>,
439 }
440
441 impl Render for Shell {
442 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
443 window.set_rem_size(px(crate::tokens::typography::BASE_PX));
444 div().size(px(1000.)).child(
445 NavigationSuite::new("suite", "Main")
446 .items(
447 ["home", "mail"]
448 .map(|value| NavItem::new(value, value, NavIcon::new("home", "home"))),
449 )
450 .selected(self.selected.clone())
451 .layout_type(self.layout_type)
452 .size_class(Some(self.size_class))
453 .navigation_hidden(self.hidden)
454 .on_change(cx.listener(|this, value: &SharedString, _, cx| {
455 this.selected = value.clone();
456 cx.notify();
457 }))
458 .on_type_change(cx.listener(|this, kind: &NavigationSuiteType, _, cx| {
459 this.changes.push(*kind);
460 cx.notify();
461 }))
462 .child(div().size_full().child("Content")),
463 )
464 }
465 }
466
467 fn open(cx: &mut gpui::TestAppContext) -> (Entity<Shell>, &mut gpui::VisualTestContext) {
468 cx.update(|cx| {
469 cx.set_reduce_motion(true);
470 });
471 cx.add_window_view(|_, _| Shell {
472 layout_type: None,
473 size_class: class(360., 800.),
474 selected: "home".into(),
475 hidden: false,
476 changes: Vec::new(),
477 })
478 }
479
480 fn draw(cx: &mut gpui::VisualTestContext) {
481 for _ in 0..3 {
482 cx.update(|window, cx| {
483 window.refresh();
484 window.draw(cx).clear(cx);
485 });
486 cx.run_until_parked();
487 }
488 }
489
490 #[gpui::test]
491 fn each_form_sits_on_its_edge_and_the_content_takes_the_rest(cx: &mut gpui::TestAppContext) {
492 let (view, cx) = open(cx);
493 draw(cx);
494 let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
495 let content = cx.debug_bounds("navigation-suite-content").unwrap();
496 assert!(nav.top() >= content.bottom(), "a bar is below the content");
497 assert_eq!(nav.size.width, px(1000.));
498
499 for (size_class, width) in [
500 (class(800., 800.), RAIL_COLLAPSED_WIDTH),
501 (class(1300., 800.), DRAWER_WIDTH),
502 ] {
503 cx.update(|_, cx| view.update(cx, |view, _| view.size_class = size_class));
504 draw(cx);
505 let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
506 let content = cx.debug_bounds("navigation-suite-content").unwrap();
507 assert_eq!(nav.size.width, px(width), "{size_class:?}");
508 assert_eq!(content.left(), nav.right(), "{size_class:?}");
509 assert_eq!(content.size.width, px(1000. - width), "{size_class:?}");
510 }
511 let changes = view.read_with(cx, |view, _| view.changes.clone());
512 assert_eq!(
513 changes,
514 [
515 NavigationSuiteType::RailCollapsed,
516 NavigationSuiteType::Drawer
517 ]
518 );
519 }
520
521 #[gpui::test]
522 fn hiding_gives_the_content_the_space_and_removes_the_navigation(
523 cx: &mut gpui::TestAppContext,
524 ) {
525 let (view, cx) = open(cx);
526 cx.update(|_, cx| {
527 view.update(cx, |view, _| {
528 view.size_class = class(800., 800.);
529 view.hidden = true;
530 })
531 });
532 draw(cx);
533 assert!(cx.debug_bounds("navigation-suite-navigation").is_none());
534 let content = cx.debug_bounds("navigation-suite-content").unwrap();
535 assert_eq!(content.size.width, px(1000.));
536 }
537
538 #[gpui::test]
539 fn a_fixed_type_wins_and_none_shows_only_content(cx: &mut gpui::TestAppContext) {
540 let (view, cx) = open(cx);
541 cx.update(|_, cx| {
542 view.update(cx, |view, _| {
543 view.layout_type = Some(NavigationSuiteType::None)
544 })
545 });
546 draw(cx);
547 assert!(cx.debug_bounds("navigation-suite-navigation").is_none());
548 cx.update(|_, cx| {
549 view.update(cx, |view, _| {
550 view.layout_type = Some(NavigationSuiteType::RailExpanded)
551 })
552 });
553 draw(cx);
554 let nav = cx.debug_bounds("navigation-suite-navigation").unwrap();
555 assert_eq!(nav.size.width, px(RAIL_EXPANDED_WIDTH));
556 }
557
558 #[gpui::test]
559 fn selection_and_keyboard_focus_survive_a_change_of_form(cx: &mut gpui::TestAppContext) {
560 let (view, cx) = open(cx);
561 draw(cx);
562 cx.update(|window, cx| window.focus_next(cx));
565 draw(cx);
566 let focused = cx.update(|window, cx| window.focused(cx));
567 assert!(focused.is_some(), "tab reaches a destination");
568 cx.update(|_, cx| view.update(cx, |view, _| view.size_class = class(800., 800.)));
569 draw(cx);
570 assert_eq!(cx.update(|window, cx| window.focused(cx)), focused);
571 assert_eq!(
572 view.read_with(cx, |view, _| view.selected.clone()).as_ref(),
573 "home"
574 );
575 }
576}