1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum AccordionMode {
62 Single,
64 #[default]
66 Multiple,
67}
68
69#[derive(Debug, Clone)]
71pub struct AccordionState {
72 pub expanded: HashSet<String>,
74 pub focused_index: usize,
76 pub total_items: usize,
78 pub mode: AccordionMode,
80 pub scroll: u16,
82}
83
84impl AccordionState {
85 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 pub fn with_mode(mut self, mode: AccordionMode) -> Self {
98 self.mode = mode;
99 self
100 }
101
102 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 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 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 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 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 pub fn collapse(&mut self, id: &str) {
152 self.expanded.remove(id);
153 }
154
155 pub fn is_expanded(&self, id: &str) -> bool {
157 self.expanded.contains(id)
158 }
159
160 pub fn expand_all(&mut self, ids: impl Iterator<Item = String>) {
162 match self.mode {
163 AccordionMode::Single => {
164 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 pub fn collapse_all(&mut self) {
180 self.expanded.clear();
181 }
182
183 pub fn focus_next(&mut self) {
185 if self.focused_index + 1 < self.total_items {
186 self.focused_index += 1;
187 }
188 }
189
190 pub fn focus_prev(&mut self) {
192 self.focused_index = self.focused_index.saturating_sub(1);
193 }
194
195 pub fn focus(&mut self, index: usize) {
197 if index < self.total_items {
198 self.focused_index = index;
199 }
200 }
201
202 pub fn focused_index(&self) -> usize {
204 self.focused_index
205 }
206
207 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 pub fn ensure_visible(&mut self, viewport_height: u16, item_heights: &[u16]) {
217 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 if focused_start < self.scroll {
233 self.scroll = focused_start;
234 }
235 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#[derive(Debug, Clone)]
250pub struct AccordionStyle {
251 pub header_style: Style,
253 pub header_focused_style: Style,
255 pub content_style: Style,
257 pub expanded_icon: &'static str,
259 pub collapsed_icon: &'static str,
261 pub border_style: Style,
263 pub show_borders: bool,
265 pub content_indent: u16,
267 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 pub fn minimal() -> Self {
309 Self {
310 expanded_icon: "- ",
311 collapsed_icon: "+ ",
312 ..Default::default()
313 }
314 }
315
316 pub fn bordered() -> Self {
318 Self {
319 show_borders: true,
320 ..Default::default()
321 }
322 }
323
324 pub fn header_style(mut self, style: Style) -> Self {
326 self.header_style = style;
327 self
328 }
329
330 pub fn header_focused_style(mut self, style: Style) -> Self {
332 self.header_focused_style = style;
333 self
334 }
335
336 pub fn content_style(mut self, style: Style) -> Self {
338 self.content_style = style;
339 self
340 }
341
342 pub fn expanded_icon(mut self, icon: &'static str) -> Self {
344 self.expanded_icon = icon;
345 self
346 }
347
348 pub fn collapsed_icon(mut self, icon: &'static str) -> Self {
350 self.collapsed_icon = icon;
351 self
352 }
353
354 pub fn icon_style(mut self, style: Style) -> Self {
356 self.icon_style = style;
357 self
358 }
359
360 pub fn content_indent(mut self, indent: u16) -> Self {
362 self.content_indent = indent;
363 self
364 }
365
366 pub fn show_borders(mut self, show: bool) -> Self {
368 self.show_borders = show;
369 self
370 }
371}
372
373pub 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 #[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 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 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 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 pub fn style(mut self, style: AccordionStyle) -> Self {
481 self.style = style;
482 self
483 }
484
485 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
487 self.style(AccordionStyle::from(theme))
488 }
489
490 pub fn content_heights(mut self, heights: &'a [u16]) -> Self {
492 self.content_heights = Some(heights);
493 self
494 }
495
496 fn get_id(&self, item: &T, idx: usize) -> String {
498 (self.id_fn)(item, idx)
499 }
500
501 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) } 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 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 if current_y + item_height <= scroll {
556 current_y += item_height;
557 continue;
558 }
559
560 if y >= area.y + area.height {
562 break;
563 }
564
565 let skip_lines = scroll.saturating_sub(current_y);
567 let available_height = (area.y + area.height).saturating_sub(y);
568
569 if skip_lines == 0 && available_height > 0 {
571 let header_area = Rect::new(area.x, y, area.width, 1);
572
573 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 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 }
605
606 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 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
641pub 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
669pub 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
705pub fn handle_accordion_mouse(
707 state: &mut AccordionState,
708 mouse: &crossterm::event::MouseEvent,
709 item_areas: &[(usize, Rect, String)], ) -> 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 state.focus_next();
807 assert_eq!(state.focused_index(), 4);
808
809 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 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 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 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 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 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 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 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}