1use 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
17pub struct Collapsible {
22 title: String,
24 content: Vec<Vec<Segment>>,
26 expanded: bool,
28 title_style: Style,
30 content_style: Style,
32 border: BorderStyle,
34 indicators: (&'static str, &'static str),
36}
37
38impl Collapsible {
39 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}"), }
50 }
51
52 #[must_use]
54 pub fn with_content(mut self, content: Vec<Vec<Segment>>) -> Self {
55 self.content = content;
56 self
57 }
58
59 #[must_use]
61 pub fn with_expanded(mut self, expanded: bool) -> Self {
62 self.expanded = expanded;
63 self
64 }
65
66 #[must_use]
68 pub fn with_title_style(mut self, style: Style) -> Self {
69 self.title_style = style;
70 self
71 }
72
73 #[must_use]
75 pub fn with_content_style(mut self, style: Style) -> Self {
76 self.content_style = style;
77 self
78 }
79
80 #[must_use]
82 pub fn with_border(mut self, border: BorderStyle) -> Self {
83 self.border = border;
84 self
85 }
86
87 #[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 pub fn toggle(&mut self) {
96 self.expanded = !self.expanded;
97 }
98
99 pub fn set_expanded(&mut self, expanded: bool) {
101 self.expanded = expanded;
102 }
103
104 pub fn is_expanded(&self) -> bool {
106 self.expanded
107 }
108
109 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 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 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 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}")); 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}")); 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 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}