Skip to main content

guise/
panel.rs

1//! `Panel` — a titled surface: `Card` chrome plus a header row (icon, title,
2//! description, trailing actions), an optional footer, and a controlled
3//! collapse.
4//!
5//! ```ignore
6//! Panel::new()
7//!     .id("status")
8//!     .title("Project status")
9//!     .description("Weekly summary")
10//!     .action(ActionIcon::new("status-more", "…"))
11//!     .collapsible()
12//!     .collapsed(self.collapsed)
13//!     .on_toggle(cx.listener(|this, _ev, _window, cx| {
14//!         this.collapsed = !this.collapsed;
15//!         cx.notify();
16//!     }))
17//!     .footer(Text::new("Updated 5 minutes ago").dimmed())
18//!     .child(Text::new("Everything on track."))
19//! ```
20
21use std::rc::Rc;
22
23use gpui::prelude::*;
24use gpui::{
25    div, px, AnyElement, App, ClickEvent, ElementId, FontWeight, IntoElement, SharedString, Window,
26};
27
28use crate::actionicon::ActionIcon;
29use crate::icon::IconName;
30use crate::paper::apply_shadow;
31use crate::theme::{theme, Size};
32
33type ToggleHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>;
34
35/// A titled surface with header/footer chrome. Reads like [`Card`](crate::Card)
36/// but framed: a header (chevron + icon + title/description on the left,
37/// actions on the right), the body, and an optional footer. The header gets a
38/// bottom divider whenever the body is visible.
39///
40/// Collapsing is controlled, like `Modal`: the parent owns the flag, passes it
41/// through [`collapsed`](Panel::collapsed), and flips it in
42/// [`on_toggle`](Panel::on_toggle) (wired to the header chevron).
43#[derive(IntoElement)]
44pub struct Panel {
45    id: Option<ElementId>,
46    title: Option<SharedString>,
47    description: Option<SharedString>,
48    icon: Option<AnyElement>,
49    actions: Vec<AnyElement>,
50    footer: Option<AnyElement>,
51    children: Vec<AnyElement>,
52    padding: Size,
53    radius: Option<Size>,
54    with_border: bool,
55    shadow: Option<Size>,
56    collapsible: bool,
57    collapsed: bool,
58    on_toggle: Option<ToggleHandler>,
59}
60
61impl Panel {
62    pub fn new() -> Self {
63        Panel {
64            id: None,
65            title: None,
66            description: None,
67            icon: None,
68            actions: Vec::new(),
69            footer: None,
70            children: Vec::new(),
71            padding: Size::Lg,
72            radius: Some(Size::Md),
73            with_border: true,
74            shadow: Some(Size::Sm),
75            collapsible: false,
76            collapsed: false,
77            on_toggle: None,
78        }
79    }
80
81    /// Scope the panel's internal element ids (the collapse chevron). Set one
82    /// when several collapsible panels are siblings.
83    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
84        self.id = Some(id.into());
85        self
86    }
87
88    /// The header title.
89    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
90        self.title = Some(title.into());
91        self
92    }
93
94    /// Dimmed secondary line under the title.
95    pub fn description(mut self, description: impl Into<SharedString>) -> Self {
96        self.description = Some(description.into());
97        self
98    }
99
100    /// Leading header content (e.g. a `ThemeIcon`), shown before the title.
101    pub fn icon(mut self, icon: impl IntoElement) -> Self {
102        self.icon = Some(icon.into_any_element());
103        self
104    }
105
106    /// Append one trailing header action (e.g. an `ActionIcon`).
107    pub fn action(mut self, action: impl IntoElement) -> Self {
108        self.actions.push(action.into_any_element());
109        self
110    }
111
112    /// Replace the trailing header actions.
113    pub fn actions(mut self, actions: Vec<AnyElement>) -> Self {
114        self.actions = actions;
115        self
116    }
117
118    /// Footer content, rendered under the body behind a top divider.
119    pub fn footer(mut self, footer: impl IntoElement) -> Self {
120        self.footer = Some(footer.into_any_element());
121        self
122    }
123
124    pub fn padding(mut self, padding: Size) -> Self {
125        self.padding = padding;
126        self
127    }
128
129    pub fn radius(mut self, radius: Size) -> Self {
130        self.radius = Some(radius);
131        self
132    }
133
134    pub fn with_border(mut self, with_border: bool) -> Self {
135        self.with_border = with_border;
136        self
137    }
138
139    pub fn shadow(mut self, shadow: Size) -> Self {
140        self.shadow = Some(shadow);
141        self
142    }
143
144    /// Show a collapse chevron in the header. Pair with
145    /// [`collapsed`](Panel::collapsed) + [`on_toggle`](Panel::on_toggle).
146    pub fn collapsible(mut self) -> Self {
147        self.collapsible = true;
148        self
149    }
150
151    /// Whether the body (and footer) are hidden. Controlled by the parent.
152    pub fn collapsed(mut self, collapsed: bool) -> Self {
153        self.collapsed = collapsed;
154        self
155    }
156
157    /// Called when the collapse chevron is clicked. Wire it with
158    /// `cx.listener(...)` to flip the parent's `collapsed` flag.
159    pub fn on_toggle(
160        mut self,
161        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
162    ) -> Self {
163        self.on_toggle = Some(Rc::new(handler));
164        self
165    }
166}
167
168impl Default for Panel {
169    fn default() -> Self {
170        Panel::new()
171    }
172}
173
174impl ParentElement for Panel {
175    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
176        self.children.extend(elements);
177    }
178}
179
180impl RenderOnce for Panel {
181    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
182        let t = theme(cx);
183        let radius = t.radius(self.radius.unwrap_or(Size::Md));
184        let pad = t.spacing(self.padding);
185        let border = t.border().hsla();
186        let text = t.text().hsla();
187        let dimmed = t.dimmed().hsla();
188        let font_md = t.font_size(Size::Md);
189        let font_sm = t.font_size(Size::Sm);
190        let surface = t.surface().hsla();
191
192        let body_visible = !(self.collapsible && self.collapsed);
193        let has_header = self.title.is_some()
194            || self.description.is_some()
195            || self.icon.is_some()
196            || !self.actions.is_empty()
197            || self.collapsible;
198        let has_body = !self.children.is_empty();
199
200        let mut root = div().flex().flex_col().bg(surface).rounded(px(radius));
201        if self.with_border {
202            root = root.border_1().border_color(border);
203        }
204        root = apply_shadow(root, self.shadow);
205
206        if has_header {
207            let mut left = div().flex().items_center().gap(px(10.0));
208
209            if self.collapsible {
210                let chevron = if self.collapsed {
211                    IconName::ChevronRight
212                } else {
213                    IconName::ChevronDown
214                };
215                let mut toggle = ActionIcon::new(
216                    "guise-panel-toggle",
217                    SharedString::new_static(chevron.glyph()),
218                )
219                .size(Size::Sm);
220                if let Some(handler) = self.on_toggle.clone() {
221                    toggle = toggle.on_click(move |ev, window, cx| handler(ev, window, cx));
222                }
223                left = left.child(toggle);
224            }
225            if let Some(icon) = self.icon {
226                left = left.child(icon);
227            }
228
229            let mut heading = div().flex().flex_col().gap(px(2.0));
230            if let Some(title) = self.title {
231                heading = heading.child(
232                    div()
233                        .text_size(px(font_md))
234                        .font_weight(FontWeight::SEMIBOLD)
235                        .text_color(text)
236                        .child(title),
237                );
238            }
239            if let Some(description) = self.description {
240                heading = heading.child(
241                    div()
242                        .text_size(px(font_sm))
243                        .text_color(dimmed)
244                        .child(description),
245                );
246            }
247            left = left.child(heading);
248
249            let mut header = div()
250                .flex()
251                .items_center()
252                .justify_between()
253                .gap(px(pad))
254                .px(px(pad))
255                .py(px(pad * 0.75));
256            if body_visible && (has_body || self.footer.is_some()) {
257                header = header.border_b_1().border_color(border);
258            }
259            header = header.child(left);
260            if !self.actions.is_empty() {
261                header = header.child(
262                    div()
263                        .flex()
264                        .items_center()
265                        .gap(px(8.0))
266                        .children(self.actions),
267                );
268            }
269            root = root.child(header);
270        }
271
272        if body_visible {
273            if has_body {
274                root = root.child(div().flex().flex_col().p(px(pad)).children(self.children));
275            }
276            if let Some(footer) = self.footer {
277                let mut foot = div().px(px(pad)).py(px(pad * 0.75));
278                if has_body {
279                    foot = foot.border_t_1().border_color(border);
280                }
281                root = root.child(foot.child(footer));
282            }
283        }
284
285        match self.id {
286            Some(id) => root.id(id).into_any_element(),
287            None => root.into_any_element(),
288        }
289    }
290}