Skip to main content

gpui_component/
accordion.rs

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/// Accordion element.
17#[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    /// Create a new Accordion with the given ID.
31    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    /// Set whether multiple accordion items can be opened simultaneously, default: false
45    pub fn multiple(mut self, multiple: bool) -> Self {
46        self.multiple = multiple;
47        self
48    }
49
50    /// Set whether the accordion items have borders, default: true
51    pub fn bordered(mut self, bordered: bool) -> Self {
52        self.bordered = bordered;
53        self
54    }
55
56    /// Set whether the accordion is disabled, default: false
57    pub fn disabled(mut self, disabled: bool) -> Self {
58        self.disabled = disabled;
59        self
60    }
61
62    /// Adds an AccordionItem to the Accordion.
63    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    /// Sets the on_toggle_click callback for the AccordionGroup.
73    ///
74    /// The first argument `Vec<usize>` is the indices of the open accordions.
75    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            // The bordered accordion is a single rounded card, the items are
107            // joined by their separators.
108            .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/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it.
159#[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    /// Create a new AccordionItem.
178    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    /// Set the icon for the accordion item.
197    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
198        self.icon = Some(icon.into());
199        self
200    }
201
202    /// Set the title for the accordion item.
203    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    /// Set extra style for the title row.
219    pub fn title_style(mut self, style: StyleRefinement) -> Self {
220        self.title_style = style;
221        self
222    }
223
224    /// Set the style of the title row while the mouse is over it.
225    ///
226    /// There is no hover style by default. The title row is the part that
227    /// toggles the item, so the hover feedback belongs there, not on the
228    /// whole item.
229    pub fn hover(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
230        self.hover_style = Some(f(StyleRefinement::default()));
231        self
232    }
233
234    /// Set extra style for the content below the title.
235    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}