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(
456 list(list_state.clone(), {
457 move |ix, window, cx| {
458 let group = self.content.get(ix).cloned();
459 let is_first = ix == 0;
460 let is_last =
461 content_len > 0 && ix == content_len.saturating_sub(1);
462 div()
463 .id(ix)
464 .when_some(group, |this, group| {
465 this.child(
466 group
467 .collapsed(layout.icon_collapsed)
468 .render(ix, window, cx)
469 .into_any_element(),
470 )
471 })
472 .when(is_first, |this| this.pt_3())
473 .when(is_last, |this| this.pb_3())
474 .into_any_element()
475 }
476 })
477 .size_full(),
478 )
479 .vertical_scrollbar(&list_state),
480 ),
481 )
482 .when_some(self.footer.take(), |this, footer| {
483 this.child(
484 h_flex()
485 .id("footer")
486 .pb_3()
487 .px_3()
488 .gap_2()
489 .when(layout.icon_collapsed, |this| this.pt_2().px_2())
490 .child(footer),
491 )
492 });
493
494 let target_width = match layout.wrapper {
495 SidebarWrapperLayout::None => return sidebar.into_any_element(),
496 SidebarWrapperLayout::Static { width } => {
497 return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
498 .w(width)
499 .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
500 .into_any_element();
501 }
502 SidebarWrapperLayout::Animated { target_width } => target_width,
503 };
504
505 let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
513 SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
514 });
515
516 let hide_request = if animation_state
517 .read(cx)
518 .needs_update(target_width, layout.offcanvas_collapsed)
519 {
520 animation_state.update(cx, |state, _| {
521 state.update_target(target_width, layout.offcanvas_collapsed)
522 })
523 } else {
524 None
525 };
526 if let Some(hide_request) = hide_request {
527 cx.spawn({
528 let animation_state = animation_state.clone();
529 async move |cx| {
530 cx.background_executor()
531 .timer(SIDEBAR_TRANSITION_DURATION)
532 .await;
533 _ = animation_state.update(cx, |state, cx| {
534 if state.finish_hide(hide_request) {
535 cx.notify();
536 }
537 });
538 }
539 })
540 .detach();
541 }
542 let animation_state = *animation_state.read(cx);
543 let from_w = animation_state.from_width;
544 let to_w = animation_state.target_width;
545
546 let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
547 .when(animation_state.render_child, |this| this.child(sidebar));
548
549 EffectTransition::new(SIDEBAR_TRANSITION_DURATION)
550 .ease(ease_in_out_cubic)
551 .width(from_w, to_w)
552 .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
553 .into_any_element()
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 fn layout(
562 collapsible: SidebarCollapsible,
563 collapsed: bool,
564 expanded_width: Option<Pixels>,
565 side: Side,
566 ) -> SidebarLayout {
567 SidebarLayout::new(collapsible, collapsed, expanded_width, side)
568 }
569
570 #[test]
571 fn bool_collapsible_should_remain_backward_compatible() {
572 assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
573 assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
574 }
575
576 #[test]
577 fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
578 let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
579
580 assert!(layout.icon_collapsed);
581 assert!(!layout.offcanvas_collapsed);
582 assert!(!layout.align_child_to_end);
583 assert_eq!(
584 layout.wrapper,
585 SidebarWrapperLayout::Animated {
586 target_width: COLLAPSED_WIDTH,
587 }
588 );
589 }
590
591 #[test]
592 fn icon_expanded_should_use_expanded_width() {
593 let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
594
595 assert!(!layout.icon_collapsed);
596 assert!(!layout.offcanvas_collapsed);
597 assert_eq!(
598 layout.wrapper,
599 SidebarWrapperLayout::Animated {
600 target_width: px(240.),
601 }
602 );
603 }
604
605 #[test]
606 fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
607 let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
608
609 assert!(!layout.icon_collapsed);
610 assert!(!layout.offcanvas_collapsed);
611 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
612 }
613
614 #[test]
615 fn none_should_ignore_collapsed_state() {
616 let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
617
618 assert!(!layout.icon_collapsed);
619 assert!(!layout.offcanvas_collapsed);
620 assert!(layout.align_child_to_end);
621 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
622 }
623
624 #[test]
625 fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
626 let layout = layout(
627 SidebarCollapsible::Offcanvas,
628 true,
629 Some(px(240.)),
630 Side::Left,
631 );
632
633 assert!(!layout.icon_collapsed);
634 assert!(layout.offcanvas_collapsed);
635 assert!(layout.align_child_to_end);
636 assert_eq!(
637 layout.wrapper,
638 SidebarWrapperLayout::Animated {
639 target_width: px(0.),
640 }
641 );
642 }
643
644 #[test]
645 fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
646 let layout = layout(
647 SidebarCollapsible::Offcanvas,
648 false,
649 Some(px(240.)),
650 Side::Left,
651 );
652
653 assert!(!layout.icon_collapsed);
654 assert!(!layout.offcanvas_collapsed);
655 assert_eq!(
656 layout.wrapper,
657 SidebarWrapperLayout::Animated {
658 target_width: px(240.),
659 }
660 );
661 }
662
663 #[test]
664 fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
665 let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
666
667 assert!(!layout.icon_collapsed);
668 assert!(layout.offcanvas_collapsed);
669 assert_eq!(
670 layout.wrapper,
671 SidebarWrapperLayout::Static { width: px(0.) }
672 );
673 }
674
675 #[test]
676 fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
677 let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
678
679 assert!(!layout.icon_collapsed);
680 assert!(!layout.offcanvas_collapsed);
681 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
682 }
683
684 #[test]
685 fn offcanvas_should_anchor_child_toward_the_content_edge() {
686 let left = layout(
687 SidebarCollapsible::Offcanvas,
688 true,
689 Some(px(240.)),
690 Side::Left,
691 );
692 let right = layout(
693 SidebarCollapsible::Offcanvas,
694 true,
695 Some(px(240.)),
696 Side::Right,
697 );
698
699 assert!(left.align_child_to_end);
700 assert!(!right.align_child_to_end);
701 }
702
703 #[test]
704 fn animation_id_should_be_scoped_to_sidebar_id() {
705 let from = px(240.);
706 let to = COLLAPSED_WIDTH;
707
708 assert_ne!(
709 sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
710 sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
711 );
712 }
713
714 #[test]
715 fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
716 let mut state = SidebarAnimationState::new(px(240.), true);
717
718 let request = state.update_target(px(0.), true);
719
720 assert_eq!(request, Some(1));
721 assert_eq!(state.from_width, px(240.));
722 assert_eq!(state.target_width, px(0.));
723 assert!(state.render_child);
724
725 assert!(state.finish_hide(1));
726
727 assert!(!state.render_child);
728 assert!(!state.hide_scheduled);
729 }
730
731 #[test]
732 fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
733 let mut state = SidebarAnimationState::new(px(240.), true);
734
735 let request = state.update_target(px(0.), true);
736
737 assert_eq!(request, Some(1));
738 assert!(!state.needs_update(px(0.), true));
739 assert_eq!(state.update_target(px(0.), true), None);
740 assert_eq!(state.hide_request, 1);
741 }
742
743 #[test]
744 fn animation_state_should_cancel_pending_hide_when_reexpanded() {
745 let mut state = SidebarAnimationState::new(px(240.), true);
746
747 let request = state.update_target(px(0.), true).unwrap();
748 state.update_target(px(240.), false);
749
750 assert!(!state.finish_hide(request));
751 assert!(state.render_child);
752 assert!(!state.hide_scheduled);
753 assert_eq!(state.from_width, px(0.));
754 assert_eq!(state.target_width, px(240.));
755 }
756
757 #[test]
758 fn animation_state_should_ignore_stale_hide_request() {
759 let mut state = SidebarAnimationState::new(px(240.), true);
760
761 let request = state.update_target(px(0.), true).unwrap();
762 state.update_target(px(240.), false);
763 state.update_target(px(0.), true);
764
765 assert!(!state.finish_hide(request));
766 assert!(state.render_child);
767 assert!(state.hide_scheduled);
768 }
769
770 #[test]
771 fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
772 let state = SidebarAnimationState::new(px(0.), false);
773
774 assert!(!state.render_child);
775 assert_eq!(state.from_width, px(0.));
776 assert_eq!(state.target_width, px(0.));
777 }
778}