use std::collections::HashSet;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AccordionMode {
Single,
#[default]
Multiple,
}
#[derive(Debug, Clone)]
pub struct AccordionState {
pub expanded: HashSet<String>,
pub focused_index: usize,
pub total_items: usize,
pub mode: AccordionMode,
pub scroll: u16,
}
impl AccordionState {
pub fn new(total_items: usize) -> Self {
Self {
expanded: HashSet::new(),
focused_index: 0,
total_items,
mode: AccordionMode::Multiple,
scroll: 0,
}
}
pub fn with_mode(mut self, mode: AccordionMode) -> Self {
self.mode = mode;
self
}
pub fn with_expanded(mut self, ids: impl IntoIterator<Item = String>) -> Self {
match self.mode {
AccordionMode::Multiple => {
self.expanded = ids.into_iter().collect();
}
AccordionMode::Single => {
if let Some(id) = ids.into_iter().last() {
self.expanded.clear();
self.expanded.insert(id);
}
}
}
self
}
pub fn toggle(&mut self, id: &str) {
if self.expanded.contains(id) {
self.expanded.remove(id);
} else {
match self.mode {
AccordionMode::Single => {
self.expanded.clear();
self.expanded.insert(id.to_string());
}
AccordionMode::Multiple => {
self.expanded.insert(id.to_string());
}
}
}
}
pub fn expand(&mut self, id: &str) {
match self.mode {
AccordionMode::Single => {
self.expanded.clear();
self.expanded.insert(id.to_string());
}
AccordionMode::Multiple => {
self.expanded.insert(id.to_string());
}
}
}
pub fn collapse(&mut self, id: &str) {
self.expanded.remove(id);
}
pub fn is_expanded(&self, id: &str) -> bool {
self.expanded.contains(id)
}
pub fn expand_all(&mut self, ids: impl Iterator<Item = String>) {
match self.mode {
AccordionMode::Single => {
if let Some(id) = ids.last() {
self.expanded.clear();
self.expanded.insert(id);
}
}
AccordionMode::Multiple => {
for id in ids {
self.expanded.insert(id);
}
}
}
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
}
pub fn focus_next(&mut self) {
if self.focused_index + 1 < self.total_items {
self.focused_index += 1;
}
}
pub fn focus_prev(&mut self) {
self.focused_index = self.focused_index.saturating_sub(1);
}
pub fn focus(&mut self, index: usize) {
if index < self.total_items {
self.focused_index = index;
}
}
pub fn focused_index(&self) -> usize {
self.focused_index
}
pub fn set_total_items(&mut self, total: usize) {
self.total_items = total;
if self.focused_index >= total && total > 0 {
self.focused_index = total - 1;
}
}
pub fn ensure_visible(&mut self, viewport_height: u16, item_heights: &[u16]) {
let mut y_pos: u16 = 0;
let mut focused_start: u16 = 0;
let mut focused_height: u16 = 1;
for (idx, &height) in item_heights.iter().enumerate() {
if idx == self.focused_index {
focused_start = y_pos;
focused_height = height;
break;
}
y_pos += height;
}
if focused_start < self.scroll {
self.scroll = focused_start;
}
else if focused_start + focused_height > self.scroll + viewport_height {
self.scroll = (focused_start + focused_height).saturating_sub(viewport_height);
}
}
}
impl Default for AccordionState {
fn default() -> Self {
Self::new(0)
}
}
#[derive(Debug, Clone)]
pub struct AccordionStyle {
pub header_style: Style,
pub header_focused_style: Style,
pub content_style: Style,
pub expanded_icon: &'static str,
pub collapsed_icon: &'static str,
pub border_style: Style,
pub show_borders: bool,
pub content_indent: u16,
pub icon_style: Style,
}
impl Default for AccordionStyle {
fn default() -> Self {
Self {
header_style: Style::default().fg(Color::White),
header_focused_style: Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
content_style: Style::default().fg(Color::Gray),
expanded_icon: "▼ ",
collapsed_icon: "▶ ",
border_style: Style::default().fg(Color::DarkGray),
show_borders: false,
content_indent: 2,
icon_style: Style::default().fg(Color::Cyan),
}
}
}
impl From<&crate::theme::Theme> for AccordionStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
header_style: Style::default().fg(p.text),
header_focused_style: Style::default().fg(p.primary).add_modifier(Modifier::BOLD),
content_style: Style::default().fg(p.text_dim),
expanded_icon: "▼ ",
collapsed_icon: "▶ ",
border_style: Style::default().fg(p.border_disabled),
show_borders: false,
content_indent: 2,
icon_style: Style::default().fg(p.secondary),
}
}
}
impl AccordionStyle {
pub fn minimal() -> Self {
Self {
expanded_icon: "- ",
collapsed_icon: "+ ",
..Default::default()
}
}
pub fn bordered() -> Self {
Self {
show_borders: true,
..Default::default()
}
}
pub fn header_style(mut self, style: Style) -> Self {
self.header_style = style;
self
}
pub fn header_focused_style(mut self, style: Style) -> Self {
self.header_focused_style = style;
self
}
pub fn content_style(mut self, style: Style) -> Self {
self.content_style = style;
self
}
pub fn expanded_icon(mut self, icon: &'static str) -> Self {
self.expanded_icon = icon;
self
}
pub fn collapsed_icon(mut self, icon: &'static str) -> Self {
self.collapsed_icon = icon;
self
}
pub fn icon_style(mut self, style: Style) -> Self {
self.icon_style = style;
self
}
pub fn content_indent(mut self, indent: u16) -> Self {
self.content_indent = indent;
self
}
pub fn show_borders(mut self, show: bool) -> Self {
self.show_borders = show;
self
}
}
pub struct Accordion<'a, T, H, C, I>
where
H: Fn(&T, usize, bool) -> Line<'static>,
C: Fn(&T, usize, Rect, &mut Buffer),
I: Fn(&T, usize) -> String,
{
items: &'a [T],
state: &'a AccordionState,
style: AccordionStyle,
render_header: H,
render_content: C,
id_fn: I,
content_heights: Option<&'a [u16]>,
}
impl<'a, T>
Accordion<
'a,
T,
fn(&T, usize, bool) -> Line<'static>,
fn(&T, usize, Rect, &mut Buffer),
fn(&T, usize) -> String,
>
{
#[allow(clippy::type_complexity)]
pub fn new(
items: &'a [T],
state: &'a AccordionState,
) -> Accordion<
'a,
T,
fn(&T, usize, bool) -> Line<'static>,
fn(&T, usize, Rect, &mut Buffer),
fn(&T, usize) -> String,
>
where
T: std::fmt::Debug,
{
Accordion {
items,
state,
style: AccordionStyle::default(),
render_header: |_item, idx, _focused| Line::raw(format!("Item {}", idx)),
render_content: |_item, _idx, _area, _buf| {},
id_fn: |_item, idx| idx.to_string(),
content_heights: None,
}
}
}
impl<'a, T, H, C, I> Accordion<'a, T, H, C, I>
where
H: Fn(&T, usize, bool) -> Line<'static>,
C: Fn(&T, usize, Rect, &mut Buffer),
I: Fn(&T, usize) -> String,
{
pub fn id_fn<I2>(self, id_fn: I2) -> Accordion<'a, T, H, C, I2>
where
I2: Fn(&T, usize) -> String,
{
Accordion {
items: self.items,
state: self.state,
style: self.style,
render_header: self.render_header,
render_content: self.render_content,
id_fn,
content_heights: self.content_heights,
}
}
pub fn render_header<H2>(self, render_header: H2) -> Accordion<'a, T, H2, C, I>
where
H2: Fn(&T, usize, bool) -> Line<'static>,
{
Accordion {
items: self.items,
state: self.state,
style: self.style,
render_header,
render_content: self.render_content,
id_fn: self.id_fn,
content_heights: self.content_heights,
}
}
pub fn render_content<C2>(self, render_content: C2) -> Accordion<'a, T, H, C2, I>
where
C2: Fn(&T, usize, Rect, &mut Buffer),
{
Accordion {
items: self.items,
state: self.state,
style: self.style,
render_header: self.render_header,
render_content,
id_fn: self.id_fn,
content_heights: self.content_heights,
}
}
pub fn style(mut self, style: AccordionStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(AccordionStyle::from(theme))
}
pub fn content_heights(mut self, heights: &'a [u16]) -> Self {
self.content_heights = Some(heights);
self
}
fn get_id(&self, item: &T, idx: usize) -> String {
(self.id_fn)(item, idx)
}
pub fn calculate_item_heights(&self) -> Vec<u16> {
self.items
.iter()
.enumerate()
.map(|(idx, item)| {
let id = self.get_id(item, idx);
let header_height = 1u16;
let content_height = if self.state.is_expanded(&id) {
self.content_heights
.and_then(|h| h.get(idx).copied())
.unwrap_or(3) } else {
0
};
let border_height = if self.style.show_borders { 1 } else { 0 };
header_height + content_height + border_height
})
.collect()
}
}
impl<'a, T, H, C, I> Widget for Accordion<'a, T, H, C, I>
where
H: Fn(&T, usize, bool) -> Line<'static>,
C: Fn(&T, usize, Rect, &mut Buffer),
I: Fn(&T, usize) -> String,
{
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width == 0 || area.height == 0 {
return;
}
let mut y = area.y;
let scroll = self.state.scroll;
let mut current_y: u16 = 0;
for (idx, item) in self.items.iter().enumerate() {
let id = self.get_id(item, idx);
let is_expanded = self.state.is_expanded(&id);
let is_focused = idx == self.state.focused_index;
let content_height = if is_expanded {
self.content_heights
.and_then(|h| h.get(idx).copied())
.unwrap_or(3)
} else {
0
};
let header_height = 1u16;
let item_height = header_height + content_height;
if current_y + item_height <= scroll {
current_y += item_height;
continue;
}
if y >= area.y + area.height {
break;
}
let skip_lines = scroll.saturating_sub(current_y);
let available_height = (area.y + area.height).saturating_sub(y);
if skip_lines == 0 && available_height > 0 {
let header_area = Rect::new(area.x, y, area.width, 1);
let icon = if is_expanded {
self.style.expanded_icon
} else {
self.style.collapsed_icon
};
let header_line = (self.render_header)(item, idx, is_focused);
let style = if is_focused {
self.style.header_focused_style
} else {
self.style.header_style
};
let icon_span = Span::styled(icon.to_string(), self.style.icon_style);
let mut spans = vec![icon_span];
spans.extend(
header_line
.spans
.into_iter()
.map(|s| Span::styled(s.content, style)),
);
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
paragraph.render(header_area, buf);
y += 1;
} else if skip_lines > 0 {
}
if is_expanded && y < area.y + area.height {
let content_start_in_item = header_height;
let content_skip = skip_lines.saturating_sub(content_start_in_item);
let content_available = (area.y + area.height)
.saturating_sub(y)
.min(content_height.saturating_sub(content_skip));
if content_available > 0 {
let indent = self.style.content_indent;
let content_area = Rect::new(
area.x + indent,
y,
area.width.saturating_sub(indent),
content_available,
);
(self.render_content)(item, idx, content_area, buf);
y += content_available;
}
}
if self.style.show_borders && y < area.y + area.height {
let border_char = "─";
for x in area.x..area.x + area.width {
buf.set_string(x, y, border_char, self.style.border_style);
}
y += 1;
}
current_y += item_height;
}
}
}
pub fn calculate_height<T, I>(
items: &[T],
state: &AccordionState,
id_fn: I,
content_heights: &[u16],
show_borders: bool,
) -> u16
where
I: Fn(&T, usize) -> String,
{
items
.iter()
.enumerate()
.map(|(idx, item)| {
let id = id_fn(item, idx);
let header_height = 1u16;
let content_height = if state.is_expanded(&id) {
content_heights.get(idx).copied().unwrap_or(3)
} else {
0
};
let border_height = if show_borders { 1 } else { 0 };
header_height + content_height + border_height
})
.sum()
}
pub fn handle_accordion_key(
state: &mut AccordionState,
key: &crossterm::event::KeyEvent,
get_id: impl Fn(usize) -> String,
) -> bool {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Up | KeyCode::Char('k') => {
state.focus_prev();
true
}
KeyCode::Down | KeyCode::Char('j') => {
state.focus_next();
true
}
KeyCode::Enter | KeyCode::Char(' ') => {
let id = get_id(state.focused_index);
state.toggle(&id);
true
}
KeyCode::Home => {
state.focus(0);
true
}
KeyCode::End => {
if state.total_items > 0 {
state.focus(state.total_items - 1);
}
true
}
_ => false,
}
}
pub fn handle_accordion_mouse(
state: &mut AccordionState,
mouse: &crossterm::event::MouseEvent,
item_areas: &[(usize, Rect, String)], ) -> bool {
use crossterm::event::MouseEventKind;
if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind {
for (idx, area, id) in item_areas {
if mouse.column >= area.x
&& mouse.column < area.x + area.width
&& mouse.row >= area.y
&& mouse.row < area.y + area.height
{
state.focus(*idx);
state.toggle(id);
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accordion_state_new() {
let state = AccordionState::new(5);
assert_eq!(state.total_items, 5);
assert_eq!(state.focused_index, 0);
assert!(state.expanded.is_empty());
assert_eq!(state.mode, AccordionMode::Multiple);
}
#[test]
fn test_accordion_state_toggle() {
let mut state = AccordionState::new(3);
state.toggle("item1");
assert!(state.is_expanded("item1"));
state.toggle("item1");
assert!(!state.is_expanded("item1"));
}
#[test]
fn test_accordion_state_single_mode() {
let mut state = AccordionState::new(3).with_mode(AccordionMode::Single);
state.expand("item1");
assert!(state.is_expanded("item1"));
state.expand("item2");
assert!(!state.is_expanded("item1"));
assert!(state.is_expanded("item2"));
}
#[test]
fn test_accordion_state_multiple_mode() {
let mut state = AccordionState::new(3).with_mode(AccordionMode::Multiple);
state.expand("item1");
state.expand("item2");
assert!(state.is_expanded("item1"));
assert!(state.is_expanded("item2"));
}
#[test]
fn test_accordion_state_expand_collapse() {
let mut state = AccordionState::new(3);
state.expand("item1");
assert!(state.is_expanded("item1"));
state.collapse("item1");
assert!(!state.is_expanded("item1"));
}
#[test]
fn test_accordion_state_navigation() {
let mut state = AccordionState::new(5);
assert_eq!(state.focused_index(), 0);
state.focus_next();
assert_eq!(state.focused_index(), 1);
state.focus_next();
assert_eq!(state.focused_index(), 2);
state.focus_prev();
assert_eq!(state.focused_index(), 1);
state.focus(4);
assert_eq!(state.focused_index(), 4);
state.focus_next();
assert_eq!(state.focused_index(), 4);
state.focus(0);
state.focus_prev();
assert_eq!(state.focused_index(), 0);
}
#[test]
fn test_accordion_state_collapse_all() {
let mut state = AccordionState::new(3).with_mode(AccordionMode::Multiple);
state.expand("item1");
state.expand("item2");
state.expand("item3");
assert_eq!(state.expanded.len(), 3);
state.collapse_all();
assert!(state.expanded.is_empty());
}
#[test]
fn test_accordion_style_default() {
let style = AccordionStyle::default();
assert_eq!(style.expanded_icon, "▼ ");
assert_eq!(style.collapsed_icon, "▶ ");
assert!(!style.show_borders);
assert_eq!(style.content_indent, 2);
}
#[test]
fn test_accordion_style_minimal() {
let style = AccordionStyle::minimal();
assert_eq!(style.expanded_icon, "- ");
assert_eq!(style.collapsed_icon, "+ ");
}
#[test]
fn test_accordion_render_collapsed() {
#[derive(Debug)]
struct Item {
id: String,
title: String,
}
let items = vec![
Item {
id: "1".into(),
title: "First".into(),
},
Item {
id: "2".into(),
title: "Second".into(),
},
];
let state = AccordionState::new(items.len());
let accordion = Accordion::new(&items, &state)
.id_fn(|item, _| item.id.clone())
.render_header(|item, _, _| Line::raw(item.title.clone()))
.render_content(|_, _, _, _| {});
let area = Rect::new(0, 0, 20, 10);
let mut buf = Buffer::empty(area);
accordion.render(area, &mut buf);
let line0 = buf
.content
.iter()
.take(20)
.map(|c| c.symbol())
.collect::<String>();
assert!(line0.contains("▶"));
assert!(line0.contains("First"));
}
#[test]
fn test_accordion_render_expanded() {
#[derive(Debug)]
struct Item {
id: String,
title: String,
}
let items = vec![
Item {
id: "1".into(),
title: "First".into(),
},
Item {
id: "2".into(),
title: "Second".into(),
},
];
let mut state = AccordionState::new(items.len());
state.expand("1");
let accordion = Accordion::new(&items, &state)
.id_fn(|item, _| item.id.clone())
.render_header(|item, _, _| Line::raw(item.title.clone()))
.render_content(|_, _, area, buf| {
let text = Paragraph::new("Content here");
text.render(area, buf);
})
.content_heights(&[2, 2]);
let area = Rect::new(0, 0, 20, 10);
let mut buf = Buffer::empty(area);
accordion.render(area, &mut buf);
let line0 = buf
.content
.iter()
.take(20)
.map(|c| c.symbol())
.collect::<String>();
assert!(line0.contains("▼"));
assert!(line0.contains("First"));
let line1 = buf
.content
.iter()
.skip(20)
.take(20)
.map(|c| c.symbol())
.collect::<String>();
assert!(line1.contains("Content"));
}
#[test]
fn test_calculate_height() {
#[derive(Debug)]
struct Item {
id: String,
}
let items = vec![
Item { id: "1".into() },
Item { id: "2".into() },
Item { id: "3".into() },
];
let mut state = AccordionState::new(items.len());
let content_heights = vec![3u16, 5, 2];
let height = calculate_height(
&items,
&state,
|item, _| item.id.clone(),
&content_heights,
false,
);
assert_eq!(height, 3);
state.expand("1");
let height = calculate_height(
&items,
&state,
|item, _| item.id.clone(),
&content_heights,
false,
);
assert_eq!(height, 6);
state.expand("2");
let height = calculate_height(
&items,
&state,
|item, _| item.id.clone(),
&content_heights,
false,
);
assert_eq!(height, 11);
let height = calculate_height(
&items,
&state,
|item, _| item.id.clone(),
&content_heights,
true,
);
assert_eq!(height, 14);
}
}