1use std::rc::Rc;
13
14use gpui::{
15 AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
16 StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
17};
18use gpui_kit_assets::{Icon, icon};
19use gpui_kit_semantics::{NodeSpec, Role, Semantic};
20use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TextTone, Theme, TypeScale};
21
22use crate::display::badge::Badge;
23use crate::foundation::{
24 Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
25};
26use crate::motion::{Flipping, flip};
27use crate::overlay::Tooltipped;
28
29const EXPANDED_WIDTH: f32 = 240.0;
32const COLLAPSED_WIDTH: f32 = 52.0;
33
34const NESTING_INDENT: f32 = 16.0;
36
37type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SidebarItem {
42 id: SharedString,
43 label: SharedString,
44 icon: Option<Icon>,
45 badge: Option<SharedString>,
46 disabled: bool,
47 children: Vec<SidebarItem>,
48}
49
50impl SidebarItem {
51 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52 Self {
53 id: id.into(),
54 label: label.into(),
55 icon: None,
56 badge: None,
57 disabled: false,
58 children: Vec::new(),
59 }
60 }
61
62 pub fn icon(mut self, glyph: Icon) -> Self {
63 self.icon = Some(glyph);
64 self
65 }
66
67 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
70 self.badge = Some(badge.into());
71 self
72 }
73
74 pub fn disabled(mut self, disabled: bool) -> Self {
75 self.disabled = disabled;
76 self
77 }
78
79 pub fn children(mut self, children: impl IntoIterator<Item = SidebarItem>) -> Self {
85 self.children = children
86 .into_iter()
87 .map(|child| SidebarItem {
88 children: Vec::new(),
89 ..child
90 })
91 .collect();
92 self
93 }
94
95 pub fn id(&self) -> &SharedString {
96 &self.id
97 }
98
99 pub fn label(&self) -> &SharedString {
100 &self.label
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SidebarSection {
107 id: SharedString,
108 title: Option<SharedString>,
109 items: Vec<SidebarItem>,
110}
111
112impl SidebarSection {
113 pub fn new(id: impl Into<SharedString>) -> Self {
114 Self {
115 id: id.into(),
116 title: None,
117 items: Vec::new(),
118 }
119 }
120
121 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
122 self.title = Some(title.into());
123 self
124 }
125
126 pub fn item(mut self, item: SidebarItem) -> Self {
127 self.items.push(item);
128 self
129 }
130
131 pub fn items(mut self, items: impl IntoIterator<Item = SidebarItem>) -> Self {
132 self.items.extend(items);
133 self
134 }
135}
136
137#[derive(IntoElement)]
139pub struct Sidebar {
140 ident: Ident,
141 sections: Vec<SidebarSection>,
142 active: Option<SharedString>,
143 collapsed: bool,
144 header: Option<AnyElement>,
145 footer: Option<AnyElement>,
146 disabled: bool,
147 size: ControlSize,
148 on_select: Option<SelectHandler>,
149}
150
151impl std::fmt::Debug for Sidebar {
152 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 formatter
154 .debug_struct("Sidebar")
155 .field("ident", &self.ident)
156 .field("sections", &self.sections.len())
157 .field("active", &self.active)
158 .field("collapsed", &self.collapsed)
159 .field("disabled", &self.disabled)
160 .field("has_handler", &self.on_select.is_some())
161 .finish()
162 }
163}
164
165impl Sidebar {
166 pub fn new(ident: impl Into<Ident>) -> Self {
167 Self {
168 ident: ident.into(),
169 sections: Vec::new(),
170 active: None,
171 collapsed: false,
172 header: None,
173 footer: None,
174 disabled: false,
175 size: ControlSize::Md,
176 on_select: None,
177 }
178 }
179
180 pub fn section(mut self, section: SidebarSection) -> Self {
181 self.sections.push(section);
182 self
183 }
184
185 pub fn sections(mut self, sections: impl IntoIterator<Item = SidebarSection>) -> Self {
186 self.sections.extend(sections);
187 self
188 }
189
190 pub fn active(mut self, id: impl Into<SharedString>) -> Self {
193 self.active = Some(id.into());
194 self
195 }
196
197 pub fn collapsed(mut self, collapsed: bool) -> Self {
199 self.collapsed = collapsed;
200 self
201 }
202
203 pub fn header(mut self, header: impl IntoElement) -> Self {
204 self.header = Some(header.into_any_element());
205 self
206 }
207
208 pub fn footer(mut self, footer: impl IntoElement) -> Self {
210 self.footer = Some(footer.into_any_element());
211 self
212 }
213
214 pub fn on_select(
215 mut self,
216 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
217 ) -> Self {
218 self.on_select = Some(Rc::new(handler));
219 self
220 }
221
222 fn item_element(
223 &self,
224 item: &SidebarItem,
225 level: u32,
226 theme: &Theme,
227 window: &mut Window,
228 cx: &mut App,
229 ) -> AnyElement {
230 let metrics = theme.control.get(self.size);
231 let ident = self.ident.child(item.id.as_ref());
232 let active = self.active.as_ref() == Some(&item.id);
233 let disabled = self.disabled || item.disabled;
234 let actionable = !disabled && self.on_select.is_some();
235 let color = if disabled {
236 theme.colors.text_faint
237 } else if active {
238 theme.colors.text
239 } else {
240 theme.colors.text_muted
241 };
242 let indent = if self.collapsed || level == 1 {
243 0.0
244 } else {
245 NESTING_INDENT
246 };
247 let glyph_slot = flip(ident.child("glyph").semantic_id(), cx);
248
249 let mut row = div()
250 .id(ident.element_id())
251 .flex()
252 .flex_row()
253 .items_center()
254 .h(px(metrics.height))
255 .w_full()
256 .gap(px(theme.space(Space::Sm)))
257 .pl(px(theme.space(Space::Sm) + indent))
258 .pr(px(theme.space(Space::Sm)))
259 .radius(theme, Radius::Control)
260 .when(self.collapsed, |element| element.justify_center())
261 .when(active, |element| element.bg(theme.colors.selected))
262 .when(disabled, |element| element.opacity(theme.opacity.disabled))
263 .child(
264 div()
268 .flex()
269 .flex_none()
270 .w(px(metrics.icon_size))
271 .justify_center()
272 .children(
273 item.icon
274 .map(|glyph| icon(glyph).size(px(metrics.icon_size)).text_color(color)),
275 )
276 .flip(&glyph_slot, window, cx),
277 )
278 .when(!self.collapsed, |element| {
279 element
280 .child(
281 div().flex_1().overflow_hidden().child(
282 foundation_text(theme, TypeScale::Label, item.label.clone())
283 .text_size(px(metrics.font_size))
284 .text_color(color),
285 ),
286 )
287 .children(item.badge.clone().map(|badge| Badge::new(badge).neutral()))
288 })
289 .when(actionable, |element| {
290 element
291 .cursor_pointer()
292 .tab_index(0)
293 .pressable(cx)
294 .hover(|style| style.bg(theme.colors.hover))
295 .focus_ring(theme)
296 });
297
298 if self.collapsed {
301 row = row.tip(ident.clone(), item.label.clone());
302 }
303
304 if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
305 let id = item.id.clone();
306 let click = Rc::clone(&handler);
307 let clicked = id.clone();
308 row = row
309 .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
310 .on_key_down(move |event, window, cx| {
311 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
312 handler(id.clone(), window, cx);
313 cx.stop_propagation();
314 }
315 });
316 }
317
318 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Link)
319 .parent(self.ident.semantic_id())
320 .selected(active)
321 .disabled(disabled)
322 .level(level)
323 .text(item.label.clone());
325 if let Some(badge) = item.badge.clone() {
326 spec = spec.value(badge);
327 }
328
329 row.semantic_in(cx, spec).into_any_element()
330 }
331}
332
333impl Disableable for Sidebar {
334 fn disabled(mut self, disabled: bool) -> Self {
336 self.disabled = disabled;
337 self
338 }
339}
340
341impl Sizable for Sidebar {
342 fn control_size(mut self, size: ControlSize) -> Self {
343 self.size = size;
344 self
345 }
346}
347
348impl RenderOnce for Sidebar {
349 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
350 let theme = cx.theme().clone();
351 let mut body = div()
352 .flex()
353 .flex_col()
354 .flex_1()
355 .gap(px(theme.space(Space::Md)))
356 .overflow_hidden();
357
358 for section in &self.sections {
359 let section_ident = self.ident.child(section.id.as_ref());
360 let mut rows: Vec<AnyElement> = Vec::new();
361 for item in §ion.items {
362 rows.push(self.item_element(item, 1, &theme, window, cx));
363 for child in &item.children {
364 rows.push(self.item_element(child, 2, &theme, window, cx));
365 }
366 }
367
368 let heading = section
371 .title
372 .clone()
373 .filter(|_| !self.collapsed)
374 .map(|title| {
375 foundation_text(&theme, TypeScale::Caption, title.clone())
376 .px(px(theme.space(Space::Sm)))
377 .text_tone(&theme, TextTone::Faint)
378 .semantic_in(
379 cx,
380 NodeSpec::new(section_ident.semantic_id(), Role::Heading)
381 .parent(self.ident.semantic_id())
382 .level(1)
383 .text(title),
384 )
385 });
386
387 body = body.child(
388 div()
389 .flex()
390 .flex_col()
391 .gap(px(theme.space(Space::Xs)))
392 .children(heading)
393 .children(rows),
394 );
395 }
396
397 div()
398 .id(self.ident.element_id())
399 .flex()
400 .flex_col()
401 .h_full()
402 .w(px(if self.collapsed {
403 COLLAPSED_WIDTH
404 } else {
405 EXPANDED_WIDTH
406 }))
407 .gap(px(theme.space(Space::Md)))
408 .p(px(theme.space(Space::Sm)))
409 .bg(theme.colors.panel)
410 .children(self.header)
411 .child(body)
412 .children(self.footer)
413 .semantic_in(
414 cx,
415 NodeSpec::new(self.ident.semantic_id(), Role::List).expanded(!self.collapsed),
416 )
417 }
418}