Skip to main content

ccf_gpui_widgets/widgets/
collapsible.rs

1//! Collapsible section widget
2//!
3//! A section header that can be collapsed/expanded. Use with child content
4//! that you conditionally render based on the collapsed state.
5//!
6//! Can also be used as a static (non-collapsible) section header by calling
7//! `.collapsible(false)`.
8//!
9//! # Example - Collapsible Section
10//!
11//! ```ignore
12//! use ccf_gpui_widgets::widgets::Collapsible;
13//!
14//! let section = cx.new(|cx| {
15//!     Collapsible::new("Advanced Options", cx)
16//!         .with_collapsed(true)
17//! });
18//!
19//! // In your parent render, wrap header and content in a container
20//! // to get clean borders without visual seams:
21//! div()
22//!     .overflow_hidden()
23//!     .rounded_md()
24//!     .border_1()
25//!     .border_color(rgb(theme.border_default))
26//!     .child(section.clone())
27//!     .when(!section.read(cx).is_collapsed(), |d| {
28//!         d.child(
29//!             div()
30//!                 .p_3()
31//!                 .bg(rgb(theme.bg_input))
32//!                 .child(/* your content here */)
33//!         )
34//!     })
35//! ```
36//!
37//! # Example - Static Section Header
38//!
39//! ```ignore
40//! let header = cx.new(|cx| {
41//!     Collapsible::new("Settings", cx)
42//!         .collapsible(false)  // No chevron, not interactive
43//! });
44//!
45//! // Content is always visible
46//! div()
47//!     .overflow_hidden()
48//!     .rounded_md()
49//!     .border_1()
50//!     .border_color(rgb(theme.border_default))
51//!     .child(header.clone())
52//!     .child(
53//!         div()
54//!             .p_3()
55//!             .bg(rgb(theme.bg_input))
56//!             .child(/* your content */)
57//!     )
58//! ```
59
60use 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/// Events emitted by Collapsible
67#[derive(Clone, Debug)]
68pub enum CollapsibleEvent {
69    /// Collapsed state changed.
70    /// The boolean indicates the new collapsed state: `true` = collapsed, `false` = expanded.
71    Change(bool),
72}
73
74/// Collapsible section widget
75pub struct Collapsible {
76    title: SharedString,
77    collapsed: bool,
78    focus_handle: FocusHandle,
79    custom_theme: Option<Theme>,
80    /// Whether the widget is enabled (interactive)
81    enabled: bool,
82    /// Whether collapsing is allowed (when false, acts as static section header)
83    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    /// Create a new collapsible section
96    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    /// Set initial collapsed state (builder pattern)
108    #[must_use]
109    pub fn with_collapsed(mut self, collapsed: bool) -> Self {
110        self.collapsed = collapsed;
111        self
112    }
113
114    /// Set custom theme (builder pattern)
115    #[must_use]
116    pub fn theme(mut self, theme: Theme) -> Self {
117        self.custom_theme = Some(theme);
118        self
119    }
120
121    /// Set enabled state (builder pattern)
122    #[must_use]
123    pub fn with_enabled(mut self, enabled: bool) -> Self {
124        self.enabled = enabled;
125        self
126    }
127
128    /// Set whether collapsing is allowed (builder pattern)
129    ///
130    /// When `false`, the widget acts as a static section header:
131    /// - No chevron icon
132    /// - No click/keyboard interaction
133    /// - Not focusable
134    ///
135    /// Default is `true`.
136    #[must_use]
137    pub fn collapsible(mut self, collapsible: bool) -> Self {
138        self.collapsible = collapsible;
139        self
140    }
141
142    /// Get the focus handle
143    pub fn focus_handle(&self) -> &FocusHandle {
144        &self.focus_handle
145    }
146
147    /// Check if currently collapsed
148    pub fn is_collapsed(&self) -> bool {
149        self.collapsed
150    }
151
152    /// Set collapsed state programmatically
153    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    /// Toggle collapsed state
162    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    /// Check if the collapsible is enabled
169    pub fn is_enabled(&self) -> bool {
170        self.enabled
171    }
172
173    /// Set enabled state programmatically
174    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    /// Check if collapsing is allowed
182    pub fn is_collapsible(&self) -> bool {
183        self.collapsible
184    }
185
186    /// Set whether collapsing is allowed programmatically
187    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        // Only show interactive state when both collapsible and enabled
203        let interactive = collapsible && enabled;
204
205        // For non-collapsible mode, return a simple static header (always has content below)
206        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        // Collapsible mode - full interactive header
229        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            // Arrow keys for expand/collapse, space/enter to toggle
248            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        // Rounded corners: all corners when collapsed, only top when expanded
264        .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            // Chevron icon
278            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            // Section title
287            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}