envision/component/
accordion.rs

1//! An accordion component with collapsible panels.
2//!
3//! `Accordion` provides a vertically stacked list of panels that can be
4//! expanded or collapsed. Multiple panels can be open simultaneously,
5//! and keyboard navigation is supported.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{Accordion, AccordionMessage, AccordionOutput, AccordionPanel, AccordionState, Component, Focusable};
11//!
12//! // Create panels
13//! let panels = vec![
14//!     AccordionPanel::new("Getting Started", "Welcome to the app..."),
15//!     AccordionPanel::new("Configuration", "Set up your preferences..."),
16//!     AccordionPanel::new("FAQ", "Frequently asked questions..."),
17//! ];
18//!
19//! let mut state = AccordionState::new(panels);
20//! Accordion::focus(&mut state);
21//!
22//! // Toggle first panel (expands it)
23//! let output = Accordion::update(&mut state, AccordionMessage::Toggle);
24//! assert_eq!(output, Some(AccordionOutput::Expanded(0)));
25//! assert!(state.panels()[0].is_expanded());
26//!
27//! // Navigate to next panel and toggle
28//! Accordion::update(&mut state, AccordionMessage::Next);
29//! Accordion::update(&mut state, AccordionMessage::Toggle);
30//! // Now panels 0 and 1 are both expanded
31//! ```
32
33use ratatui::prelude::*;
34use ratatui::widgets::Paragraph;
35
36use super::{Component, Focusable};
37
38/// A single accordion panel with a title and content.
39///
40/// Panels can be created collapsed (default) or expanded using the builder method.
41///
42/// # Example
43///
44/// ```rust
45/// use envision::component::AccordionPanel;
46///
47/// // Create a collapsed panel
48/// let panel = AccordionPanel::new("Title", "Content here");
49/// assert!(!panel.is_expanded());
50///
51/// // Create an expanded panel
52/// let panel = AccordionPanel::new("Title", "Content").expanded();
53/// assert!(panel.is_expanded());
54/// ```
55#[derive(Clone, Debug)]
56pub struct AccordionPanel {
57    /// The panel header/title.
58    title: String,
59    /// The panel content.
60    content: String,
61    /// Whether this panel is expanded.
62    expanded: bool,
63}
64
65impl AccordionPanel {
66    /// Creates a new collapsed panel with the given title and content.
67    ///
68    /// # Example
69    ///
70    /// ```rust
71    /// use envision::component::AccordionPanel;
72    ///
73    /// let panel = AccordionPanel::new("Section 1", "Content for section 1");
74    /// assert_eq!(panel.title(), "Section 1");
75    /// assert_eq!(panel.content(), "Content for section 1");
76    /// assert!(!panel.is_expanded());
77    /// ```
78    pub fn new(title: impl Into<String>, content: impl Into<String>) -> Self {
79        Self {
80            title: title.into(),
81            content: content.into(),
82            expanded: false,
83        }
84    }
85
86    /// Sets the panel to be expanded (builder method).
87    ///
88    /// # Example
89    ///
90    /// ```rust
91    /// use envision::component::AccordionPanel;
92    ///
93    /// let panel = AccordionPanel::new("Title", "Content").expanded();
94    /// assert!(panel.is_expanded());
95    /// ```
96    pub fn expanded(mut self) -> Self {
97        self.expanded = true;
98        self
99    }
100
101    /// Returns the panel title.
102    pub fn title(&self) -> &str {
103        &self.title
104    }
105
106    /// Returns the panel content.
107    pub fn content(&self) -> &str {
108        &self.content
109    }
110
111    /// Returns whether the panel is expanded.
112    pub fn is_expanded(&self) -> bool {
113        self.expanded
114    }
115}
116
117/// Messages that can be sent to an Accordion.
118#[derive(Clone, Debug, PartialEq, Eq)]
119pub enum AccordionMessage {
120    /// Move focus to the next panel.
121    Next,
122    /// Move focus to the previous panel.
123    Previous,
124    /// Jump to the first panel.
125    First,
126    /// Jump to the last panel.
127    Last,
128    /// Toggle the currently focused panel.
129    Toggle,
130    /// Expand the currently focused panel.
131    Expand,
132    /// Collapse the currently focused panel.
133    Collapse,
134    /// Toggle a specific panel by index.
135    ToggleIndex(usize),
136    /// Expand all panels.
137    ExpandAll,
138    /// Collapse all panels.
139    CollapseAll,
140}
141
142/// Output messages from an Accordion.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub enum AccordionOutput {
145    /// A panel was expanded (index).
146    Expanded(usize),
147    /// A panel was collapsed (index).
148    Collapsed(usize),
149    /// Focus moved to a panel (index).
150    FocusChanged(usize),
151}
152
153/// State for an Accordion component.
154#[derive(Clone, Debug, Default)]
155pub struct AccordionState {
156    /// The accordion panels.
157    panels: Vec<AccordionPanel>,
158    /// Currently focused panel index.
159    focused_index: usize,
160    /// Whether the component is focused.
161    focused: bool,
162    /// Whether the component is disabled.
163    disabled: bool,
164}
165
166impl AccordionState {
167    /// Creates a new accordion with the given panels.
168    ///
169    /// # Example
170    ///
171    /// ```rust
172    /// use envision::component::{AccordionPanel, AccordionState};
173    ///
174    /// let panels = vec![
175    ///     AccordionPanel::new("Section 1", "Content 1"),
176    ///     AccordionPanel::new("Section 2", "Content 2"),
177    /// ];
178    /// let state = AccordionState::new(panels);
179    /// assert_eq!(state.len(), 2);
180    /// assert_eq!(state.focused_index(), 0);
181    /// ```
182    pub fn new(panels: Vec<AccordionPanel>) -> Self {
183        Self {
184            panels,
185            focused_index: 0,
186            focused: false,
187            disabled: false,
188        }
189    }
190
191    /// Creates an accordion from title/content pairs.
192    ///
193    /// All panels start collapsed.
194    ///
195    /// # Example
196    ///
197    /// ```rust
198    /// use envision::component::AccordionState;
199    ///
200    /// let state = AccordionState::from_pairs(vec![
201    ///     ("Section 1", "Content 1"),
202    ///     ("Section 2", "Content 2"),
203    /// ]);
204    /// assert_eq!(state.len(), 2);
205    /// ```
206    pub fn from_pairs<S: Into<String>, T: Into<String>>(pairs: Vec<(S, T)>) -> Self {
207        let panels = pairs
208            .into_iter()
209            .map(|(title, content)| AccordionPanel::new(title, content))
210            .collect();
211        Self::new(panels)
212    }
213
214    /// Returns the panels slice.
215    pub fn panels(&self) -> &[AccordionPanel] {
216        &self.panels
217    }
218
219    /// Returns the number of panels.
220    pub fn len(&self) -> usize {
221        self.panels.len()
222    }
223
224    /// Returns true if there are no panels.
225    pub fn is_empty(&self) -> bool {
226        self.panels.is_empty()
227    }
228
229    /// Returns the currently focused panel index.
230    pub fn focused_index(&self) -> usize {
231        self.focused_index
232    }
233
234    /// Returns the currently focused panel.
235    pub fn focused_panel(&self) -> Option<&AccordionPanel> {
236        self.panels.get(self.focused_index)
237    }
238
239    /// Returns whether the accordion is disabled.
240    pub fn is_disabled(&self) -> bool {
241        self.disabled
242    }
243
244    /// Sets new panels, resetting the focused index if needed.
245    pub fn set_panels(&mut self, panels: Vec<AccordionPanel>) {
246        self.panels = panels;
247        if self.focused_index >= self.panels.len() && !self.panels.is_empty() {
248            self.focused_index = 0;
249        }
250    }
251
252    /// Adds a panel to the accordion.
253    pub fn add_panel(&mut self, panel: AccordionPanel) {
254        self.panels.push(panel);
255    }
256
257    /// Sets the disabled state.
258    pub fn set_disabled(&mut self, disabled: bool) {
259        self.disabled = disabled;
260    }
261
262    /// Returns the count of expanded panels.
263    pub fn expanded_count(&self) -> usize {
264        self.panels.iter().filter(|p| p.expanded).count()
265    }
266
267    /// Returns true if any panel is expanded.
268    pub fn is_any_expanded(&self) -> bool {
269        self.panels.iter().any(|p| p.expanded)
270    }
271
272    /// Returns true if all panels are expanded.
273    pub fn is_all_expanded(&self) -> bool {
274        !self.panels.is_empty() && self.panels.iter().all(|p| p.expanded)
275    }
276}
277
278/// An accordion component with collapsible panels.
279///
280/// The accordion displays a vertical list of panels. Each panel has a header
281/// that can be clicked (or toggled via keyboard) to expand or collapse its
282/// content. Multiple panels can be expanded simultaneously.
283///
284/// # Keyboard Navigation
285///
286/// The accordion itself doesn't handle keyboard events directly. Your application
287/// should map:
288/// - Up arrow to [`AccordionMessage::Previous`]
289/// - Down arrow to [`AccordionMessage::Next`]
290/// - Enter/Space to [`AccordionMessage::Toggle`]
291/// - Home to [`AccordionMessage::First`]
292/// - End to [`AccordionMessage::Last`]
293///
294/// # Visual Layout
295///
296/// ```text
297/// ▼ Section 1            ← Focused, expanded
298///   Content for section 1...
299///   More content here.
300/// ▶ Section 2            ← Collapsed
301/// ▼ Section 3            ← Expanded
302///   Content for section 3...
303/// ```
304///
305/// # Example
306///
307/// ```rust
308/// use envision::component::{Accordion, AccordionMessage, AccordionPanel, AccordionState, Component};
309///
310/// let panels = vec![
311///     AccordionPanel::new("FAQ", "Frequently asked questions..."),
312///     AccordionPanel::new("Help", "How to get help..."),
313/// ];
314///
315/// let mut state = AccordionState::new(panels);
316///
317/// // Toggle first panel
318/// Accordion::update(&mut state, AccordionMessage::Toggle);
319/// assert!(state.panels()[0].is_expanded());
320///
321/// // Navigate and toggle second
322/// Accordion::update(&mut state, AccordionMessage::Next);
323/// Accordion::update(&mut state, AccordionMessage::Toggle);
324/// // Both panels are now expanded
325/// assert_eq!(state.expanded_count(), 2);
326/// ```
327pub struct Accordion;
328
329impl Component for Accordion {
330    type State = AccordionState;
331    type Message = AccordionMessage;
332    type Output = AccordionOutput;
333
334    fn init() -> Self::State {
335        AccordionState::default()
336    }
337
338    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
339        if state.disabled {
340            return None;
341        }
342
343        match msg {
344            AccordionMessage::Next => {
345                if !state.panels.is_empty() {
346                    state.focused_index = (state.focused_index + 1) % state.panels.len();
347                    Some(AccordionOutput::FocusChanged(state.focused_index))
348                } else {
349                    None
350                }
351            }
352            AccordionMessage::Previous => {
353                if !state.panels.is_empty() {
354                    if state.focused_index == 0 {
355                        state.focused_index = state.panels.len() - 1;
356                    } else {
357                        state.focused_index -= 1;
358                    }
359                    Some(AccordionOutput::FocusChanged(state.focused_index))
360                } else {
361                    None
362                }
363            }
364            AccordionMessage::First => {
365                if !state.panels.is_empty() && state.focused_index != 0 {
366                    state.focused_index = 0;
367                    Some(AccordionOutput::FocusChanged(0))
368                } else {
369                    None
370                }
371            }
372            AccordionMessage::Last => {
373                if !state.panels.is_empty() {
374                    let last = state.panels.len() - 1;
375                    if state.focused_index != last {
376                        state.focused_index = last;
377                        Some(AccordionOutput::FocusChanged(last))
378                    } else {
379                        None
380                    }
381                } else {
382                    None
383                }
384            }
385            AccordionMessage::Toggle => {
386                if let Some(panel) = state.panels.get_mut(state.focused_index) {
387                    panel.expanded = !panel.expanded;
388                    if panel.expanded {
389                        Some(AccordionOutput::Expanded(state.focused_index))
390                    } else {
391                        Some(AccordionOutput::Collapsed(state.focused_index))
392                    }
393                } else {
394                    None
395                }
396            }
397            AccordionMessage::Expand => {
398                if let Some(panel) = state.panels.get_mut(state.focused_index) {
399                    if !panel.expanded {
400                        panel.expanded = true;
401                        Some(AccordionOutput::Expanded(state.focused_index))
402                    } else {
403                        None
404                    }
405                } else {
406                    None
407                }
408            }
409            AccordionMessage::Collapse => {
410                if let Some(panel) = state.panels.get_mut(state.focused_index) {
411                    if panel.expanded {
412                        panel.expanded = false;
413                        Some(AccordionOutput::Collapsed(state.focused_index))
414                    } else {
415                        None
416                    }
417                } else {
418                    None
419                }
420            }
421            AccordionMessage::ToggleIndex(index) => {
422                if let Some(panel) = state.panels.get_mut(index) {
423                    panel.expanded = !panel.expanded;
424                    if panel.expanded {
425                        Some(AccordionOutput::Expanded(index))
426                    } else {
427                        Some(AccordionOutput::Collapsed(index))
428                    }
429                } else {
430                    None
431                }
432            }
433            AccordionMessage::ExpandAll => {
434                let mut any_changed = false;
435                for (i, panel) in state.panels.iter_mut().enumerate() {
436                    if !panel.expanded {
437                        panel.expanded = true;
438                        any_changed = true;
439                        // Return the first one that was expanded
440                        if !any_changed {
441                            return Some(AccordionOutput::Expanded(i));
442                        }
443                    }
444                }
445                if any_changed {
446                    // Return a general expanded signal for the first panel
447                    Some(AccordionOutput::Expanded(0))
448                } else {
449                    None
450                }
451            }
452            AccordionMessage::CollapseAll => {
453                let mut any_changed = false;
454                for (i, panel) in state.panels.iter_mut().enumerate() {
455                    if panel.expanded {
456                        panel.expanded = false;
457                        any_changed = true;
458                        if !any_changed {
459                            return Some(AccordionOutput::Collapsed(i));
460                        }
461                    }
462                }
463                if any_changed {
464                    Some(AccordionOutput::Collapsed(0))
465                } else {
466                    None
467                }
468            }
469        }
470    }
471
472    fn view(state: &Self::State, frame: &mut Frame, area: Rect) {
473        if state.panels.is_empty() {
474            return;
475        }
476
477        let mut y = area.y;
478
479        for (i, panel) in state.panels.iter().enumerate() {
480            if y >= area.bottom() {
481                break;
482            }
483
484            // Header line
485            let is_focused_panel = state.focused && i == state.focused_index;
486            let icon = if panel.expanded { "▼" } else { "▶" };
487            let header = format!("{} {}", icon, panel.title);
488
489            let header_style = if state.disabled {
490                Style::default().fg(Color::DarkGray)
491            } else if is_focused_panel {
492                Style::default()
493                    .fg(Color::Yellow)
494                    .add_modifier(Modifier::BOLD)
495            } else {
496                Style::default()
497            };
498
499            let header_area = Rect::new(area.x, y, area.width, 1);
500            frame.render_widget(Paragraph::new(header).style(header_style), header_area);
501            y += 1;
502
503            // Content (if expanded)
504            if panel.expanded && y < area.bottom() {
505                let content_lines = panel.content.lines().count().max(1) as u16;
506                let available_height = area.bottom().saturating_sub(y);
507                let content_height = content_lines.min(available_height);
508
509                if content_height > 0 {
510                    let content_area =
511                        Rect::new(area.x + 2, y, area.width.saturating_sub(2), content_height);
512                    let content_style = if state.disabled {
513                        Style::default().fg(Color::DarkGray)
514                    } else {
515                        Style::default().fg(Color::Gray)
516                    };
517                    frame.render_widget(
518                        Paragraph::new(panel.content.as_str()).style(content_style),
519                        content_area,
520                    );
521                    y += content_height;
522                }
523            }
524        }
525    }
526}
527
528impl Focusable for Accordion {
529    fn is_focused(state: &Self::State) -> bool {
530        state.focused
531    }
532
533    fn set_focused(state: &mut Self::State, focused: bool) {
534        state.focused = focused;
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    // ========== AccordionPanel Tests ==========
543
544    #[test]
545    fn test_panel_new() {
546        let panel = AccordionPanel::new("Title", "Content");
547        assert_eq!(panel.title(), "Title");
548        assert_eq!(panel.content(), "Content");
549        assert!(!panel.is_expanded());
550    }
551
552    #[test]
553    fn test_panel_expanded_builder() {
554        let panel = AccordionPanel::new("Title", "Content").expanded();
555        assert!(panel.is_expanded());
556    }
557
558    #[test]
559    fn test_panel_accessors() {
560        let panel = AccordionPanel::new("My Title", "My Content");
561        assert_eq!(panel.title(), "My Title");
562        assert_eq!(panel.content(), "My Content");
563        assert!(!panel.is_expanded());
564    }
565
566    #[test]
567    fn test_panel_clone() {
568        let panel = AccordionPanel::new("Title", "Content").expanded();
569        let cloned = panel.clone();
570        assert_eq!(cloned.title(), "Title");
571        assert!(cloned.is_expanded());
572    }
573
574    // ========== State Creation Tests ==========
575
576    #[test]
577    fn test_new() {
578        let panels = vec![
579            AccordionPanel::new("A", "Content A"),
580            AccordionPanel::new("B", "Content B"),
581        ];
582        let state = AccordionState::new(panels);
583        assert_eq!(state.len(), 2);
584        assert_eq!(state.focused_index(), 0);
585        assert!(!state.is_disabled());
586    }
587
588    #[test]
589    fn test_from_pairs() {
590        let state = AccordionState::from_pairs(vec![("A", "Content A"), ("B", "Content B")]);
591        assert_eq!(state.len(), 2);
592        assert_eq!(state.panels()[0].title(), "A");
593        assert_eq!(state.panels()[1].content(), "Content B");
594    }
595
596    #[test]
597    fn test_default() {
598        let state = AccordionState::default();
599        assert!(state.is_empty());
600        assert_eq!(state.len(), 0);
601    }
602
603    #[test]
604    fn test_new_empty() {
605        let state = AccordionState::new(Vec::new());
606        assert!(state.is_empty());
607        assert_eq!(state.focused_index(), 0);
608    }
609
610    // ========== Accessor Tests ==========
611
612    #[test]
613    fn test_panels() {
614        let state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
615        assert_eq!(state.panels().len(), 2);
616        assert_eq!(state.panels()[0].title(), "A");
617    }
618
619    #[test]
620    fn test_len() {
621        let state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
622        assert_eq!(state.len(), 3);
623    }
624
625    #[test]
626    fn test_is_empty() {
627        let empty = AccordionState::default();
628        assert!(empty.is_empty());
629
630        let not_empty = AccordionState::from_pairs(vec![("A", "1")]);
631        assert!(!not_empty.is_empty());
632    }
633
634    #[test]
635    fn test_focused_index() {
636        let state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
637        assert_eq!(state.focused_index(), 0);
638    }
639
640    #[test]
641    fn test_focused_panel() {
642        let state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
643        assert_eq!(state.focused_panel().unwrap().title(), "A");
644
645        let empty = AccordionState::default();
646        assert!(empty.focused_panel().is_none());
647    }
648
649    #[test]
650    fn test_is_disabled() {
651        let mut state = AccordionState::default();
652        assert!(!state.is_disabled());
653        state.set_disabled(true);
654        assert!(state.is_disabled());
655    }
656
657    // ========== Mutator Tests ==========
658
659    #[test]
660    fn test_set_panels() {
661        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
662        state.set_panels(vec![
663            AccordionPanel::new("X", "10"),
664            AccordionPanel::new("Y", "20"),
665        ]);
666        assert_eq!(state.len(), 2);
667        assert_eq!(state.panels()[0].title(), "X");
668    }
669
670    #[test]
671    fn test_add_panel() {
672        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
673        state.add_panel(AccordionPanel::new("B", "2"));
674        assert_eq!(state.len(), 2);
675        assert_eq!(state.panels()[1].title(), "B");
676    }
677
678    #[test]
679    fn test_set_disabled() {
680        let mut state = AccordionState::default();
681        state.set_disabled(true);
682        assert!(state.is_disabled());
683        state.set_disabled(false);
684        assert!(!state.is_disabled());
685    }
686
687    // ========== Query Method Tests ==========
688
689    #[test]
690    fn test_expanded_count() {
691        let panels = vec![
692            AccordionPanel::new("A", "1").expanded(),
693            AccordionPanel::new("B", "2"),
694            AccordionPanel::new("C", "3").expanded(),
695        ];
696        let state = AccordionState::new(panels);
697        assert_eq!(state.expanded_count(), 2);
698    }
699
700    #[test]
701    fn test_is_any_expanded() {
702        let none_expanded = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
703        assert!(!none_expanded.is_any_expanded());
704
705        let some_expanded = AccordionState::new(vec![
706            AccordionPanel::new("A", "1"),
707            AccordionPanel::new("B", "2").expanded(),
708        ]);
709        assert!(some_expanded.is_any_expanded());
710    }
711
712    #[test]
713    fn test_is_all_expanded() {
714        let all_expanded = AccordionState::new(vec![
715            AccordionPanel::new("A", "1").expanded(),
716            AccordionPanel::new("B", "2").expanded(),
717        ]);
718        assert!(all_expanded.is_all_expanded());
719
720        let partial = AccordionState::new(vec![
721            AccordionPanel::new("A", "1").expanded(),
722            AccordionPanel::new("B", "2"),
723        ]);
724        assert!(!partial.is_all_expanded());
725
726        let empty = AccordionState::default();
727        assert!(!empty.is_all_expanded());
728    }
729
730    // ========== Navigation Tests ==========
731
732    #[test]
733    fn test_next() {
734        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
735        assert_eq!(state.focused_index(), 0);
736
737        Accordion::update(&mut state, AccordionMessage::Next);
738        assert_eq!(state.focused_index(), 1);
739
740        Accordion::update(&mut state, AccordionMessage::Next);
741        assert_eq!(state.focused_index(), 2);
742    }
743
744    #[test]
745    fn test_previous() {
746        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
747        Accordion::update(&mut state, AccordionMessage::Next);
748        Accordion::update(&mut state, AccordionMessage::Next);
749        assert_eq!(state.focused_index(), 2);
750
751        Accordion::update(&mut state, AccordionMessage::Previous);
752        assert_eq!(state.focused_index(), 1);
753
754        Accordion::update(&mut state, AccordionMessage::Previous);
755        assert_eq!(state.focused_index(), 0);
756    }
757
758    #[test]
759    fn test_next_wraps() {
760        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
761        Accordion::update(&mut state, AccordionMessage::Next);
762        assert_eq!(state.focused_index(), 1);
763
764        Accordion::update(&mut state, AccordionMessage::Next);
765        assert_eq!(state.focused_index(), 0); // Wrapped
766    }
767
768    #[test]
769    fn test_previous_wraps() {
770        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
771        assert_eq!(state.focused_index(), 0);
772
773        Accordion::update(&mut state, AccordionMessage::Previous);
774        assert_eq!(state.focused_index(), 1); // Wrapped to end
775    }
776
777    #[test]
778    fn test_first() {
779        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
780        Accordion::update(&mut state, AccordionMessage::Next);
781        Accordion::update(&mut state, AccordionMessage::Next);
782        assert_eq!(state.focused_index(), 2);
783
784        let output = Accordion::update(&mut state, AccordionMessage::First);
785        assert_eq!(state.focused_index(), 0);
786        assert_eq!(output, Some(AccordionOutput::FocusChanged(0)));
787    }
788
789    #[test]
790    fn test_last() {
791        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
792        assert_eq!(state.focused_index(), 0);
793
794        let output = Accordion::update(&mut state, AccordionMessage::Last);
795        assert_eq!(state.focused_index(), 2);
796        assert_eq!(output, Some(AccordionOutput::FocusChanged(2)));
797    }
798
799    #[test]
800    fn test_navigation_empty() {
801        let mut state = AccordionState::default();
802
803        let output = Accordion::update(&mut state, AccordionMessage::Next);
804        assert_eq!(output, None);
805
806        let output = Accordion::update(&mut state, AccordionMessage::Previous);
807        assert_eq!(output, None);
808
809        let output = Accordion::update(&mut state, AccordionMessage::First);
810        assert_eq!(output, None);
811
812        let output = Accordion::update(&mut state, AccordionMessage::Last);
813        assert_eq!(output, None);
814    }
815
816    #[test]
817    fn test_navigation_returns_focus_changed() {
818        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
819
820        let output = Accordion::update(&mut state, AccordionMessage::Next);
821        assert_eq!(output, Some(AccordionOutput::FocusChanged(1)));
822    }
823
824    // ========== Toggle/Expand/Collapse Tests ==========
825
826    #[test]
827    fn test_toggle() {
828        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
829        assert!(!state.panels()[0].is_expanded());
830
831        Accordion::update(&mut state, AccordionMessage::Toggle);
832        assert!(state.panels()[0].is_expanded());
833
834        Accordion::update(&mut state, AccordionMessage::Toggle);
835        assert!(!state.panels()[0].is_expanded());
836    }
837
838    #[test]
839    fn test_toggle_returns_expanded() {
840        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
841        let output = Accordion::update(&mut state, AccordionMessage::Toggle);
842        assert_eq!(output, Some(AccordionOutput::Expanded(0)));
843    }
844
845    #[test]
846    fn test_toggle_returns_collapsed() {
847        let mut state = AccordionState::new(vec![AccordionPanel::new("A", "1").expanded()]);
848        let output = Accordion::update(&mut state, AccordionMessage::Toggle);
849        assert_eq!(output, Some(AccordionOutput::Collapsed(0)));
850    }
851
852    #[test]
853    fn test_expand() {
854        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
855        let output = Accordion::update(&mut state, AccordionMessage::Expand);
856        assert_eq!(output, Some(AccordionOutput::Expanded(0)));
857        assert!(state.panels()[0].is_expanded());
858    }
859
860    #[test]
861    fn test_expand_already_expanded() {
862        let mut state = AccordionState::new(vec![AccordionPanel::new("A", "1").expanded()]);
863        let output = Accordion::update(&mut state, AccordionMessage::Expand);
864        assert_eq!(output, None);
865    }
866
867    #[test]
868    fn test_collapse() {
869        let mut state = AccordionState::new(vec![AccordionPanel::new("A", "1").expanded()]);
870        let output = Accordion::update(&mut state, AccordionMessage::Collapse);
871        assert_eq!(output, Some(AccordionOutput::Collapsed(0)));
872        assert!(!state.panels()[0].is_expanded());
873    }
874
875    #[test]
876    fn test_collapse_already_collapsed() {
877        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
878        let output = Accordion::update(&mut state, AccordionMessage::Collapse);
879        assert_eq!(output, None);
880    }
881
882    #[test]
883    fn test_toggle_index() {
884        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
885
886        let output = Accordion::update(&mut state, AccordionMessage::ToggleIndex(1));
887        assert_eq!(output, Some(AccordionOutput::Expanded(1)));
888        assert!(state.panels()[1].is_expanded());
889
890        let output = Accordion::update(&mut state, AccordionMessage::ToggleIndex(1));
891        assert_eq!(output, Some(AccordionOutput::Collapsed(1)));
892        assert!(!state.panels()[1].is_expanded());
893    }
894
895    #[test]
896    fn test_toggle_index_out_of_bounds() {
897        let mut state = AccordionState::from_pairs(vec![("A", "1")]);
898        let output = Accordion::update(&mut state, AccordionMessage::ToggleIndex(5));
899        assert_eq!(output, None);
900    }
901
902    // ========== Bulk Operations Tests ==========
903
904    #[test]
905    fn test_expand_all() {
906        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2"), ("C", "3")]);
907        assert_eq!(state.expanded_count(), 0);
908
909        let output = Accordion::update(&mut state, AccordionMessage::ExpandAll);
910        assert!(output.is_some());
911        assert_eq!(state.expanded_count(), 3);
912        assert!(state.is_all_expanded());
913    }
914
915    #[test]
916    fn test_collapse_all() {
917        let mut state = AccordionState::new(vec![
918            AccordionPanel::new("A", "1").expanded(),
919            AccordionPanel::new("B", "2").expanded(),
920        ]);
921        assert_eq!(state.expanded_count(), 2);
922
923        let output = Accordion::update(&mut state, AccordionMessage::CollapseAll);
924        assert!(output.is_some());
925        assert_eq!(state.expanded_count(), 0);
926        assert!(!state.is_any_expanded());
927    }
928
929    #[test]
930    fn test_expand_all_already_expanded() {
931        let mut state = AccordionState::new(vec![
932            AccordionPanel::new("A", "1").expanded(),
933            AccordionPanel::new("B", "2").expanded(),
934        ]);
935        let output = Accordion::update(&mut state, AccordionMessage::ExpandAll);
936        assert_eq!(output, None);
937    }
938
939    #[test]
940    fn test_collapse_all_already_collapsed() {
941        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
942        let output = Accordion::update(&mut state, AccordionMessage::CollapseAll);
943        assert_eq!(output, None);
944    }
945
946    // ========== Disabled State Tests ==========
947
948    #[test]
949    fn test_disabled_ignores_messages() {
950        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
951        state.set_disabled(true);
952
953        let output = Accordion::update(&mut state, AccordionMessage::Toggle);
954        assert_eq!(output, None);
955        assert!(!state.panels()[0].is_expanded());
956
957        let output = Accordion::update(&mut state, AccordionMessage::Next);
958        assert_eq!(output, None);
959        assert_eq!(state.focused_index(), 0);
960    }
961
962    #[test]
963    fn test_disabling_preserves_state() {
964        let mut state = AccordionState::new(vec![AccordionPanel::new("A", "1").expanded()]);
965        assert!(state.panels()[0].is_expanded());
966
967        state.set_disabled(true);
968        assert!(state.panels()[0].is_expanded()); // Still expanded
969    }
970
971    // ========== Focus Tests ==========
972
973    #[test]
974    fn test_focusable_is_focused() {
975        let state = AccordionState::default();
976        assert!(!Accordion::is_focused(&state));
977    }
978
979    #[test]
980    fn test_focusable_set_focused() {
981        let mut state = AccordionState::default();
982        Accordion::set_focused(&mut state, true);
983        assert!(Accordion::is_focused(&state));
984    }
985
986    #[test]
987    fn test_focus_blur() {
988        let mut state = AccordionState::default();
989
990        Accordion::focus(&mut state);
991        assert!(Accordion::is_focused(&state));
992
993        Accordion::blur(&mut state);
994        assert!(!Accordion::is_focused(&state));
995    }
996
997    // ========== View Tests ==========
998
999    #[test]
1000    fn test_view_empty() {
1001        use crate::backend::CaptureBackend;
1002        use ratatui::Terminal;
1003
1004        let state = AccordionState::default();
1005
1006        let backend = CaptureBackend::new(40, 10);
1007        let mut terminal = Terminal::new(backend).unwrap();
1008
1009        terminal
1010            .draw(|frame| {
1011                Accordion::view(&state, frame, frame.area());
1012            })
1013            .unwrap();
1014
1015        // Should render without error
1016        let _ = terminal.backend().to_string();
1017    }
1018
1019    #[test]
1020    fn test_view_collapsed() {
1021        use crate::backend::CaptureBackend;
1022        use ratatui::Terminal;
1023
1024        let state = AccordionState::from_pairs(vec![("Section 1", "Content 1")]);
1025
1026        let backend = CaptureBackend::new(40, 10);
1027        let mut terminal = Terminal::new(backend).unwrap();
1028
1029        terminal
1030            .draw(|frame| {
1031                Accordion::view(&state, frame, frame.area());
1032            })
1033            .unwrap();
1034
1035        let output = terminal.backend().to_string();
1036        assert!(output.contains("▶")); // Collapsed indicator
1037        assert!(output.contains("Section 1"));
1038    }
1039
1040    #[test]
1041    fn test_view_expanded() {
1042        use crate::backend::CaptureBackend;
1043        use ratatui::Terminal;
1044
1045        let state = AccordionState::new(vec![
1046            AccordionPanel::new("Section 1", "Content 1").expanded()
1047        ]);
1048
1049        let backend = CaptureBackend::new(40, 10);
1050        let mut terminal = Terminal::new(backend).unwrap();
1051
1052        terminal
1053            .draw(|frame| {
1054                Accordion::view(&state, frame, frame.area());
1055            })
1056            .unwrap();
1057
1058        let output = terminal.backend().to_string();
1059        assert!(output.contains("▼")); // Expanded indicator
1060        assert!(output.contains("Section 1"));
1061        assert!(output.contains("Content 1"));
1062    }
1063
1064    #[test]
1065    fn test_view_mixed() {
1066        use crate::backend::CaptureBackend;
1067        use ratatui::Terminal;
1068
1069        let state = AccordionState::new(vec![
1070            AccordionPanel::new("Expanded", "Expanded content").expanded(),
1071            AccordionPanel::new("Collapsed", "Collapsed content"),
1072        ]);
1073
1074        let backend = CaptureBackend::new(40, 10);
1075        let mut terminal = Terminal::new(backend).unwrap();
1076
1077        terminal
1078            .draw(|frame| {
1079                Accordion::view(&state, frame, frame.area());
1080            })
1081            .unwrap();
1082
1083        let output = terminal.backend().to_string();
1084        assert!(output.contains("Expanded"));
1085        assert!(output.contains("Collapsed"));
1086        assert!(output.contains("Expanded content"));
1087    }
1088
1089    #[test]
1090    fn test_view_focused_highlight() {
1091        use crate::backend::CaptureBackend;
1092        use ratatui::Terminal;
1093
1094        let mut state = AccordionState::from_pairs(vec![("A", "1"), ("B", "2")]);
1095        Accordion::focus(&mut state);
1096
1097        let backend = CaptureBackend::new(40, 10);
1098        let mut terminal = Terminal::new(backend).unwrap();
1099
1100        terminal
1101            .draw(|frame| {
1102                Accordion::view(&state, frame, frame.area());
1103            })
1104            .unwrap();
1105
1106        // Should render without error (we can't easily check color in text)
1107        let output = terminal.backend().to_string();
1108        assert!(output.contains("A"));
1109    }
1110
1111    #[test]
1112    fn test_view_long_content() {
1113        use crate::backend::CaptureBackend;
1114        use ratatui::Terminal;
1115
1116        let state = AccordionState::new(vec![AccordionPanel::new(
1117            "Multi-line",
1118            "Line 1\nLine 2\nLine 3",
1119        )
1120        .expanded()]);
1121
1122        let backend = CaptureBackend::new(40, 10);
1123        let mut terminal = Terminal::new(backend).unwrap();
1124
1125        terminal
1126            .draw(|frame| {
1127                Accordion::view(&state, frame, frame.area());
1128            })
1129            .unwrap();
1130
1131        let output = terminal.backend().to_string();
1132        assert!(output.contains("Multi-line"));
1133        assert!(output.contains("Line 1"));
1134    }
1135
1136    // ========== Integration Tests ==========
1137
1138    #[test]
1139    fn test_clone() {
1140        let state = AccordionState::new(vec![AccordionPanel::new("A", "1").expanded()]);
1141        let cloned = state.clone();
1142        assert_eq!(cloned.len(), 1);
1143        assert!(cloned.panels()[0].is_expanded());
1144    }
1145
1146    #[test]
1147    fn test_init() {
1148        let state = Accordion::init();
1149        assert!(state.is_empty());
1150        assert!(!Accordion::is_focused(&state));
1151    }
1152
1153    #[test]
1154    fn test_full_workflow() {
1155        let mut state = AccordionState::from_pairs(vec![
1156            ("Section 1", "Content 1"),
1157            ("Section 2", "Content 2"),
1158            ("Section 3", "Content 3"),
1159        ]);
1160        Accordion::focus(&mut state);
1161
1162        // Initially no panels expanded
1163        assert_eq!(state.expanded_count(), 0);
1164
1165        // Toggle first panel
1166        let output = Accordion::update(&mut state, AccordionMessage::Toggle);
1167        assert_eq!(output, Some(AccordionOutput::Expanded(0)));
1168        assert_eq!(state.expanded_count(), 1);
1169
1170        // Navigate to next and toggle
1171        Accordion::update(&mut state, AccordionMessage::Next);
1172        assert_eq!(state.focused_index(), 1);
1173        Accordion::update(&mut state, AccordionMessage::Toggle);
1174        assert_eq!(state.expanded_count(), 2);
1175
1176        // Both panels 0 and 1 are expanded (multi-expand)
1177        assert!(state.panels()[0].is_expanded());
1178        assert!(state.panels()[1].is_expanded());
1179        assert!(!state.panels()[2].is_expanded());
1180
1181        // Collapse all
1182        Accordion::update(&mut state, AccordionMessage::CollapseAll);
1183        assert_eq!(state.expanded_count(), 0);
1184
1185        // Expand all
1186        Accordion::update(&mut state, AccordionMessage::ExpandAll);
1187        assert!(state.is_all_expanded());
1188    }
1189}