Skip to main content

egui/containers/
collapsing_header.rs

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    /// Height of the region when open. Used for animations
15    #[cfg_attr(feature = "serde", serde(default))]
16    open_height: Option<f32>,
17}
18
19/// This is a a building block for building collapsing regions.
20///
21/// It is used by [`CollapsingHeader`] and [`crate::Window`], but can also be used on its own.
22///
23/// See [`CollapsingState::show_header`] for how to show a collapsing header with a custom header.
24#[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    /// 0 for closed, 1 for open, with tweening
74    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    /// Will toggle when clicked, etc.
83    fn show_default_button_indented(&mut self, ui: &mut Ui) -> Response {
84        self.show_button_indented(ui, paint_default_icon)
85    }
86
87    /// Will toggle when clicked, etc.
88    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    /// Shows header and body (if expanded).
112    ///
113    /// The header will start with the default button in a horizontal layout, followed by whatever you add.
114    ///
115    /// Will also store the state.
116    ///
117    /// Returns the response of the collapsing button, the custom header, and the custom body.
118    ///
119    /// ```
120    /// # egui::__run_test_ui(|ui| {
121    /// let id = ui.make_persistent_id("my_collapsing_header");
122    /// egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false)
123    ///     .show_header(ui, |ui| {
124    ///         ui.label("Header"); // you can put checkboxes or whatever here
125    ///     })
126    ///     .body(|ui| ui.label("Body"));
127    /// # });
128    /// ```
129    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; // the toggler button uses the full indent width
137            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    /// Show body if we are open, with a nice animation between closed and open.
153    /// Indent the body to show it belongs to the header.
154    ///
155    /// Will also store the state.
156    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                // make as wide as the header:
166                ui.expand_to_include_x(header_response.rect.right());
167                add_body(ui)
168            })
169            .inner
170        })
171    }
172
173    /// Show body if we are open, with a nice animation between closed and open.
174    /// Will also store the state.
175    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()); // we store any earlier toggling as promised in the docstring
188            None
189        } else if openness < 1.0 {
190            // The spacing between the header and the body. We animate this too.
191            let item_spacing = ui.spacing().item_spacing.y;
192
193            let fallback_height_guess = 10.0; // Just use a placeholder value that shows some movement for the first frame
194            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); // animate the spacing!
202            }
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()); // remember the height
224
225                // Pretend children took up at most `clipped_child_height` space:
226                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()); // remember the height
238            Some(ret_response)
239        }
240    }
241
242    /// Paint this [`CollapsingState`]'s toggle button. Takes an [`IconPainter`] as the icon.
243    /// ```
244    /// # egui::__run_test_ui(|ui| {
245    /// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
246    ///     let stroke = ui.style().interact(&response).fg_stroke;
247    ///     let radius = egui::lerp(2.0..=3.0, openness);
248    ///     ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
249    /// }
250    ///
251    /// let mut state = egui::collapsing_header::CollapsingState::load_with_default_open(
252    ///     ui.ctx(),
253    ///     ui.make_persistent_id("my_collapsing_state"),
254    ///     false,
255    /// );
256    ///
257    /// let header_res = ui.horizontal(|ui| {
258    ///     ui.label("Header");
259    ///     state.show_toggle_button(ui, circle_icon);
260    /// });
261    ///
262    /// state.show_body_indented(&header_res.response, ui, |ui| ui.label("Body"));
263    /// # });
264    /// ```
265    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/// From [`CollapsingState::show_header`].
275#[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    /// Returns the response of the collapsing button, the custom header, and the custom body.
297    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    /// Returns the response of the collapsing button, the custom header, and the custom body, without indentation.
316    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
333// ----------------------------------------------------------------------------
334
335/// Paint the arrow icon that indicated if the region is open or not
336pub 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    // Draw a pointy triangle arrow:
342    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
358/// A function that paints an icon indicating if the region is open or not
359pub type IconPainter = Box<dyn FnOnce(&mut Ui, f32, &Response)>;
360
361/// A header which can be collapsed/expanded, revealing a contained [`Ui`] region.
362///
363/// ```
364/// # egui::__run_test_ui(|ui| {
365/// egui::CollapsingHeader::new("Heading")
366///     .show(ui, |ui| {
367///         ui.label("Body");
368///     });
369///
370/// // Short version:
371/// ui.collapsing("Heading", |ui| { ui.label("Body"); });
372/// # });
373/// ```
374///
375/// If you want to customize the header contents, see [`CollapsingState::show_header`].
376#[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    /// The [`CollapsingHeader`] starts out collapsed unless you call `default_open`.
391    ///
392    /// The label is used as an [`Id`] source.
393    /// If the label is unique and static this is fine,
394    /// but if it changes or there are several [`CollapsingHeader`] with the same title
395    /// you need to provide a unique id source with [`Self::id_salt`].
396    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    /// By default, the [`CollapsingHeader`] is collapsed.
413    /// Call `.default_open(true)` to change this.
414    #[inline]
415    pub fn default_open(mut self, open: bool) -> Self {
416        self.default_open = open;
417        self
418    }
419
420    /// Calling `.open(Some(true))` will make the collapsing header open this frame (or stay open).
421    ///
422    /// Calling `.open(Some(false))` will make the collapsing header close this frame (or stay closed).
423    ///
424    /// Calling `.open(None)` has no effect (default).
425    #[inline]
426    pub fn open(mut self, open: Option<bool>) -> Self {
427        self.open = open;
428        self
429    }
430
431    /// Explicitly set the source of the [`Id`] of this widget, instead of using title label.
432    /// This is useful if the title label is dynamic or not unique.
433    #[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    /// If you set this to `false`, the [`CollapsingHeader`] will be grayed out and un-clickable.
440    ///
441    /// This is a convenience for [`Ui::disable`].
442    #[inline]
443    pub fn enabled(mut self, enabled: bool) -> Self {
444        self.enabled = enabled;
445        self
446    }
447
448    /// Should the [`CollapsingHeader`] show a background behind it? Default: `false`.
449    ///
450    /// To show it behind all [`CollapsingHeader`] you can just use:
451    /// ```
452    /// # egui::__run_test_ui(|ui| {
453    /// ui.visuals_mut().collapsing_header_frame = true;
454    /// # });
455    /// ```
456    #[inline]
457    pub fn show_background(mut self, show_background: bool) -> Self {
458        self.show_background = show_background;
459        self
460    }
461
462    /// Use the provided function to render a different [`CollapsingHeader`] icon.
463    /// Defaults to a triangle that animates as the [`CollapsingHeader`] opens and closes.
464    ///
465    /// For example:
466    /// ```
467    /// # egui::__run_test_ui(|ui| {
468    /// fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
469    ///     let stroke = ui.style().interact(&response).fg_stroke;
470    ///     let radius = egui::lerp(2.0..=3.0, openness);
471    ///     ui.painter().circle_filled(response.rect.center(), radius, stroke.color);
472    /// }
473    ///
474    /// egui::CollapsingHeader::new("Circles")
475    ///   .icon(circle_icon)
476    ///   .show(ui, |ui| { ui.label("Hi!"); });
477    /// # });
478    /// ```
479    #[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        // TODO(emilk): horizontal layout, with icon and text as labels. Insert background behind using Frame.
511
512        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()); // fill full width
529        }
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        // Make sure body is bellow header,
633        // and make sure it is one unit (necessary for putting a [`CollapsingHeader`] in a grid).
634        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); // show the header
644
645            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
671/// The response from showing a [`CollapsingHeader`].
672pub struct CollapsingResponse<R> {
673    /// Response of the actual clickable header.
674    pub header_response: Response,
675
676    /// None iff collapsed.
677    pub body_response: Option<Response>,
678
679    /// None iff collapsed.
680    pub body_returned: Option<R>,
681
682    /// 0.0 if fully closed, 1.0 if fully open, and something in-between while animating.
683    pub openness: f32,
684}
685
686impl<R> CollapsingResponse<R> {
687    /// Was the [`CollapsingHeader`] fully closed (and not being animated)?
688    pub fn fully_closed(&self) -> bool {
689        self.openness <= 0.0
690    }
691
692    /// Was the [`CollapsingHeader`] fully open (and not being animated)?
693    pub fn fully_open(&self) -> bool {
694        self.openness >= 1.0
695    }
696}