Skip to main content

guise/
spoiler.rs

1//! `Spoiler` — clips tall content to a max height behind a "Show more" toggle.
2//!
3//! Controlled: the parent owns `expanded` and flips it in `on_toggle`, exactly
4//! like `Modal`'s `opened`/`on_close` pair.
5//!
6//! ```ignore
7//! Spoiler::new("bio-spoiler")
8//!     .max_height(60.0)
9//!     .expanded(self.bio_open)
10//!     .on_toggle(cx.listener(|this, _, _, cx| {
11//!         this.bio_open = !this.bio_open;
12//!         cx.notify();
13//!     }))
14//!     .child(Text::new(LONG_BIO).size(Size::Sm))
15//! ```
16
17use gpui::prelude::*;
18use gpui::{div, px, AnyElement, App, ClickEvent, ElementId, IntoElement, SharedString, Window};
19
20use crate::devtools::Probed;
21use crate::input::ClickHandler;
22use crate::theme::{theme, ColorName, Size};
23
24/// A collapsible content clip.
25///
26/// While collapsed the children render inside an `overflow-hidden` box capped
27/// at `max_height`; the toggle below is styled like an `Anchor` link.
28#[derive(IntoElement)]
29pub struct Spoiler {
30  id: ElementId,
31  children: Vec<AnyElement>,
32  max_height: f32,
33  expanded: bool,
34  show_label: SharedString,
35  hide_label: SharedString,
36  color: ColorName,
37  size: Size,
38  on_toggle: Option<ClickHandler>,
39}
40
41impl Spoiler {
42  pub fn new(id: impl Into<ElementId>) -> Self {
43    Spoiler {
44      id: id.into(),
45      children: Vec::new(),
46      max_height: 100.0,
47      expanded: false,
48      show_label: SharedString::new_static("Show more"),
49      hide_label: SharedString::new_static("Hide"),
50      color: ColorName::Blue,
51      size: Size::Sm,
52      on_toggle: None,
53    }
54  }
55
56  /// Visible height in px while collapsed (default 100).
57  pub fn max_height(mut self, max_height: f32) -> Self {
58    self.max_height = max_height;
59    self
60  }
61
62  /// Whether the full content is shown. The parent owns this flag.
63  pub fn expanded(mut self, expanded: bool) -> Self {
64    self.expanded = expanded;
65    self
66  }
67
68  /// Toggle label while collapsed (default "Show more").
69  pub fn show_label(mut self, label: impl Into<SharedString>) -> Self {
70    self.show_label = label.into();
71    self
72  }
73
74  /// Toggle label while expanded (default "Hide").
75  pub fn hide_label(mut self, label: impl Into<SharedString>) -> Self {
76    self.hide_label = label.into();
77    self
78  }
79
80  /// The toggle link color (default `Blue`).
81  pub fn color(mut self, color: ColorName) -> Self {
82    self.color = color;
83    self
84  }
85
86  /// The toggle label font size (default `Sm`).
87  pub fn size(mut self, size: Size) -> Self {
88    self.size = size;
89    self
90  }
91
92  /// Called when the toggle is clicked. Wire with `cx.listener(...)` to
93  /// flip the parent's `expanded` flag.
94  pub fn on_toggle(
95    mut self,
96    handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
97  ) -> Self {
98    self.on_toggle = Some(Box::new(handler));
99    self
100  }
101}
102
103impl ParentElement for Spoiler {
104  fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
105    self.children.extend(elements);
106  }
107}
108
109impl RenderOnce for Spoiler {
110  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
111    let t = theme(cx);
112    let dark = t.scheme.is_dark();
113    let link = t.color(self.color, if dark { 4 } else { 6 }).hsla();
114    let link_hover = t.color(self.color, if dark { 3 } else { 7 }).hsla();
115    let font = t.font_size(self.size);
116    let gap = t.spacing(Size::Xs);
117
118    let mut content = div().w_full().children(self.children);
119    if !self.expanded {
120      content = content.max_h(px(self.max_height)).overflow_hidden();
121    }
122
123    let label = if self.expanded {
124      self.hide_label
125    } else {
126      self.show_label
127    };
128    let mut toggle = div()
129      .id(self.id)
130      .cursor_pointer()
131      .text_size(px(font))
132      .text_color(link)
133      .hover(move |s| s.text_color(link_hover))
134      .child(label);
135    if let Some(handler) = self.on_toggle {
136      toggle = toggle.on_click(handler);
137    }
138
139    div()
140      .flex()
141      .flex_col()
142      .items_start()
143      .gap(px(gap))
144      .child(content)
145      .child(toggle)
146      .probe("Spoiler")
147  }
148}