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