gpui_component/
collapsible.rs1use 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#[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 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 pub fn open(mut self, open: bool) -> Self {
31 self.open = open;
32 self.base = self.base.open(open);
33 self
34 }
35
36 pub fn motion_id(mut self, id: impl Into<ElementId>) -> Self {
38 self.motion_id = Some(id.into());
39 self
40 }
41
42 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}