Skip to main content

ratatui_interact/components/
accordion.rs

1//! Accordion widget
2//!
3//! A flexible, reusable accordion widget with collapsible/expandable sections.
4//! Supports both single-expand (true accordion) and multiple-expand modes.
5//!
6//! # Example
7//!
8//! ```rust
9//! use ratatui_interact::components::{Accordion, AccordionState, AccordionStyle, AccordionMode};
10//! use ratatui::layout::Rect;
11//! use ratatui::buffer::Buffer;
12//! use ratatui::text::Line;
13//!
14//! // Define your accordion items
15//! #[derive(Debug)]
16//! struct FaqItem {
17//!     id: String,
18//!     question: String,
19//!     answer: String,
20//! }
21//!
22//! let items = vec![
23//!     FaqItem {
24//!         id: "1".into(),
25//!         question: "What is ratatui?".into(),
26//!         answer: "A Rust library for building terminal UIs.".into(),
27//!     },
28//!     FaqItem {
29//!         id: "2".into(),
30//!         question: "How do I install it?".into(),
31//!         answer: "Add ratatui to your Cargo.toml.".into(),
32//!     },
33//! ];
34//!
35//! // Create state (single mode = only one expanded at a time)
36//! let mut state = AccordionState::new(items.len()).with_mode(AccordionMode::Single);
37//!
38//! // Create the accordion widget
39//! let accordion = Accordion::new(&items, &state)
40//!     .id_fn(|item, _| item.id.clone())
41//!     .render_header(|item, _idx, is_focused| {
42//!         Line::raw(item.question.clone())
43//!     })
44//!     .render_content(|item, _idx, area, buf| {
45//!         // Render answer content here
46//!     });
47//! ```
48
49use std::collections::HashSet;
50
51use ratatui::{
52    buffer::Buffer,
53    layout::Rect,
54    style::{Color, Modifier, Style},
55    text::{Line, Span},
56    widgets::{Paragraph, Widget},
57};
58
59/// Expansion mode for the accordion
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum AccordionMode {
62    /// Only one section can be expanded at a time (traditional accordion)
63    Single,
64    /// Any number of sections can be expanded simultaneously
65    #[default]
66    Multiple,
67}
68
69/// State for the accordion widget
70#[derive(Debug, Clone)]
71pub struct AccordionState {
72    /// Set of expanded item IDs
73    pub expanded: HashSet<String>,
74    /// Currently focused item index
75    pub focused_index: usize,
76    /// Total number of items
77    pub total_items: usize,
78    /// Expansion mode
79    pub mode: AccordionMode,
80    /// Scroll offset for when content exceeds viewport
81    pub scroll: u16,
82}
83
84impl AccordionState {
85    /// Create a new accordion state
86    pub fn new(total_items: usize) -> Self {
87        Self {
88            expanded: HashSet::new(),
89            focused_index: 0,
90            total_items,
91            mode: AccordionMode::Multiple,
92            scroll: 0,
93        }
94    }
95
96    /// Set the expansion mode
97    pub fn with_mode(mut self, mode: AccordionMode) -> Self {
98        self.mode = mode;
99        self
100    }
101
102    /// Set initially expanded items
103    pub fn with_expanded(mut self, ids: impl IntoIterator<Item = String>) -> Self {
104        match self.mode {
105            AccordionMode::Multiple => {
106                self.expanded = ids.into_iter().collect();
107            }
108            AccordionMode::Single => {
109                // In single mode, only keep the last item
110                if let Some(id) = ids.into_iter().last() {
111                    self.expanded.clear();
112                    self.expanded.insert(id);
113                }
114            }
115        }
116        self
117    }
118
119    /// Toggle the expansion state of an item
120    pub fn toggle(&mut self, id: &str) {
121        if self.expanded.contains(id) {
122            self.expanded.remove(id);
123        } else {
124            match self.mode {
125                AccordionMode::Single => {
126                    // Collapse all others first
127                    self.expanded.clear();
128                    self.expanded.insert(id.to_string());
129                }
130                AccordionMode::Multiple => {
131                    self.expanded.insert(id.to_string());
132                }
133            }
134        }
135    }
136
137    /// Expand an item
138    pub fn expand(&mut self, id: &str) {
139        match self.mode {
140            AccordionMode::Single => {
141                self.expanded.clear();
142                self.expanded.insert(id.to_string());
143            }
144            AccordionMode::Multiple => {
145                self.expanded.insert(id.to_string());
146            }
147        }
148    }
149
150    /// Collapse an item
151    pub fn collapse(&mut self, id: &str) {
152        self.expanded.remove(id);
153    }
154
155    /// Check if an item is expanded
156    pub fn is_expanded(&self, id: &str) -> bool {
157        self.expanded.contains(id)
158    }
159
160    /// Expand all items (only effective in Multiple mode)
161    pub fn expand_all(&mut self, ids: impl Iterator<Item = String>) {
162        match self.mode {
163            AccordionMode::Single => {
164                // In single mode, expand only the last
165                if let Some(id) = ids.last() {
166                    self.expanded.clear();
167                    self.expanded.insert(id);
168                }
169            }
170            AccordionMode::Multiple => {
171                for id in ids {
172                    self.expanded.insert(id);
173                }
174            }
175        }
176    }
177
178    /// Collapse all items
179    pub fn collapse_all(&mut self) {
180        self.expanded.clear();
181    }
182
183    /// Move focus to the next item
184    pub fn focus_next(&mut self) {
185        if self.focused_index + 1 < self.total_items {
186            self.focused_index += 1;
187        }
188    }
189
190    /// Move focus to the previous item
191    pub fn focus_prev(&mut self) {
192        self.focused_index = self.focused_index.saturating_sub(1);
193    }
194
195    /// Set focus to a specific index
196    pub fn focus(&mut self, index: usize) {
197        if index < self.total_items {
198            self.focused_index = index;
199        }
200    }
201
202    /// Get the currently focused index
203    pub fn focused_index(&self) -> usize {
204        self.focused_index
205    }
206
207    /// Update the total items count
208    pub fn set_total_items(&mut self, total: usize) {
209        self.total_items = total;
210        if self.focused_index >= total && total > 0 {
211            self.focused_index = total - 1;
212        }
213    }
214
215    /// Ensure focused item is visible in viewport
216    pub fn ensure_visible(&mut self, viewport_height: u16, item_heights: &[u16]) {
217        // Calculate the Y position of the focused item
218        let mut y_pos: u16 = 0;
219        let mut focused_start: u16 = 0;
220        let mut focused_height: u16 = 1;
221
222        for (idx, &height) in item_heights.iter().enumerate() {
223            if idx == self.focused_index {
224                focused_start = y_pos;
225                focused_height = height;
226                break;
227            }
228            y_pos += height;
229        }
230
231        // Scroll up if focused item is above viewport
232        if focused_start < self.scroll {
233            self.scroll = focused_start;
234        }
235        // Scroll down if focused item is below viewport
236        else if focused_start + focused_height > self.scroll + viewport_height {
237            self.scroll = (focused_start + focused_height).saturating_sub(viewport_height);
238        }
239    }
240}
241
242impl Default for AccordionState {
243    fn default() -> Self {
244        Self::new(0)
245    }
246}
247
248/// Style configuration for accordion
249#[derive(Debug, Clone)]
250pub struct AccordionStyle {
251    /// Style for headers
252    pub header_style: Style,
253    /// Style for focused headers
254    pub header_focused_style: Style,
255    /// Style for content
256    pub content_style: Style,
257    /// Icon for expanded items
258    pub expanded_icon: &'static str,
259    /// Icon for collapsed items
260    pub collapsed_icon: &'static str,
261    /// Style for borders
262    pub border_style: Style,
263    /// Whether to show borders between items
264    pub show_borders: bool,
265    /// Indentation for content (in characters)
266    pub content_indent: u16,
267    /// Style for icons
268    pub icon_style: Style,
269}
270
271impl Default for AccordionStyle {
272    fn default() -> Self {
273        Self {
274            header_style: Style::default().fg(Color::White),
275            header_focused_style: Style::default()
276                .fg(Color::Yellow)
277                .add_modifier(Modifier::BOLD),
278            content_style: Style::default().fg(Color::Gray),
279            expanded_icon: "▼ ",
280            collapsed_icon: "▶ ",
281            border_style: Style::default().fg(Color::DarkGray),
282            show_borders: false,
283            content_indent: 2,
284            icon_style: Style::default().fg(Color::Cyan),
285        }
286    }
287}
288
289impl From<&crate::theme::Theme> for AccordionStyle {
290    fn from(theme: &crate::theme::Theme) -> Self {
291        let p = &theme.palette;
292        Self {
293            header_style: Style::default().fg(p.text),
294            header_focused_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
295            content_style: Style::default().fg(p.text_dim),
296            expanded_icon: "▼ ",
297            collapsed_icon: "▶ ",
298            border_style: Style::default().fg(p.border_disabled),
299            show_borders: false,
300            content_indent: 2,
301            icon_style: Style::default().fg(p.secondary),
302        }
303    }
304}
305
306impl AccordionStyle {
307    /// Create a minimal style without icons
308    pub fn minimal() -> Self {
309        Self {
310            expanded_icon: "- ",
311            collapsed_icon: "+ ",
312            ..Default::default()
313        }
314    }
315
316    /// Create a bordered style
317    pub fn bordered() -> Self {
318        Self {
319            show_borders: true,
320            ..Default::default()
321        }
322    }
323
324    /// Set the header style
325    pub fn header_style(mut self, style: Style) -> Self {
326        self.header_style = style;
327        self
328    }
329
330    /// Set the focused header style
331    pub fn header_focused_style(mut self, style: Style) -> Self {
332        self.header_focused_style = style;
333        self
334    }
335
336    /// Set the content style
337    pub fn content_style(mut self, style: Style) -> Self {
338        self.content_style = style;
339        self
340    }
341
342    /// Set the expanded icon
343    pub fn expanded_icon(mut self, icon: &'static str) -> Self {
344        self.expanded_icon = icon;
345        self
346    }
347
348    /// Set the collapsed icon
349    pub fn collapsed_icon(mut self, icon: &'static str) -> Self {
350        self.collapsed_icon = icon;
351        self
352    }
353
354    /// Set the icon style
355    pub fn icon_style(mut self, style: Style) -> Self {
356        self.icon_style = style;
357        self
358    }
359
360    /// Set content indentation
361    pub fn content_indent(mut self, indent: u16) -> Self {
362        self.content_indent = indent;
363        self
364    }
365
366    /// Set whether to show borders
367    pub fn show_borders(mut self, show: bool) -> Self {
368        self.show_borders = show;
369        self
370    }
371}
372
373/// Accordion widget with collapsible sections
374pub struct Accordion<'a, T, H, C, I>
375where
376    H: Fn(&T, usize, bool) -> Line<'static>,
377    C: Fn(&T, usize, Rect, &mut Buffer),
378    I: Fn(&T, usize) -> String,
379{
380    items: &'a [T],
381    state: &'a AccordionState,
382    style: AccordionStyle,
383    render_header: H,
384    render_content: C,
385    id_fn: I,
386    content_heights: Option<&'a [u16]>,
387}
388
389impl<'a, T>
390    Accordion<
391        'a,
392        T,
393        fn(&T, usize, bool) -> Line<'static>,
394        fn(&T, usize, Rect, &mut Buffer),
395        fn(&T, usize) -> String,
396    >
397{
398    /// Create a new accordion with default rendering
399    #[allow(clippy::type_complexity)]
400    pub fn new(
401        items: &'a [T],
402        state: &'a AccordionState,
403    ) -> Accordion<
404        'a,
405        T,
406        fn(&T, usize, bool) -> Line<'static>,
407        fn(&T, usize, Rect, &mut Buffer),
408        fn(&T, usize) -> String,
409    >
410    where
411        T: std::fmt::Debug,
412    {
413        Accordion {
414            items,
415            state,
416            style: AccordionStyle::default(),
417            render_header: |_item, idx, _focused| Line::raw(format!("Item {}", idx)),
418            render_content: |_item, _idx, _area, _buf| {},
419            id_fn: |_item, idx| idx.to_string(),
420            content_heights: None,
421        }
422    }
423}
424
425impl<'a, T, H, C, I> Accordion<'a, T, H, C, I>
426where
427    H: Fn(&T, usize, bool) -> Line<'static>,
428    C: Fn(&T, usize, Rect, &mut Buffer),
429    I: Fn(&T, usize) -> String,
430{
431    /// Set the ID extraction function
432    pub fn id_fn<I2>(self, id_fn: I2) -> Accordion<'a, T, H, C, I2>
433    where
434        I2: Fn(&T, usize) -> String,
435    {
436        Accordion {
437            items: self.items,
438            state: self.state,
439            style: self.style,
440            render_header: self.render_header,
441            render_content: self.render_content,
442            id_fn,
443            content_heights: self.content_heights,
444        }
445    }
446
447    /// Set the header render function
448    pub fn render_header<H2>(self, render_header: H2) -> Accordion<'a, T, H2, C, I>
449    where
450        H2: Fn(&T, usize, bool) -> Line<'static>,
451    {
452        Accordion {
453            items: self.items,
454            state: self.state,
455            style: self.style,
456            render_header,
457            render_content: self.render_content,
458            id_fn: self.id_fn,
459            content_heights: self.content_heights,
460        }
461    }
462
463    /// Set the content render function
464    pub fn render_content<C2>(self, render_content: C2) -> Accordion<'a, T, H, C2, I>
465    where
466        C2: Fn(&T, usize, Rect, &mut Buffer),
467    {
468        Accordion {
469            items: self.items,
470            state: self.state,
471            style: self.style,
472            render_header: self.render_header,
473            render_content,
474            id_fn: self.id_fn,
475            content_heights: self.content_heights,
476        }
477    }
478
479    /// Set the style
480    pub fn style(mut self, style: AccordionStyle) -> Self {
481        self.style = style;
482        self
483    }
484
485    /// Apply a theme to derive the style
486    pub fn theme(self, theme: &crate::theme::Theme) -> Self {
487        self.style(AccordionStyle::from(theme))
488    }
489
490    /// Set content heights for proper scrolling
491    pub fn content_heights(mut self, heights: &'a [u16]) -> Self {
492        self.content_heights = Some(heights);
493        self
494    }
495
496    /// Get the ID for an item at the given index
497    fn get_id(&self, item: &T, idx: usize) -> String {
498        (self.id_fn)(item, idx)
499    }
500
501    /// Calculate heights for all items (useful for scrolling calculations)
502    pub fn calculate_item_heights(&self) -> Vec<u16> {
503        self.items
504            .iter()
505            .enumerate()
506            .map(|(idx, item)| {
507                let id = self.get_id(item, idx);
508                let header_height = 1u16;
509                let content_height = if self.state.is_expanded(&id) {
510                    self.content_heights
511                        .and_then(|h| h.get(idx).copied())
512                        .unwrap_or(3) // Default content height
513                } else {
514                    0
515                };
516                let border_height = if self.style.show_borders { 1 } else { 0 };
517                header_height + content_height + border_height
518            })
519            .collect()
520    }
521}
522
523impl<'a, T, H, C, I> Widget for Accordion<'a, T, H, C, I>
524where
525    H: Fn(&T, usize, bool) -> Line<'static>,
526    C: Fn(&T, usize, Rect, &mut Buffer),
527    I: Fn(&T, usize) -> String,
528{
529    fn render(self, area: Rect, buf: &mut Buffer) {
530        if area.width == 0 || area.height == 0 {
531            return;
532        }
533
534        let mut y = area.y;
535        let scroll = self.state.scroll;
536        let mut current_y: u16 = 0;
537
538        for (idx, item) in self.items.iter().enumerate() {
539            let id = self.get_id(item, idx);
540            let is_expanded = self.state.is_expanded(&id);
541            let is_focused = idx == self.state.focused_index;
542
543            // Calculate item height
544            let content_height = if is_expanded {
545                self.content_heights
546                    .and_then(|h| h.get(idx).copied())
547                    .unwrap_or(3)
548            } else {
549                0
550            };
551            let header_height = 1u16;
552            let item_height = header_height + content_height;
553
554            // Skip items above scroll position
555            if current_y + item_height <= scroll {
556                current_y += item_height;
557                continue;
558            }
559
560            // Stop if we've filled the area
561            if y >= area.y + area.height {
562                break;
563            }
564
565            // Calculate visible portion
566            let skip_lines = scroll.saturating_sub(current_y);
567            let available_height = (area.y + area.height).saturating_sub(y);
568
569            // Render header (if visible)
570            if skip_lines == 0 && available_height > 0 {
571                let header_area = Rect::new(area.x, y, area.width, 1);
572
573                // Build header line with icon
574                let icon = if is_expanded {
575                    self.style.expanded_icon
576                } else {
577                    self.style.collapsed_icon
578                };
579
580                let header_line = (self.render_header)(item, idx, is_focused);
581                let style = if is_focused {
582                    self.style.header_focused_style
583                } else {
584                    self.style.header_style
585                };
586
587                // Render icon
588                let icon_span = Span::styled(icon.to_string(), self.style.icon_style);
589                let mut spans = vec![icon_span];
590                spans.extend(
591                    header_line
592                        .spans
593                        .into_iter()
594                        .map(|s| Span::styled(s.content, style)),
595                );
596
597                let line = Line::from(spans);
598                let paragraph = Paragraph::new(line);
599                paragraph.render(header_area, buf);
600
601                y += 1;
602            } else if skip_lines > 0 {
603                // Header is above scroll, skip it
604            }
605
606            // Render content (if expanded and visible)
607            if is_expanded && y < area.y + area.height {
608                let content_start_in_item = header_height;
609                let content_skip = skip_lines.saturating_sub(content_start_in_item);
610                let content_available = (area.y + area.height)
611                    .saturating_sub(y)
612                    .min(content_height.saturating_sub(content_skip));
613
614                if content_available > 0 {
615                    let indent = self.style.content_indent;
616                    let content_area = Rect::new(
617                        area.x + indent,
618                        y,
619                        area.width.saturating_sub(indent),
620                        content_available,
621                    );
622                    (self.render_content)(item, idx, content_area, buf);
623                    y += content_available;
624                }
625            }
626
627            // Render border (if enabled)
628            if self.style.show_borders && y < area.y + area.height {
629                let border_char = "─";
630                for x in area.x..area.x + area.width {
631                    buf.set_string(x, y, border_char, self.style.border_style);
632                }
633                y += 1;
634            }
635
636            current_y += item_height;
637        }
638    }
639}
640
641/// Calculate the total height needed for an accordion
642pub fn calculate_height<T, I>(
643    items: &[T],
644    state: &AccordionState,
645    id_fn: I,
646    content_heights: &[u16],
647    show_borders: bool,
648) -> u16
649where
650    I: Fn(&T, usize) -> String,
651{
652    items
653        .iter()
654        .enumerate()
655        .map(|(idx, item)| {
656            let id = id_fn(item, idx);
657            let header_height = 1u16;
658            let content_height = if state.is_expanded(&id) {
659                content_heights.get(idx).copied().unwrap_or(3)
660            } else {
661                0
662            };
663            let border_height = if show_borders { 1 } else { 0 };
664            header_height + content_height + border_height
665        })
666        .sum()
667}
668
669/// Handle keyboard input for accordion navigation
670pub fn handle_accordion_key(
671    state: &mut AccordionState,
672    key: &crossterm::event::KeyEvent,
673    get_id: impl Fn(usize) -> String,
674) -> bool {
675    use crossterm::event::KeyCode;
676
677    match key.code {
678        KeyCode::Up | KeyCode::Char('k') => {
679            state.focus_prev();
680            true
681        }
682        KeyCode::Down | KeyCode::Char('j') => {
683            state.focus_next();
684            true
685        }
686        KeyCode::Enter | KeyCode::Char(' ') => {
687            let id = get_id(state.focused_index);
688            state.toggle(&id);
689            true
690        }
691        KeyCode::Home => {
692            state.focus(0);
693            true
694        }
695        KeyCode::End => {
696            if state.total_items > 0 {
697                state.focus(state.total_items - 1);
698            }
699            true
700        }
701        _ => false,
702    }
703}
704
705/// Handle mouse click for accordion
706pub fn handle_accordion_mouse(
707    state: &mut AccordionState,
708    mouse: &crossterm::event::MouseEvent,
709    item_areas: &[(usize, Rect, String)], // (index, header_area, id)
710) -> bool {
711    use crossterm::event::MouseEventKind;
712
713    if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind {
714        for (idx, area, id) in item_areas {
715            if mouse.column >= area.x
716                && mouse.column < area.x + area.width
717                && mouse.row >= area.y
718                && mouse.row < area.y + area.height
719            {
720                state.focus(*idx);
721                state.toggle(id);
722                return true;
723            }
724        }
725    }
726    false
727}
728
729#[cfg(test)]
730mod tests {
731    use super::*;
732
733    #[test]
734    fn test_accordion_state_new() {
735        let state = AccordionState::new(5);
736        assert_eq!(state.total_items, 5);
737        assert_eq!(state.focused_index, 0);
738        assert!(state.expanded.is_empty());
739        assert_eq!(state.mode, AccordionMode::Multiple);
740    }
741
742    #[test]
743    fn test_accordion_state_toggle() {
744        let mut state = AccordionState::new(3);
745
746        state.toggle("item1");
747        assert!(state.is_expanded("item1"));
748
749        state.toggle("item1");
750        assert!(!state.is_expanded("item1"));
751    }
752
753    #[test]
754    fn test_accordion_state_single_mode() {
755        let mut state = AccordionState::new(3).with_mode(AccordionMode::Single);
756
757        state.expand("item1");
758        assert!(state.is_expanded("item1"));
759
760        state.expand("item2");
761        assert!(!state.is_expanded("item1"));
762        assert!(state.is_expanded("item2"));
763    }
764
765    #[test]
766    fn test_accordion_state_multiple_mode() {
767        let mut state = AccordionState::new(3).with_mode(AccordionMode::Multiple);
768
769        state.expand("item1");
770        state.expand("item2");
771
772        assert!(state.is_expanded("item1"));
773        assert!(state.is_expanded("item2"));
774    }
775
776    #[test]
777    fn test_accordion_state_expand_collapse() {
778        let mut state = AccordionState::new(3);
779
780        state.expand("item1");
781        assert!(state.is_expanded("item1"));
782
783        state.collapse("item1");
784        assert!(!state.is_expanded("item1"));
785    }
786
787    #[test]
788    fn test_accordion_state_navigation() {
789        let mut state = AccordionState::new(5);
790
791        assert_eq!(state.focused_index(), 0);
792
793        state.focus_next();
794        assert_eq!(state.focused_index(), 1);
795
796        state.focus_next();
797        assert_eq!(state.focused_index(), 2);
798
799        state.focus_prev();
800        assert_eq!(state.focused_index(), 1);
801
802        state.focus(4);
803        assert_eq!(state.focused_index(), 4);
804
805        // Should not go past last item
806        state.focus_next();
807        assert_eq!(state.focused_index(), 4);
808
809        // Should not go below 0
810        state.focus(0);
811        state.focus_prev();
812        assert_eq!(state.focused_index(), 0);
813    }
814
815    #[test]
816    fn test_accordion_state_collapse_all() {
817        let mut state = AccordionState::new(3).with_mode(AccordionMode::Multiple);
818
819        state.expand("item1");
820        state.expand("item2");
821        state.expand("item3");
822
823        assert_eq!(state.expanded.len(), 3);
824
825        state.collapse_all();
826        assert!(state.expanded.is_empty());
827    }
828
829    #[test]
830    fn test_accordion_style_default() {
831        let style = AccordionStyle::default();
832        assert_eq!(style.expanded_icon, "▼ ");
833        assert_eq!(style.collapsed_icon, "▶ ");
834        assert!(!style.show_borders);
835        assert_eq!(style.content_indent, 2);
836    }
837
838    #[test]
839    fn test_accordion_style_minimal() {
840        let style = AccordionStyle::minimal();
841        assert_eq!(style.expanded_icon, "- ");
842        assert_eq!(style.collapsed_icon, "+ ");
843    }
844
845    #[test]
846    fn test_accordion_render_collapsed() {
847        #[derive(Debug)]
848        struct Item {
849            id: String,
850            title: String,
851        }
852
853        let items = vec![
854            Item {
855                id: "1".into(),
856                title: "First".into(),
857            },
858            Item {
859                id: "2".into(),
860                title: "Second".into(),
861            },
862        ];
863        let state = AccordionState::new(items.len());
864
865        let accordion = Accordion::new(&items, &state)
866            .id_fn(|item, _| item.id.clone())
867            .render_header(|item, _, _| Line::raw(item.title.clone()))
868            .render_content(|_, _, _, _| {});
869
870        let area = Rect::new(0, 0, 20, 10);
871        let mut buf = Buffer::empty(area);
872        accordion.render(area, &mut buf);
873
874        // Check that first line contains the collapsed icon and title
875        let line0 = buf
876            .content
877            .iter()
878            .take(20)
879            .map(|c| c.symbol())
880            .collect::<String>();
881        assert!(line0.contains("▶"));
882        assert!(line0.contains("First"));
883    }
884
885    #[test]
886    fn test_accordion_render_expanded() {
887        #[derive(Debug)]
888        struct Item {
889            id: String,
890            title: String,
891        }
892
893        let items = vec![
894            Item {
895                id: "1".into(),
896                title: "First".into(),
897            },
898            Item {
899                id: "2".into(),
900                title: "Second".into(),
901            },
902        ];
903        let mut state = AccordionState::new(items.len());
904        state.expand("1");
905
906        let accordion = Accordion::new(&items, &state)
907            .id_fn(|item, _| item.id.clone())
908            .render_header(|item, _, _| Line::raw(item.title.clone()))
909            .render_content(|_, _, area, buf| {
910                let text = Paragraph::new("Content here");
911                text.render(area, buf);
912            })
913            .content_heights(&[2, 2]);
914
915        let area = Rect::new(0, 0, 20, 10);
916        let mut buf = Buffer::empty(area);
917        accordion.render(area, &mut buf);
918
919        // Check that first line contains the expanded icon
920        let line0 = buf
921            .content
922            .iter()
923            .take(20)
924            .map(|c| c.symbol())
925            .collect::<String>();
926        assert!(line0.contains("▼"));
927        assert!(line0.contains("First"));
928
929        // Content should be rendered below the header
930        let line1 = buf
931            .content
932            .iter()
933            .skip(20)
934            .take(20)
935            .map(|c| c.symbol())
936            .collect::<String>();
937        assert!(line1.contains("Content"));
938    }
939
940    #[test]
941    fn test_calculate_height() {
942        #[derive(Debug)]
943        struct Item {
944            id: String,
945        }
946
947        let items = vec![
948            Item { id: "1".into() },
949            Item { id: "2".into() },
950            Item { id: "3".into() },
951        ];
952        let mut state = AccordionState::new(items.len());
953        let content_heights = vec![3u16, 5, 2];
954
955        // All collapsed: 3 headers = 3
956        let height = calculate_height(
957            &items,
958            &state,
959            |item, _| item.id.clone(),
960            &content_heights,
961            false,
962        );
963        assert_eq!(height, 3);
964
965        // One expanded: 3 headers + 3 content = 6
966        state.expand("1");
967        let height = calculate_height(
968            &items,
969            &state,
970            |item, _| item.id.clone(),
971            &content_heights,
972            false,
973        );
974        assert_eq!(height, 6);
975
976        // Two expanded: 3 headers + 3 + 5 = 11
977        state.expand("2");
978        let height = calculate_height(
979            &items,
980            &state,
981            |item, _| item.id.clone(),
982            &content_heights,
983            false,
984        );
985        assert_eq!(height, 11);
986
987        // With borders: 11 + 3 borders = 14
988        let height = calculate_height(
989            &items,
990            &state,
991            |item, _| item.id.clone(),
992            &content_heights,
993            true,
994        );
995        assert_eq!(height, 14);
996    }
997}