use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use super::{Component, Disableable, Focusable, ViewContext};
use crate::input::{Event, KeyCode};
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct AccordionPanel {
title: String,
content: String,
expanded: bool,
}
impl AccordionPanel {
pub fn new(title: impl Into<String>, content: impl Into<String>) -> Self {
Self {
title: title.into(),
content: content.into(),
expanded: false,
}
}
pub fn expanded(mut self) -> Self {
self.expanded = true;
self
}
pub fn title(&self) -> &str {
&self.title
}
pub fn content(&self) -> &str {
&self.content
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AccordionMessage {
Down,
Up,
First,
Last,
Toggle,
Expand,
Collapse,
ToggleIndex(usize),
ExpandAll,
CollapseAll,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AccordionOutput {
Expanded(usize),
Collapsed(usize),
FocusChanged(usize),
}
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct AccordionState {
panels: Vec<AccordionPanel>,
focused_index: usize,
focused: bool,
disabled: bool,
}
impl AccordionState {
pub fn new(panels: Vec<AccordionPanel>) -> Self {
Self {
panels,
focused_index: 0,
focused: false,
disabled: false,
}
}
pub fn from_pairs<S: Into<String>, T: Into<String>>(pairs: Vec<(S, T)>) -> Self {
let panels = pairs
.into_iter()
.map(|(title, content)| AccordionPanel::new(title, content))
.collect();
Self::new(panels)
}
pub fn panels(&self) -> &[AccordionPanel] {
&self.panels
}
pub fn len(&self) -> usize {
self.panels.len()
}
pub fn is_empty(&self) -> bool {
self.panels.is_empty()
}
pub fn focused_index(&self) -> usize {
self.focused_index
}
pub fn focused_panel(&self) -> Option<&AccordionPanel> {
self.panels.get(self.focused_index)
}
pub fn selected_index(&self) -> Option<usize> {
if self.panels.is_empty() {
None
} else {
Some(self.focused_index)
}
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn selected_item(&self) -> Option<&AccordionPanel> {
self.focused_panel()
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn set_panels(&mut self, panels: Vec<AccordionPanel>) {
self.panels = panels;
if self.focused_index >= self.panels.len() && !self.panels.is_empty() {
self.focused_index = 0;
}
}
pub fn add_panel(&mut self, panel: AccordionPanel) {
self.panels.push(panel);
}
pub fn remove_panel(&mut self, index: usize) {
if index >= self.panels.len() {
return;
}
self.panels.remove(index);
if self.panels.is_empty() {
self.focused_index = 0;
} else if self.focused_index >= self.panels.len() {
self.focused_index = self.panels.len() - 1;
}
}
pub fn set_disabled(&mut self, disabled: bool) {
self.disabled = disabled;
}
pub fn with_focused_index(mut self, index: usize) -> Self {
if !self.panels.is_empty() {
self.focused_index = index.min(self.panels.len() - 1);
}
self
}
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn expanded_count(&self) -> usize {
self.panels.iter().filter(|p| p.expanded).count()
}
pub fn is_any_expanded(&self) -> bool {
self.panels.iter().any(|p| p.expanded)
}
pub fn is_all_expanded(&self) -> bool {
!self.panels.is_empty() && self.panels.iter().all(|p| p.expanded)
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn handle_event(&self, event: &Event) -> Option<AccordionMessage> {
Accordion::handle_event(self, event)
}
pub fn dispatch_event(&mut self, event: &Event) -> Option<AccordionOutput> {
Accordion::dispatch_event(self, event)
}
pub fn update(&mut self, msg: AccordionMessage) -> Option<AccordionOutput> {
Accordion::update(self, msg)
}
}
pub struct Accordion;
impl Component for Accordion {
type State = AccordionState;
type Message = AccordionMessage;
type Output = AccordionOutput;
fn init() -> Self::State {
AccordionState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.disabled {
return None;
}
match msg {
AccordionMessage::Down => {
if !state.panels.is_empty() {
state.focused_index = (state.focused_index + 1) % state.panels.len();
Some(AccordionOutput::FocusChanged(state.focused_index))
} else {
None
}
}
AccordionMessage::Up => {
if !state.panels.is_empty() {
if state.focused_index == 0 {
state.focused_index = state.panels.len() - 1;
} else {
state.focused_index -= 1;
}
Some(AccordionOutput::FocusChanged(state.focused_index))
} else {
None
}
}
AccordionMessage::First => {
if !state.panels.is_empty() && state.focused_index != 0 {
state.focused_index = 0;
Some(AccordionOutput::FocusChanged(0))
} else {
None
}
}
AccordionMessage::Last => {
if !state.panels.is_empty() {
let last = state.panels.len() - 1;
if state.focused_index != last {
state.focused_index = last;
Some(AccordionOutput::FocusChanged(last))
} else {
None
}
} else {
None
}
}
AccordionMessage::Toggle => {
if let Some(panel) = state.panels.get_mut(state.focused_index) {
panel.expanded = !panel.expanded;
if panel.expanded {
Some(AccordionOutput::Expanded(state.focused_index))
} else {
Some(AccordionOutput::Collapsed(state.focused_index))
}
} else {
None
}
}
AccordionMessage::Expand => {
if let Some(panel) = state.panels.get_mut(state.focused_index) {
if !panel.expanded {
panel.expanded = true;
Some(AccordionOutput::Expanded(state.focused_index))
} else {
None
}
} else {
None
}
}
AccordionMessage::Collapse => {
if let Some(panel) = state.panels.get_mut(state.focused_index) {
if panel.expanded {
panel.expanded = false;
Some(AccordionOutput::Collapsed(state.focused_index))
} else {
None
}
} else {
None
}
}
AccordionMessage::ToggleIndex(index) => {
if let Some(panel) = state.panels.get_mut(index) {
panel.expanded = !panel.expanded;
if panel.expanded {
Some(AccordionOutput::Expanded(index))
} else {
Some(AccordionOutput::Collapsed(index))
}
} else {
None
}
}
AccordionMessage::ExpandAll => {
let mut any_changed = false;
for (i, panel) in state.panels.iter_mut().enumerate() {
if !panel.expanded {
panel.expanded = true;
any_changed = true;
if !any_changed {
return Some(AccordionOutput::Expanded(i));
}
}
}
if any_changed {
Some(AccordionOutput::Expanded(0))
} else {
None
}
}
AccordionMessage::CollapseAll => {
let mut any_changed = false;
for (i, panel) in state.panels.iter_mut().enumerate() {
if panel.expanded {
panel.expanded = false;
any_changed = true;
if !any_changed {
return Some(AccordionOutput::Collapsed(i));
}
}
}
if any_changed {
Some(AccordionOutput::Collapsed(0))
} else {
None
}
}
}
}
fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
if !state.focused || state.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
KeyCode::Up | KeyCode::Char('k') => Some(AccordionMessage::Up),
KeyCode::Down | KeyCode::Char('j') => Some(AccordionMessage::Down),
KeyCode::Enter | KeyCode::Char(' ') => Some(AccordionMessage::Toggle),
KeyCode::Home => Some(AccordionMessage::First),
KeyCode::End => Some(AccordionMessage::Last),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme, ctx: &ViewContext) {
if state.panels.is_empty() {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
area,
crate::annotation::Annotation::accordion("accordion")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let mut y = area.y;
for (i, panel) in state.panels.iter().enumerate() {
if y >= area.bottom() {
break;
}
let is_focused_panel = ctx.focused && i == state.focused_index;
let icon = if panel.expanded { "▼" } else { "▶" };
let header = format!("{} {}", icon, panel.title);
let header_style = if ctx.disabled {
theme.disabled_style()
} else if is_focused_panel {
theme.focused_bold_style()
} else {
theme.normal_style()
};
let header_area = Rect::new(area.x, y, area.width, 1);
frame.render_widget(Paragraph::new(header).style(header_style), header_area);
y += 1;
if panel.expanded && y < area.bottom() {
let content_lines = panel.content.lines().count().max(1) as u16;
let available_height = area.bottom().saturating_sub(y);
let content_height = content_lines.min(available_height);
if content_height > 0 {
let content_area =
Rect::new(area.x + 2, y, area.width.saturating_sub(2), content_height);
let content_style = if ctx.disabled {
theme.disabled_style()
} else {
theme.placeholder_style()
};
frame.render_widget(
Paragraph::new(panel.content.as_str()).style(content_style),
content_area,
);
y += content_height;
}
}
}
}
}
impl Focusable for Accordion {
fn is_focused(state: &Self::State) -> bool {
state.focused
}
fn set_focused(state: &mut Self::State, focused: bool) {
state.focused = focused;
}
}
impl Disableable for Accordion {
fn is_disabled(state: &Self::State) -> bool {
state.disabled
}
fn set_disabled(state: &mut Self::State, disabled: bool) {
state.disabled = disabled;
}
}
#[cfg(test)]
mod tests;