1use 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
30pub 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 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#[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 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(§ion.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 .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, §ion.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 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 .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 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
331fn 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}