1use crate::{
2 AsIdSalt, Context, Id, IdSalt, InnerResponse, NumExt as _, Rect, Response, Sense, Stroke,
3 TextStyle, TextWrapMode, Ui, UiBuilder, UiKind, UiStackInfo, WidgetInfo, WidgetText,
4 WidgetType, emath, epaint, pos2, remap, remap_clamp, vec2,
5};
6use emath::GuiRounding as _;
7use epaint::{Shape, StrokeKind};
8
9#[derive(Clone, Copy, Debug)]
10#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
11pub(crate) struct InnerState {
12 open: bool,
13
14 #[cfg_attr(feature = "serde", serde(default))]
16 open_height: Option<f32>,
17}
18
19#[derive(Clone, Debug)]
25pub struct CollapsingState {
26 id: Id,
27 state: InnerState,
28}
29
30impl CollapsingState {
31 pub fn load(ctx: &Context, id: Id) -> Option<Self> {
32 ctx.data_mut(|d| {
33 d.get_persisted::<InnerState>(id)
34 .map(|state| Self { id, state })
35 })
36 }
37
38 pub fn store(&self, ctx: &Context) {
39 ctx.data_mut(|d| d.insert_persisted(self.id, self.state));
40 }
41
42 pub fn remove(&self, ctx: &Context) {
43 ctx.data_mut(|d| d.remove::<InnerState>(self.id));
44 }
45
46 pub fn id(&self) -> Id {
47 self.id
48 }
49
50 pub fn load_with_default_open(ctx: &Context, id: Id, default_open: bool) -> Self {
51 Self::load(ctx, id).unwrap_or(Self {
52 id,
53 state: InnerState {
54 open: default_open,
55 open_height: None,
56 },
57 })
58 }
59
60 pub fn is_open(&self) -> bool {
61 self.state.open
62 }
63
64 pub fn set_open(&mut self, open: bool) {
65 self.state.open = open;
66 }
67
68 pub fn toggle(&mut self, ui: &Ui) {
69 self.state.open = !self.state.open;
70 ui.request_repaint();
71 }
72
73 pub fn openness(&self, ctx: &Context) -> f32 {
75 if ctx.memory(|mem| mem.everything_is_visible()) {
76 1.0
77 } else {
78 ctx.animate_bool_responsive(self.id, self.state.open)
79 }
80 }
81
82 fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response {
84 self.show_button_indented(ui, paint_default_icon)
85 }
86
87 fn show_button_indented(
89 &mut self,
90 ui: &mut Ui,
91 icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
92 ) -> Response {
93 let size = vec2(ui.spacing().indent, ui.spacing().icon_width);
94 let (_id, rect) = ui.allocate_space(size);
95 let response = ui.interact(rect, self.id, Sense::click());
96 if response.clicked() {
97 self.toggle(ui);
98 }
99
100 let (mut icon_rect, _) = ui.spacing().icon_rectangles(response.rect);
101 icon_rect.set_center(pos2(
102 response.rect.left() + ui.spacing().indent / 2.0,
103 response.rect.center().y,
104 ));
105 let openness = self.openness(ui.ctx());
106 let small_icon_response = response.clone().with_new_rect(icon_rect);
107 icon_fn(ui, openness, &small_icon_response);
108 response
109 }
110
111 pub fn show_header<HeaderRet>(
130 mut self,
131 ui: &mut Ui,
132 add_header: impl FnOnce(&mut Ui) -> HeaderRet,
133 ) -> HeaderResponse<'_, HeaderRet> {
134 let header_response = ui.horizontal(|ui| {
135 let prev_item_spacing = ui.spacing_mut().item_spacing;
136 ui.spacing_mut().item_spacing.x = 0.0; let collapser = self.show_default_button_indented(ui);
138 ui.spacing_mut().item_spacing = prev_item_spacing;
139 (collapser, add_header(ui))
140 });
141 HeaderResponse {
142 state: self,
143 ui,
144 toggle_button_response: header_response.inner.0,
145 header_response: InnerResponse {
146 response: header_response.response,
147 inner: header_response.inner.1,
148 },
149 }
150 }
151
152 pub fn show_body_indented<R>(
157 &mut self,
158 header_response: &Response,
159 ui: &mut Ui,
160 add_body: impl FnOnce(&mut Ui) -> R,
161 ) -> Option<InnerResponse<R>> {
162 let id = self.id;
163 self.show_body_unindented(ui, |ui| {
164 ui.indent(id, |ui| {
165 ui.expand_to_include_x(header_response.rect.right());
167 add_body(ui)
168 })
169 .inner
170 })
171 }
172
173 pub fn show_body_unindented<R>(
176 &mut self,
177 ui: &mut Ui,
178 add_body: impl FnOnce(&mut Ui) -> R,
179 ) -> Option<InnerResponse<R>> {
180 let openness = self.openness(ui.ctx());
181
182 let builder = UiBuilder::new()
183 .ui_stack_info(UiStackInfo::new(UiKind::Collapsible))
184 .closable();
185
186 if openness <= 0.0 {
187 self.store(ui.ctx()); None
189 } else if openness < 1.0 {
190 let item_spacing = ui.spacing().item_spacing.y;
192
193 let fallback_height_guess = 10.0; let full_height = self.state.open_height.unwrap_or(fallback_height_guess);
195
196 let clipped_child_height =
197 (remap_clamp(openness, 0.0..=1.0, 0.0..=full_height + item_spacing) - item_spacing)
198 .round_ui();
199
200 if clipped_child_height < 0.0 {
201 ui.add_space(clipped_child_height); }
203
204 Some(ui.scope_builder(builder, |child_ui| {
205 let clipped_child_height = clipped_child_height.at_least(0.0);
206
207 {
208 let mut clip_rect = child_ui.clip_rect();
209 clip_rect.max.y = f32::min(
210 clip_rect.max.y,
211 child_ui.max_rect().top() + clipped_child_height,
212 );
213 child_ui.set_clip_rect(clip_rect);
214 }
215
216 let ret = add_body(child_ui);
217
218 let mut min_rect = child_ui.min_rect();
219 self.state.open_height = Some(min_rect.height());
220 if child_ui.should_close() {
221 self.state.open = false;
222 }
223 self.store(child_ui.ctx()); min_rect.max.y = f32::min(min_rect.max.y, min_rect.top() + clipped_child_height);
227 child_ui.force_set_min_rect(min_rect);
228 ret
229 }))
230 } else {
231 let ret_response = ui.scope_builder(builder, add_body);
232 if ret_response.response.should_close() {
233 self.state.open = false;
234 }
235 let full_size = ret_response.response.rect.size();
236 self.state.open_height = Some(full_size.y);
237 self.store(ui.ctx()); Some(ret_response)
239 }
240 }
241
242 pub fn show_toggle_button(
266 &mut self,
267 ui: &mut Ui,
268 icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
269 ) -> Response {
270 self.show_button_indented(ui, icon_fn)
271 }
272}
273
274#[must_use = "Remember to show the body"]
276pub struct HeaderResponse<'ui, HeaderRet> {
277 state: CollapsingState,
278 ui: &'ui mut Ui,
279 toggle_button_response: Response,
280 header_response: InnerResponse<HeaderRet>,
281}
282
283impl<HeaderRet> HeaderResponse<'_, HeaderRet> {
284 pub fn is_open(&self) -> bool {
285 self.state.is_open()
286 }
287
288 pub fn set_open(&mut self, open: bool) {
289 self.state.set_open(open);
290 }
291
292 pub fn toggle(&mut self) {
293 self.state.toggle(self.ui);
294 }
295
296 pub fn body<BodyRet>(
298 mut self,
299 add_body: impl FnOnce(&mut Ui) -> BodyRet,
300 ) -> (
301 Response,
302 InnerResponse<HeaderRet>,
303 Option<InnerResponse<BodyRet>>,
304 ) {
305 let body_response =
306 self.state
307 .show_body_indented(&self.header_response.response, self.ui, add_body);
308 (
309 self.toggle_button_response,
310 self.header_response,
311 body_response,
312 )
313 }
314
315 pub fn body_unindented<BodyRet>(
317 mut self,
318 add_body: impl FnOnce(&mut Ui) -> BodyRet,
319 ) -> (
320 Response,
321 InnerResponse<HeaderRet>,
322 Option<InnerResponse<BodyRet>>,
323 ) {
324 let body_response = self.state.show_body_unindented(self.ui, add_body);
325 (
326 self.toggle_button_response,
327 self.header_response,
328 body_response,
329 )
330 }
331}
332
333pub fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) {
337 let visuals = ui.style().interact(response);
338
339 let rect = response.rect;
340
341 let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75);
343 let rect = rect.expand(visuals.expansion);
344 let mut points = vec![rect.left_top(), rect.right_top(), rect.center_bottom()];
345 use core::f32::consts::TAU;
346 let rotation = emath::Rot2::from_angle(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0));
347 for p in &mut points {
348 *p = rect.center() + rotation * (*p - rect.center());
349 }
350
351 ui.painter().add(Shape::convex_polygon(
352 points,
353 visuals.fg_stroke.color,
354 Stroke::NONE,
355 ));
356}
357
358pub type IconPainter = Box<dyn FnOnce(&mut Ui, f32, &Response)>;
360
361#[must_use = "You should call .show()"]
377pub struct CollapsingHeader {
378 text: WidgetText,
379 default_open: bool,
380 open: Option<bool>,
381 id_salt: IdSalt,
382 enabled: bool,
383 selectable: bool,
384 selected: bool,
385 show_background: bool,
386 icon: Option<IconPainter>,
387}
388
389impl CollapsingHeader {
390 pub fn new(text: impl Into<WidgetText>) -> Self {
397 let text = text.into();
398 let id_salt = IdSalt::new(text.text());
399 Self {
400 text,
401 default_open: false,
402 open: None,
403 id_salt,
404 enabled: true,
405 selectable: false,
406 selected: false,
407 show_background: false,
408 icon: None,
409 }
410 }
411
412 #[inline]
415 pub fn default_open(mut self, open: bool) -> Self {
416 self.default_open = open;
417 self
418 }
419
420 #[inline]
426 pub fn open(mut self, open: Option<bool>) -> Self {
427 self.open = open;
428 self
429 }
430
431 #[inline]
434 pub fn id_salt(mut self, id_salt: impl AsIdSalt) -> Self {
435 self.id_salt = IdSalt::new(id_salt);
436 self
437 }
438
439 #[inline]
443 pub fn enabled(mut self, enabled: bool) -> Self {
444 self.enabled = enabled;
445 self
446 }
447
448 #[inline]
457 pub fn show_background(mut self, show_background: bool) -> Self {
458 self.show_background = show_background;
459 self
460 }
461
462 #[inline]
480 pub fn icon(mut self, icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static) -> Self {
481 self.icon = Some(Box::new(icon_fn));
482 self
483 }
484}
485
486struct Prepared {
487 header_response: Response,
488 state: CollapsingState,
489 openness: f32,
490}
491
492impl CollapsingHeader {
493 fn begin(self, ui: &mut Ui) -> Prepared {
494 assert!(
495 ui.layout().main_dir().is_vertical(),
496 "Horizontal collapsing is unimplemented"
497 );
498 let Self {
499 icon,
500 text,
501 default_open,
502 open,
503 id_salt,
504 enabled: _,
505 selectable,
506 selected,
507 show_background,
508 } = self;
509
510 let id = ui.make_persistent_id(id_salt);
513 let button_padding = ui.spacing().button_padding;
514
515 let available = ui.available_rect_before_wrap();
516 let text_pos = available.min + vec2(ui.spacing().indent, 0.0);
517 let wrap_width = available.right() - text_pos.x;
518 let galley = text.into_galley(
519 ui,
520 Some(TextWrapMode::Extend),
521 wrap_width,
522 TextStyle::Button,
523 );
524 let text_max_x = text_pos.x + galley.size().x;
525
526 let mut desired_width = text_max_x + button_padding.x - available.left();
527 if ui.visuals().collapsing_header_frame {
528 desired_width = desired_width.max(available.width()); }
530
531 let mut desired_size = vec2(desired_width, galley.size().y + 2.0 * button_padding.y);
532 desired_size = desired_size.at_least(ui.spacing().interact_size);
533 let (_, rect) = ui.allocate_space(desired_size);
534
535 let mut header_response = ui.interact(rect, id, Sense::click());
536 let text_pos = pos2(
537 text_pos.x,
538 header_response.rect.center().y - galley.size().y / 2.0,
539 );
540
541 let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
542 if let Some(open) = open {
543 if open != state.is_open() {
544 state.toggle(ui);
545 header_response.mark_changed();
546 }
547 } else if header_response.clicked() {
548 state.toggle(ui);
549 header_response.mark_changed();
550 }
551
552 header_response.widget_info(|| {
553 WidgetInfo::labeled(WidgetType::CollapsingHeader, ui.is_enabled(), galley.text())
554 });
555
556 let openness = state.openness(ui.ctx());
557
558 if ui.is_rect_visible(rect) {
559 let visuals = ui.style().interact_selectable(&header_response, selected);
560
561 if ui.visuals().collapsing_header_frame || show_background {
562 ui.painter().add(epaint::RectShape::new(
563 header_response.rect.expand(visuals.expansion),
564 visuals.corner_radius,
565 visuals.weak_bg_fill,
566 visuals.bg_stroke,
567 StrokeKind::Inside,
568 ));
569 }
570
571 if selected || selectable && (header_response.hovered() || header_response.has_focus())
572 {
573 let rect = rect.expand(visuals.expansion);
574
575 ui.painter().rect(
576 rect,
577 visuals.corner_radius,
578 visuals.bg_fill,
579 visuals.bg_stroke,
580 StrokeKind::Inside,
581 );
582 }
583
584 {
585 let (mut icon_rect, _) = ui.spacing().icon_rectangles(header_response.rect);
586 icon_rect.set_center(pos2(
587 header_response.rect.left() + ui.spacing().indent / 2.0,
588 header_response.rect.center().y,
589 ));
590 let icon_response = header_response.clone().with_new_rect(icon_rect);
591 if let Some(icon) = icon {
592 icon(ui, openness, &icon_response);
593 } else {
594 paint_default_icon(ui, openness, &icon_response);
595 }
596 }
597
598 ui.painter().galley(text_pos, galley, visuals.text_color());
599 }
600
601 Prepared {
602 header_response,
603 state,
604 openness,
605 }
606 }
607
608 #[inline]
609 pub fn show<R>(
610 self,
611 ui: &mut Ui,
612 add_body: impl FnOnce(&mut Ui) -> R,
613 ) -> CollapsingResponse<R> {
614 self.show_dyn(ui, Box::new(add_body), true)
615 }
616
617 #[inline]
618 pub fn show_unindented<R>(
619 self,
620 ui: &mut Ui,
621 add_body: impl FnOnce(&mut Ui) -> R,
622 ) -> CollapsingResponse<R> {
623 self.show_dyn(ui, Box::new(add_body), false)
624 }
625
626 fn show_dyn<'c, R>(
627 self,
628 ui: &mut Ui,
629 add_body: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
630 indented: bool,
631 ) -> CollapsingResponse<R> {
632 ui.vertical(|ui| {
635 if !self.enabled {
636 ui.disable();
637 }
638
639 let Prepared {
640 header_response,
641 mut state,
642 openness,
643 } = self.begin(ui); let ret_response = if indented {
646 state.show_body_indented(&header_response, ui, add_body)
647 } else {
648 state.show_body_unindented(ui, add_body)
649 };
650
651 if let Some(ret_response) = ret_response {
652 CollapsingResponse {
653 header_response,
654 body_response: Some(ret_response.response),
655 body_returned: Some(ret_response.inner),
656 openness,
657 }
658 } else {
659 CollapsingResponse {
660 header_response,
661 body_response: None,
662 body_returned: None,
663 openness,
664 }
665 }
666 })
667 .inner
668 }
669}
670
671pub struct CollapsingResponse<R> {
673 pub header_response: Response,
675
676 pub body_response: Option<Response>,
678
679 pub body_returned: Option<R>,
681
682 pub openness: f32,
684}
685
686impl<R> CollapsingResponse<R> {
687 pub fn fully_closed(&self) -> bool {
689 self.openness <= 0.0
690 }
691
692 pub fn fully_open(&self) -> bool {
694 self.openness >= 1.0
695 }
696}