use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, EventContext, RenderContext, Toggleable};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CollapsibleMessage {
Toggle,
Expand,
Collapse,
SetHeader(String),
SetContentHeight(u16),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CollapsibleOutput {
Expanded,
Collapsed,
Toggled(bool),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct CollapsibleState {
header: String,
expanded: bool,
content_height: u16,
}
impl Default for CollapsibleState {
fn default() -> Self {
Self {
header: String::new(),
expanded: true,
content_height: 5,
}
}
}
impl CollapsibleState {
pub fn new(header: impl Into<String>) -> Self {
Self {
header: header.into(),
..Default::default()
}
}
pub fn with_expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn with_content_height(mut self, height: u16) -> Self {
self.content_height = height;
self
}
pub fn header(&self) -> &str {
&self.header
}
pub fn set_header(&mut self, header: impl Into<String>) {
self.header = header.into();
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
pub fn set_expanded(&mut self, expanded: bool) {
self.expanded = expanded;
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
}
pub fn content_height(&self) -> u16 {
self.content_height
}
pub fn set_content_height(&mut self, height: u16) {
self.content_height = height;
}
pub fn content_area(&self, area: Rect) -> Rect {
if !self.expanded || area.height <= 1 {
return Rect::new(
area.x,
area.y.saturating_add(1).min(area.bottom()),
area.width,
0,
);
}
let available = area.height.saturating_sub(1);
let height = self.content_height.min(available);
Rect::new(area.x, area.y + 1, area.width, height)
}
pub fn update(&mut self, msg: CollapsibleMessage) -> Option<CollapsibleOutput> {
Collapsible::update(self, msg)
}
}
pub struct Collapsible;
impl Component for Collapsible {
type State = CollapsibleState;
type Message = CollapsibleMessage;
type Output = CollapsibleOutput;
fn init() -> Self::State {
CollapsibleState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
CollapsibleMessage::Toggle => {
state.expanded = !state.expanded;
Some(CollapsibleOutput::Toggled(state.expanded))
}
CollapsibleMessage::Expand => {
if !state.expanded {
state.expanded = true;
Some(CollapsibleOutput::Expanded)
} else {
None
}
}
CollapsibleMessage::Collapse => {
if state.expanded {
state.expanded = false;
Some(CollapsibleOutput::Collapsed)
} else {
None
}
}
CollapsibleMessage::SetHeader(header) => {
state.header = header;
None
}
CollapsibleMessage::SetContentHeight(height) => {
state.content_height = height;
None
}
}
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
if let Some(key) = event.as_key() {
match key.code {
Key::Char(' ') | Key::Enter => Some(CollapsibleMessage::Toggle),
Key::Right => Some(CollapsibleMessage::Expand),
Key::Left => Some(CollapsibleMessage::Collapse),
_ => None,
}
} else {
None
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if ctx.area.height == 0 || ctx.area.width == 0 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"Collapsible".to_string(),
))
.with_id("collapsible")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled)
.with_expanded(state.expanded),
);
});
let indicator = if state.expanded {
"\u{25be}"
} else {
"\u{25b8}"
};
let header_text = format!("{} {}", indicator, state.header);
let header_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_style()
} else {
ctx.theme.normal_style()
};
let header_line = Line::from(Span::styled(header_text, header_style));
let header_area = Rect::new(ctx.area.x, ctx.area.y, ctx.area.width, 1);
ctx.frame
.render_widget(Paragraph::new(header_line), header_area);
if state.expanded && ctx.area.height > 1 {
let available = ctx.area.height.saturating_sub(1);
let content_h = state.content_height.min(available);
if content_h > 0 {
let content_area = Rect::new(ctx.area.x, ctx.area.y + 1, ctx.area.width, content_h);
let border_style = if ctx.disabled {
ctx.theme.disabled_style()
} else if ctx.focused {
ctx.theme.focused_border_style()
} else {
ctx.theme.border_style()
};
let content_block = Block::default()
.borders(Borders::LEFT | Borders::BOTTOM)
.border_style(border_style);
ctx.frame.render_widget(content_block, content_area);
}
}
}
}
impl Toggleable for Collapsible {
fn is_visible(state: &Self::State) -> bool {
state.expanded
}
fn set_visible(state: &mut Self::State, visible: bool) {
state.expanded = visible;
}
}
#[cfg(test)]
mod tests;