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