1use crate::{
2 ActiveTheme, Collapsible, Icon, IconName, Side, Sizable, StyledExt,
3 button::{Button, ButtonVariants},
4 h_flex,
5 scroll::ScrollableElement,
6 v_flex,
7};
8use gpui::{
9 AbsoluteLength, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, ElementId,
10 InteractiveElement as _, IntoElement, Length, ListAlignment, ListState, ParentElement, Pixels,
11 RenderOnce, SharedString, StyleRefinement, Styled, Window, div, list, prelude::FluentBuilder,
12 px,
13};
14use std::{rc::Rc, time::Duration};
15
16use crate::animation::{EffectTransition, ease_in_out_cubic};
17
18mod footer;
19mod group;
20mod header;
21mod menu;
22pub use footer::*;
23pub use group::*;
24pub use header::*;
25pub use menu::*;
26
27const DEFAULT_WIDTH: Pixels = px(255.);
28const COLLAPSED_WIDTH: Pixels = px(48.);
29const SIDEBAR_TRANSITION_DURATION: Duration = Duration::from_millis(200);
30
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum SidebarCollapsible {
39 #[default]
41 Icon,
42 Offcanvas,
44 None,
46}
47
48impl From<bool> for SidebarCollapsible {
49 fn from(collapsible: bool) -> Self {
50 if collapsible { Self::Icon } else { Self::None }
51 }
52}
53
54#[derive(Clone, Copy, Debug, PartialEq)]
55enum SidebarWrapperLayout {
56 None,
57 Static { width: Pixels },
58 Animated { target_width: Pixels },
59}
60
61#[derive(Clone, Copy, Debug, PartialEq)]
62struct SidebarLayout {
63 icon_collapsed: bool,
64 offcanvas_collapsed: bool,
65 align_child_to_end: bool,
66 wrapper: SidebarWrapperLayout,
67}
68
69impl SidebarLayout {
70 fn new(
71 collapsible: SidebarCollapsible,
72 collapsed: bool,
73 expanded_width: Option<Pixels>,
74 side: Side,
75 ) -> Self {
76 let collapsed = collapsed && collapsible != SidebarCollapsible::None;
77 let wrapper = match collapsible {
78 SidebarCollapsible::None => SidebarWrapperLayout::None,
79 SidebarCollapsible::Icon => match expanded_width {
80 Some(expanded_width) => SidebarWrapperLayout::Animated {
81 target_width: if collapsed {
82 COLLAPSED_WIDTH
83 } else {
84 expanded_width
85 },
86 },
87 None => SidebarWrapperLayout::None,
88 },
89 SidebarCollapsible::Offcanvas => match (expanded_width, collapsed) {
90 (Some(_), true) => SidebarWrapperLayout::Animated {
91 target_width: px(0.),
92 },
93 (Some(expanded_width), false) => SidebarWrapperLayout::Animated {
94 target_width: expanded_width,
95 },
96 (None, true) => SidebarWrapperLayout::Static { width: px(0.) },
97 (None, false) => SidebarWrapperLayout::None,
98 },
99 };
100 let align_child_to_end = match collapsible {
101 SidebarCollapsible::Offcanvas => side.is_left(),
102 _ => side.is_right(),
103 };
104
105 Self {
106 icon_collapsed: collapsed && collapsible == SidebarCollapsible::Icon,
107 offcanvas_collapsed: collapsed && collapsible == SidebarCollapsible::Offcanvas,
108 align_child_to_end,
109 wrapper,
110 }
111 }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq)]
115struct SidebarAnimationState {
116 from_width: Pixels,
117 target_width: Pixels,
118 render_child: bool,
119 hide_scheduled: bool,
120 hide_request: u64,
121}
122
123impl SidebarAnimationState {
124 fn new(target_width: Pixels, render_child: bool) -> Self {
125 Self {
126 from_width: target_width,
127 target_width,
128 render_child,
129 hide_scheduled: false,
130 hide_request: 0,
131 }
132 }
133
134 fn needs_update(&self, target_width: Pixels, offcanvas_collapsed: bool) -> bool {
135 let child_state_changed = if offcanvas_collapsed {
136 self.render_child && !self.hide_scheduled
137 } else {
138 !self.render_child || self.hide_scheduled
139 };
140
141 self.target_width != target_width || child_state_changed
142 }
143
144 fn update_target(&mut self, target_width: Pixels, offcanvas_collapsed: bool) -> Option<u64> {
145 if self.target_width != target_width {
146 self.from_width = self.target_width;
147 self.target_width = target_width;
148 }
149
150 if offcanvas_collapsed {
151 if self.render_child && !self.hide_scheduled {
152 self.hide_scheduled = true;
153 self.hide_request = self.hide_request.wrapping_add(1);
154 Some(self.hide_request)
155 } else {
156 None
157 }
158 } else {
159 self.render_child = true;
160 if self.hide_scheduled {
161 self.hide_request = self.hide_request.wrapping_add(1);
162 }
163 self.hide_scheduled = false;
164 None
165 }
166 }
167
168 fn finish_hide(&mut self, request: u64) -> bool {
169 if self.render_child
170 && self.hide_scheduled
171 && self.hide_request == request
172 && self.target_width == px(0.)
173 {
174 self.render_child = false;
175 self.hide_scheduled = false;
176 true
177 } else {
178 false
179 }
180 }
181}
182
183fn sidebar_wrapper(
184 id: impl Into<ElementId>,
185 align_child_to_end: bool,
186) -> impl ParentElement + IntoElement + Styled {
187 div()
188 .id(id)
189 .flex()
190 .h_full()
191 .flex_shrink_0()
192 .overflow_hidden()
193 .when(align_child_to_end, |this| this.justify_end())
194}
195
196fn sidebar_expanded_width(style: &StyleRefinement) -> Option<Pixels> {
197 match style.size.width {
198 Some(Length::Definite(DefiniteLength::Absolute(AbsoluteLength::Pixels(px)))) => Some(px),
199 Some(_) => None,
200 None => Some(DEFAULT_WIDTH),
201 }
202}
203
204fn sidebar_animation_id(id: &ElementId, from: Pixels, to: Pixels) -> ElementId {
205 ElementId::NamedInteger(
206 format!("{id}-anim-w").into(),
207 (from.as_f32().to_bits() as u64) << 32 | to.as_f32().to_bits() as u64,
208 )
209}
210
211pub trait SidebarItem: Collapsible + Clone {
212 fn render(
213 self,
214 id: impl Into<ElementId>,
215 window: &mut Window,
216 cx: &mut App,
217 ) -> impl IntoElement;
218}
219
220#[derive(IntoElement)]
222pub struct Sidebar<E: SidebarItem + 'static> {
223 id: ElementId,
224 style: StyleRefinement,
225 content: Vec<E>,
226 header: Option<AnyElement>,
228 footer: Option<AnyElement>,
230 side: Side,
232 collapsible: SidebarCollapsible,
233 collapsed: bool,
234}
235
236impl<E: SidebarItem> Sidebar<E> {
237 pub fn new(id: impl Into<ElementId>) -> Self {
239 Self {
240 id: id.into(),
241 style: StyleRefinement::default(),
242 content: vec![],
243 header: None,
244 footer: None,
245 side: Side::Left,
246 collapsible: SidebarCollapsible::Icon,
247 collapsed: false,
248 }
249 }
250
251 pub fn side(mut self, side: Side) -> Self {
255 self.side = side;
256 self
257 }
258
259 pub fn collapsible(mut self, collapsible: impl Into<SidebarCollapsible>) -> Self {
265 self.collapsible = collapsible.into();
266 self
267 }
268
269 pub fn collapsed(mut self, collapsed: bool) -> Self {
271 self.collapsed = collapsed;
272 self
273 }
274
275 pub fn header(mut self, header: impl IntoElement) -> Self {
277 self.header = Some(header.into_any_element());
278 self
279 }
280
281 pub fn footer(mut self, footer: impl IntoElement) -> Self {
283 self.footer = Some(footer.into_any_element());
284 self
285 }
286
287 pub fn child(mut self, child: E) -> Self {
289 self.content.push(child);
290 self
291 }
292
293 pub fn children(mut self, children: impl IntoIterator<Item = E>) -> Self {
295 self.content.extend(children);
296 self
297 }
298}
299
300#[derive(IntoElement)]
302pub struct SidebarToggleButton {
303 btn: Button,
304 collapsed: bool,
305 side: Side,
306 on_click: Option<Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>>,
307}
308
309impl SidebarToggleButton {
310 pub fn new() -> Self {
312 Self {
313 btn: Button::new("collapse").ghost().small(),
314 collapsed: false,
315 side: Side::Left,
316 on_click: None,
317 }
318 }
319
320 pub fn side(mut self, side: Side) -> Self {
324 self.side = side;
325 self
326 }
327
328 pub fn collapsed(mut self, collapsed: bool) -> Self {
330 self.collapsed = collapsed;
331 self
332 }
333
334 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
339 self.btn = self.btn.accessibility_label(label);
340 self
341 }
342
343 pub fn on_click(
345 mut self,
346 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
347 ) -> Self {
348 self.on_click = Some(Rc::new(on_click));
349 self
350 }
351}
352
353impl RenderOnce for SidebarToggleButton {
354 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
355 let collapsed = self.collapsed;
356 let on_click = self.on_click.clone();
357
358 let icon = if collapsed {
359 if self.side.is_left() {
360 IconName::PanelLeftOpen
361 } else {
362 IconName::PanelRightOpen
363 }
364 } else {
365 if self.side.is_left() {
366 IconName::PanelLeftClose
367 } else {
368 IconName::PanelRightClose
369 }
370 };
371
372 self.btn
373 .when_some(on_click, |this, on_click| {
374 this.on_click(move |ev, window, cx| {
375 on_click(ev, window, cx);
376 })
377 })
378 .icon(Icon::new(icon).size_4())
379 }
380}
381
382impl<E: SidebarItem> Styled for Sidebar<E> {
383 fn style(&mut self) -> &mut StyleRefinement {
384 &mut self.style
385 }
386}
387
388impl<E: SidebarItem> RenderOnce for Sidebar<E> {
389 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
390 self.style.padding = EdgesRefinement::default();
391
392 let id = self.id;
393 let content_len = self.content.len();
394 let overdraw = px(window.viewport_size().height.as_f32() * 0.3);
395 let list_state = window
396 .use_keyed_state(
397 SharedString::from(format!("{}-list-state", id)),
398 cx,
399 |_, _| ListState::new(content_len, ListAlignment::Top, overdraw),
400 )
401 .read(cx)
402 .clone();
403 if list_state.item_count() != content_len {
404 list_state.reset(content_len);
405 }
406
407 let expanded_width = sidebar_expanded_width(&self.style);
410 let layout =
411 SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side);
412
413 let sidebar = v_flex()
417 .id(id.clone())
418 .flex_shrink_0()
419 .h_full()
420 .overflow_hidden()
421 .relative()
422 .bg(cx.theme().tokens.sidebar)
423 .text_color(cx.theme().sidebar_foreground)
424 .border_color(cx.theme().sidebar_border)
425 .map(|this| match self.side {
426 Side::Left => this.border_r_1(),
427 Side::Right => this.border_l_1(),
428 })
429 .when(self.style.size.width.is_none(), |this| {
430 this.w(DEFAULT_WIDTH)
431 })
432 .refine_style(&self.style)
433 .when(layout.icon_collapsed, |this| {
434 this.w(COLLAPSED_WIDTH).gap_2()
435 })
436 .when_some(self.header.take(), |this, header| {
437 this.child(
438 h_flex()
439 .id("header")
440 .pt_3()
441 .px_3()
442 .gap_2()
443 .when(layout.icon_collapsed, |this| this.pt_2().px_2())
444 .child(header),
445 )
446 })
447 .child(
448 v_flex().id("content").flex_1().min_h_0().child(
449 v_flex()
450 .id("inner")
451 .size_full()
452 .px_3()
453 .gap_y_3()
454 .when(layout.icon_collapsed, |this| this.p_2())
455 .child(gpui_base::ScrollBounce::new(
456 "bounce",
457 &list_state,
458 list(list_state.clone(), {
459 move |ix, window, cx| {
460 let group = self.content.get(ix).cloned();
461 let is_first = ix == 0;
462 let is_last =
463 content_len > 0 && ix == content_len.saturating_sub(1);
464 div()
465 .id(ix)
466 .when_some(group, |this, group| {
467 this.child(
468 group
469 .collapsed(layout.icon_collapsed)
470 .render(ix, window, cx)
471 .into_any_element(),
472 )
473 })
474 .when(is_first, |this| this.pt_3())
475 .when(is_last, |this| this.pb_3())
476 .into_any_element()
477 }
478 })
479 .size_full(),
480 ))
481 .vertical_scrollbar(&list_state),
482 ),
483 )
484 .when_some(self.footer.take(), |this, footer| {
485 this.child(
486 h_flex()
487 .id("footer")
488 .pb_3()
489 .px_3()
490 .gap_2()
491 .when(layout.icon_collapsed, |this| this.pt_2().px_2())
492 .child(footer),
493 )
494 });
495
496 let target_width = match layout.wrapper {
497 SidebarWrapperLayout::None => return sidebar.into_any_element(),
498 SidebarWrapperLayout::Static { width } => {
499 return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
500 .w(width)
501 .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
502 .into_any_element();
503 }
504 SidebarWrapperLayout::Animated { target_width } => target_width,
505 };
506
507 let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
515 SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
516 });
517
518 let hide_request = if animation_state
519 .read(cx)
520 .needs_update(target_width, layout.offcanvas_collapsed)
521 {
522 animation_state.update(cx, |state, _| {
523 state.update_target(target_width, layout.offcanvas_collapsed)
524 })
525 } else {
526 None
527 };
528 if let Some(hide_request) = hide_request {
529 cx.spawn({
530 let animation_state = animation_state.clone();
531 async move |cx| {
532 cx.background_executor()
533 .timer(SIDEBAR_TRANSITION_DURATION)
534 .await;
535 _ = animation_state.update(cx, |state, cx| {
536 if state.finish_hide(hide_request) {
537 cx.notify();
538 }
539 });
540 }
541 })
542 .detach();
543 }
544 let animation_state = *animation_state.read(cx);
545 let from_w = animation_state.from_width;
546 let to_w = animation_state.target_width;
547
548 let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
549 .when(animation_state.render_child, |this| this.child(sidebar));
550
551 EffectTransition::new(SIDEBAR_TRANSITION_DURATION)
552 .ease(ease_in_out_cubic)
553 .width(from_w, to_w)
554 .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
555 .into_any_element()
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 fn layout(
564 collapsible: SidebarCollapsible,
565 collapsed: bool,
566 expanded_width: Option<Pixels>,
567 side: Side,
568 ) -> SidebarLayout {
569 SidebarLayout::new(collapsible, collapsed, expanded_width, side)
570 }
571
572 #[test]
573 fn bool_collapsible_should_remain_backward_compatible() {
574 assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
575 assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
576 }
577
578 #[test]
579 fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
580 let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
581
582 assert!(layout.icon_collapsed);
583 assert!(!layout.offcanvas_collapsed);
584 assert!(!layout.align_child_to_end);
585 assert_eq!(
586 layout.wrapper,
587 SidebarWrapperLayout::Animated {
588 target_width: COLLAPSED_WIDTH,
589 }
590 );
591 }
592
593 #[test]
594 fn icon_expanded_should_use_expanded_width() {
595 let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
596
597 assert!(!layout.icon_collapsed);
598 assert!(!layout.offcanvas_collapsed);
599 assert_eq!(
600 layout.wrapper,
601 SidebarWrapperLayout::Animated {
602 target_width: px(240.),
603 }
604 );
605 }
606
607 #[test]
608 fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
609 let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
610
611 assert!(!layout.icon_collapsed);
612 assert!(!layout.offcanvas_collapsed);
613 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
614 }
615
616 #[test]
617 fn none_should_ignore_collapsed_state() {
618 let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
619
620 assert!(!layout.icon_collapsed);
621 assert!(!layout.offcanvas_collapsed);
622 assert!(layout.align_child_to_end);
623 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
624 }
625
626 #[test]
627 fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
628 let layout = layout(
629 SidebarCollapsible::Offcanvas,
630 true,
631 Some(px(240.)),
632 Side::Left,
633 );
634
635 assert!(!layout.icon_collapsed);
636 assert!(layout.offcanvas_collapsed);
637 assert!(layout.align_child_to_end);
638 assert_eq!(
639 layout.wrapper,
640 SidebarWrapperLayout::Animated {
641 target_width: px(0.),
642 }
643 );
644 }
645
646 #[test]
647 fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
648 let layout = layout(
649 SidebarCollapsible::Offcanvas,
650 false,
651 Some(px(240.)),
652 Side::Left,
653 );
654
655 assert!(!layout.icon_collapsed);
656 assert!(!layout.offcanvas_collapsed);
657 assert_eq!(
658 layout.wrapper,
659 SidebarWrapperLayout::Animated {
660 target_width: px(240.),
661 }
662 );
663 }
664
665 #[test]
666 fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
667 let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
668
669 assert!(!layout.icon_collapsed);
670 assert!(layout.offcanvas_collapsed);
671 assert_eq!(
672 layout.wrapper,
673 SidebarWrapperLayout::Static { width: px(0.) }
674 );
675 }
676
677 #[test]
678 fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
679 let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
680
681 assert!(!layout.icon_collapsed);
682 assert!(!layout.offcanvas_collapsed);
683 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
684 }
685
686 #[test]
687 fn offcanvas_should_anchor_child_toward_the_content_edge() {
688 let left = layout(
689 SidebarCollapsible::Offcanvas,
690 true,
691 Some(px(240.)),
692 Side::Left,
693 );
694 let right = layout(
695 SidebarCollapsible::Offcanvas,
696 true,
697 Some(px(240.)),
698 Side::Right,
699 );
700
701 assert!(left.align_child_to_end);
702 assert!(!right.align_child_to_end);
703 }
704
705 #[test]
706 fn animation_id_should_be_scoped_to_sidebar_id() {
707 let from = px(240.);
708 let to = COLLAPSED_WIDTH;
709
710 assert_ne!(
711 sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
712 sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
713 );
714 }
715
716 #[test]
717 fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
718 let mut state = SidebarAnimationState::new(px(240.), true);
719
720 let request = state.update_target(px(0.), true);
721
722 assert_eq!(request, Some(1));
723 assert_eq!(state.from_width, px(240.));
724 assert_eq!(state.target_width, px(0.));
725 assert!(state.render_child);
726
727 assert!(state.finish_hide(1));
728
729 assert!(!state.render_child);
730 assert!(!state.hide_scheduled);
731 }
732
733 #[test]
734 fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
735 let mut state = SidebarAnimationState::new(px(240.), true);
736
737 let request = state.update_target(px(0.), true);
738
739 assert_eq!(request, Some(1));
740 assert!(!state.needs_update(px(0.), true));
741 assert_eq!(state.update_target(px(0.), true), None);
742 assert_eq!(state.hide_request, 1);
743 }
744
745 #[test]
746 fn animation_state_should_cancel_pending_hide_when_reexpanded() {
747 let mut state = SidebarAnimationState::new(px(240.), true);
748
749 let request = state.update_target(px(0.), true).unwrap();
750 state.update_target(px(240.), false);
751
752 assert!(!state.finish_hide(request));
753 assert!(state.render_child);
754 assert!(!state.hide_scheduled);
755 assert_eq!(state.from_width, px(0.));
756 assert_eq!(state.target_width, px(240.));
757 }
758
759 #[test]
760 fn animation_state_should_ignore_stale_hide_request() {
761 let mut state = SidebarAnimationState::new(px(240.), true);
762
763 let request = state.update_target(px(0.), true).unwrap();
764 state.update_target(px(240.), false);
765 state.update_target(px(0.), true);
766
767 assert!(!state.finish_hide(request));
768 assert!(state.render_child);
769 assert!(state.hide_scheduled);
770 }
771
772 #[test]
773 fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
774 let state = SidebarAnimationState::new(px(0.), false);
775
776 assert!(!state.render_child);
777 assert_eq!(state.from_width, px(0.));
778 assert_eq!(state.target_width, px(0.));
779 }
780}