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