egui/containers/
collapsing_header.rs

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