Skip to main content

ashlar_ui/content/
collapsible.rs

1//! A section that opens and closes under its header.
2
3use egui::{CornerRadius, Key, Modifiers, Pos2, Response, Sense, Shape, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
4
5use crate::automation::{self, Details, Role};
6use crate::controls::{FOCUS_WIDTH, text_galley};
7use crate::tokens::{self, Tokens};
8
9/// The chevron's three points in a square of side `side` centred at `c`: pointing right when
10/// closed, down when open.
11pub(crate) fn chevron(c: Pos2, side: f32, open: bool) -> Vec<Pos2> {
12    let h = side / 2.0;
13    if open {
14        vec![pos2(c.x - h, c.y - h / 2.0), pos2(c.x + h, c.y - h / 2.0), pos2(c.x, c.y + h / 2.0 + h / 4.0)]
15    } else {
16        vec![pos2(c.x - h / 2.0, c.y - h), pos2(c.x + h / 2.0 + h / 4.0, c.y), pos2(c.x - h / 2.0, c.y + h)]
17    }
18}
19
20/// Where the chevron sits in a header row starting at `left`, centred on `y`.
21pub(crate) fn chevron_center(t: &Tokens, left: f32, y: f32) -> (Pos2, f32) {
22    let side = (t.type_scale.body * 0.6).round().max(4.0);
23    (pos2(left + t.space.sm + side / 2.0, y), side)
24}
25
26/// A header row (chevron and title) that shows or hides the content under it. A click, Space or
27/// Enter toggles `open`; with the header focused, the right arrow opens and the left arrow closes.
28/// Several in a column make an accordion; [`Accordion`] keeps at most one of them open.
29pub struct Collapsible<'a> {
30    id: &'a str,
31    title: &'a str,
32    open: &'a mut bool,
33}
34
35/// What [`Collapsible::show`] reports.
36pub struct CollapsibleOutput<R> {
37    /// The header: `changed()` when it was opened or closed this frame.
38    pub header: Response,
39    /// What the content closure returned, when open.
40    pub body: Option<R>,
41}
42
43impl<'a> Collapsible<'a> {
44    pub fn new(id: &'a str, title: &'a str, open: &'a mut bool) -> Self {
45        Collapsible { id, title, open }
46    }
47
48    pub fn show<R>(self, ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> CollapsibleOutput<R> {
49        let t = tokens::current(ui.ctx());
50        let galley = text_galley(ui, ("ashlar_ui::content::collapsible", self.id), self.title, t.type_scale.body);
51        let width = ui.available_width().clamp(0.0, 8000.0);
52        let (auto_id, rect) = ui.allocate_space(vec2(width, t.control_height.max(galley.size().y)));
53        let id = if self.id.is_empty() { auto_id } else { ui.make_persistent_id(("ashlar_ui::content::collapsible", self.id)) };
54        let mut header = ui.interact(rect, id, Sense::click());
55        let mut toggled = header.clicked();
56        if header.has_focus() {
57            let open = *self.open;
58            let (right, left) = ui.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowRight), i.consume_key(Modifiers::NONE, Key::ArrowLeft)));
59            toggled |= (right && !open) || (left && open);
60        }
61        if toggled {
62            *self.open = !*self.open;
63            header.mark_changed();
64        }
65        let open = *self.open;
66        let enabled = ui.is_enabled();
67        header.widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, enabled, self.title));
68        ui.ctx().accesskit_node_builder(header.id, |node| node.set_expanded(open));
69
70        if ui.is_rect_visible(rect) {
71            let painter = ui.painter();
72            let radius = CornerRadius::same(t.radius.sm);
73            if enabled && header.is_pointer_button_down_on() {
74                painter.rect_filled(rect, radius, t.surface.pressed);
75            } else if enabled && header.hovered() {
76                painter.rect_filled(rect, radius, t.surface.hover);
77            }
78            let (c, side) = chevron_center(&t, rect.left(), rect.center().y);
79            painter.add(Shape::convex_polygon(chevron(c, side, open), t.text.secondary, Stroke::NONE));
80            let x = c.x + side / 2.0 + t.space.sm;
81            painter.galley(pos2(x, rect.center().y - galley.size().y / 2.0), galley, t.text.primary);
82            if header.has_focus() {
83                painter.rect_stroke(rect, radius, Stroke::new(FOCUS_WIDTH, t.focus), egui::StrokeKind::Inside);
84            }
85        }
86        automation::register_with(ui, &header, self.id, self.title, Role::Disclosure, Details { value: None, selected: Some(open) });
87        // indented under the title, without a guide line
88        let indent = t.space.xl;
89        let body = open.then(|| {
90            ui.horizontal_top(|ui| {
91                ui.add_space(indent);
92                ui.vertical(add).inner
93            })
94            .inner
95        });
96        CollapsibleOutput { header, body }
97    }
98}
99
100/// Collapsible sections of which at most one is open: opening one closes the one open before.
101/// `open` is the open section's index (`None`: all closed). Each section's automation id is
102/// `<id>.<index>`.
103///
104/// ```
105/// # use ashlar_ui::content::Accordion;
106/// # fn ui(ui: &mut egui::Ui, open: &mut Option<usize>) {
107/// let mut acc = Accordion::new("help.faq", open);
108/// acc.section(ui, 0, "Shipping", |ui| { ui.label("…"); });
109/// acc.section(ui, 1, "Returns", |ui| { ui.label("…"); });
110/// # }
111/// ```
112pub struct Accordion<'a> {
113    id: &'a str,
114    open: &'a mut Option<usize>,
115}
116
117impl<'a> Accordion<'a> {
118    pub fn new(id: &'a str, open: &'a mut Option<usize>) -> Self {
119        Accordion { id, open }
120    }
121
122    /// The section at `index`, shown open when it is the open one.
123    pub fn section<R>(&mut self, ui: &mut Ui, index: usize, title: &str, add: impl FnOnce(&mut Ui) -> R) -> CollapsibleOutput<R> {
124        let mut open = *self.open == Some(index);
125        let id = if self.id.is_empty() { String::new() } else { format!("{}.{index}", self.id) };
126        let out = Collapsible::new(&id, title, &mut open).show(ui, add);
127        if out.header.changed() {
128            if open {
129                // a section above this one may have drawn open this frame: draw once more
130                if self.open.is_some_and(|o| o < index) {
131                    crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
132                }
133                *self.open = Some(index);
134            } else if *self.open == Some(index) {
135                *self.open = None;
136            }
137        }
138        out
139    }
140}