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", "…").label("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 label = if self.collapsed { "Expand" } else { "Collapse" };
217        let mut toggle = ActionIcon::new("guise-panel-toggle", chevron)
218          .label(label)
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    let element = match self.id {
286      Some(id) => root.id(id).into_any_element(),
287      None => root.into_any_element(),
288    };
289
290    element.probe_any("Panel")
291  }
292}