use crate::event::Key;
use crate::layout::Rect;
use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::widget::theme::{DARK_GRAY, DISABLED_FG, SECONDARY_TEXT};
use crate::widget::traits::{RenderContext, View, WidgetProps, WidgetState};
use crate::{impl_styled_view, impl_widget_builders};
pub struct Collapsible {
title: String,
content: Vec<String>,
expanded: bool,
collapsed_icon: char,
expanded_icon: char,
header_fg: Color,
header_bg: Option<Color>,
content_fg: Color,
content_bg: Option<Color>,
show_border: bool,
border_color: Color,
state: WidgetState,
min_width: u16,
min_height: u16,
max_width: u16,
max_height: u16,
props: WidgetProps,
}
impl Collapsible {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
content: Vec::new(),
expanded: false,
collapsed_icon: '▶',
expanded_icon: '▼',
header_fg: Color::WHITE,
header_bg: None,
content_fg: SECONDARY_TEXT,
content_bg: None,
show_border: true,
border_color: DARK_GRAY,
state: WidgetState::new(),
min_width: 0,
min_height: 0,
max_width: 0,
max_height: 0,
props: WidgetProps::new(),
}
}
pub fn content(mut self, text: impl Into<String>) -> Self {
self.content = text.into().lines().map(|s| s.to_string()).collect();
self
}
pub fn line(mut self, line: impl Into<String>) -> Self {
self.content.push(line.into());
self
}
pub fn lines(mut self, lines: &[&str]) -> Self {
self.content.extend(lines.iter().map(|s| s.to_string()));
self
}
pub fn expanded(mut self, expanded: bool) -> Self {
self.expanded = expanded;
self
}
pub fn icons(mut self, collapsed: char, expanded: char) -> Self {
self.collapsed_icon = collapsed;
self.expanded_icon = expanded;
self
}
pub fn header_colors(mut self, fg: Color, bg: Option<Color>) -> Self {
self.header_fg = fg;
self.header_bg = bg;
self
}
pub fn content_colors(mut self, fg: Color, bg: Option<Color>) -> Self {
self.content_fg = fg;
self.content_bg = bg;
self
}
pub fn border(mut self, show: bool) -> Self {
self.show_border = show;
self
}
pub fn border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
pub fn toggle(&mut self) {
self.expanded = !self.expanded;
}
pub fn expand(&mut self) {
self.expanded = true;
}
pub fn collapse(&mut self) {
self.expanded = false;
}
pub fn is_expanded(&self) -> bool {
self.expanded
}
pub fn set_expanded(&mut self, expanded: bool) {
self.expanded = expanded;
}
pub fn min_width(mut self, width: u16) -> Self {
self.min_width = width;
self
}
pub fn min_height(mut self, height: u16) -> Self {
self.min_height = height;
self
}
pub fn max_width(mut self, width: u16) -> Self {
self.max_width = width;
self
}
pub fn max_height(mut self, height: u16) -> Self {
self.max_height = height;
self
}
pub fn min_size(self, width: u16, height: u16) -> Self {
self.min_width(width).min_height(height)
}
pub fn max_size(self, width: u16, height: u16) -> Self {
self.max_width(width).max_height(height)
}
pub fn constrain(self, min_w: u16, min_h: u16, max_w: u16, max_h: u16) -> Self {
self.min_width(min_w)
.min_height(min_h)
.max_width(max_w)
.max_height(max_h)
}
fn apply_constraints(&self, area: Rect) -> Rect {
let eff_max_w = if self.max_width > 0 {
self.max_width.max(self.min_width)
} else {
u16::MAX
};
let eff_max_h = if self.max_height > 0 {
self.max_height.max(self.min_height)
} else {
u16::MAX
};
let width = area.width.clamp(self.min_width, eff_max_w);
let height = area.height.clamp(self.min_height, eff_max_h);
Rect::new(area.x, area.y, width, height)
}
fn icon(&self) -> char {
if self.expanded {
self.expanded_icon
} else {
self.collapsed_icon
}
}
pub fn height(&self) -> u16 {
if self.expanded {
let content_height = self.content.len() as u16;
if self.show_border {
1 + content_height + 1
} else {
1 + content_height
}
} else {
1 }
}
pub fn handle_key(&mut self, key: &Key) -> bool {
if self.state.disabled {
return false;
}
match key {
Key::Enter | Key::Char(' ') => {
self.toggle();
true
}
Key::Right | Key::Char('l') => {
self.expand();
true
}
Key::Left | Key::Char('h') => {
self.collapse();
true
}
_ => false,
}
}
}
impl Default for Collapsible {
fn default() -> Self {
Self::new("Details")
}
}
impl View for Collapsible {
crate::impl_view_meta!("Collapsible");
fn render(&self, ctx: &mut RenderContext) {
let area = self.apply_constraints(ctx.area);
if area.width < 4 || area.height < 1 {
return;
}
let is_focused = self.state.focused || ctx.is_focused();
let header_fg = if self.state.disabled {
DISABLED_FG
} else {
self.header_fg
};
let mut x: u16 = 0;
if let Some(bg) = self.header_bg {
for dx in 0..area.width {
let mut cell = Cell::new(' ');
cell.bg = Some(bg);
ctx.set(dx, 0, cell);
}
}
let mut icon_cell = Cell::new(self.icon());
icon_cell.fg = Some(header_fg);
if let Some(bg) = self.header_bg {
icon_cell.bg = Some(bg);
}
if is_focused {
icon_cell.modifier |= Modifier::BOLD;
}
ctx.set(x, 0, icon_cell);
x += 2;
let max_title_width = (area.width.saturating_sub(3)) as usize;
let title_display = crate::utils::truncate_to_width(&self.title, max_title_width);
for ch in title_display.chars() {
let cw = crate::utils::char_width(ch) as u16;
if x + cw > area.width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(header_fg);
if let Some(bg) = self.header_bg {
cell.bg = Some(bg);
}
if is_focused {
cell.modifier |= Modifier::BOLD;
}
ctx.set(x, 0, cell);
x += cw;
}
if self.expanded && area.height > 1 {
let content_start_y: u16 = 1;
let available_height = area.height.saturating_sub(1);
let content_width = area.width.saturating_sub(2);
let lines_to_show = if self.show_border {
available_height.saturating_sub(1) as usize
} else {
available_height as usize
};
for (i, line) in self.content.iter().take(lines_to_show).enumerate() {
let y = content_start_y + i as u16;
if y >= area.height {
break;
}
if let Some(bg) = self.content_bg {
for dx in 0..area.width {
let mut cell = Cell::new(' ');
cell.bg = Some(bg);
ctx.set(dx, y, cell);
}
}
if self.show_border {
let mut border_cell = Cell::new('│');
border_cell.fg = Some(self.border_color);
ctx.set(0, y, border_cell);
}
let text_x: u16 = if self.show_border { 2 } else { 1 };
let max_content_width = content_width.saturating_sub(1) as usize;
for (ci, ch) in line.chars().enumerate() {
if ci >= max_content_width {
break;
}
let mut cell = Cell::new(ch);
cell.fg = Some(self.content_fg);
if let Some(bg) = self.content_bg {
cell.bg = Some(bg);
}
ctx.set(text_x + ci as u16, y, cell);
}
}
if self.show_border {
let bottom_y = content_start_y + lines_to_show.min(self.content.len()) as u16;
if bottom_y < area.height {
let mut corner = Cell::new('└');
corner.fg = Some(self.border_color);
ctx.set(0, bottom_y, corner);
let line_width = area.width.saturating_sub(1);
for dx in 1..line_width {
let mut line_cell = Cell::new('─');
line_cell.fg = Some(self.border_color);
ctx.set(dx, bottom_y, line_cell);
}
}
}
}
}
}
impl_styled_view!(Collapsible);
impl_widget_builders!(Collapsible);
pub fn collapsible(title: impl Into<String>) -> Collapsible {
Collapsible::new(title)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render::Buffer;
#[test]
fn test_collapsible_new() {
let c = Collapsible::new("Info");
assert_eq!(c.title, "Info");
assert!(!c.is_expanded());
assert_eq!(c.height(), 1);
}
#[test]
fn test_collapsible_expand_collapse() {
let mut c = Collapsible::new("Info").content("Line 1\nLine 2");
assert!(!c.is_expanded());
assert_eq!(c.height(), 1);
c.expand();
assert!(c.is_expanded());
assert!(c.height() > 1);
c.collapse();
assert!(!c.is_expanded());
assert_eq!(c.height(), 1);
}
#[test]
fn test_collapsible_toggle() {
let mut c = Collapsible::new("Info");
assert!(!c.is_expanded());
c.toggle();
assert!(c.is_expanded());
c.toggle();
assert!(!c.is_expanded());
}
#[test]
fn test_collapsible_content() {
let c = Collapsible::new("Info")
.content("Line 1\nLine 2\nLine 3")
.expanded(true);
assert!(c.is_expanded());
assert_eq!(c.content.len(), 3);
assert_eq!(c.height(), 5);
}
#[test]
fn test_collapsible_content_no_border() {
let c = Collapsible::new("Info")
.content("Line 1\nLine 2")
.border(false)
.expanded(true);
assert_eq!(c.height(), 3);
}
#[test]
fn test_collapsible_handle_key() {
let mut c = Collapsible::new("Info").content("Text");
assert!(c.handle_key(&Key::Enter));
assert!(c.is_expanded());
assert!(c.handle_key(&Key::Char(' ')));
assert!(!c.is_expanded());
assert!(c.handle_key(&Key::Right));
assert!(c.is_expanded());
assert!(c.handle_key(&Key::Left));
assert!(!c.is_expanded());
assert!(!c.handle_key(&Key::Char('x')));
}
#[test]
fn test_collapsible_handle_key_disabled() {
let mut c = Collapsible::new("Info").content("Text");
c.state.disabled = true;
assert!(!c.handle_key(&Key::Enter));
assert!(!c.is_expanded());
}
#[test]
fn test_collapsible_icons() {
let c = Collapsible::new("Info").icons('+', '-');
assert_eq!(c.icon(), '+');
let c = Collapsible::new("Info").icons('+', '-').expanded(true);
assert_eq!(c.icon(), '-');
}
#[test]
fn test_collapsible_render_collapsed() {
let mut buf = Buffer::new(30, 10);
let area = Rect::new(0, 0, 30, 10);
let mut ctx = RenderContext::new(&mut buf, area);
let c = Collapsible::new("Details");
c.render(&mut ctx);
assert_eq!(buf.get(0, 0).unwrap().symbol, '▶');
}
#[test]
fn test_collapsible_render_expanded() {
let mut buf = Buffer::new(30, 10);
let area = Rect::new(0, 0, 30, 10);
let mut ctx = RenderContext::new(&mut buf, area);
let c = Collapsible::new("Details").content("Hello").expanded(true);
c.render(&mut ctx);
assert_eq!(buf.get(0, 0).unwrap().symbol, '▼');
}
#[test]
fn test_collapsible_render_small_area_no_panic() {
let mut buf = Buffer::new(10, 5);
let area = Rect::new(0, 0, 3, 1);
let mut ctx = RenderContext::new(&mut buf, area);
let c = Collapsible::new("Long Title").expanded(true);
c.render(&mut ctx); }
#[test]
fn test_collapsible_default() {
let c = Collapsible::default();
assert_eq!(c.title, "Details");
}
#[test]
fn test_collapsible_helper_fn() {
let c = collapsible("Test");
assert_eq!(c.title, "Test");
}
#[test]
fn test_collapsible_line_and_lines() {
let c = Collapsible::new("Info")
.line("First")
.lines(&["Second", "Third"]);
assert_eq!(c.content.len(), 3);
}
}