Skip to main content

elegance/
collapsing.rs

1//! Collapsing section — a clickable trigger row with a rotating chevron
2//! that hides or reveals a body of content.
3
4use egui::{
5    pos2, Color32, Id, InnerResponse, Pos2, Response, Sense, Stroke, Ui, Vec2, WidgetInfo,
6    WidgetText, WidgetType,
7};
8
9use crate::theme::Theme;
10
11/// A collapsible content section.
12///
13/// ```no_run
14/// # use elegance::CollapsingSection;
15/// # egui::__run_test_ui(|ui| {
16/// CollapsingSection::new("advanced", "Show advanced options").show(ui, |ui| {
17///     ui.label("…hidden until expanded…");
18/// });
19/// # });
20/// ```
21#[must_use = "Call `.show(ui, |ui| ...)` to render."]
22pub struct CollapsingSection<'a> {
23    id_salt: Id,
24    label: WidgetText,
25    open: Option<&'a mut bool>,
26    default_open: bool,
27}
28
29impl<'a> std::fmt::Debug for CollapsingSection<'a> {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        f.debug_struct("CollapsingSection")
32            .field("id_salt", &self.id_salt)
33            .field("label", &self.label.text())
34            .field("open", &self.open.as_deref().copied())
35            .field("default_open", &self.default_open)
36            .finish()
37    }
38}
39
40impl<'a> CollapsingSection<'a> {
41    /// Create a collapsing section keyed by `id_salt` with the given header label.
42    /// The section is closed by default.
43    pub fn new(id_salt: impl std::hash::Hash, label: impl Into<WidgetText>) -> Self {
44        Self {
45            id_salt: Id::new(("elegance_collapsing", id_salt)),
46            label: label.into(),
47            open: None,
48            default_open: false,
49        }
50    }
51
52    /// Bind the open state to a `&mut bool` the caller owns. If omitted,
53    /// the section remembers its state in egui's temp storage.
54    pub fn open(mut self, open: &'a mut bool) -> Self {
55        self.open = Some(open);
56        self
57    }
58
59    /// Starting state when no prior state exists. Default: closed.
60    pub fn default_open(mut self, open: bool) -> Self {
61        self.default_open = open;
62        self
63    }
64
65    /// Render the trigger row, and the body when open. Returns an
66    /// [`InnerResponse`] whose `response` is the trigger row (useful for
67    /// checking `clicked()`, `hovered()`, etc.) and whose `inner` is the
68    /// body closure's return value, or `None` if the section is closed.
69    pub fn show<R>(
70        self,
71        ui: &mut Ui,
72        add_body: impl FnOnce(&mut Ui) -> R,
73    ) -> InnerResponse<Option<R>> {
74        let theme = Theme::current(ui.ctx());
75
76        let mut is_open = match self.open.as_deref() {
77            Some(flag) => *flag,
78            None => ui.ctx().data(|d| {
79                d.get_temp::<bool>(self.id_salt)
80                    .unwrap_or(self.default_open)
81            }),
82        };
83
84        let trigger = trigger_row(ui, self.label.text(), &theme, is_open);
85        let label_text = self.label.text().to_string();
86        trigger.widget_info(|| {
87            WidgetInfo::selected(WidgetType::CollapsingHeader, true, is_open, &label_text)
88        });
89        if trigger.clicked() {
90            is_open = !is_open;
91        }
92
93        match self.open {
94            Some(flag) => *flag = is_open,
95            None => {
96                ui.ctx().data_mut(|d| d.insert_temp(self.id_salt, is_open));
97            }
98        }
99
100        let inner = if is_open { Some(add_body(ui)) } else { None };
101        InnerResponse::new(inner, trigger)
102    }
103}
104
105fn trigger_row(ui: &mut Ui, label: &str, theme: &Theme, open: bool) -> Response {
106    let p = &theme.palette;
107    let t = &theme.typography;
108
109    const PAD_X: f32 = 4.0;
110    const PAD_Y: f32 = 4.0;
111    const CHEVRON: f32 = 12.0;
112    const GAP: f32 = 8.0;
113
114    let galley = crate::theme::placeholder_galley(ui, label, t.label, false, f32::INFINITY);
115
116    let content_w = CHEVRON + GAP + galley.size().x;
117    let desired = Vec2::new(content_w + PAD_X * 2.0, galley.size().y + PAD_Y * 2.0);
118    let (rect, resp) = ui.allocate_exact_size(desired, Sense::click());
119
120    if ui.is_rect_visible(rect) {
121        let hovered = resp.hovered();
122        let label_color = if hovered { p.text } else { p.text_muted };
123        let chevron_color = if hovered { p.sky } else { p.text_muted };
124
125        let chev_center = pos2(rect.min.x + PAD_X + CHEVRON * 0.5, rect.center().y);
126        draw_chevron(ui, chev_center, CHEVRON, chevron_color, open);
127
128        let text_pos = pos2(
129            rect.min.x + PAD_X + CHEVRON + GAP,
130            rect.center().y - galley.size().y * 0.5,
131        );
132        ui.painter().galley(text_pos, galley, label_color);
133    }
134
135    resp
136}
137
138fn draw_chevron(ui: &mut Ui, center: Pos2, size: f32, color: Color32, open: bool) {
139    let half = size * 0.3;
140    let points: Vec<Pos2> = if open {
141        // ▾ — pointing down
142        vec![
143            pos2(center.x - half, center.y - half * 0.55),
144            pos2(center.x + half, center.y - half * 0.55),
145            pos2(center.x, center.y + half * 0.75),
146        ]
147    } else {
148        // ▸ — pointing right
149        vec![
150            pos2(center.x - half * 0.55, center.y - half),
151            pos2(center.x - half * 0.55, center.y + half),
152            pos2(center.x + half * 0.75, center.y),
153        ]
154    };
155    ui.painter()
156        .add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
157}