Skip to main content

egui_components/
collapsible.rs

1//! `Collapsible` — the low-level show/hide primitive behind
2//! [`Accordion`](crate::accordion::Accordion).
3//!
4//! Unlike `Accordion` (which owns a styled title bar), `Collapsible` lets you
5//! render any trigger content next to the toggle chevron and any body when
6//! open. Open state lives in egui memory keyed by the id, or pass
7//! [`open`](Collapsible::open) for controlled state.
8//!
9//! ```ignore
10//! sc::Collapsible::new("adv").show(
11//!     ui,
12//!     |ui| { ui.add(sc::Label::new("Advanced settings").strong()); },
13//!     |ui| { ui.add(sc::Switch::new(&mut flag)); },
14//! );
15//! ```
16
17use egui::{vec2, Id, Rect, Sense, Ui};
18use egui_components_theme::Theme;
19
20use crate::icon::{paint_icon, IconKind};
21
22pub struct Collapsible<'a> {
23    id: Id,
24    default_open: bool,
25    open: Option<&'a mut bool>,
26}
27
28impl<'a> Collapsible<'a> {
29    pub fn new(id_salt: impl std::hash::Hash) -> Self {
30        Self {
31            id: Id::new(id_salt),
32            default_open: false,
33            open: None,
34        }
35    }
36    pub fn default_open(mut self, open: bool) -> Self {
37        self.default_open = open;
38        self
39    }
40    pub fn open(mut self, open: &'a mut bool) -> Self {
41        self.open = Some(open);
42        self
43    }
44
45    pub fn show<R>(
46        mut self,
47        ui: &mut Ui,
48        header: impl FnOnce(&mut Ui),
49        body: impl FnOnce(&mut Ui) -> R,
50    ) -> egui::InnerResponse<Option<R>> {
51        let theme = Theme::get(ui.ctx());
52        let c = theme.colors;
53        let mem_id = ui.make_persistent_id(self.id);
54
55        let mut is_open = match &self.open {
56            Some(b) => **b,
57            None => ui
58                .data(|d| d.get_temp::<bool>(mem_id))
59                .unwrap_or(self.default_open),
60        };
61
62        // Header row: clickable chevron + caller content.
63        let header_resp = ui
64            .horizontal(|ui| {
65                let chevron = 16.0;
66                let (rect, resp) = ui.allocate_exact_size(vec2(chevron, chevron), Sense::click());
67                let t = ui.ctx().animate_bool(mem_id.with("anim"), is_open);
68                let kind = if t > 0.5 {
69                    IconKind::ChevronDown
70                } else {
71                    IconKind::ChevronRight
72                };
73                let ir = Rect::from_center_size(rect.center(), vec2(chevron, chevron));
74                paint_icon(ui.painter(), kind, ir, c.muted_foreground, 1.6);
75                if resp.hovered() {
76                    ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
77                }
78                ui.add_space(4.0);
79                header(ui);
80                resp
81            })
82            .inner;
83
84        if header_resp.clicked() {
85            is_open = !is_open;
86            if let Some(b) = self.open.as_deref_mut() {
87                *b = is_open;
88            }
89        }
90        ui.data_mut(|d| d.insert_temp(mem_id, is_open));
91
92        let inner = if is_open {
93            let r = ui.scope(|ui| {
94                egui::Frame::new()
95                    .inner_margin(egui::Margin {
96                        left: 20,
97                        right: 0,
98                        top: 4,
99                        bottom: 4,
100                    })
101                    .show(ui, |ui| body(ui))
102                    .inner
103            });
104            Some(r.inner)
105        } else {
106            None
107        };
108
109        egui::InnerResponse::new(inner, header_resp)
110    }
111}