1use std::{cell::RefCell, collections::HashSet, rc::Rc, sync::Arc};
2
3use gpui::{
4 AnyElement, App, ElementId, InteractiveElement as _, IntoElement, ParentElement, RenderOnce,
5 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div,
6 percentage, prelude::FluentBuilder as _, rems,
7};
8
9use crate::{ActiveTheme as _, Icon, IconName, Sizable, Size, StyledExt as _, h_flex};
10use gpui_base::{
11 Accordion as BaseAccordion, AccordionHeader as BaseAccordionHeader,
12 AccordionItem as BaseAccordionItem, AccordionPanel as BaseAccordionPanel, AccordionTrigger,
13 MotionReveal, spring,
14};
15
16#[derive(IntoElement)]
18pub struct Accordion {
19 id: ElementId,
20 style: StyleRefinement,
21 multiple: bool,
22 size: Size,
23 bordered: bool,
24 disabled: bool,
25 children: Vec<AccordionItem>,
26 on_toggle_click: Option<Rc<dyn Fn(&[usize], &mut Window, &mut App)>>,
27}
28
29impl Accordion {
30 pub fn new(id: impl Into<ElementId>) -> Self {
32 Self {
33 id: id.into(),
34 style: StyleRefinement::default(),
35 multiple: false,
36 size: Size::default(),
37 bordered: true,
38 children: Vec::new(),
39 disabled: false,
40 on_toggle_click: None,
41 }
42 }
43
44 pub fn multiple(mut self, multiple: bool) -> Self {
46 self.multiple = multiple;
47 self
48 }
49
50 pub fn bordered(mut self, bordered: bool) -> Self {
52 self.bordered = bordered;
53 self
54 }
55
56 pub fn disabled(mut self, disabled: bool) -> Self {
58 self.disabled = disabled;
59 self
60 }
61
62 pub fn item<F>(mut self, child: F) -> Self
64 where
65 F: FnOnce(AccordionItem) -> AccordionItem,
66 {
67 let item = child(AccordionItem::new());
68 self.children.push(item);
69 self
70 }
71
72 pub fn on_toggle_click(
76 mut self,
77 on_toggle_click: impl Fn(&[usize], &mut Window, &mut App) + 'static,
78 ) -> Self {
79 self.on_toggle_click = Some(Rc::new(on_toggle_click));
80 self
81 }
82}
83
84impl Sizable for Accordion {
85 fn with_size(mut self, size: impl Into<Size>) -> Self {
86 self.size = size.into();
87 self
88 }
89}
90
91impl Styled for Accordion {
92 fn style(&mut self) -> &mut StyleRefinement {
93 &mut self.style
94 }
95}
96
97impl RenderOnce for Accordion {
98 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
99 let open_indices = Rc::new(RefCell::new(HashSet::new()));
100 let multiple = self.multiple;
101 let last_ix = self.children.len().saturating_sub(1);
102
103 BaseAccordion::new(self.id)
104 .v_flex()
105 .size_full()
106 .when(self.bordered, |this| {
109 this.border_1()
110 .border_color(cx.theme().border)
111 .rounded(cx.theme().radius_lg)
112 .overflow_hidden()
113 })
114 .refine_style(&self.style)
115 .children(
116 self.children
117 .into_iter()
118 .enumerate()
119 .map(|(ix, accordion)| {
120 if accordion.open {
121 open_indices.borrow_mut().insert(ix);
122 }
123
124 accordion
125 .index(ix)
126 .last(ix == last_ix)
127 .with_size(self.size)
128 .disabled(self.disabled)
129 .on_toggle_click({
130 let open_indices = open_indices.clone();
131 move |open, _, _| {
132 let mut open_indices = open_indices.borrow_mut();
133 if *open {
134 if !multiple {
135 open_indices.clear();
136 }
137 open_indices.insert(ix);
138 } else {
139 open_indices.remove(&ix);
140 }
141 }
142 })
143 }),
144 )
145 .when_some(
146 self.on_toggle_click.filter(|_| !self.disabled),
147 |this, on_toggle| {
148 this.on_click(move |_, window, cx| {
149 let open_indices =
150 open_indices.borrow().iter().copied().collect::<Vec<_>>();
151 on_toggle(&open_indices, window, cx)
152 })
153 },
154 )
155 }
156}
157
158#[derive(IntoElement)]
160pub struct AccordionItem {
161 index: usize,
162 last: bool,
163 style: StyleRefinement,
164 hover_style: Option<StyleRefinement>,
165 title_style: StyleRefinement,
166 content_style: StyleRefinement,
167 icon: Option<Icon>,
168 title: AnyElement,
169 children: Vec<AnyElement>,
170 open: bool,
171 size: Size,
172 disabled: bool,
173 on_toggle_click: Option<Arc<dyn Fn(&bool, &mut Window, &mut App)>>,
174}
175
176impl AccordionItem {
177 pub fn new() -> Self {
179 Self {
180 index: 0,
181 last: false,
182 style: StyleRefinement::default(),
183 hover_style: None,
184 title_style: StyleRefinement::default(),
185 content_style: StyleRefinement::default(),
186 icon: None,
187 title: SharedString::default().into_any_element(),
188 children: Vec::new(),
189 open: false,
190 disabled: false,
191 on_toggle_click: None,
192 size: Size::default(),
193 }
194 }
195
196 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
198 self.icon = Some(icon.into());
199 self
200 }
201
202 pub fn title(mut self, title: impl IntoElement) -> Self {
204 self.title = title.into_any_element();
205 self
206 }
207
208 pub fn open(mut self, open: bool) -> Self {
209 self.open = open;
210 self
211 }
212
213 pub fn disabled(mut self, disabled: bool) -> Self {
214 self.disabled = disabled;
215 self
216 }
217
218 pub fn title_style(mut self, style: StyleRefinement) -> Self {
220 self.title_style = style;
221 self
222 }
223
224 pub fn hover(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
230 self.hover_style = Some(f(StyleRefinement::default()));
231 self
232 }
233
234 pub fn content_style(mut self, style: StyleRefinement) -> Self {
236 self.content_style = style;
237 self
238 }
239
240 fn index(mut self, index: usize) -> Self {
241 self.index = index;
242 self
243 }
244
245 fn last(mut self, last: bool) -> Self {
246 self.last = last;
247 self
248 }
249
250 fn on_toggle_click(
251 mut self,
252 on_toggle_click: impl Fn(&bool, &mut Window, &mut App) + 'static,
253 ) -> Self {
254 self.on_toggle_click = Some(Arc::new(on_toggle_click));
255 self
256 }
257}
258
259impl ParentElement for AccordionItem {
260 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
261 self.children.extend(elements);
262 }
263}
264
265impl Sizable for AccordionItem {
266 fn with_size(mut self, size: impl Into<Size>) -> Self {
267 self.size = size.into();
268 self
269 }
270}
271
272impl Styled for AccordionItem {
273 fn style(&mut self) -> &mut StyleRefinement {
274 &mut self.style
275 }
276}
277
278impl RenderOnce for AccordionItem {
279 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
280 let text_size = match self.size {
281 Size::XSmall => rems(0.8125),
282 Size::Large => rems(1.0),
283 _ => rems(0.875),
284 };
285 let progress = spring(
286 (self.index, "accordion-panel"),
287 if self.open { 1. } else { 0. },
288 cx.theme().motion_tokens().spring_control,
289 window,
290 cx,
291 );
292 let trigger = AccordionTrigger::new(("trigger", self.index))
293 .open(self.open)
294 .disabled(self.disabled)
295 .h_flex()
296 .justify_between()
297 .gap_3()
298 .font_medium()
299 .map(|this| match self.size {
300 Size::XSmall => this.py_1().px_1p5(),
301 Size::Small => this.py_1p5().px_2(),
302 Size::Large => this.py_3().px_4(),
303 _ => this.py_2().px_3(),
304 })
305 .when(self.open, |this| this.text_color(cx.theme().foreground))
306 .refine_style(&self.title_style)
307 .child(
308 h_flex()
309 .flex_1()
310 .min_w_0()
311 .items_center()
312 .map(|this| match self.size {
313 Size::XSmall | Size::Small => this.gap_1(),
314 _ => this.gap_2(),
315 })
316 .when_some(self.icon, |this, icon| {
317 this.child(icon.with_size(self.size))
318 })
319 .child(self.title),
320 )
321 .when(!self.disabled, |this| {
322 this.when_some(self.hover_style, |this, hover_style| {
323 this.hover(move |this| this.refine_style(&hover_style))
324 })
325 .child(
326 Icon::new(IconName::ChevronDown)
327 .xsmall()
328 .flex_none()
329 .text_color(cx.theme().muted_foreground)
330 .rotate(percentage(if self.open { 0.5 } else { 0. })),
331 )
332 .when_some(self.on_toggle_click, |this, on_toggle_click| {
333 this.on_change(move |open, _, window, cx| {
334 on_toggle_click(&open, window, cx);
335 })
336 })
337 });
338
339 div().flex_1().child(
340 BaseAccordionItem::new()
341 .open(self.open)
342 .disabled(self.disabled)
343 .header(
344 BaseAccordionHeader::new(trigger)
345 .id(("header", self.index))
346 .w_full(),
347 )
348 .panel(
349 BaseAccordionPanel::new()
350 .id(("panel", self.index))
351 .open(self.open)
352 .keep_mounted(true)
353 .w_full()
354 .child(MotionReveal::new(
355 ("content", self.index),
356 progress,
357 div()
358 .map(|this| match self.size {
359 Size::XSmall => this.pb_1().px_1p5(),
360 Size::Small => this.pb_1p5().px_2(),
361 Size::Large => this.pb_3().px_4(),
362 _ => this.pb_2().px_3(),
363 })
364 .refine_style(&self.content_style)
365 .children(self.children)
366 .into_any_element(),
367 )),
368 )
369 .v_flex()
370 .w_full()
371 .bg(cx.theme().tokens.accordion)
372 .overflow_hidden()
373 .when(!self.last, |this| {
374 this.border_b_1().border_color(cx.theme().border)
375 })
376 .text_size(text_size)
377 .refine_style(&self.style),
378 )
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use gpui::{Context, Render, TestAppContext, div, px};
385
386 use super::*;
387
388 struct Harness;
389
390 impl Render for Harness {
391 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
392 Accordion::new("accordion-layout")
393 .w(px(240.))
394 .h(px(100.))
395 .item(|item| {
396 item.open(true)
397 .title(div().debug_selector(|| "first-title".into()).child("First"))
398 .child(div().debug_selector(|| "first-content".into()).h(px(60.)))
399 })
400 .item(|item| {
401 item.title(
402 div()
403 .debug_selector(|| "second-title".into())
404 .child("Second"),
405 )
406 })
407 }
408 }
409
410 #[gpui::test]
411 fn expanded_panel_keeps_content_between_its_header_and_the_next_item(cx: &mut TestAppContext) {
412 cx.update(crate::theme::init);
413 let (_, cx) = cx.add_window_view(|_, _| Harness);
414 cx.update(|window, cx| window.draw(cx).clear(cx));
415
416 let first = cx.debug_bounds("first-title").unwrap();
417 let content = cx.debug_bounds("first-content").unwrap();
418 let second = cx.debug_bounds("second-title").unwrap();
419 assert!(first.origin.y < content.origin.y);
420 assert!(content.origin.y + content.size.height <= second.origin.y);
421 }
422}