egui/containers/
collapsing_header.rs

1use std::hash::Hash;
2
3use crate::{
4    emath, epaint, pos2, remap, remap_clamp, vec2, Context, Id, InnerResponse, NumExt, Rect,
5    Response, Sense, Stroke, TextStyle, TextWrapMode, Ui, Vec2, WidgetInfo, WidgetText, WidgetType,
6};
7use emath::GuiRounding as _;
8use epaint::{Shape, StrokeKind};
9
10#[derive(Clone, Copy, Debug)]
11#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
12pub(crate) struct InnerState {
13    open: bool,
14
15    /// Height of the region when open. Used for animations
16    #[cfg_attr(feature = "serde", serde(default))]
17    open_height: Option<f32>,
18}
19
20/// This is a a building block for building collapsing regions.
21///
22/// It is used by [`CollapsingHeader`] and [`crate::Window`], but can also be used on its own.
23///
24/// See [`CollapsingState::show_header`] for how to show a collapsing header with a custom header.
25#[derive(Clone, Debug)]
26pub struct CollapsingState {
27    id: Id,
28    state: InnerState,
29}
30
31impl CollapsingState {
32    pub fn load(ctx: &Context, id: Id) -> Option<Self> {
33        ctx.data_mut(|d| {
34            d.get_persisted::<InnerState>(id)
35                .map(|state| Self { id, state })
36        })
37    }
38
39    pub fn store(&self, ctx: &Context) {
40        ctx.data_mut(|d| d.insert_persisted(self.id, self.state));
41    }
42
43    pub fn remove(&self, ctx: &Context) {
44        ctx.data_mut(|d| d.remove::<InnerState>(self.id));
45    }
46
47    pub fn id(&self) -> Id {
48        self.id
49    }
50
51    pub fn load_with_default_open(ctx: &Context, id: Id, default_open: bool) -> Self {
52        Self::load(ctx, id).unwrap_or(Self {
53            id,
54            state: InnerState {
55                open: default_open,
56                open_height: None,
57            },
58        })
59    }
60
61    pub fn is_open(&self) -> bool {
62        self.state.open
63    }
64
65    pub fn set_open(&mut self, open: bool) {
66        self.state.open = open;
67    }
68
69    pub fn toggle(&mut self, ui: &Ui) {
70        self.state.open = !self.state.open;
71        ui.ctx().request_repaint();
72    }
73
74    /// 0 for closed, 1 for open, with tweening
75    pub fn openness(&self, ctx: &Context) -> f32 {
76        if ctx.memory(|mem| mem.everything_is_visible()) {
77            1.0
78        } else {
79            ctx.animate_bool_responsive(self.id, self.state.open)
80        }
81    }
82
83    /// Will toggle when clicked, etc.
84    pub(crate) fn show_default_button_with_size(
85        &mut self,
86        ui: &mut Ui,
87        button_size: Vec2,
88    ) -> Response {
89        let (_id, rect) = ui.allocate_space(button_size);
90        let response = ui.interact(rect, self.id, Sense::click());
91        response.widget_info(|| {
92            WidgetInfo::labeled(
93                WidgetType::Button,
94                ui.is_enabled(),
95                if self.is_open() { "Hide" } else { "Show" },
96            )
97        });
98
99        if response.clicked() {
100            self.toggle(ui);
101        }
102        let openness = self.openness(ui.ctx());
103        paint_default_icon(ui, openness, &response);
104        response
105    }
106
107    /// Will toggle when clicked, etc.
108    fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response {
109        self.show_button_indented(ui, paint_default_icon)
110    }
111
112    /// Will toggle when clicked, etc.
113    fn show_button_indented(
114        &mut self,
115        ui: &mut Ui,
116        icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
117    ) -> Response {
118        let size = vec2(ui.spacing().indent, ui.spacing().icon_width);
119        let (_id, rect) = ui.allocate_space(size);
120        let response = ui.interact(rect, self.id, Sense::click());
121        if response.clicked() {
122            self.toggle(ui);
123        }
124
125        let (mut icon_rect, _) = ui.spacing().icon_rectangles(response.rect);
126        icon_rect.set_center(pos2(
127            response.rect.left() + ui.spacing().indent / 2.0,
128            response.rect.center().y,
129        ));
130        let openness = self.openness(ui.ctx());
131        let small_icon_response = response.clone().with_new_rect(icon_rect);
132        icon_fn(ui, openness, &small_icon_response);
133        response
134    }
135
136    /// Shows header and body (if expanded).
137    ///
138    /// The header will start with the default button in a horizontal layout, followed by whatever you add.
139    ///
140    /// Will also store the state.
141    ///
142    /// Returns the response of the collapsing button, the custom header, and the custom body.
143    ///
144    /// ```
145    /// # egui::__run_test_ui(|ui| {
146    /// let id = ui.make_persistent_id("my_collapsing_header");
147    /// egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false)
148    ///     .show_header(ui, |ui| {
149    ///         ui.label("Header"); // you can put checkboxes or whatever here
150    ///     })
151    ///     .body(|ui| ui.label("Body"));
152    /// # });
153    /// ```
154    pub fn show_header<HeaderRet>(
155        mut self,
156        ui: &mut Ui,
157        add_header: impl FnOnce(&mut Ui) -> HeaderRet,
158    ) -> HeaderResponse<'_, HeaderRet> {
159        let header_response = ui.horizontal(|ui| {
160            let prev_item_spacing = ui.spacing_mut().item_spacing;
161            ui.spacing_mut().item_spacing.x = 0.0; // the toggler button uses the full indent width
162            let collapser = self.show_default_button_indented(ui);
163            ui.spacing_mut().item_spacing = prev_item_spacing;
164            (collapser, add_header(ui))
165        });
166        HeaderResponse {
167            state: self,
168            ui,
169            toggle_button_response: header_response.inner.0,
170            header_response: InnerResponse {
171                response: header_response.response,
172                inner: header_response.inner.1,
173            },
174        }
175    }
176
177    /// Show body if we are open, with a nice animation between closed and open.
178    /// Indent the body to show it belongs to the header.
179    ///
180    /// Will also store the state.
181    pub fn show_body_indented<R>(
182        &mut self,
183        header_response: &Response,
184        ui: &mut Ui,
185        add_body: impl FnOnce(&mut Ui) -> R,
186    ) -> Option<InnerResponse<R>> {
187        let id = self.id;
188        self.show_body_unindented(ui, |ui| {
189            ui.indent(id, |ui| {
190                // make as wide as the header:
191                ui.expand_to_include_x(header_response.rect.right());
192                add_body(ui)
193            })
194            .inner
195        })
196    }
197
198    /// Show body if we are open, with a nice animation between closed and open.
199    /// Will also store the state.
200    pub fn show_body_unindented<R>(
201        &mut self,
202        ui: &mut Ui,
203        add_body: impl FnOnce(&mut Ui) -> R,
204    ) -> Option<InnerResponse<R>> {
205        let openness = self.openness(ui.ctx());
206        if openness <= 0.0 {
207            self.store(ui.ctx()); // we store any earlier toggling as promised in the docstring
208            None
209        } else if openness < 1.0 {
210            Some(ui.scope(|child_ui| {
211                let max_height = if self.state.open && self.state.open_height.is_none() {
212                    // First frame of expansion.
213                    // We don't know full height yet, but we will next frame.
214                    // Just use a placeholder value that shows some movement:
215                    10.0
216                } else {
217                    let full_height = self.state.open_height.unwrap_or_default();
218                    remap_clamp(openness, 0.0..=1.0, 0.0..=full_height).round_ui()
219                };
220
221                let mut clip_rect = child_ui.clip_rect();
222                clip_rect.max.y = clip_rect.max.y.min(child_ui.max_rect().top() + max_height);
223                child_ui.set_clip_rect(clip_rect);
224
225                let ret = add_body(child_ui);
226
227                let mut min_rect = child_ui.min_rect();
228                self.state.open_height = Some(min_rect.height());
229                self.store(child_ui.ctx()); // remember the height
230
231                // Pretend children took up at most `max_height` space:
232                min_rect.max.y = min_rect.max.y.at_most(min_rect.top() + max_height);
233                child_ui.force_set_min_rect(min_rect);
234                ret
235            }))
236        } else {
237            let ret_response = ui.scope(add_body);
238            let full_size = ret_response.response.rect.size();
239            self.state.open_height = Some(full_size.y);
240            self.store(ui.ctx()); // remember the height
241            Some(ret_response)
242        }
243    }
244
245    /// Paint this [`CollapsingState`]'s toggle button. Takes an [`IconPainter`] as the icon.
246    /// ```
247    /// # egui::__run_test_ui(|ui| {
248    /// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
249    ///     let stroke = ui.style().interact(&response).fg_stroke;
250    ///     let radius = egui::lerp(2.0..=3.0, openness);
251    ///     ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
252    /// }
253    ///
254    /// let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
255    ///     ui.ctx(),
256    ///     ui.make_persistent_id("my_collapsing_state"),
257    ///     false,
258    /// );
259    ///
260    /// let header_res = ui.horizontal(|ui| {
261    ///     ui.label("Header");
262    ///     state.show_toggle_button(ui, circle_icon);
263    /// });
264    ///
265    /// state.show_body_indented(&header_res.response, ui, |ui| ui.label("Body"));
266    /// # });
267    /// ```
268    pub fn show_toggle_button(
269        &mut self,
270        ui: &mut Ui,
271        icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static,
272    ) -> Response {
273        self.show_button_indented(ui, icon_fn)
274    }
275}
276
277/// From [`CollapsingState::show_header`].
278#[must_use = "Remember to show the body"]
279pub struct HeaderResponse<'ui, HeaderRet> {
280    state: CollapsingState,
281    ui: &'ui mut Ui,
282    toggle_button_response: Response,
283    header_response: InnerResponse<HeaderRet>,
284}
285
286impl<HeaderRet> HeaderResponse<'_, HeaderRet> {
287    pub fn is_open(&self) -> bool {
288        self.state.is_open()
289    }
290
291    pub fn set_open(&mut self, open: bool) {
292        self.state.set_open(open);
293    }
294
295    pub fn toggle(&mut self) {
296        self.state.toggle(self.ui);
297    }
298
299    /// Returns the response of the collapsing button, the custom header, and the custom body.
300    pub fn body<BodyRet>(
301        mut self,
302        add_body: impl FnOnce(&mut Ui) -> BodyRet,
303    ) -> (
304        Response,
305        InnerResponse<HeaderRet>,
306        Option<InnerResponse<BodyRet>>,
307    ) {
308        let body_response =
309            self.state
310                .show_body_indented(&self.header_response.response, self.ui, add_body);
311        (
312            self.toggle_button_response,
313            self.header_response,
314            body_response,
315        )
316    }
317
318    /// Returns the response of the collapsing button, the custom header, and the custom body, without indentation.
319    pub fn body_unindented<BodyRet>(
320        mut self,
321        add_body: impl FnOnce(&mut Ui) -> BodyRet,
322    ) -> (
323        Response,
324        InnerResponse<HeaderRet>,
325        Option<InnerResponse<BodyRet>>,
326    ) {
327        let body_response = self.state.show_body_unindented(self.ui, add_body);
328        (
329            self.toggle_button_response,
330            self.header_response,
331            body_response,
332        )
333    }
334}
335
336// ----------------------------------------------------------------------------
337
338/// Paint the arrow icon that indicated if the region is open or not
339pub fn paint_default_icon(ui: &mut Ui, openness: f32, response: &Response) {
340    let visuals = ui.style().interact(response);
341
342    let rect = response.rect;
343
344    // Draw a pointy triangle arrow:
345    let rect = Rect::from_center_size(rect.center(), vec2(rect.width(), rect.height()) * 0.75);
346    let rect = rect.expand(visuals.expansion);
347    let mut points = vec![rect.left_top(), rect.right_top(), rect.center_bottom()];
348    use std::f32::consts::TAU;
349    let rotation = emath::Rot2::from_angle(remap(openness, 0.0..=1.0, -TAU / 4.0..=0.0));
350    for p in &mut points {
351        *p = rect.center() + rotation * (*p - rect.center());
352    }
353
354    ui.painter().add(Shape::convex_polygon(
355        points,
356        visuals.fg_stroke.color,
357        Stroke::NONE,
358    ));
359}
360
361/// A function that paints an icon indicating if the region is open or not
362pub type IconPainter = Box<dyn FnOnce(&mut Ui, f32, &Response)>;
363
364/// A header which can be collapsed/expanded, revealing a contained [`Ui`] region.
365///
366/// ```
367/// # egui::__run_test_ui(|ui| {
368/// egui::CollapsingHeader::new("Heading")
369///     .show(ui, |ui| {
370///         ui.label("Body");
371///     });
372///
373/// // Short version:
374/// ui.collapsing("Heading", |ui| { ui.label("Body"); });
375/// # });
376/// ```
377///
378/// If you want to customize the header contents, see [`CollapsingState::show_header`].
379#[must_use = "You should call .show()"]
380pub struct CollapsingHeader {
381    text: WidgetText,
382    default_open: bool,
383    open: Option<bool>,
384    id_salt: Id,
385    enabled: bool,
386    selectable: bool,
387    selected: bool,
388    show_background: bool,
389    icon: Option<IconPainter>,
390}
391
392impl CollapsingHeader {
393    /// The [`CollapsingHeader`] starts out collapsed unless you call `default_open`.
394    ///
395    /// The label is used as an [`Id`] source.
396    /// If the label is unique and static this is fine,
397    /// but if it changes or there are several [`CollapsingHeader`] with the same title
398    /// you need to provide a unique id source with [`Self::id_salt`].
399    pub fn new(text: impl Into<WidgetText>) -> Self {
400        let text = text.into();
401        let id_salt = Id::new(text.text());
402        Self {
403            text,
404            default_open: false,
405            open: None,
406            id_salt,
407            enabled: true,
408            selectable: false,
409            selected: false,
410            show_background: false,
411            icon: None,
412        }
413    }
414
415    /// By default, the [`CollapsingHeader`] is collapsed.
416    /// Call `.default_open(true)` to change this.
417    #[inline]
418    pub fn default_open(mut self, open: bool) -> Self {
419        self.default_open = open;
420        self
421    }
422
423    /// Calling `.open(Some(true))` will make the collapsing header open this frame (or stay open).
424    ///
425    /// Calling `.open(Some(false))` will make the collapsing header close this frame (or stay closed).
426    ///
427    /// Calling `.open(None)` has no effect (default).
428    #[inline]
429    pub fn open(mut self, open: Option<bool>) -> Self {
430        self.open = open;
431        self
432    }
433
434    /// Explicitly set the source of the [`Id`] of this widget, instead of using title label.
435    /// This is useful if the title label is dynamic or not unique.
436    #[inline]
437    pub fn id_salt(mut self, id_salt: impl Hash) -> Self {
438        self.id_salt = Id::new(id_salt);
439        self
440    }
441
442    /// Explicitly set the source of the [`Id`] of this widget, instead of using title label.
443    /// This is useful if the title label is dynamic or not unique.
444    #[deprecated = "Renamed id_salt"]
445    #[inline]
446    pub fn id_source(mut self, id_salt: impl Hash) -> Self {
447        self.id_salt = Id::new(id_salt);
448        self
449    }
450
451    /// If you set this to `false`, the [`CollapsingHeader`] will be grayed out and un-clickable.
452    ///
453    /// This is a convenience for [`Ui::disable`].
454    #[inline]
455    pub fn enabled(mut self, enabled: bool) -> Self {
456        self.enabled = enabled;
457        self
458    }
459
460    /// Should the [`CollapsingHeader`] show a background behind it? Default: `false`.
461    ///
462    /// To show it behind all [`CollapsingHeader`] you can just use:
463    /// ```
464    /// # egui::__run_test_ui(|ui| {
465    /// ui.visuals_mut().collapsing_header_frame = true;
466    /// # });
467    /// ```
468    #[inline]
469    pub fn show_background(mut self, show_background: bool) -> Self {
470        self.show_background = show_background;
471        self
472    }
473
474    /// Use the provided function to render a different [`CollapsingHeader`] icon.
475    /// Defaults to a triangle that animates as the [`CollapsingHeader`] opens and closes.
476    ///
477    /// For example:
478    /// ```
479    /// # egui::__run_test_ui(|ui| {
480    /// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
481    ///     let stroke = ui.style().interact(&response).fg_stroke;
482    ///     let radius = egui::lerp(2.0..=3.0, openness);
483    ///     ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
484    /// }
485    ///
486    /// egui::CollapsingHeader::new("Circles")
487    ///   .icon(circle_icon)
488    ///   .show(ui, |ui| { ui.label("Hi!"); });
489    /// # });
490    /// ```
491    #[inline]
492    pub fn icon(mut self, icon_fn: impl FnOnce(&mut Ui, f32, &Response) + 'static) -> Self {
493        self.icon = Some(Box::new(icon_fn));
494        self
495    }
496}
497
498struct Prepared {
499    header_response: Response,
500    state: CollapsingState,
501    openness: f32,
502}
503
504impl CollapsingHeader {
505    fn begin(self, ui: &mut Ui) -> Prepared {
506        assert!(
507            ui.layout().main_dir().is_vertical(),
508            "Horizontal collapsing is unimplemented"
509        );
510        let Self {
511            icon,
512            text,
513            default_open,
514            open,
515            id_salt,
516            enabled: _,
517            selectable,
518            selected,
519            show_background,
520        } = self;
521
522        // TODO(emilk): horizontal layout, with icon and text as labels. Insert background behind using Frame.
523
524        let id = ui.make_persistent_id(id_salt);
525        let button_padding = ui.spacing().button_padding;
526
527        let available = ui.available_rect_before_wrap();
528        let text_pos = available.min + vec2(ui.spacing().indent, 0.0);
529        let wrap_width = available.right() - text_pos.x;
530        let galley = text.into_galley(
531            ui,
532            Some(TextWrapMode::Extend),
533            wrap_width,
534            TextStyle::Button,
535        );
536        let text_max_x = text_pos.x + galley.size().x;
537
538        let mut desired_width = text_max_x + button_padding.x - available.left();
539        if ui.visuals().collapsing_header_frame {
540            desired_width = desired_width.max(available.width()); // fill full width
541        }
542
543        let mut desired_size = vec2(desired_width, galley.size().y + 2.0 * button_padding.y);
544        desired_size = desired_size.at_least(ui.spacing().interact_size);
545        let (_, rect) = ui.allocate_space(desired_size);
546
547        let mut header_response = ui.interact(rect, id, Sense::click());
548        let text_pos = pos2(
549            text_pos.x,
550            header_response.rect.center().y - galley.size().y / 2.0,
551        );
552
553        let mut state = CollapsingState::load_with_default_open(ui.ctx(), id, default_open);
554        if let Some(open) = open {
555            if open != state.is_open() {
556                state.toggle(ui);
557                header_response.mark_changed();
558            }
559        } else if header_response.clicked() {
560            state.toggle(ui);
561            header_response.mark_changed();
562        }
563
564        header_response.widget_info(|| {
565            WidgetInfo::labeled(WidgetType::CollapsingHeader, ui.is_enabled(), galley.text())
566        });
567
568        let openness = state.openness(ui.ctx());
569
570        if ui.is_rect_visible(rect) {
571            let visuals = ui.style().interact_selectable(&header_response, selected);
572
573            if ui.visuals().collapsing_header_frame || show_background {
574                ui.painter().add(epaint::RectShape::new(
575                    header_response.rect.expand(visuals.expansion),
576                    visuals.corner_radius,
577                    visuals.weak_bg_fill,
578                    visuals.bg_stroke,
579                    StrokeKind::Inside,
580                ));
581            }
582
583            if selected || selectable && (header_response.hovered() || header_response.has_focus())
584            {
585                let rect = rect.expand(visuals.expansion);
586
587                ui.painter().rect(
588                    rect,
589                    visuals.corner_radius,
590                    visuals.bg_fill,
591                    visuals.bg_stroke,
592                    StrokeKind::Inside,
593                );
594            }
595
596            {
597                let (mut icon_rect, _) = ui.spacing().icon_rectangles(header_response.rect);
598                icon_rect.set_center(pos2(
599                    header_response.rect.left() + ui.spacing().indent / 2.0,
600                    header_response.rect.center().y,
601                ));
602                let icon_response = header_response.clone().with_new_rect(icon_rect);
603                if let Some(icon) = icon {
604                    icon(ui, openness, &icon_response);
605                } else {
606                    paint_default_icon(ui, openness, &icon_response);
607                }
608            }
609
610            ui.painter().galley(text_pos, galley, visuals.text_color());
611        }
612
613        Prepared {
614            header_response,
615            state,
616            openness,
617        }
618    }
619
620    #[inline]
621    pub fn show<R>(
622        self,
623        ui: &mut Ui,
624        add_body: impl FnOnce(&mut Ui) -> R,
625    ) -> CollapsingResponse<R> {
626        self.show_dyn(ui, Box::new(add_body), true)
627    }
628
629    #[inline]
630    pub fn show_unindented<R>(
631        self,
632        ui: &mut Ui,
633        add_body: impl FnOnce(&mut Ui) -> R,
634    ) -> CollapsingResponse<R> {
635        self.show_dyn(ui, Box::new(add_body), false)
636    }
637
638    fn show_dyn<'c, R>(
639        self,
640        ui: &mut Ui,
641        add_body: Box<dyn FnOnce(&mut Ui) -> R + 'c>,
642        indented: bool,
643    ) -> CollapsingResponse<R> {
644        // Make sure body is bellow header,
645        // and make sure it is one unit (necessary for putting a [`CollapsingHeader`] in a grid).
646        ui.vertical(|ui| {
647            if !self.enabled {
648                ui.disable();
649            }
650
651            let Prepared {
652                header_response,
653                mut state,
654                openness,
655            } = self.begin(ui); // show the header
656
657            let ret_response = if indented {
658                state.show_body_indented(&header_response, ui, add_body)
659            } else {
660                state.show_body_unindented(ui, add_body)
661            };
662
663            if let Some(ret_response) = ret_response {
664                CollapsingResponse {
665                    header_response,
666                    body_response: Some(ret_response.response),
667                    body_returned: Some(ret_response.inner),
668                    openness,
669                }
670            } else {
671                CollapsingResponse {
672                    header_response,
673                    body_response: None,
674                    body_returned: None,
675                    openness,
676                }
677            }
678        })
679        .inner
680    }
681}
682
683/// The response from showing a [`CollapsingHeader`].
684pub struct CollapsingResponse<R> {
685    /// Response of the actual clickable header.
686    pub header_response: Response,
687
688    /// None iff collapsed.
689    pub body_response: Option<Response>,
690
691    /// None iff collapsed.
692    pub body_returned: Option<R>,
693
694    /// 0.0 if fully closed, 1.0 if fully open, and something in-between while animating.
695    pub openness: f32,
696}
697
698impl<R> CollapsingResponse<R> {
699    /// Was the [`CollapsingHeader`] fully closed (and not being animated)?
700    pub fn fully_closed(&self) -> bool {
701        self.openness <= 0.0
702    }
703
704    /// Was the [`CollapsingHeader`] fully open (and not being animated)?
705    pub fn fully_open(&self) -> bool {
706        self.openness >= 1.0
707    }
708}