Skip to main content

agg_gui/widgets/
collapsing_header.rs

1//! `CollapsingHeader` — a clickable header that shows/hides child content.
2//!
3//! # Composition
4//!
5//! ```text
6//! CollapsingHeader
7//!   ├── Label  (children[0]: header text, framework-painted)
8//!   └── child  (children[1]: shown when expanded, swapped out when collapsed)
9//! ```
10//!
11//! The triangle indicator is drawn as a path.  Clicking anywhere on the header
12//! row toggles the collapsed/expanded state.
13
14use std::sync::Arc;
15
16use crate::color::Color;
17use crate::draw_ctx::DrawCtx;
18use crate::event::{Event, EventResult, MouseButton};
19use crate::geometry::{Point, Rect, Size};
20use crate::text::Font;
21use crate::widget::Widget;
22use crate::widgets::label::Label;
23
24const HEADER_H: f64 = 22.0;
25const TRIANGLE_SIZE: f64 = 6.0;
26const INDENT: f64 = 12.0;
27
28/// A collapsible section header.  When expanded, the child widget is visible
29/// below the header row.  When collapsed, only the header row is shown.
30pub struct CollapsingHeader {
31    bounds: Rect,
32    /// `children[0]` is the header [`Label`].  When expanded, the content lives
33    /// at `children[1]`; when collapsed, it is parked in `self.content`.
34    children: Vec<Box<dyn Widget>>,
35    open: bool,
36    hovered: bool,
37    /// The content shown when expanded.  Stored here while collapsed.
38    content: Option<Box<dyn Widget>>,
39}
40
41impl CollapsingHeader {
42    /// Create a new header with the given text, using the provided font.
43    /// Starts expanded by default.
44    pub fn new(text: impl Into<String>, font: Arc<Font>) -> Self {
45        let label = Label::new(text, Arc::clone(&font)).with_font_size(13.0);
46        Self {
47            bounds: Rect::default(),
48            children: vec![Box::new(label)],
49            open: true,
50            hovered: false,
51            content: None,
52        }
53    }
54
55    /// Set whether the section is open (expanded) by default.
56    pub fn default_open(mut self, open: bool) -> Self {
57        self.open = open;
58        self
59    }
60
61    /// Set the child content widget shown when expanded.
62    pub fn with_content(mut self, content: Box<dyn Widget>) -> Self {
63        self.content = Some(content);
64        self
65    }
66}
67
68impl Widget for CollapsingHeader {
69    fn type_name(&self) -> &'static str {
70        "CollapsingHeader"
71    }
72    fn bounds(&self) -> Rect {
73        self.bounds
74    }
75    fn set_bounds(&mut self, b: Rect) {
76        self.bounds = b;
77    }
78    fn children(&self) -> &[Box<dyn Widget>] {
79        &self.children
80    }
81    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
82        &mut self.children
83    }
84
85    fn layout(&mut self, available: Size) -> Size {
86        let w = available.width;
87
88        // Sync `children[1]` with `open` state so the framework dispatches
89        // events to content only when visible.  Closed → parked in `self.content`.
90        if self.open && self.children.len() == 1 {
91            if let Some(c) = self.content.take() {
92                self.children.push(c);
93            }
94        } else if !self.open && self.children.len() > 1 {
95            if let Some(c) = self.children.pop() {
96                self.content = Some(c);
97            }
98        }
99
100        // Layout content first so we know total height before placing the label.
101        let content_h = if self.open && self.children.len() > 1 {
102            let inset = INDENT * 0.5;
103            let avail_w = (w - inset).max(0.0);
104            let child = &mut self.children[1];
105            let cs = child.layout(Size::new(avail_w, available.height - HEADER_H));
106            child.set_bounds(Rect::new(inset, 0.0, cs.width, cs.height));
107            cs.height
108        } else {
109            0.0
110        };
111        let total_h = HEADER_H + content_h;
112
113        // Layout label inside the header row (Y-up: header sits at the top).
114        let label_avail = Size::new(w - INDENT - TRIANGLE_SIZE * 2.0, HEADER_H);
115        let ls = self.children[0].layout(label_avail);
116        let header_bottom = total_h - HEADER_H;
117        let label_y = header_bottom + (HEADER_H - ls.height) * 0.5;
118        self.children[0].set_bounds(Rect::new(
119            INDENT + TRIANGLE_SIZE * 2.0 + 4.0,
120            label_y,
121            ls.width,
122            ls.height,
123        ));
124
125        self.bounds = Rect::new(0.0, 0.0, w, total_h);
126        Size::new(w, total_h)
127    }
128
129    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
130        let v = ctx.visuals();
131        let w = self.bounds.width;
132        let h = self.bounds.height;
133
134        // Header row background — always shown at a subtle tint so the header
135        // reads as a distinct section boundary even when not hovered.  Hover
136        // deepens the tint slightly as click affordance.  Sits just below the
137        // top divider line so the line remains crisp.
138        let alpha = if self.hovered { 0.10 } else { 0.06 };
139        ctx.set_fill_color(Color::rgba(
140            v.text_color.r,
141            v.text_color.g,
142            v.text_color.b,
143            alpha,
144        ));
145        ctx.begin_path();
146        ctx.rect(0.0, h - HEADER_H, w, HEADER_H - 1.0);
147        ctx.fill();
148
149        // Top divider line — 1px, full-width, in the shared separator colour
150        // so a vertical stack of headers forms consistent section boundaries
151        // matching any `Separator` widgets elsewhere in the UI.
152        ctx.set_fill_color(v.separator);
153        ctx.begin_path();
154        ctx.rect(0.0, h - 1.0, w, 1.0);
155        ctx.fill();
156
157        // Triangle indicator (▶ collapsed, ▼ expanded).
158        // In Y-up: the header row occupies y = h - HEADER_H .. h.
159        let center_y = h - HEADER_H * 0.5;
160        let tx = INDENT;
161        let ts = TRIANGLE_SIZE * 0.5;
162        ctx.set_fill_color(v.text_dim);
163        ctx.begin_path();
164        if self.open {
165            // Pointing down (▼): triangle with point at bottom.
166            ctx.move_to(tx, center_y + ts * 0.5);
167            ctx.line_to(tx + ts * 2.0, center_y + ts * 0.5);
168            ctx.line_to(tx + ts, center_y - ts * 0.8);
169        } else {
170            // Pointing right (▶): triangle with point to the right.
171            ctx.move_to(tx, center_y + ts);
172            ctx.line_to(tx, center_y - ts);
173            ctx.line_to(tx + ts * 1.6, center_y);
174        }
175        ctx.fill();
176
177        // Label colour — child paints itself via the framework's tree walk.
178        self.children[0].set_label_color(v.text_color);
179
180        // Content (children[1] when open) is painted by the framework via
181        // normal child recursion.
182    }
183
184    fn on_event(&mut self, event: &Event) -> EventResult {
185        let h = self.bounds.height;
186
187        match event {
188            Event::MouseMove { pos } => {
189                // Header row: top portion in Y-up = y from (h - HEADER_H) to h.
190                let in_header = pos.x >= 0.0
191                    && pos.x <= self.bounds.width
192                    && pos.y >= h - HEADER_H
193                    && pos.y <= h;
194                let was = self.hovered;
195                self.hovered = in_header;
196                if self.hovered != was {
197                    crate::animation::request_draw();
198                    return EventResult::Consumed;
199                }
200                EventResult::Ignored
201            }
202            Event::MouseDown {
203                button: MouseButton::Left,
204                pos,
205                ..
206            } => {
207                let in_header = pos.x >= 0.0
208                    && pos.x <= self.bounds.width
209                    && pos.y >= h - HEADER_H
210                    && pos.y <= h;
211                if in_header {
212                    self.open = !self.open;
213                    crate::animation::request_draw();
214                    return EventResult::Consumed;
215                }
216                EventResult::Ignored
217            }
218            _ => EventResult::Ignored,
219        }
220    }
221
222    fn hit_test(&self, local_pos: Point) -> bool {
223        local_pos.x >= 0.0
224            && local_pos.x <= self.bounds.width
225            && local_pos.y >= 0.0
226            && local_pos.y <= self.bounds.height
227    }
228}