Skip to main content

gpui_kit/navigation/
accordion.rs

1//! Sections that disclose their body when the caller says they are open.
2//!
3//! Which sections are open is caller-owned. The accordion reports the section
4//! that was activated and the state it should take next; it shows exactly the
5//! set the caller passed, so a host that refuses to open a section leaves it
6//! closed.
7
8use std::f32::consts::FRAC_PI_2;
9use std::rc::Rc;
10
11use gpui::{
12    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
13    StatefulInteractiveElement, Styled, Transformation, Window, div, prelude::FluentBuilder, px,
14    radians,
15};
16use gpui_kit_assets::{Icon, icon};
17use gpui_kit_semantics::{NodeSpec, Role, Semantic};
18use gpui_kit_theme::{
19    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, TypeScale,
20};
21
22use crate::display::icon::flips;
23use crate::foundation::direction::{ActiveDirection, DirectionalExt};
24use crate::foundation::{FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text};
25use crate::layout::measure;
26use crate::motion;
27
28type ToggleHandler = Rc<dyn Fn(SharedString, bool, &mut Window, &mut App)>;
29
30/// One disclosure section: a header the typist can operate and a body that
31/// exists only while the section is open.
32pub struct AccordionSection {
33    id: SharedString,
34    title: SharedString,
35    description: Option<SharedString>,
36    disabled: bool,
37    body: Option<AnyElement>,
38}
39
40impl std::fmt::Debug for AccordionSection {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        formatter
43            .debug_struct("AccordionSection")
44            .field("id", &self.id)
45            .field("title", &self.title)
46            .field("disabled", &self.disabled)
47            .field("has_body", &self.body.is_some())
48            .finish()
49    }
50}
51
52impl AccordionSection {
53    pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
54        Self {
55            id: id.into(),
56            title: title.into(),
57            description: None,
58            disabled: false,
59            body: None,
60        }
61    }
62
63    /// Secondary text in the header, readable while the section is closed.
64    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
65        self.description = Some(description.into());
66        self
67    }
68
69    pub fn disabled(mut self, disabled: bool) -> Self {
70        self.disabled = disabled;
71        self
72    }
73
74    pub fn body(mut self, body: impl IntoElement) -> Self {
75        self.body = Some(body.into_any_element());
76        self
77    }
78}
79
80/// A stack of disclosure sections.
81#[derive(IntoElement)]
82pub struct Accordion {
83    ident: Ident,
84    sections: Vec<AccordionSection>,
85    expanded: Vec<SharedString>,
86    exclusive: bool,
87    size: ControlSize,
88    on_toggle: Option<ToggleHandler>,
89}
90
91impl std::fmt::Debug for Accordion {
92    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        formatter
94            .debug_struct("Accordion")
95            .field("ident", &self.ident)
96            .field("sections", &self.sections.len())
97            .field("expanded", &self.expanded)
98            .field("exclusive", &self.exclusive)
99            .field("has_handler", &self.on_toggle.is_some())
100            .finish()
101    }
102}
103
104impl Accordion {
105    pub fn new(ident: impl Into<Ident>) -> Self {
106        Self {
107            ident: ident.into(),
108            sections: Vec::new(),
109            expanded: Vec::new(),
110            exclusive: false,
111            size: ControlSize::Md,
112            on_toggle: None,
113        }
114    }
115
116    pub fn section(mut self, section: AccordionSection) -> Self {
117        self.sections.push(section);
118        self
119    }
120
121    pub fn sections(mut self, sections: impl IntoIterator<Item = AccordionSection>) -> Self {
122        self.sections.extend(sections);
123        self
124    }
125
126    pub fn expanded(mut self, ids: impl IntoIterator<Item = SharedString>) -> Self {
127        self.expanded = ids.into_iter().collect();
128        self
129    }
130
131    pub fn expanded_ids<S: AsRef<str>>(mut self, ids: &[S]) -> Self {
132        self.expanded = ids
133            .iter()
134            .map(|id| SharedString::from(id.as_ref().to_string()))
135            .collect();
136        self
137    }
138
139    /// Whether opening a section also reports a close for every other open
140    /// section.
141    ///
142    /// Exclusivity changes only what is reported, never what is shown: the
143    /// accordion always renders the set the caller passed to
144    /// [`Accordion::expanded`], so a host that applies only part of the report
145    /// gets exactly what it applied.
146    pub fn exclusive(mut self, exclusive: bool) -> Self {
147        self.exclusive = exclusive;
148        self
149    }
150
151    pub fn on_toggle(
152        mut self,
153        handler: impl Fn(SharedString, bool, &mut Window, &mut App) + 'static,
154    ) -> Self {
155        self.on_toggle = Some(Rc::new(handler));
156        self
157    }
158}
159
160impl Sizable for Accordion {
161    fn control_size(mut self, size: ControlSize) -> Self {
162        self.size = size;
163        self
164    }
165}
166
167impl RenderOnce for Accordion {
168    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
169        let theme = cx.theme().clone();
170        let metrics = theme.control.get(self.size);
171        let expanded_ids = self.expanded.clone();
172        let direction = cx.layout_direction();
173
174        let mut stack = div()
175            .column()
176            .radius(&theme, Radius::Card)
177            .frame(&theme, Surface::Panel, Elevation::Raised)
178            .overflow_hidden();
179
180        for section in self.sections.into_iter() {
181            let open = expanded_ids.contains(&section.id);
182            let actionable = !section.disabled && self.on_toggle.is_some();
183            let ident = self.ident.child(section.id.as_ref());
184            let color = if section.disabled {
185                theme.colors.text_faint
186            } else {
187                theme.colors.text
188            };
189
190            let mut header = div()
191                .id(ident.element_id())
192                .row_reading(direction)
193                .w_full()
194                .gap(px(theme.space(Space::Sm)))
195                .px(px(metrics.padding_x))
196                .py(px(theme.space(Space::Sm)))
197                .child(
198                    icon(Icon::AltArrowRight)
199                        .size(px(metrics.icon_size))
200                        .text_color(theme.colors.text_muted)
201                        .when(open, |glyph| {
202                            glyph.with_transformation(Transformation::rotate(radians(FRAC_PI_2)))
203                        })
204                        // Open, it points down, and down is down either way.
205                        .when(!open && flips(Icon::AltArrowRight, direction), |glyph| {
206                            glyph.with_transformation(Transformation::scale(gpui::size(-1.0, 1.0)))
207                        }),
208                )
209                .child(
210                    div()
211                        .column()
212                        .flex_1()
213                        .gap(px(2.0))
214                        .child(
215                            foundation_text(&theme, TypeScale::Label, section.title.clone())
216                                .text_size(px(metrics.font_size))
217                                .text_start(direction)
218                                .text_color(color),
219                        )
220                        .children(section.description.clone().map(|description| {
221                            foundation_text(&theme, TypeScale::Caption, description)
222                                .text_start(direction)
223                                .text_tone(&theme, TextTone::Muted)
224                        })),
225                )
226                .when(section.disabled, |element| {
227                    element.opacity(theme.opacity.disabled)
228                })
229                .when(actionable, |element| {
230                    element
231                        .cursor_pointer()
232                        .tab_index(0)
233                        .pressable(cx)
234                        .hover(|style| style.bg(theme.colors.hover))
235                        .focus_ring(&theme)
236                });
237
238            if let (true, Some(handler)) = (actionable, self.on_toggle.clone()) {
239                let reports = reports(&expanded_ids, &section.id, open, self.exclusive);
240                let key_reports = reports.clone();
241                let click = Rc::clone(&handler);
242                header = header
243                    .on_click(move |_, window, cx| {
244                        for (id, next) in &reports {
245                            click(id.clone(), *next, window, cx);
246                        }
247                    })
248                    .on_key_down(move |event, window, cx| {
249                        if matches!(event.keystroke.key.as_str(), "enter" | "space") {
250                            for (id, next) in &key_reports {
251                                handler(id.clone(), *next, window, cx);
252                            }
253                            cx.stop_propagation();
254                        }
255                    });
256            }
257
258            let header = header.semantic_in(
259                cx,
260                NodeSpec::new(ident.semantic_id(), Role::Button)
261                    .parent(self.ident.semantic_id())
262                    .expanded(open)
263                    .disabled(section.disabled)
264                    .text(section.title.clone()),
265            );
266
267            let body_id = ident.child("body").semantic_id();
268            let disclosed = motion::tracked(
269                &body_id,
270                f32::from(u8::from(open)),
271                motion::resize(&theme),
272                window,
273                cx,
274            );
275            // A section that is not disclosing at all drops its body entirely
276            // rather than hiding it, so nothing invisible stays addressable.
277            // While a section is still collapsing its body is still on screen,
278            // and something on screen is something a typist can point at, so
279            // it stays addressable exactly as long as it stays visible.
280            let body = (disclosed > 0.0).then_some(section.body).flatten();
281            let measured = measure::cell(&body_id, cx);
282            let height = px(f32::from(measured.get().size.height) * disclosed);
283
284            stack = stack.child(div().column().child(header).children(body.map(|body| {
285                let content = div()
286                    // Indented to the title rather than to the
287                    // chevron, so the body reads as belonging to the
288                    // section it hangs under.
289                    .pl(px(metrics.padding_x
290                        + metrics.icon_size
291                        + theme.space(Space::Sm)))
292                    .pr(px(metrics.padding_x))
293                    .pb(px(theme.space(Space::Sm)))
294                    .child(body);
295                let record = {
296                    let measured = Rc::clone(&measured);
297                    move |bounds: Vec<gpui::Bounds<gpui::Pixels>>,
298                          window: &mut Window,
299                          _: &mut App| {
300                        if let Some(first) = bounds.first() {
301                            measure::record(&measured, *first, window);
302                        }
303                    }
304                };
305
306                // A settled section is laid out exactly as it was
307                // before there was any motion here: the body sits in
308                // the flow and its own height is the section's height.
309                // Only a section in flight uses the driven height, and
310                // it takes the body out of the flow to get one, so the
311                // measurement stays the body's natural height instead
312                // of chasing the frame being animated around it.
313                if disclosed >= 1.0 {
314                    div().w_full().on_children_prepainted(record).child(content)
315                } else {
316                    div()
317                        .relative()
318                        .w_full()
319                        .h(height)
320                        .overflow_hidden()
321                        .on_children_prepainted(record)
322                        .child(content.absolute().top_0().left_0().right_0())
323                }
324            })));
325        }
326
327        stack.semantic_in(cx, NodeSpec::new(self.ident.semantic_id(), Role::Group))
328    }
329}
330
331/// What activating `id` reports.
332///
333/// Under exclusivity, opening a section also reports a close for every other
334/// open one, which is a report about intent and not a change to what is drawn.
335fn reports(
336    expanded: &[SharedString],
337    id: &SharedString,
338    open: bool,
339    exclusive: bool,
340) -> Vec<(SharedString, bool)> {
341    let mut reports = vec![(id.clone(), !open)];
342    if exclusive && !open {
343        reports.extend(
344            expanded
345                .iter()
346                .filter(|other| *other != id)
347                .map(|other| (other.clone(), false)),
348        );
349    }
350    reports
351}