1use crate::{h_flex, v_flex, ActiveTheme as _, Collapsible, Icon, IconName, StyledExt};
2use gpui::{
3 div, percentage, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, ElementId,
4 InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
5 StatefulInteractiveElement as _, Styled as _, Window,
6};
7use std::rc::Rc;
8
9#[derive(IntoElement)]
10pub struct SidebarMenu {
11 collapsed: bool,
12 items: Vec<SidebarMenuItem>,
13}
14
15impl SidebarMenu {
16 pub fn new() -> Self {
17 Self {
18 items: Vec::new(),
19 collapsed: false,
20 }
21 }
22
23 pub fn child(mut self, child: impl Into<SidebarMenuItem>) -> Self {
24 self.items.push(child.into());
25 self
26 }
27
28 pub fn children(
29 mut self,
30 children: impl IntoIterator<Item = impl Into<SidebarMenuItem>>,
31 ) -> Self {
32 self.items = children.into_iter().map(Into::into).collect();
33 self
34 }
35}
36impl Collapsible for SidebarMenu {
37 fn is_collapsed(&self) -> bool {
38 self.collapsed
39 }
40
41 fn collapsed(mut self, collapsed: bool) -> Self {
42 self.collapsed = collapsed;
43 self
44 }
45}
46impl RenderOnce for SidebarMenu {
47 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
48 v_flex().gap_2().children(
49 self.items
50 .into_iter()
51 .enumerate()
52 .map(|(ix, item)| item.id(ix).collapsed(self.collapsed)),
53 )
54 }
55}
56
57#[derive(IntoElement)]
59pub struct SidebarMenuItem {
60 id: ElementId,
61 icon: Option<Icon>,
62 label: SharedString,
63 handler: Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>,
64 active: bool,
65 collapsed: bool,
66 children: Vec<Self>,
67 suffix: Option<AnyElement>,
68}
69
70impl SidebarMenuItem {
71 pub fn new(label: impl Into<SharedString>) -> Self {
73 Self {
74 id: ElementId::Integer(0),
75 icon: None,
76 label: label.into(),
77 handler: Rc::new(|_, _, _| {}),
78 active: false,
79 collapsed: false,
80 children: Vec::new(),
81 suffix: None,
82 }
83 }
84
85 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
87 self.icon = Some(icon.into());
88 self
89 }
90
91 fn id(mut self, id: impl Into<ElementId>) -> Self {
93 self.id = id.into();
94 self
95 }
96
97 pub fn active(mut self, active: bool) -> Self {
99 self.active = active;
100 self
101 }
102
103 pub fn on_click(
105 mut self,
106 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
107 ) -> Self {
108 self.handler = Rc::new(handler);
109 self
110 }
111
112 pub fn collapsed(mut self, collapsed: bool) -> Self {
114 self.collapsed = collapsed;
115 self
116 }
117
118 pub fn children(mut self, children: impl IntoIterator<Item = impl Into<Self>>) -> Self {
119 self.children = children.into_iter().map(Into::into).collect();
120 self
121 }
122
123 pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
125 self.suffix = Some(suffix.into_any_element());
126 self
127 }
128
129 fn is_submenu(&self) -> bool {
130 self.children.len() > 0
131 }
132
133 fn is_open(&self) -> bool {
134 if self.is_submenu() {
135 self.active
136 } else {
137 false
138 }
139 }
140}
141
142impl RenderOnce for SidebarMenuItem {
143 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
144 let handler = self.handler.clone();
145 let is_collapsed = self.collapsed;
146 let is_active = self.active;
147 let is_open = self.is_open();
148 let is_submenu = self.is_submenu();
149
150 div()
151 .id(self.id.clone())
152 .w_full()
153 .child(
154 h_flex()
155 .size_full()
156 .id("item")
157 .overflow_x_hidden()
158 .flex_shrink_0()
159 .p_2()
160 .gap_x_2()
161 .rounded(cx.theme().radius)
162 .text_sm()
163 .hover(|this| {
164 if is_active {
165 return this;
166 }
167
168 this.bg(cx.theme().sidebar_accent.opacity(0.8))
169 .text_color(cx.theme().sidebar_accent_foreground)
170 })
171 .when(is_active && !is_submenu, |this| {
172 this.font_medium()
173 .bg(cx.theme().sidebar_accent)
174 .text_color(cx.theme().sidebar_accent_foreground)
175 })
176 .when_some(self.icon.clone(), |this, icon| this.child(icon))
177 .when(is_collapsed, |this| {
178 this.justify_center().when(is_active, |this| {
179 this.bg(cx.theme().sidebar_accent)
180 .text_color(cx.theme().sidebar_accent_foreground)
181 })
182 })
183 .when(!is_collapsed, |this| {
184 this.h_7()
185 .child(
186 h_flex()
187 .flex_1()
188 .gap_x_2()
189 .justify_between()
190 .overflow_x_hidden()
191 .child(
192 h_flex()
193 .flex_1()
194 .overflow_x_hidden()
195 .child(self.label.clone()),
196 )
197 .when_some(self.suffix, |this, suffix| this.child(suffix)),
198 )
199 .when(is_submenu, |this| {
200 this.child(
201 Icon::new(IconName::ChevronRight)
202 .size_4()
203 .when(is_open, |this| this.rotate(percentage(90. / 360.))),
204 )
205 })
206 })
207 .on_click(move |ev, window, cx| handler(ev, window, cx)),
208 )
209 .when(is_submenu && is_open && !is_collapsed, |this| {
210 this.child(
211 v_flex()
212 .id("submenu")
213 .border_l_1()
214 .border_color(cx.theme().sidebar_border)
215 .gap_1()
216 .ml_3p5()
217 .pl_2p5()
218 .py_0p5()
219 .children(
220 self.children
221 .into_iter()
222 .enumerate()
223 .map(|(ix, item)| item.id(ix)),
224 ),
225 )
226 })
227 }
228}