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 let disabled = self.disabled || accordion.disabled;
124
125 accordion
126 .index(ix)
127 .last(ix == last_ix)
128 .with_size(self.size)
129 .disabled(disabled)
130 .on_toggle_click({
131 let open_indices = open_indices.clone();
132 move |open, _, _| {
133 let mut open_indices = open_indices.borrow_mut();
134 if *open {
135 if !multiple {
136 open_indices.clear();
137 }
138 open_indices.insert(ix);
139 } else {
140 open_indices.remove(&ix);
141 }
142 }
143 })
144 }),
145 )
146 .when_some(
147 self.on_toggle_click.filter(|_| !self.disabled),
148 |this, on_toggle| {
149 this.on_click(move |_, window, cx| {
150 let open_indices =
151 open_indices.borrow().iter().copied().collect::<Vec<_>>();
152 on_toggle(&open_indices, window, cx)
153 })
154 },
155 )
156 }
157}
158
159#[derive(IntoElement)]
161pub struct AccordionItem {
162 index: usize,
163 last: bool,
164 style: StyleRefinement,
165 hover_style: Option<StyleRefinement>,
166 title_style: StyleRefinement,
167 content_style: StyleRefinement,
168 icon: Option<Icon>,
169 title: AnyElement,
170 children: Vec<AnyElement>,
171 open: bool,
172 size: Size,
173 disabled: bool,
174 on_toggle_click: Option<Arc<dyn Fn(&bool, &mut Window, &mut App)>>,
175}
176
177impl AccordionItem {
178 pub fn new() -> Self {
180 Self {
181 index: 0,
182 last: false,
183 style: StyleRefinement::default(),
184 hover_style: None,
185 title_style: StyleRefinement::default(),
186 content_style: StyleRefinement::default(),
187 icon: None,
188 title: SharedString::default().into_any_element(),
189 children: Vec::new(),
190 open: false,
191 disabled: false,
192 on_toggle_click: None,
193 size: Size::default(),
194 }
195 }
196
197 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
199 self.icon = Some(icon.into());
200 self
201 }
202
203 pub fn title(mut self, title: impl IntoElement) -> Self {
205 self.title = title.into_any_element();
206 self
207 }
208
209 pub fn open(mut self, open: bool) -> Self {
210 self.open = open;
211 self
212 }
213
214 pub fn disabled(mut self, disabled: bool) -> Self {
215 self.disabled = disabled;
216 self
217 }
218
219 pub fn title_style(mut self, style: StyleRefinement) -> Self {
221 self.title_style = style;
222 self
223 }
224
225 pub fn hover(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
231 self.hover_style = Some(f(StyleRefinement::default()));
232 self
233 }
234
235 pub fn content_style(mut self, style: StyleRefinement) -> Self {
237 self.content_style = style;
238 self
239 }
240
241 fn index(mut self, index: usize) -> Self {
242 self.index = index;
243 self
244 }
245
246 fn last(mut self, last: bool) -> Self {
247 self.last = last;
248 self
249 }
250
251 fn on_toggle_click(
252 mut self,
253 on_toggle_click: impl Fn(&bool, &mut Window, &mut App) + 'static,
254 ) -> Self {
255 self.on_toggle_click = Some(Arc::new(on_toggle_click));
256 self
257 }
258}
259
260impl ParentElement for AccordionItem {
261 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
262 self.children.extend(elements);
263 }
264}
265
266impl Sizable for AccordionItem {
267 fn with_size(mut self, size: impl Into<Size>) -> Self {
268 self.size = size.into();
269 self
270 }
271}
272
273impl Styled for AccordionItem {
274 fn style(&mut self) -> &mut StyleRefinement {
275 &mut self.style
276 }
277}
278
279impl RenderOnce for AccordionItem {
280 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
281 let text_size = match self.size {
282 Size::XSmall => rems(0.8125),
283 Size::Large => rems(1.0),
284 _ => rems(0.875),
285 };
286 let progress = spring(
287 (self.index, "accordion-panel"),
288 if self.open { 1. } else { 0. },
289 cx.theme().motion_tokens().spring_control,
290 window,
291 cx,
292 );
293 let trigger = AccordionTrigger::new(("trigger", self.index))
294 .open(self.open)
295 .disabled(self.disabled)
296 .h_flex()
297 .justify_between()
298 .gap_3()
299 .font_medium()
300 .map(|this| match self.size {
301 Size::XSmall => this.py_1().px_1p5(),
302 Size::Small => this.py_1p5().px_2(),
303 Size::Large => this.py_3().px_4(),
304 _ => this.py_2().px_3(),
305 })
306 .when(self.open, |this| this.text_color(cx.theme().foreground))
307 .refine_style(&self.title_style)
308 .child(
309 h_flex()
310 .flex_1()
311 .min_w_0()
312 .items_center()
313 .map(|this| match self.size {
314 Size::XSmall | Size::Small => this.gap_1(),
315 _ => this.gap_2(),
316 })
317 .when_some(self.icon, |this, icon| {
318 this.child(icon.with_size(self.size))
319 })
320 .child(self.title),
321 )
322 .when(!self.disabled, |this| {
323 this.when_some(self.hover_style, |this, hover_style| {
324 this.hover(move |this| this.refine_style(&hover_style))
325 })
326 .child(
327 Icon::new(IconName::ChevronDown)
328 .xsmall()
329 .flex_none()
330 .text_color(cx.theme().muted_foreground)
331 .rotate(percentage(if self.open { 0.5 } else { 0. })),
332 )
333 .when_some(self.on_toggle_click, |this, on_toggle_click| {
334 this.on_change(move |open, _, window, cx| {
335 on_toggle_click(&open, window, cx);
336 })
337 })
338 });
339
340 div().flex_1().child(
341 BaseAccordionItem::new()
342 .open(self.open)
343 .disabled(self.disabled)
344 .header(
345 BaseAccordionHeader::new(trigger)
346 .id(("header", self.index))
347 .w_full(),
348 )
349 .panel(
350 BaseAccordionPanel::new()
351 .id(("panel", self.index))
352 .open(self.open)
353 .keep_mounted(true)
354 .w_full()
355 .child(MotionReveal::new(
356 ("content", self.index),
357 progress,
358 div()
359 .map(|this| match self.size {
360 Size::XSmall => this.pb_1().px_1p5(),
361 Size::Small => this.pb_1p5().px_2(),
362 Size::Large => this.pb_3().px_4(),
363 _ => this.pb_2().px_3(),
364 })
365 .refine_style(&self.content_style)
366 .children(self.children)
367 .into_any_element(),
368 )),
369 )
370 .v_flex()
371 .w_full()
372 .bg(cx.theme().tokens.accordion)
373 .overflow_hidden()
374 .when(!self.last, |this| {
375 this.border_b_1().border_color(cx.theme().border)
376 })
377 .text_size(text_size)
378 .refine_style(&self.style),
379 )
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use gpui::{Context, Render, TestAppContext, div, px};
386
387 use super::*;
388
389 struct Harness;
390
391 impl Render for Harness {
392 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
393 Accordion::new("accordion-layout")
394 .w(px(240.))
395 .h(px(100.))
396 .item(|item| {
397 item.open(true)
398 .title(div().debug_selector(|| "first-title".into()).child("First"))
399 .child(div().debug_selector(|| "first-content".into()).h(px(60.)))
400 })
401 .item(|item| {
402 item.title(
403 div()
404 .debug_selector(|| "second-title".into())
405 .child("Second"),
406 )
407 })
408 }
409 }
410
411 #[gpui::test]
412 fn expanded_panel_keeps_content_between_its_header_and_the_next_item(cx: &mut TestAppContext) {
413 cx.update(crate::theme::init);
414 let (_, cx) = cx.add_window_view(|_, _| Harness);
415 cx.update(|window, cx| window.draw(cx).clear(cx));
416
417 let first = cx.debug_bounds("first-title").unwrap();
418 let content = cx.debug_bounds("first-content").unwrap();
419 let second = cx.debug_bounds("second-title").unwrap();
420 assert!(first.origin.y < content.origin.y);
421 assert!(content.origin.y + content.size.height <= second.origin.y);
422 }
423}