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                        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/// An Accordion is a vertically stacked list of items, each of which can be expanded to reveal the content associated with it.
160#[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    /// Create a new AccordionItem.
179    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    /// Set the icon for the accordion item.
198    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
199        self.icon = Some(icon.into());
200        self
201    }
202
203    /// Set the title for the accordion item.
204    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    /// Set extra style for the title row.
220    pub fn title_style(mut self, style: StyleRefinement) -> Self {
221        self.title_style = style;
222        self
223    }
224
225    /// Set the style of the title row while the mouse is over it.
226    ///
227    /// There is no hover style by default. The title row is the part that
228    /// toggles the item, so the hover feedback belongs there, not on the
229    /// whole item.
230    pub fn hover(mut self, f: impl FnOnce(StyleRefinement) -> StyleRefinement) -> Self {
231        self.hover_style = Some(f(StyleRefinement::default()));
232        self
233    }
234
235    /// Set extra style for the content below the title.
236    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}