1use std::collections::HashSet;
60use std::rc::Rc;
61use std::sync::Arc;
62
63use gpui::{
64 div, prelude::FluentBuilder as _, px, AnyElement, App, ElementId, InteractiveElement,
65 IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString,
66 StatefulInteractiveElement, Styled, Window,
67};
68use herogpui_core::element_id;
69use herogpui_theme::ActiveTheme;
70
71use crate::a11y::{self, A11y as _};
72use crate::{anim, util, IconName, Tooltip, TooltipPlacement};
73
74type KeyCallback = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
75type BoolCallback = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
76type KeysCallback = Arc<dyn Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static>;
77
78const ITEM_HEIGHT: Pixels = px(32.);
80
81#[must_use]
83pub struct SidebarItem {
84 key: SharedString,
85 label: SharedString,
86 icon: Option<SharedString>,
87 badge: Option<SharedString>,
88 is_disabled: bool,
89}
90
91impl SidebarItem {
92 pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
94 Self {
95 key: key.into(),
96 label: label.into(),
97 icon: None,
98 badge: None,
99 is_disabled: false,
100 }
101 }
102
103 pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
105 self.icon = Some(icon.into());
106 self
107 }
108
109 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
111 self.badge = Some(badge.into());
112 self
113 }
114
115 pub fn is_disabled(mut self, disabled: bool) -> Self {
117 self.is_disabled = disabled;
118 self
119 }
120}
121
122#[must_use]
124pub struct SidebarGroup {
125 key: SharedString,
126 label: Option<SharedString>,
127 items: Vec<SidebarItem>,
128 is_collapsible: bool,
129 default_expanded: bool,
130}
131
132impl SidebarGroup {
133 pub fn new(key: impl Into<SharedString>) -> Self {
135 Self {
136 key: key.into(),
137 label: None,
138 items: Vec::new(),
139 is_collapsible: false,
140 default_expanded: true,
141 }
142 }
143
144 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
146 self.label = Some(label.into());
147 self
148 }
149
150 pub fn item(mut self, item: SidebarItem) -> Self {
152 self.items.push(item);
153 self
154 }
155
156 pub fn items(mut self, items: impl IntoIterator<Item = SidebarItem>) -> Self {
158 self.items.extend(items);
159 self
160 }
161
162 pub fn is_collapsible(mut self, collapsible: bool) -> Self {
165 self.is_collapsible = collapsible;
166 self
167 }
168
169 pub fn default_expanded(mut self, expanded: bool) -> Self {
172 self.default_expanded = expanded;
173 self
174 }
175
176 fn collapses(&self) -> bool {
177 self.is_collapsible && self.label.is_some()
178 }
179}
180
181#[must_use]
183#[derive(IntoElement)]
184pub struct Sidebar {
185 id: ElementId,
186 groups: Vec<SidebarGroup>,
187 header: Vec<AnyElement>,
188 footer: Vec<AnyElement>,
189 label: SharedString,
190 active_key: Option<SharedString>,
191 is_active_controlled: bool,
192 default_active_key: Option<SharedString>,
193 is_collapsed: Option<bool>,
194 default_collapsed: bool,
195 expanded_keys: HashSet<SharedString>,
196 is_expansion_controlled: bool,
197 show_collapse_toggle: bool,
198 width: Pixels,
199 collapsed_width: Pixels,
200 is_disabled: bool,
201 on_select: Option<KeyCallback>,
202 on_collapsed_change: Option<BoolCallback>,
203 on_expanded_change: Option<KeysCallback>,
204 sx: Option<Box<gpui::StyleRefinement>>,
205}
206
207impl Sidebar {
208 pub fn new(id: impl Into<ElementId>, groups: Vec<SidebarGroup>) -> Self {
211 Self {
212 id: id.into(),
213 groups,
214 header: Vec::new(),
215 footer: Vec::new(),
216 label: SharedString::new_static("Sidebar"),
217 active_key: None,
218 is_active_controlled: false,
219 default_active_key: None,
220 is_collapsed: None,
221 default_collapsed: false,
222 expanded_keys: HashSet::new(),
223 is_expansion_controlled: false,
224 show_collapse_toggle: true,
225 width: px(256.),
226 collapsed_width: px(56.),
227 is_disabled: false,
228 on_select: None,
229 on_collapsed_change: None,
230 on_expanded_change: None,
231 sx: None,
232 }
233 }
234
235 pub fn header(mut self, content: impl IntoElement) -> Self {
239 self.header.push(content.into_any_element());
240 self
241 }
242
243 pub fn footer(mut self, content: impl IntoElement) -> Self {
245 self.footer.push(content.into_any_element());
246 self
247 }
248
249 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
251 self.label = label.into();
252 self
253 }
254
255 pub fn active_key(mut self, key: Option<SharedString>) -> Self {
257 self.active_key = key;
258 self.is_active_controlled = true;
259 self
260 }
261
262 pub fn default_active_key(mut self, key: impl Into<SharedString>) -> Self {
264 self.default_active_key = Some(key.into());
265 self
266 }
267
268 pub fn is_collapsed(mut self, collapsed: bool) -> Self {
270 self.is_collapsed = Some(collapsed);
271 self
272 }
273
274 pub fn default_collapsed(mut self, collapsed: bool) -> Self {
276 self.default_collapsed = collapsed;
277 self
278 }
279
280 pub fn expanded_keys(mut self, keys: impl IntoIterator<Item = SharedString>) -> Self {
282 self.expanded_keys = keys.into_iter().collect();
283 self.is_expansion_controlled = true;
284 self
285 }
286
287 pub fn show_collapse_toggle(mut self, show: bool) -> Self {
290 self.show_collapse_toggle = show;
291 self
292 }
293
294 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
296 self.width = width.into();
297 self
298 }
299
300 pub fn collapsed_width(mut self, width: impl Into<Pixels>) -> Self {
302 self.collapsed_width = width.into();
303 self
304 }
305
306 pub fn is_disabled(mut self, disabled: bool) -> Self {
309 self.is_disabled = disabled;
310 self
311 }
312
313 pub fn on_select(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
315 self.on_select = Some(Arc::new(f));
316 self
317 }
318
319 pub fn on_collapsed_change(
321 mut self,
322 f: impl Fn(&bool, &mut Window, &mut App) + 'static,
323 ) -> Self {
324 self.on_collapsed_change = Some(Arc::new(f));
325 self
326 }
327
328 pub fn on_expanded_change(
330 mut self,
331 f: impl Fn(&HashSet<SharedString>, &mut Window, &mut App) + 'static,
332 ) -> Self {
333 self.on_expanded_change = Some(Arc::new(f));
334 self
335 }
336
337 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
339 util::refine_sx(&mut self.sx, style);
340 self
341 }
342}
343
344#[derive(Clone, Debug, PartialEq, Eq)]
346enum Stop {
347 Header { group: usize },
348 Item { group: usize, item: usize },
349}
350
351fn stops_of(groups: &[SidebarGroup], expanded: &HashSet<SharedString>, icons: bool) -> Vec<Stop> {
354 let mut stops = Vec::new();
355 for (g, group) in groups.iter().enumerate() {
356 let collapses = group.collapses() && !icons;
357 if collapses {
358 stops.push(Stop::Header { group: g });
359 }
360 if collapses && !expanded.contains(&group.key) {
361 continue;
362 }
363 for (i, item) in group.items.iter().enumerate() {
364 if !item.is_disabled {
365 stops.push(Stop::Item { group: g, item: i });
366 }
367 }
368 }
369 stops
370}
371
372#[derive(Clone, Debug, PartialEq, Eq)]
375enum CursorKey {
376 Header(SharedString),
377 Item(SharedString),
378}
379
380fn cursor_key(groups: &[SidebarGroup], stop: &Stop) -> CursorKey {
381 match *stop {
382 Stop::Header { group } => CursorKey::Header(groups[group].key.clone()),
383 Stop::Item { group, item } => CursorKey::Item(groups[group].items[item].key.clone()),
384 }
385}
386
387#[derive(Clone)]
389struct SidebarState {
390 cursor: gpui::Entity<Option<CursorKey>>,
391 active_own: Option<gpui::Entity<Option<SharedString>>>,
392 expanded: HashSet<SharedString>,
393 expanded_own: Option<gpui::Entity<HashSet<SharedString>>>,
394 collapsed: bool,
395 collapsed_own: Option<gpui::Entity<bool>>,
396 on_select: Option<KeyCallback>,
397 on_collapsed_change: Option<BoolCallback>,
398 on_expanded_change: Option<KeysCallback>,
399}
400
401impl SidebarState {
402 fn set_cursor(&self, key: CursorKey, cx: &mut App) {
403 self.cursor.update(cx, |value, cx| {
404 if value.as_ref() != Some(&key) {
405 *value = Some(key);
406 cx.notify();
407 }
408 });
409 }
410
411 fn select(&self, key: &SharedString, window: &mut Window, cx: &mut App) {
412 if let Some(own) = &self.active_own {
413 own.update(cx, |value, cx| {
414 *value = Some(key.clone());
415 cx.notify();
416 });
417 }
418 if let Some(cb) = &self.on_select {
419 cb(key, window, cx);
420 }
421 }
422
423 fn set_expanded(&self, key: &SharedString, open: bool, window: &mut Window, cx: &mut App) {
424 if self.expanded.contains(key) == open {
425 return;
426 }
427 let mut next = self.expanded.clone();
428 if open {
429 next.insert(key.clone());
430 } else {
431 next.remove(key);
432 }
433 if let Some(own) = &self.expanded_own {
434 own.update(cx, |value, cx| {
435 value.clone_from(&next);
436 cx.notify();
437 });
438 }
439 if let Some(cb) = &self.on_expanded_change {
440 cb(&next, window, cx);
441 }
442 }
443
444 fn toggle_collapsed(&self, window: &mut Window, cx: &mut App) {
445 let next = !self.collapsed;
446 if let Some(own) = &self.collapsed_own {
447 own.update(cx, |value, cx| {
448 *value = next;
449 cx.notify();
450 });
451 }
452 if let Some(cb) = &self.on_collapsed_change {
453 cb(&next, window, cx);
454 }
455 }
456}
457
458fn selector_base(id: &ElementId) -> String {
460 match id {
461 ElementId::Name(name) => name.to_string(),
462 other => format!("{other:?}"),
463 }
464}
465
466impl RenderOnce for Sidebar {
467 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
468 let base = self.id.clone();
469 let selector = selector_base(&base);
470 let disabled = self.is_disabled;
471 let focus = window
472 .use_keyed_state(element_id::scoped(&base, "focus"), cx, |_, cx| {
473 cx.focus_handle()
474 })
475 .read(cx)
476 .clone()
477 .tab_stop(!disabled);
478 let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
479 None::<CursorKey>
480 });
481 let typed = window.use_keyed_state(element_id::scoped(&base, "typed"), cx, |_, _| {
482 crate::list_nav::Typeahead::default()
483 });
484 let (active, active_own) = util::controlled(
485 window,
486 cx,
487 element_id::scoped(&base, "active"),
488 self.is_active_controlled.then(|| self.active_key.clone()),
489 self.default_active_key.clone(),
490 );
491 let (collapsed, collapsed_own) = util::controlled(
492 window,
493 cx,
494 element_id::scoped(&base, "collapsed"),
495 self.is_collapsed,
496 self.default_collapsed,
497 );
498 let default_expanded: HashSet<SharedString> = self
499 .groups
500 .iter()
501 .filter(|g| g.collapses() && g.default_expanded)
502 .map(|g| g.key.clone())
503 .collect();
504 let (expanded, expanded_own) = util::controlled(
505 window,
506 cx,
507 element_id::scoped(&base, "expanded"),
508 self.is_expansion_controlled
509 .then(|| self.expanded_keys.clone()),
510 default_expanded,
511 );
512
513 let groups = Rc::new(self.groups);
514 let stops = if disabled {
515 Vec::new()
516 } else {
517 stops_of(&groups, &expanded, collapsed)
518 };
519 let has_focus = focus.is_focused(window);
520 let held = cursor
523 .read(cx)
524 .as_ref()
525 .and_then(|key| stops.iter().position(|s| &cursor_key(&groups, s) == key));
526 let cursor_at = held.or_else(|| {
527 has_focus
528 .then(|| {
529 stops
530 .iter()
531 .position(|s| match *s {
532 Stop::Item { group, item } => {
533 Some(&groups[group].items[item].key) == active.as_ref()
534 }
535 Stop::Header { .. } => false,
536 })
537 .or((!stops.is_empty()).then_some(0))
538 })
539 .flatten()
540 });
541 let focused_at = (has_focus && window.is_window_active())
542 .then_some(cursor_at)
543 .flatten();
544 let cursor_stop = cursor_at.map(|at| stops[at].clone());
545
546 let state = SidebarState {
547 cursor,
548 active_own,
549 expanded: expanded.clone(),
550 expanded_own,
551 collapsed,
552 collapsed_own,
553 on_select: self.on_select.clone(),
554 on_collapsed_change: self.on_collapsed_change.clone(),
555 on_expanded_change: self.on_expanded_change.clone(),
556 };
557
558 let colors = cx.colors().clone();
559 let layout = cx.layout().clone();
560 let radius = util::soft_radius(cx);
561 let pointer = util::interactive_cursor(cx);
562 let width = if collapsed {
563 self.collapsed_width
564 } else {
565 self.width
566 };
567
568 let mut menu = div()
570 .id(element_id::scoped(&base, "menu"))
571 .flex()
572 .flex_col()
573 .flex_1()
574 .min_h_0()
575 .overflow_y_scroll()
576 .gap(px(12.))
577 .p(px(8.))
578 .debug_selector({
579 let name = format!("{selector}-menu");
580 move || name
581 });
582 if cursor_stop.is_none() {
583 menu = menu.track_focus(&focus);
584 }
585 if !disabled {
586 let keys_state = state.clone();
587 let keys_groups = groups.clone();
588 let keys_focus = focus.clone();
589 let keys_stops = stops;
590 menu = menu
591 .key_context("Sidebar")
592 .on_mouse_down(MouseButton::Left, {
593 let focus = focus.clone();
594 move |_, window, cx| window.focus(&focus, cx)
595 })
596 .on_key_down(move |event, window, cx| {
597 if !keys_focus.is_focused(window) {
598 return;
599 }
600 let m = &event.keystroke.modifiers;
601 if m.control || m.alt || m.platform || m.function {
602 return;
603 }
604 let key = event.keystroke.key.as_str();
605 let state = &keys_state;
606 let groups = &keys_groups;
607 let stops = &keys_stops;
608 let moved = |to: usize, cx: &mut App| {
609 state.set_cursor(cursor_key(groups, &stops[to]), cx);
610 util::set_focus_visible(true, cx);
611 };
612 let here = cursor_at.map(|at| &stops[at]);
613 let positions: Vec<usize> = (0..stops.len()).collect();
615 match (key, here) {
616 ("right" | "left", Some(Stop::Header { group })) => {
617 let group_key = groups[*group].key.clone();
618 state.set_expanded(&group_key, key == "right", window, cx);
619 util::set_focus_visible(true, cx);
620 cx.stop_propagation();
621 }
622 _ => match crate::list_nav::resolve(&positions, cursor_at, key, false) {
623 crate::list_nav::Move::To(to) => {
624 moved(to, cx);
625 cx.stop_propagation();
626 }
627 crate::list_nav::Move::Activate => {
628 util::set_focus_visible(true, cx);
633 }
634 crate::list_nav::Move::Ignore => {
635 if m.shift || !crate::list_nav::is_typeahead_key(key) {
636 return;
637 }
638 let labels: Vec<String> = stops
639 .iter()
640 .map(|s| match *s {
641 Stop::Header { group } => groups[group]
642 .label
643 .as_ref()
644 .map(|l| l.to_string())
645 .unwrap_or_default(),
646 Stop::Item { group, item } => {
647 groups[group].items[item].label.to_string()
648 }
649 })
650 .collect();
651 let now = web_time::Instant::now();
652 let (query, repeat) = typed.update(cx, |t, _| {
653 let query = t.push(key, now);
654 (query, t.is_repeat())
655 });
656 if let Some(found) = crate::list_nav::typeahead(
657 &labels, &positions, cursor_at, &query, repeat,
658 ) {
659 moved(found, cx);
660 }
661 }
662 },
663 }
664 });
665 }
666
667 for (g, group) in groups.iter().enumerate() {
668 let group_open = expanded.contains(&group.key);
669 let collapses = group.collapses() && !collapsed;
670 let mut block = div()
671 .id(element_id::scoped(
672 &element_id::scoped(&base, "group"),
673 group.key.clone(),
674 ))
675 .a11y_named(a11y::Role::Group, &a11y::Name::maybe(group.label.clone()))
676 .flex()
677 .flex_col()
678 .gap(px(2.));
679 if collapsed {
682 if g > 0 {
683 block = block.child(
684 div()
685 .h(layout.border_width)
686 .mx(px(8.))
687 .mb(px(4.))
688 .bg(colors.separator),
689 );
690 }
691 } else if let Some(label) = &group.label {
692 let mut heading = div()
693 .flex()
694 .items_center()
695 .gap(px(4.))
696 .h(px(28.))
697 .px(px(8.))
698 .text_size(px(12.))
699 .line_height(px(16.))
700 .font_weight(gpui::FontWeight::MEDIUM)
701 .text_color(colors.muted)
702 .child(div().flex_1().min_w_0().truncate().child(label.clone()));
703 if collapses {
704 let is_cursor = cursor_stop == Some(Stop::Header { group: g });
705 let chevron_id = element_id::scoped(
706 &element_id::scoped(&base, "chevron"),
707 group.key.clone(),
708 );
709 let mut trigger = div()
710 .id(element_id::scoped(
711 &element_id::scoped(&base, "heading"),
712 group.key.clone(),
713 ))
714 .a11y_named(a11y::Role::Button, &a11y::Name::labelled(label.clone()))
715 .a11y_expanded(group_open)
716 .when(is_cursor, |el| el.a11y_active_descendant())
717 .relative()
718 .rounded(radius)
719 .debug_selector({
720 let name = format!("{selector}-heading-{}", group.key);
721 move || name
722 })
723 .child(
724 heading.child(anim::rotating_indicator(
725 &chevron_id,
726 group_open,
727 gpui::svg()
728 .size(px(14.))
729 .path(crate::icons::CHEVRON_DOWN)
730 .flex_shrink_0()
731 .text_color(colors.muted),
732 window,
733 cx,
734 )),
735 );
736 if is_cursor {
737 trigger = trigger.track_focus(&focus);
738 }
739 trigger = util::with_focus_ring_overlay(
740 trigger,
741 util::shows_focus_ring(is_cursor && focused_at.is_some(), cx),
742 true,
743 radius,
744 Vec::new(),
745 cx,
746 );
747 if !disabled {
748 let press = state.clone();
749 let focus = focus.clone();
750 let key = group.key.clone();
751 trigger = trigger
752 .cursor(pointer)
753 .hover(|s| s.bg(colors.default.color))
754 .on_click(move |event, window, cx| {
755 util::set_focus_visible(event.is_keyboard(), cx);
756 window.focus(&focus, cx);
757 press.set_cursor(CursorKey::Header(key.clone()), cx);
758 press.set_expanded(&key, !group_open, window, cx);
759 });
760 }
761 if is_cursor {
762 trigger = util::record_focus_bounds(trigger, &focus, window, cx);
763 }
764 block = block.child(trigger);
765 } else {
766 heading = heading.debug_selector({
767 let name = format!("{selector}-heading-{}", group.key);
768 move || name
769 });
770 block = block.child(heading);
771 }
772 }
773
774 let mut items = div().flex().flex_col().gap(px(2.));
775 for (i, item) in group.items.iter().enumerate() {
776 let is_active = active.as_ref() == Some(&item.key);
777 let is_cursor = cursor_stop == Some(Stop::Item { group: g, item: i });
778 let item_disabled = disabled || item.is_disabled;
779 let fg = if is_active {
780 colors.accent.soft_foreground(colors.foreground)
781 } else {
782 colors.foreground
783 };
784 let hover_bg = colors.default.color;
785 let mut row = div()
786 .id(element_id::scoped(
787 &element_id::scoped(&base, "item"),
788 item.key.clone(),
789 ))
790 .a11y_named(a11y::Role::Link, &a11y::Name::labelled(item.label.clone()))
791 .when(is_active, |el| el.a11y_current(a11y::AriaCurrent::Page))
792 .when(is_cursor, |el| el.a11y_active_descendant())
793 .relative()
794 .flex()
795 .flex_row()
796 .items_center()
797 .gap(px(8.))
798 .h(ITEM_HEIGHT)
799 .flex_shrink_0()
800 .rounded(radius)
801 .text_size(px(14.))
802 .text_color(fg)
803 .when(collapsed, |el| el.w(px(40.)).justify_center())
804 .when(!collapsed, |el| el.w_full().px(px(8.)))
805 .when(is_active, |el| el.bg(colors.accent.soft()))
806 .debug_selector({
807 let name = format!("{selector}-item-{}", item.key);
808 move || name
809 });
810 if is_cursor {
811 row = row.track_focus(&focus);
812 }
813 let icon = item.icon.clone().unwrap_or_else(|| IconName::Circle.into());
814 if item.icon.is_some() || collapsed {
815 row = row.child(
816 gpui::svg()
817 .size(util::FIELD_ICON)
818 .flex_shrink_0()
819 .path(icon)
820 .text_color(fg),
821 );
822 }
823 if !collapsed {
824 row = row.child(
825 div()
826 .flex_1()
827 .min_w_0()
828 .truncate()
829 .child(item.label.clone()),
830 );
831 if let Some(badge) = &item.badge {
832 row = row.child(
833 div()
834 .flex_shrink_0()
835 .text_size(px(12.))
836 .text_color(colors.muted)
837 .child(badge.clone()),
838 );
839 }
840 }
841 row = util::with_focus_ring_overlay(
842 row,
843 util::shows_focus_ring(is_cursor && focused_at.is_some(), cx),
844 true,
845 radius,
846 Vec::new(),
847 cx,
848 );
849 if item_disabled {
850 row = row.opacity(layout.disabled_opacity);
851 } else {
852 let press = state.clone();
853 let focus = focus.clone();
854 let key = item.key.clone();
855 row = row
856 .cursor(pointer)
857 .when(!is_active, |el| el.hover(move |s| s.bg(hover_bg)))
858 .on_click(move |event, window, cx| {
859 util::set_focus_visible(event.is_keyboard(), cx);
860 window.focus(&focus, cx);
861 press.set_cursor(CursorKey::Item(key.clone()), cx);
862 press.select(&key, window, cx);
863 });
864 }
865 if is_cursor {
866 row = util::record_focus_bounds(row, &focus, window, cx);
867 }
868 let row = if collapsed {
869 Tooltip::new(item.label.clone())
870 .id(element_id::scoped(
871 &element_id::scoped(&base, "tip"),
872 item.key.clone(),
873 ))
874 .placement(TooltipPlacement::Right)
875 .delay(0)
876 .child(row)
877 .into_any_element()
878 } else {
879 row.into_any_element()
880 };
881 items = items.child(row);
882 }
883 if collapses {
884 let panel_id =
885 element_id::scoped(&element_id::scoped(&base, "panel"), group.key.clone());
886 if let Some(panel) = anim::collapsible_panel(
887 &panel_id,
888 group_open,
889 div().id(panel_id.clone()),
890 items.into_any_element(),
891 window,
892 cx,
893 ) {
894 block = block.child(panel);
895 }
896 } else {
897 block = block.child(items);
898 }
899 menu = menu.child(block);
900 }
901 if cursor_stop.is_none() {
902 menu = util::record_focus_bounds(menu, &focus, window, cx);
903 }
904
905 let mut footer = div()
906 .flex()
907 .flex_col()
908 .gap(px(4.))
909 .p(px(8.))
910 .children(self.footer);
911 if self.show_collapse_toggle {
912 let toggle_state = state;
913 let toggle_label: SharedString = if collapsed {
914 "Expand sidebar".into()
915 } else {
916 "Collapse sidebar".into()
917 };
918 let mut toggle = crate::Button::new(element_id::scoped(&base, "toggle"))
919 .variant(herogpui_core::Variant::Tertiary)
920 .is_icon_only(true)
921 .size(herogpui_core::Size::Sm)
922 .label(toggle_label)
925 .is_disabled(disabled)
926 .content({
927 let muted = colors.muted;
928 move |_| {
929 gpui::svg()
930 .size(util::FIELD_ICON)
931 .path(IconName::PanelLeft)
932 .text_color(muted)
933 .into_any_element()
934 }
935 });
936 toggle =
937 toggle.on_press(move |_, window, cx| toggle_state.toggle_collapsed(window, cx));
938 footer = footer.child(
939 div()
940 .flex()
941 .when(!collapsed, |el| el.justify_end())
942 .when(collapsed, |el| el.justify_center())
943 .child(
944 div()
945 .debug_selector({
946 let name = format!("{selector}-toggle");
947 move || name
948 })
949 .child(toggle),
950 ),
951 );
952 }
953
954 let root = div()
955 .id(base)
956 .a11y_named(
957 a11y::Role::Navigation,
958 &a11y::Name::labelled(self.label.clone()),
959 )
960 .flex()
961 .flex_col()
962 .h_full()
963 .w(width)
964 .flex_shrink_0()
965 .overflow_hidden()
966 .bg(colors.surface.background)
967 .text_color(colors.foreground)
968 .border_r(layout.border_width)
969 .border_color(colors.separator)
970 .when(disabled, |el| el.opacity(layout.disabled_opacity))
971 .debug_selector({
972 let name = format!("{selector}-sidebar");
973 move || name
974 })
975 .when(!self.header.is_empty(), |el| {
976 el.child(
977 div()
978 .flex()
979 .flex_col()
980 .gap(px(4.))
981 .p(px(8.))
982 .overflow_hidden()
983 .children(self.header),
984 )
985 })
986 .child(menu)
987 .child(footer);
988 util::apply_sx(root, &self.sx)
989 }
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 fn groups() -> Vec<SidebarGroup> {
997 vec![
998 SidebarGroup::new("main").items(vec![
999 SidebarItem::new("a", "A"),
1000 SidebarItem::new("b", "B").is_disabled(true),
1001 ]),
1002 SidebarGroup::new("more")
1003 .label("More")
1004 .is_collapsible(true)
1005 .items(vec![SidebarItem::new("c", "C")]),
1006 SidebarGroup::new("bare")
1008 .is_collapsible(true)
1009 .items(vec![SidebarItem::new("d", "D")]),
1010 ]
1011 }
1012
1013 #[test]
1014 fn stops_skip_disabled_items_and_closed_groups() {
1015 let open: HashSet<SharedString> = ["more".into()].into_iter().collect();
1016 assert_eq!(
1017 stops_of(&groups(), &open, false),
1018 [
1019 Stop::Item { group: 0, item: 0 },
1020 Stop::Header { group: 1 },
1021 Stop::Item { group: 1, item: 0 },
1022 Stop::Item { group: 2, item: 0 },
1023 ]
1024 );
1025 assert_eq!(
1026 stops_of(&groups(), &HashSet::new(), false),
1027 [
1028 Stop::Item { group: 0, item: 0 },
1029 Stop::Header { group: 1 },
1030 Stop::Item { group: 2, item: 0 },
1031 ]
1032 );
1033 }
1034
1035 #[test]
1036 fn icon_mode_lists_every_item_and_no_headers() {
1037 assert_eq!(
1038 stops_of(&groups(), &HashSet::new(), true),
1039 [
1040 Stop::Item { group: 0, item: 0 },
1041 Stop::Item { group: 1, item: 0 },
1042 Stop::Item { group: 2, item: 0 },
1043 ]
1044 );
1045 }
1046}
1047
1048crate::util::impl_component_styled!(Sidebar);