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 rgpui::{
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::{Transition, 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 on_click(
336 mut self,
337 on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
338 ) -> Self {
339 self.on_click = Some(Rc::new(on_click));
340 self
341 }
342}
343
344impl RenderOnce for SidebarToggleButton {
345 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
346 let collapsed = self.collapsed;
347 let on_click = self.on_click.clone();
348
349 let icon = if collapsed {
350 if self.side.is_left() {
351 IconName::PanelLeftOpen
352 } else {
353 IconName::PanelRightOpen
354 }
355 } else {
356 if self.side.is_left() {
357 IconName::PanelLeftClose
358 } else {
359 IconName::PanelRightClose
360 }
361 };
362
363 self.btn
364 .when_some(on_click, |this, on_click| {
365 this.on_click(move |ev, window, cx| {
366 on_click(ev, window, cx);
367 })
368 })
369 .icon(Icon::new(icon).size_4())
370 }
371}
372
373impl<E: SidebarItem> Styled for Sidebar<E> {
374 fn style(&mut self) -> &mut StyleRefinement {
375 &mut self.style
376 }
377}
378
379impl<E: SidebarItem> RenderOnce for Sidebar<E> {
380 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
381 self.style.padding = EdgesRefinement::default();
382
383 let id = self.id;
384 let content_len = self.content.len();
385 let overdraw = px(window.viewport_size().height.as_f32() * 0.3);
386 let list_state = window
387 .use_keyed_state(
388 SharedString::from(format!("{}-list-state", id)),
389 cx,
390 |_, _| ListState::new(content_len, ListAlignment::Top, overdraw),
391 )
392 .read(cx)
393 .clone();
394 if list_state.item_count() != content_len {
395 list_state.reset(content_len);
396 }
397
398 let expanded_width = sidebar_expanded_width(&self.style);
401 let layout =
402 SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side);
403
404 let sidebar = v_flex()
408 .id(id.clone())
409 .flex_shrink_0()
410 .h_full()
411 .overflow_hidden()
412 .relative()
413 .bg(cx.theme().sidebar)
414 .text_color(cx.theme().sidebar_foreground)
415 .border_color(cx.theme().sidebar_border)
416 .map(|this| match self.side {
417 Side::Left => this.border_r_1(),
418 Side::Right => this.border_l_1(),
419 })
420 .when(self.style.size.width.is_none(), |this| {
421 this.w(DEFAULT_WIDTH)
422 })
423 .refine_style(&self.style)
424 .when(layout.icon_collapsed, |this| {
425 this.w(COLLAPSED_WIDTH).gap_2()
426 })
427 .when_some(self.header.take(), |this, header| {
428 this.child(
429 h_flex()
430 .id("header")
431 .pt_3()
432 .px_3()
433 .gap_2()
434 .when(layout.icon_collapsed, |this| this.pt_2().px_2())
435 .child(header),
436 )
437 })
438 .child(
439 v_flex().id("content").flex_1().min_h_0().child(
440 v_flex()
441 .id("inner")
442 .size_full()
443 .px_3()
444 .gap_y_3()
445 .when(layout.icon_collapsed, |this| this.p_2())
446 .child(
447 list(list_state.clone(), {
448 move |ix, window, cx| {
449 let group = self.content.get(ix).cloned();
450 let is_first = ix == 0;
451 let is_last =
452 content_len > 0 && ix == content_len.saturating_sub(1);
453 div()
454 .id(ix)
455 .when_some(group, |this, group| {
456 this.child(
457 group
458 .collapsed(layout.icon_collapsed)
459 .render(ix, window, cx)
460 .into_any_element(),
461 )
462 })
463 .when(is_first, |this| this.pt_3())
464 .when(is_last, |this| this.pb_3())
465 .into_any_element()
466 }
467 })
468 .size_full(),
469 )
470 .vertical_scrollbar(&list_state),
471 ),
472 )
473 .when_some(self.footer.take(), |this, footer| {
474 this.child(
475 h_flex()
476 .id("footer")
477 .pb_3()
478 .px_3()
479 .gap_2()
480 .when(layout.icon_collapsed, |this| this.pt_2().px_2())
481 .child(footer),
482 )
483 });
484
485 let target_width = match layout.wrapper {
486 SidebarWrapperLayout::None => return sidebar.into_any_element(),
487 SidebarWrapperLayout::Static { width } => {
488 return sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
489 .w(width)
490 .when(!layout.offcanvas_collapsed, |this| this.child(sidebar))
491 .into_any_element();
492 }
493 SidebarWrapperLayout::Animated { target_width } => target_width,
494 };
495
496 let animation_state = window.use_keyed_state(format!("{}-anim-w", id), cx, |_, _| {
504 SidebarAnimationState::new(target_width, !layout.offcanvas_collapsed)
505 });
506
507 let hide_request = if animation_state
508 .read(cx)
509 .needs_update(target_width, layout.offcanvas_collapsed)
510 {
511 animation_state.update(cx, |state, _| {
512 state.update_target(target_width, layout.offcanvas_collapsed)
513 })
514 } else {
515 None
516 };
517 if let Some(hide_request) = hide_request {
518 cx.spawn({
519 let animation_state = animation_state.clone();
520 async move |cx| {
521 cx.background_executor()
522 .timer(SIDEBAR_TRANSITION_DURATION)
523 .await;
524 _ = animation_state.update(cx, |state, cx| {
525 if state.finish_hide(hide_request) {
526 cx.notify();
527 }
528 });
529 }
530 })
531 .detach();
532 }
533 let animation_state = *animation_state.read(cx);
534 let from_w = animation_state.from_width;
535 let to_w = animation_state.target_width;
536
537 let wrapper = sidebar_wrapper(format!("{}-anim", id), layout.align_child_to_end)
538 .when(animation_state.render_child, |this| this.child(sidebar));
539
540 Transition::new(SIDEBAR_TRANSITION_DURATION)
541 .ease(ease_in_out_cubic)
542 .width(from_w, to_w)
543 .apply(wrapper, sidebar_animation_id(&id, from_w, to_w))
544 .into_any_element()
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 fn layout(
553 collapsible: SidebarCollapsible,
554 collapsed: bool,
555 expanded_width: Option<Pixels>,
556 side: Side,
557 ) -> SidebarLayout {
558 SidebarLayout::new(collapsible, collapsed, expanded_width, side)
559 }
560
561 #[test]
562 fn bool_collapsible_should_remain_backward_compatible() {
563 assert_eq!(SidebarCollapsible::from(true), SidebarCollapsible::Icon);
564 assert_eq!(SidebarCollapsible::from(false), SidebarCollapsible::None);
565 }
566
567 #[test]
568 fn icon_collapsed_should_use_icon_width_and_icon_rendering() {
569 let layout = layout(SidebarCollapsible::Icon, true, Some(px(240.)), Side::Left);
570
571 assert!(layout.icon_collapsed);
572 assert!(!layout.offcanvas_collapsed);
573 assert!(!layout.align_child_to_end);
574 assert_eq!(
575 layout.wrapper,
576 SidebarWrapperLayout::Animated {
577 target_width: COLLAPSED_WIDTH,
578 }
579 );
580 }
581
582 #[test]
583 fn icon_expanded_should_use_expanded_width() {
584 let layout = layout(SidebarCollapsible::Icon, false, Some(px(240.)), Side::Left);
585
586 assert!(!layout.icon_collapsed);
587 assert!(!layout.offcanvas_collapsed);
588 assert_eq!(
589 layout.wrapper,
590 SidebarWrapperLayout::Animated {
591 target_width: px(240.),
592 }
593 );
594 }
595
596 #[test]
597 fn icon_expanded_with_non_pixel_width_should_keep_original_layout() {
598 let layout = layout(SidebarCollapsible::Icon, false, None, Side::Left);
599
600 assert!(!layout.icon_collapsed);
601 assert!(!layout.offcanvas_collapsed);
602 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
603 }
604
605 #[test]
606 fn none_should_ignore_collapsed_state() {
607 let layout = layout(SidebarCollapsible::None, true, Some(px(240.)), Side::Right);
608
609 assert!(!layout.icon_collapsed);
610 assert!(!layout.offcanvas_collapsed);
611 assert!(layout.align_child_to_end);
612 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
613 }
614
615 #[test]
616 fn offcanvas_collapsed_with_pixel_width_should_animate_to_zero() {
617 let layout = layout(
618 SidebarCollapsible::Offcanvas,
619 true,
620 Some(px(240.)),
621 Side::Left,
622 );
623
624 assert!(!layout.icon_collapsed);
625 assert!(layout.offcanvas_collapsed);
626 assert!(layout.align_child_to_end);
627 assert_eq!(
628 layout.wrapper,
629 SidebarWrapperLayout::Animated {
630 target_width: px(0.),
631 }
632 );
633 }
634
635 #[test]
636 fn offcanvas_expanded_with_pixel_width_should_use_expanded_width() {
637 let layout = layout(
638 SidebarCollapsible::Offcanvas,
639 false,
640 Some(px(240.)),
641 Side::Left,
642 );
643
644 assert!(!layout.icon_collapsed);
645 assert!(!layout.offcanvas_collapsed);
646 assert_eq!(
647 layout.wrapper,
648 SidebarWrapperLayout::Animated {
649 target_width: px(240.),
650 }
651 );
652 }
653
654 #[test]
655 fn offcanvas_collapsed_with_non_pixel_width_should_statically_release_layout() {
656 let layout = layout(SidebarCollapsible::Offcanvas, true, None, Side::Left);
657
658 assert!(!layout.icon_collapsed);
659 assert!(layout.offcanvas_collapsed);
660 assert_eq!(
661 layout.wrapper,
662 SidebarWrapperLayout::Static { width: px(0.) }
663 );
664 }
665
666 #[test]
667 fn offcanvas_expanded_with_non_pixel_width_should_keep_original_layout() {
668 let layout = layout(SidebarCollapsible::Offcanvas, false, None, Side::Left);
669
670 assert!(!layout.icon_collapsed);
671 assert!(!layout.offcanvas_collapsed);
672 assert_eq!(layout.wrapper, SidebarWrapperLayout::None);
673 }
674
675 #[test]
676 fn offcanvas_should_anchor_child_toward_the_content_edge() {
677 let left = layout(
678 SidebarCollapsible::Offcanvas,
679 true,
680 Some(px(240.)),
681 Side::Left,
682 );
683 let right = layout(
684 SidebarCollapsible::Offcanvas,
685 true,
686 Some(px(240.)),
687 Side::Right,
688 );
689
690 assert!(left.align_child_to_end);
691 assert!(!right.align_child_to_end);
692 }
693
694 #[test]
695 fn animation_id_should_be_scoped_to_sidebar_id() {
696 let from = px(240.);
697 let to = COLLAPSED_WIDTH;
698
699 assert_ne!(
700 sidebar_animation_id(&ElementId::Name("sidebar-a".into()), from, to),
701 sidebar_animation_id(&ElementId::Name("sidebar-b".into()), from, to)
702 );
703 }
704
705 #[test]
706 fn animation_state_should_keep_child_until_offcanvas_hide_finishes() {
707 let mut state = SidebarAnimationState::new(px(240.), true);
708
709 let request = state.update_target(px(0.), true);
710
711 assert_eq!(request, Some(1));
712 assert_eq!(state.from_width, px(240.));
713 assert_eq!(state.target_width, px(0.));
714 assert!(state.render_child);
715
716 assert!(state.finish_hide(1));
717
718 assert!(!state.render_child);
719 assert!(!state.hide_scheduled);
720 }
721
722 #[test]
723 fn animation_state_should_not_reschedule_pending_offcanvas_hide() {
724 let mut state = SidebarAnimationState::new(px(240.), true);
725
726 let request = state.update_target(px(0.), true);
727
728 assert_eq!(request, Some(1));
729 assert!(!state.needs_update(px(0.), true));
730 assert_eq!(state.update_target(px(0.), true), None);
731 assert_eq!(state.hide_request, 1);
732 }
733
734 #[test]
735 fn animation_state_should_cancel_pending_hide_when_reexpanded() {
736 let mut state = SidebarAnimationState::new(px(240.), true);
737
738 let request = state.update_target(px(0.), true).unwrap();
739 state.update_target(px(240.), false);
740
741 assert!(!state.finish_hide(request));
742 assert!(state.render_child);
743 assert!(!state.hide_scheduled);
744 assert_eq!(state.from_width, px(0.));
745 assert_eq!(state.target_width, px(240.));
746 }
747
748 #[test]
749 fn animation_state_should_ignore_stale_hide_request() {
750 let mut state = SidebarAnimationState::new(px(240.), true);
751
752 let request = state.update_target(px(0.), true).unwrap();
753 state.update_target(px(240.), false);
754 state.update_target(px(0.), true);
755
756 assert!(!state.finish_hide(request));
757 assert!(state.render_child);
758 assert!(state.hide_scheduled);
759 }
760
761 #[test]
762 fn animation_state_should_start_hidden_when_initially_offcanvas_collapsed() {
763 let state = SidebarAnimationState::new(px(0.), false);
764
765 assert!(!state.render_child);
766 assert_eq!(state.from_width, px(0.));
767 assert_eq!(state.target_width, px(0.));
768 }
769}