Skip to main content

saorsa_core/widget/
collapsible.rs

1//! Collapsible section widget with expandable/collapsible content.
2//!
3//! Displays a title line with an expand/collapse indicator and optional
4//! content that is shown or hidden based on the expanded state.
5
6use crate::buffer::ScreenBuffer;
7use crate::cell::Cell;
8use crate::event::{Event, KeyCode, KeyEvent};
9use crate::geometry::Rect;
10use crate::segment::Segment;
11use crate::style::Style;
12use crate::text::truncate_to_display_width;
13use unicode_width::UnicodeWidthStr;
14
15use super::{BorderStyle, EventResult, InteractiveWidget, Widget};
16
17/// A collapsible section widget.
18///
19/// Shows a title with an expand/collapse indicator. When expanded,
20/// content lines are rendered below the title.
21pub struct Collapsible {
22    /// Section title.
23    title: String,
24    /// Content lines (only visible when expanded).
25    content: Vec<Vec<Segment>>,
26    /// Whether the section is expanded.
27    expanded: bool,
28    /// Style for the title line.
29    title_style: Style,
30    /// Style for content lines.
31    content_style: Style,
32    /// Border style.
33    border: BorderStyle,
34    /// Indicator characters: (collapsed, expanded).
35    indicators: (&'static str, &'static str),
36}
37
38impl Collapsible {
39    /// Create a new collapsible section (collapsed by default).
40    pub fn new(title: &str) -> Self {
41        Self {
42            title: title.to_string(),
43            content: Vec::new(),
44            expanded: false,
45            title_style: Style::default(),
46            content_style: Style::default(),
47            border: BorderStyle::None,
48            indicators: ("\u{25b6}", "\u{25bc}"), // ▶ collapsed, ▼ expanded
49        }
50    }
51
52    /// Set the content lines.
53    #[must_use]
54    pub fn with_content(mut self, content: Vec<Vec<Segment>>) -> Self {
55        self.content = content;
56        self
57    }
58
59    /// Set the initial expanded state.
60    #[must_use]
61    pub fn with_expanded(mut self, expanded: bool) -> Self {
62        self.expanded = expanded;
63        self
64    }
65
66    /// Set the title style.
67    #[must_use]
68    pub fn with_title_style(mut self, style: Style) -> Self {
69        self.title_style = style;
70        self
71    }
72
73    /// Set the content style.
74    #[must_use]
75    pub fn with_content_style(mut self, style: Style) -> Self {
76        self.content_style = style;
77        self
78    }
79
80    /// Set the border style.
81    #[must_use]
82    pub fn with_border(mut self, border: BorderStyle) -> Self {
83        self.border = border;
84        self
85    }
86
87    /// Set custom indicator characters.
88    #[must_use]
89    pub fn with_indicators(mut self, collapsed: &'static str, expanded: &'static str) -> Self {
90        self.indicators = (collapsed, expanded);
91        self
92    }
93
94    /// Toggle the expanded/collapsed state.
95    pub fn toggle(&mut self) {
96        self.expanded = !self.expanded;
97    }
98
99    /// Set the expanded state.
100    pub fn set_expanded(&mut self, expanded: bool) {
101        self.expanded = expanded;
102    }
103
104    /// Check if the section is expanded.
105    pub fn is_expanded(&self) -> bool {
106        self.expanded
107    }
108
109    /// Render a row of segments into the buffer at the given position.
110    fn render_segments(segments: &[Segment], x0: u16, y: u16, w: usize, buf: &mut ScreenBuffer) {
111        let mut col: u16 = 0;
112        for segment in segments {
113            if col as usize >= w {
114                break;
115            }
116            let remaining = w.saturating_sub(col as usize);
117            let truncated = truncate_to_display_width(&segment.text, remaining);
118            for ch in truncated.chars() {
119                if col as usize >= w {
120                    break;
121                }
122                let char_w = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]) as &str);
123                if col as usize + char_w > w {
124                    break;
125                }
126                buf.set(
127                    x0 + col,
128                    y,
129                    Cell::new(ch.to_string(), segment.style.clone()),
130                );
131                col += char_w as u16;
132            }
133        }
134    }
135}
136
137impl Widget for Collapsible {
138    fn render(&self, area: Rect, buf: &mut ScreenBuffer) {
139        if area.size.width == 0 || area.size.height == 0 {
140            return;
141        }
142
143        super::border::render_border(area, self.border, self.title_style.clone(), buf);
144        let inner = super::border::inner_area(area, self.border);
145        if inner.size.width == 0 || inner.size.height == 0 {
146            return;
147        }
148
149        let w = inner.size.width as usize;
150        let x0 = inner.position.x;
151        let mut y = inner.position.y;
152
153        // Render title line: indicator + space + title
154        let indicator = if self.expanded {
155            self.indicators.1
156        } else {
157            self.indicators.0
158        };
159        let title_line = format!("{indicator} {}", self.title);
160        let title_segments = vec![Segment::styled(&title_line, self.title_style.clone())];
161        Self::render_segments(&title_segments, x0, y, w, buf);
162        y += 1;
163
164        // Render content if expanded
165        if self.expanded {
166            for line in &self.content {
167                if y >= inner.position.y + inner.size.height {
168                    break;
169                }
170                Self::render_segments(line, x0, y, w, buf);
171                y += 1;
172            }
173        }
174    }
175}
176
177impl InteractiveWidget for Collapsible {
178    fn handle_event(&mut self, event: &Event) -> EventResult {
179        let Event::Key(KeyEvent { code, .. }) = event else {
180            return EventResult::Ignored;
181        };
182
183        match code {
184            KeyCode::Enter | KeyCode::Char(' ') => {
185                self.toggle();
186                EventResult::Consumed
187            }
188            KeyCode::Left => {
189                self.set_expanded(false);
190                EventResult::Consumed
191            }
192            KeyCode::Right => {
193                self.set_expanded(true);
194                EventResult::Consumed
195            }
196            _ => EventResult::Ignored,
197        }
198    }
199}
200
201#[cfg(test)]
202#[allow(clippy::unwrap_used)]
203mod tests {
204    use super::*;
205    use crate::geometry::Size;
206
207    fn content_lines(texts: &[&str]) -> Vec<Vec<Segment>> {
208        texts.iter().map(|t| vec![Segment::new(*t)]).collect()
209    }
210
211    #[test]
212    fn create_collapsed() {
213        let c = Collapsible::new("Section");
214        assert!(!c.is_expanded());
215    }
216
217    #[test]
218    fn create_expanded() {
219        let c = Collapsible::new("Section").with_expanded(true);
220        assert!(c.is_expanded());
221    }
222
223    #[test]
224    fn render_collapsed_title_only() {
225        let c = Collapsible::new("Hello").with_content(content_lines(&["line1", "line2"]));
226        let mut buf = ScreenBuffer::new(Size::new(30, 5));
227        c.render(Rect::new(0, 0, 30, 5), &mut buf);
228
229        // Title row should contain indicator and title
230        let row0: String = (0..30)
231            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
232            .collect();
233        assert!(row0.contains("Hello"));
234        assert!(row0.contains("\u{25b6}")); // ▶
235
236        // Content rows should be empty (default spaces)
237        let row1: String = (0..30)
238            .map(|x| buf.get(x, 1).map(|c| c.grapheme.as_str()).unwrap_or(" "))
239            .collect();
240        assert!(!row1.contains("line1"));
241    }
242
243    #[test]
244    fn render_expanded_title_and_content() {
245        let c = Collapsible::new("Hello")
246            .with_content(content_lines(&["line1", "line2"]))
247            .with_expanded(true);
248        let mut buf = ScreenBuffer::new(Size::new(30, 5));
249        c.render(Rect::new(0, 0, 30, 5), &mut buf);
250
251        let row0: String = (0..30)
252            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
253            .collect();
254        assert!(row0.contains("\u{25bc}")); // ▼
255
256        let row1: String = (0..30)
257            .map(|x| buf.get(x, 1).map(|c| c.grapheme.as_str()).unwrap_or(" "))
258            .collect();
259        assert!(row1.contains("line1"));
260    }
261
262    #[test]
263    fn toggle_changes_state() {
264        let mut c = Collapsible::new("T");
265        assert!(!c.is_expanded());
266        c.toggle();
267        assert!(c.is_expanded());
268        c.toggle();
269        assert!(!c.is_expanded());
270    }
271
272    #[test]
273    fn set_expanded_explicitly() {
274        let mut c = Collapsible::new("T");
275        c.set_expanded(true);
276        assert!(c.is_expanded());
277        c.set_expanded(false);
278        assert!(!c.is_expanded());
279    }
280
281    #[test]
282    fn custom_indicators() {
283        let c = Collapsible::new("Test")
284            .with_indicators("+", "-")
285            .with_expanded(false);
286        let mut buf = ScreenBuffer::new(Size::new(20, 3));
287        c.render(Rect::new(0, 0, 20, 3), &mut buf);
288
289        let row0: String = (0..20)
290            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
291            .collect();
292        assert!(row0.contains("+"));
293    }
294
295    #[test]
296    fn enter_toggles() {
297        let mut c = Collapsible::new("T");
298        let result = c.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Enter)));
299        assert_eq!(result, EventResult::Consumed);
300        assert!(c.is_expanded());
301    }
302
303    #[test]
304    fn left_collapses_right_expands() {
305        let mut c = Collapsible::new("T").with_expanded(true);
306
307        c.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Left)));
308        assert!(!c.is_expanded());
309
310        c.handle_event(&Event::Key(KeyEvent::plain(KeyCode::Right)));
311        assert!(c.is_expanded());
312    }
313
314    #[test]
315    fn empty_content_when_expanded() {
316        let c = Collapsible::new("Empty").with_expanded(true);
317        let mut buf = ScreenBuffer::new(Size::new(20, 5));
318        c.render(Rect::new(0, 0, 20, 5), &mut buf);
319        // Should render title only, no panic
320        let row0: String = (0..20)
321            .map(|x| buf.get(x, 0).map(|c| c.grapheme.as_str()).unwrap_or(" "))
322            .collect();
323        assert!(row0.contains("Empty"));
324    }
325
326    #[test]
327    fn border_rendering() {
328        let c = Collapsible::new("B").with_border(BorderStyle::Single);
329        let mut buf = ScreenBuffer::new(Size::new(20, 5));
330        c.render(Rect::new(0, 0, 20, 5), &mut buf);
331
332        assert_eq!(buf.get(0, 0).unwrap().grapheme, "┌");
333    }
334}