1use egui::{
5 pos2, Color32, Id, InnerResponse, Pos2, Response, Sense, Stroke, Ui, Vec2, WidgetInfo,
6 WidgetText, WidgetType,
7};
8
9use crate::theme::Theme;
10
11#[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 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 pub fn open(mut self, open: &'a mut bool) -> Self {
55 self.open = Some(open);
56 self
57 }
58
59 pub fn default_open(mut self, open: bool) -> Self {
61 self.default_open = open;
62 self
63 }
64
65 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 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 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}