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}