ccf_gpui_widgets/widgets/
collapsible.rs1use gpui::prelude::*;
61use gpui::*;
62
63use crate::theme::{get_theme_or, Theme};
64use super::focus_navigation::{handle_tab_navigation, with_focus_actions, EnabledCursorExt};
65
66#[derive(Clone, Debug)]
68pub enum CollapsibleEvent {
69 Change(bool),
72}
73
74pub struct Collapsible {
76 title: SharedString,
77 collapsed: bool,
78 focus_handle: FocusHandle,
79 custom_theme: Option<Theme>,
80 enabled: bool,
82 collapsible: bool,
84}
85
86impl EventEmitter<CollapsibleEvent> for Collapsible {}
87
88impl Focusable for Collapsible {
89 fn focus_handle(&self, _cx: &App) -> FocusHandle {
90 self.focus_handle.clone()
91 }
92}
93
94impl Collapsible {
95 pub fn new(title: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
97 Self {
98 title: title.into(),
99 collapsed: false,
100 focus_handle: cx.focus_handle().tab_stop(true),
101 custom_theme: None,
102 enabled: true,
103 collapsible: true,
104 }
105 }
106
107 #[must_use]
109 pub fn with_collapsed(mut self, collapsed: bool) -> Self {
110 self.collapsed = collapsed;
111 self
112 }
113
114 #[must_use]
116 pub fn theme(mut self, theme: Theme) -> Self {
117 self.custom_theme = Some(theme);
118 self
119 }
120
121 #[must_use]
123 pub fn with_enabled(mut self, enabled: bool) -> Self {
124 self.enabled = enabled;
125 self
126 }
127
128 #[must_use]
137 pub fn collapsible(mut self, collapsible: bool) -> Self {
138 self.collapsible = collapsible;
139 self
140 }
141
142 pub fn focus_handle(&self) -> &FocusHandle {
144 &self.focus_handle
145 }
146
147 pub fn is_collapsed(&self) -> bool {
149 self.collapsed
150 }
151
152 pub fn set_collapsed(&mut self, collapsed: bool, cx: &mut Context<Self>) {
154 if self.collapsed != collapsed {
155 self.collapsed = collapsed;
156 cx.emit(CollapsibleEvent::Change(collapsed));
157 cx.notify();
158 }
159 }
160
161 pub fn toggle(&mut self, cx: &mut Context<Self>) {
163 self.collapsed = !self.collapsed;
164 cx.emit(CollapsibleEvent::Change(self.collapsed));
165 cx.notify();
166 }
167
168 pub fn is_enabled(&self) -> bool {
170 self.enabled
171 }
172
173 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
175 if self.enabled != enabled {
176 self.enabled = enabled;
177 cx.notify();
178 }
179 }
180
181 pub fn is_collapsible(&self) -> bool {
183 self.collapsible
184 }
185
186 pub fn set_collapsible(&mut self, collapsible: bool, cx: &mut Context<Self>) {
188 if self.collapsible != collapsible {
189 self.collapsible = collapsible;
190 cx.notify();
191 }
192 }
193}
194
195impl Render for Collapsible {
196 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
197 let theme = get_theme_or(cx, self.custom_theme.as_ref());
198 let collapsed = self.collapsed;
199 let title = self.title.clone();
200 let collapsible = self.collapsible;
201 let enabled = self.enabled;
202 let interactive = collapsible && enabled;
204
205 if !collapsible {
207 return div()
208 .id("ccf_collapsible_header")
209 .flex()
210 .flex_row()
211 .items_center()
212 .gap_2()
213 .py(px(6.))
214 .px_2()
215 .bg(rgb(theme.bg_section_header))
216 .rounded_t_md()
217 .border_2()
218 .border_color(rgba(0x00000000))
219 .child(
220 div()
221 .text_sm()
222 .font_weight(FontWeight::SEMIBOLD)
223 .text_color(rgb(theme.text_section_header))
224 .child(title)
225 );
226 }
227
228 let chevron = if collapsed { "▶" } else { "▼" };
230 let focus_handle = self.focus_handle.clone();
231 let is_focused = self.focus_handle.is_focused(window);
232
233 with_focus_actions(
234 div()
235 .id("ccf_collapsible_header")
236 .track_focus(&focus_handle)
237 .tab_stop(enabled),
238 cx,
239 )
240 .on_key_down(cx.listener(move |this, event: &KeyDownEvent, window, cx| {
241 if !this.enabled {
242 return;
243 }
244 if handle_tab_navigation(event, window) {
245 return;
246 }
247 match event.keystroke.key.as_str() {
249 "down" => this.set_collapsed(false, cx),
250 "up" => this.set_collapsed(true, cx),
251 "space" | "enter" => this.toggle(cx),
252 _ => {}
253 }
254 }))
255 .flex()
256 .flex_row()
257 .items_center()
258 .gap_2()
259 .py(px(6.))
260 .px_2()
261 .when(enabled, |d| d.bg(rgb(theme.bg_section_header)))
262 .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
263 .when(collapsed, |d| d.rounded_md())
265 .when(!collapsed, |d| d.rounded_t_md())
266 .cursor_for_enabled(interactive)
267 .border_2()
268 .border_color(if is_focused && enabled { rgb(theme.border_focus) } else { rgba(0x00000000) })
269 .when(interactive, |d| {
270 d.hover(|d| d.bg(rgb(theme.bg_section_header_hover)))
271 .on_mouse_down(MouseButton::Left, cx.listener(|this, _event, window, cx| {
272 this.focus_handle.focus(window);
273 this.toggle(cx);
274 }))
275 })
276 .child(
277 div()
279 .text_sm()
280 .when(enabled, |d| d.text_color(rgb(theme.text_dimmed)))
281 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
282 .w(px(16.))
283 .child(chevron)
284 )
285 .child(
286 div()
288 .text_sm()
289 .font_weight(FontWeight::SEMIBOLD)
290 .when(enabled, |d| d.text_color(rgb(theme.text_section_header)))
291 .when(!enabled, |d| d.text_color(rgb(theme.disabled_text)))
292 .child(title)
293 )
294 }
295}