Skip to main content

gpui_kit/navigation/
collapsible.rs

1//! One region that opens and shuts.
2//!
3//! [`Accordion`] is the many-region case and holds every rule worth holding:
4//! the header treatment, the chevron that turns, the measured height a body
5//! grows and shrinks through, and the decision that a shut body leaves the
6//! tree rather than hiding in it. A collapsible is the same thing with one
7//! section, so it is built by handing that section to an accordion rather than
8//! by writing any of it again. If the disclosure animation is ever wrong here,
9//! it is wrong in exactly one place.
10//!
11//! Whether it is open belongs to the caller, as it does for the accordion: the
12//! collapsible reports the state activating the header asks for and draws the
13//! state it was handed.
14
15use std::rc::Rc;
16
17use gpui::{
18    AnyElement, App, IntoElement, RenderOnce, SharedString, Window, prelude::FluentBuilder,
19};
20use gpui_kit_theme::ControlSize;
21
22use crate::foundation::{Ident, Sizable};
23use crate::navigation::accordion::{Accordion, AccordionSection};
24
25/// The section id the one region is filed under.
26///
27/// It is a fixed word rather than the caller's own id so the published header
28/// lands at `{ident}.header` for every collapsible in a tree, which is what a
29/// test addresses.
30const SECTION: &str = "header";
31
32type ToggleHandler = Rc<dyn Fn(bool, &mut Window, &mut App)>;
33
34/// A single region behind a header that opens and shuts it.
35#[derive(IntoElement)]
36pub struct Collapsible {
37    ident: Ident,
38    title: SharedString,
39    description: Option<SharedString>,
40    open: bool,
41    disabled: bool,
42    size: ControlSize,
43    body: Option<AnyElement>,
44    on_toggle: Option<ToggleHandler>,
45}
46
47impl std::fmt::Debug for Collapsible {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        formatter
50            .debug_struct("Collapsible")
51            .field("ident", &self.ident)
52            .field("title", &self.title)
53            .field("open", &self.open)
54            .field("disabled", &self.disabled)
55            .field("has_body", &self.body.is_some())
56            .field("has_handler", &self.on_toggle.is_some())
57            .finish()
58    }
59}
60
61impl Collapsible {
62    pub fn new(ident: impl Into<Ident>, title: impl Into<SharedString>) -> Self {
63        Self {
64            ident: ident.into(),
65            title: title.into(),
66            description: None,
67            open: false,
68            disabled: false,
69            size: ControlSize::Md,
70            body: None,
71            on_toggle: None,
72        }
73    }
74
75    /// Secondary text in the header, readable while the region is shut.
76    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
77        self.description = Some(description.into());
78        self
79    }
80
81    /// Whether the region is open. The caller's answer, not the component's.
82    pub fn open(mut self, open: bool) -> Self {
83        self.open = open;
84        self
85    }
86
87    /// Refuses the header. A refused collapsible installs no handler.
88    pub fn disabled(mut self, disabled: bool) -> Self {
89        self.disabled = disabled;
90        self
91    }
92
93    pub fn body(mut self, body: impl IntoElement) -> Self {
94        self.body = Some(body.into_any_element());
95        self
96    }
97
98    /// Reports the state activating the header asks for.
99    pub fn on_toggle(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
100        self.on_toggle = Some(Rc::new(handler));
101        self
102    }
103
104    /// The id of the header a test clicks and a reader announces.
105    pub fn header_id(ident: &Ident) -> SharedString {
106        ident.child(SECTION).semantic_id()
107    }
108}
109
110impl Sizable for Collapsible {
111    fn control_size(mut self, size: ControlSize) -> Self {
112        self.size = size;
113        self
114    }
115}
116
117impl RenderOnce for Collapsible {
118    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
119        let mut section =
120            AccordionSection::new(SECTION, self.title.clone()).disabled(self.disabled);
121        if let Some(description) = self.description.clone() {
122            section = section.description(description);
123        }
124        if let Some(body) = self.body {
125            section = section.body(body);
126        }
127
128        Accordion::new(self.ident.clone())
129            .control_size(self.size)
130            .section(section)
131            .when(self.open, |accordion| accordion.expanded_ids(&[SECTION]))
132            .when_some(self.on_toggle, |accordion, handler| {
133                accordion.on_toggle(move |_, next, window, cx| handler(next, window, cx))
134            })
135    }
136}