Skip to main content

gpui_component/
collapsible.rs

1use gpui::{
2    AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, StyleRefinement, Styled,
3    Window,
4};
5use gpui_base::spring;
6
7use crate::{ActiveTheme as _, StyledExt};
8
9/// An interactive element which expands/collapses.
10#[derive(IntoElement)]
11pub struct Collapsible {
12    base: gpui_base::Collapsible,
13    style: StyleRefinement,
14    motion_id: Option<ElementId>,
15    open: bool,
16}
17
18impl Collapsible {
19    /// Creates a new `Collapsible` instance.
20    pub fn new() -> Self {
21        Self {
22            base: gpui_base::Collapsible::new(),
23            style: StyleRefinement::default(),
24            motion_id: None,
25            open: false,
26        }
27    }
28
29    /// Sets whether the collapsible is open. default is false.
30    pub fn open(mut self, open: bool) -> Self {
31        self.open = open;
32        self.base = self.base.open(open);
33        self
34    }
35
36    /// Enables a reversible measured reveal under a stable identity.
37    pub fn motion_id(mut self, id: impl Into<ElementId>) -> Self {
38        self.motion_id = Some(id.into());
39        self
40    }
41
42    /// Sets the content of the collapsible.
43    ///
44    /// If `open` is false, content will be hidden.
45    pub fn content(mut self, content: impl IntoElement) -> Self {
46        self.base = self.base.content(content);
47        self
48    }
49}
50
51impl Styled for Collapsible {
52    fn style(&mut self) -> &mut StyleRefinement {
53        &mut self.style
54    }
55}
56
57impl ParentElement for Collapsible {
58    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
59        self.base.extend(elements);
60    }
61}
62
63impl RenderOnce for Collapsible {
64    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
65        let base = match self.motion_id {
66            Some(id) => {
67                let progress = spring(
68                    (id.clone(), "reveal"),
69                    if self.open { 1.0 } else { 0.0 },
70                    cx.theme().motion_tokens().spring_control,
71                    window,
72                    cx,
73                );
74                self.base.reveal(id, progress)
75            }
76            None => self.base,
77        };
78        base.v_flex().refine_style(&self.style)
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use gpui::{Context, InteractiveElement as _, Render, TestAppContext, div, px};
85
86    use super::*;
87    use crate::Theme;
88
89    struct Harness(bool);
90
91    impl Render for Harness {
92        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
93            Collapsible::new()
94                .open(self.0)
95                .child(
96                    div()
97                        .debug_selector(|| "collapsible-trigger".into())
98                        .size(px(10.)),
99                )
100                .content(
101                    div()
102                        .debug_selector(|| "collapsible-content".into())
103                        .size(px(10.)),
104                )
105        }
106    }
107
108    #[gpui::test]
109    fn facade_preserves_vertical_layout_and_visibility(cx: &mut TestAppContext) {
110        let (_, cx) = cx.add_window_view(|_, _| Harness(true));
111        cx.update(|window, cx| window.draw(cx).clear(cx));
112        let trigger = cx.debug_bounds("collapsible-trigger").unwrap();
113        let content = cx.debug_bounds("collapsible-content").unwrap();
114        assert!(trigger.origin.y < content.origin.y);
115
116        let (_, cx) = cx.add_window_view(|_, _| Harness(false));
117        cx.update(|window, cx| window.draw(cx).clear(cx));
118        assert!(cx.debug_bounds("collapsible-trigger").is_some());
119        assert!(cx.debug_bounds("collapsible-content").is_none());
120    }
121
122    #[gpui::test]
123    fn motion_id_keeps_closed_content_mounted_for_reversible_reveal(cx: &mut TestAppContext) {
124        cx.update(|cx| cx.set_global(Theme::default()));
125
126        struct MotionHarness;
127
128        impl Render for MotionHarness {
129            fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
130                Collapsible::new()
131                    .motion_id("details-motion")
132                    .open(false)
133                    .content(
134                        div()
135                            .debug_selector(|| "motion-content".into())
136                            .size(px(10.)),
137                    )
138            }
139        }
140
141        let (_, cx) = cx.add_window_view(|_, _| MotionHarness);
142        cx.update(|window, cx| window.draw(cx).clear(cx));
143        assert!(cx.debug_bounds("motion-content").is_some());
144    }
145}