egui/containers/
collapsing_header.rs

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