use crate::widgets::border::BorderStyle;
use presentar_core::{
Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
};
use std::any::Any;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CollapseDirection {
#[default]
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone)]
pub struct CollapseIndicators {
pub expanded: char,
pub collapsed: char,
}
impl Default for CollapseIndicators {
fn default() -> Self {
Self::triangle()
}
}
impl CollapseIndicators {
#[must_use]
pub fn triangle() -> Self {
Self {
expanded: '▼',
collapsed: '▶',
}
}
#[must_use]
pub fn plus_minus() -> Self {
Self {
expanded: '−',
collapsed: '+',
}
}
#[must_use]
pub fn chevron() -> Self {
Self {
expanded: '˅',
collapsed: '˃',
}
}
#[must_use]
pub fn arrow() -> Self {
Self {
expanded: '↓',
collapsed: '→',
}
}
#[must_use]
pub fn current(&self, collapsed: bool) -> char {
if collapsed {
self.collapsed
} else {
self.expanded
}
}
}
#[derive(Debug, Clone)]
pub struct CollapsiblePanel {
title: String,
collapsed: bool,
direction: CollapseDirection,
indicators: CollapseIndicators,
border_style: BorderStyle,
title_color: Color,
border_color: Color,
indicator_color: Color,
content_height: usize,
bounds: Rect,
}
impl Default for CollapsiblePanel {
fn default() -> Self {
Self::new("Panel")
}
}
impl CollapsiblePanel {
#[must_use]
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
collapsed: false,
direction: CollapseDirection::default(),
indicators: CollapseIndicators::default(),
border_style: BorderStyle::Rounded,
title_color: Color::WHITE,
border_color: Color::new(0.4, 0.5, 0.6, 1.0),
indicator_color: Color::new(0.8, 0.8, 0.3, 1.0),
content_height: 3,
bounds: Rect::default(),
}
}
#[must_use]
pub fn with_collapsed(mut self, collapsed: bool) -> Self {
self.collapsed = collapsed;
self
}
#[must_use]
pub fn with_direction(mut self, direction: CollapseDirection) -> Self {
self.direction = direction;
self
}
#[must_use]
pub fn with_indicators(mut self, indicators: CollapseIndicators) -> Self {
self.indicators = indicators;
self
}
#[must_use]
pub fn with_border_style(mut self, style: BorderStyle) -> Self {
self.border_style = style;
self
}
#[must_use]
pub fn with_title_color(mut self, color: Color) -> Self {
self.title_color = color;
self
}
#[must_use]
pub fn with_border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
#[must_use]
pub fn with_indicator_color(mut self, color: Color) -> Self {
self.indicator_color = color;
self
}
#[must_use]
pub fn with_content_height(mut self, height: usize) -> Self {
self.content_height = height;
self
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub fn is_collapsed(&self) -> bool {
self.collapsed
}
#[must_use]
pub fn is_expanded(&self) -> bool {
!self.collapsed
}
#[must_use]
pub fn direction(&self) -> CollapseDirection {
self.direction
}
#[must_use]
pub fn inner_rect(&self) -> Rect {
if self.collapsed {
Rect::new(self.bounds.x + 1.0, self.bounds.y + 1.0, 0.0, 0.0)
} else {
Rect::new(
self.bounds.x + 1.0,
self.bounds.y + 1.0,
(self.bounds.width - 2.0).max(0.0),
(self.bounds.height - 2.0).max(0.0),
)
}
}
#[must_use]
pub fn effective_height(&self) -> usize {
if self.collapsed {
2 } else {
2 + self.content_height }
}
pub fn toggle(&mut self) {
self.collapsed = !self.collapsed;
}
pub fn expand(&mut self) {
self.collapsed = false;
}
pub fn collapse(&mut self) {
self.collapsed = true;
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
}
impl Brick for CollapsiblePanel {
fn brick_name(&self) -> &'static str {
"collapsible_panel"
}
fn assertions(&self) -> &[BrickAssertion] {
static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
ASSERTIONS
}
fn budget(&self) -> BrickBudget {
BrickBudget::uniform(8)
}
fn verify(&self) -> BrickVerification {
BrickVerification {
passed: self.assertions().to_vec(),
failed: vec![],
verification_time: Duration::from_micros(5),
}
}
fn to_html(&self) -> String {
String::new()
}
fn to_css(&self) -> String {
String::new()
}
}
impl Widget for CollapsiblePanel {
fn type_id(&self) -> TypeId {
TypeId::of::<Self>()
}
fn measure(&self, constraints: Constraints) -> Size {
let width = constraints.max_width.clamp(10.0, 40.0);
let height = self.effective_height() as f32;
constraints.constrain(Size::new(width, height))
}
fn layout(&mut self, bounds: Rect) -> LayoutResult {
self.bounds = bounds;
LayoutResult {
size: Size::new(bounds.width, bounds.height),
}
}
fn paint(&self, canvas: &mut dyn Canvas) {
let width = self.bounds.width as usize;
let height = self.bounds.height as usize;
if width < 4 || height < 2 {
return;
}
let (tl, top, tr, left, right, bl, bottom, br) = self.border_style.chars();
let border_style = TextStyle {
color: self.border_color,
..Default::default()
};
let indicator_style = TextStyle {
color: self.indicator_color,
..Default::default()
};
let title_style = TextStyle {
color: self.title_color,
..Default::default()
};
let indicator = self.indicators.current(self.collapsed);
canvas.draw_text(
&tl.to_string(),
Point::new(self.bounds.x, self.bounds.y),
&border_style,
);
canvas.draw_text(
&indicator.to_string(),
Point::new(self.bounds.x + 1.0, self.bounds.y),
&indicator_style,
);
let title_start = 3;
let available = width.saturating_sub(title_start + 2);
let displayed_title: String = self.title.chars().take(available).collect();
canvas.draw_text(
&format!(" {displayed_title} "),
Point::new(self.bounds.x + 2.0, self.bounds.y),
&title_style,
);
let title_len = displayed_title.chars().count() + 2; let rest_start = title_start + title_len;
if rest_start < width - 1 {
let rest: String = std::iter::repeat(top)
.take(width - rest_start - 1)
.collect();
canvas.draw_text(
&rest,
Point::new(self.bounds.x + rest_start as f32, self.bounds.y),
&border_style,
);
}
canvas.draw_text(
&tr.to_string(),
Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y),
&border_style,
);
if self.collapsed {
let mut bottom_line = String::with_capacity(width);
bottom_line.push(bl);
for _ in 0..(width - 2) {
bottom_line.push(bottom);
}
bottom_line.push(br);
canvas.draw_text(
&bottom_line,
Point::new(self.bounds.x, self.bounds.y + 1.0),
&border_style,
);
} else {
for y in 1..(height - 1) {
canvas.draw_text(
&left.to_string(),
Point::new(self.bounds.x, self.bounds.y + y as f32),
&border_style,
);
canvas.draw_text(
&right.to_string(),
Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y + y as f32),
&border_style,
);
}
let mut bottom_line = String::with_capacity(width);
bottom_line.push(bl);
for _ in 0..(width - 2) {
bottom_line.push(bottom);
}
bottom_line.push(br);
canvas.draw_text(
&bottom_line,
Point::new(self.bounds.x, self.bounds.y + (height - 1) as f32),
&border_style,
);
}
}
fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
match event {
Event::KeyDown {
key: Key::Enter | Key::Space,
} => {
self.toggle();
Some(Box::new(self.collapsed))
}
_ => None,
}
}
fn children(&self) -> &[Box<dyn Widget>] {
&[]
}
fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
&mut []
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockCanvas {
texts: Vec<(String, Point)>,
}
impl MockCanvas {
fn new() -> Self {
Self { texts: vec![] }
}
}
impl Canvas for MockCanvas {
fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
self.texts.push((text.to_string(), position));
}
fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
fn push_clip(&mut self, _rect: Rect) {}
fn pop_clip(&mut self) {}
fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
fn pop_transform(&mut self) {}
}
#[test]
fn test_new() {
let panel = CollapsiblePanel::new("CPU");
assert_eq!(panel.title(), "CPU");
assert!(!panel.is_collapsed());
}
#[test]
fn test_default() {
let panel = CollapsiblePanel::default();
assert_eq!(panel.title(), "Panel");
assert!(!panel.is_collapsed());
}
#[test]
fn test_with_collapsed() {
let panel = CollapsiblePanel::new("Test").with_collapsed(true);
assert!(panel.is_collapsed());
}
#[test]
fn test_with_direction() {
let panel = CollapsiblePanel::new("Test").with_direction(CollapseDirection::Left);
assert_eq!(panel.direction(), CollapseDirection::Left);
}
#[test]
fn test_with_indicators() {
let panel = CollapsiblePanel::new("Test").with_indicators(CollapseIndicators::plus_minus());
assert_eq!(panel.indicators.expanded, '−');
}
#[test]
fn test_with_border_style() {
let panel = CollapsiblePanel::new("Test").with_border_style(BorderStyle::Double);
assert_eq!(panel.border_style, BorderStyle::Double);
}
#[test]
fn test_with_title_color() {
let panel = CollapsiblePanel::new("Test").with_title_color(Color::RED);
assert_eq!(panel.title_color, Color::RED);
}
#[test]
fn test_with_border_color() {
let panel = CollapsiblePanel::new("Test").with_border_color(Color::GREEN);
assert_eq!(panel.border_color, Color::GREEN);
}
#[test]
fn test_with_indicator_color() {
let panel = CollapsiblePanel::new("Test").with_indicator_color(Color::BLUE);
assert_eq!(panel.indicator_color, Color::BLUE);
}
#[test]
fn test_with_content_height() {
let panel = CollapsiblePanel::new("Test").with_content_height(5);
assert_eq!(panel.content_height, 5);
}
#[test]
fn test_indicators_default() {
let ind = CollapseIndicators::default();
assert_eq!(ind.expanded, '▼');
assert_eq!(ind.collapsed, '▶');
}
#[test]
fn test_indicators_triangle() {
let ind = CollapseIndicators::triangle();
assert_eq!(ind.expanded, '▼');
assert_eq!(ind.collapsed, '▶');
}
#[test]
fn test_indicators_plus_minus() {
let ind = CollapseIndicators::plus_minus();
assert_eq!(ind.expanded, '−');
assert_eq!(ind.collapsed, '+');
}
#[test]
fn test_indicators_chevron() {
let ind = CollapseIndicators::chevron();
assert_eq!(ind.expanded, '˅');
assert_eq!(ind.collapsed, '˃');
}
#[test]
fn test_indicators_arrow() {
let ind = CollapseIndicators::arrow();
assert_eq!(ind.expanded, '↓');
assert_eq!(ind.collapsed, '→');
}
#[test]
fn test_indicators_current_expanded() {
let ind = CollapseIndicators::triangle();
assert_eq!(ind.current(false), '▼');
}
#[test]
fn test_indicators_current_collapsed() {
let ind = CollapseIndicators::triangle();
assert_eq!(ind.current(true), '▶');
}
#[test]
fn test_direction_default() {
assert_eq!(CollapseDirection::default(), CollapseDirection::Up);
}
#[test]
fn test_direction_variants() {
let _ = CollapseDirection::Up;
let _ = CollapseDirection::Down;
let _ = CollapseDirection::Left;
let _ = CollapseDirection::Right;
}
#[test]
fn test_is_expanded() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.is_expanded());
}
#[test]
fn test_is_expanded_when_collapsed() {
let panel = CollapsiblePanel::new("Test").with_collapsed(true);
assert!(!panel.is_expanded());
}
#[test]
fn test_effective_height_expanded() {
let panel = CollapsiblePanel::new("Test").with_content_height(5);
assert_eq!(panel.effective_height(), 7); }
#[test]
fn test_effective_height_collapsed() {
let panel = CollapsiblePanel::new("Test").with_collapsed(true);
assert_eq!(panel.effective_height(), 2);
}
#[test]
fn test_inner_rect_expanded() {
let mut panel = CollapsiblePanel::new("Test");
panel.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
let inner = panel.inner_rect();
assert_eq!(inner.x, 1.0);
assert_eq!(inner.y, 1.0);
assert_eq!(inner.width, 18.0);
assert_eq!(inner.height, 8.0);
}
#[test]
fn test_inner_rect_collapsed() {
let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
panel.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
let inner = panel.inner_rect();
assert_eq!(inner.width, 0.0);
assert_eq!(inner.height, 0.0);
}
#[test]
fn test_toggle_expand_to_collapse() {
let mut panel = CollapsiblePanel::new("Test");
panel.toggle();
assert!(panel.is_collapsed());
}
#[test]
fn test_toggle_collapse_to_expand() {
let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
panel.toggle();
assert!(panel.is_expanded());
}
#[test]
fn test_expand() {
let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
panel.expand();
assert!(panel.is_expanded());
}
#[test]
fn test_collapse() {
let mut panel = CollapsiblePanel::new("Test");
panel.collapse();
assert!(panel.is_collapsed());
}
#[test]
fn test_set_title() {
let mut panel = CollapsiblePanel::new("Old");
panel.set_title("New");
assert_eq!(panel.title(), "New");
}
#[test]
fn test_brick_name() {
let panel = CollapsiblePanel::new("Test");
assert_eq!(panel.brick_name(), "collapsible_panel");
}
#[test]
fn test_assertions_not_empty() {
let panel = CollapsiblePanel::new("Test");
assert!(!panel.assertions().is_empty());
}
#[test]
fn test_budget() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.budget().paint_ms > 0);
}
#[test]
fn test_verify() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.verify().is_valid());
}
#[test]
fn test_to_html() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.to_html().is_empty());
}
#[test]
fn test_to_css() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.to_css().is_empty());
}
#[test]
fn test_type_id() {
let panel = CollapsiblePanel::new("Test");
assert_eq!(Widget::type_id(&panel), TypeId::of::<CollapsiblePanel>());
}
#[test]
fn test_measure_expanded() {
let panel = CollapsiblePanel::new("Test").with_content_height(5);
let size = panel.measure(Constraints::loose(Size::new(100.0, 100.0)));
assert!(size.width >= 10.0);
assert_eq!(size.height, 7.0); }
#[test]
fn test_measure_collapsed() {
let panel = CollapsiblePanel::new("Test").with_collapsed(true);
let size = panel.measure(Constraints::loose(Size::new(100.0, 100.0)));
assert_eq!(size.height, 2.0);
}
#[test]
fn test_layout() {
let mut panel = CollapsiblePanel::new("Test");
let bounds = Rect::new(5.0, 10.0, 30.0, 8.0);
let result = panel.layout(bounds);
assert_eq!(result.size.width, 30.0);
assert_eq!(panel.bounds, bounds);
}
#[test]
fn test_children() {
let panel = CollapsiblePanel::new("Test");
assert!(panel.children().is_empty());
}
#[test]
fn test_children_mut() {
let mut panel = CollapsiblePanel::new("Test");
assert!(panel.children_mut().is_empty());
}
#[test]
fn test_paint_expanded() {
let mut panel = CollapsiblePanel::new("CPU");
panel.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
assert!(!canvas.texts.is_empty());
assert!(canvas.texts.iter().any(|(t, _)| t.contains("CPU")));
}
#[test]
fn test_paint_collapsed() {
let mut panel = CollapsiblePanel::new("CPU").with_collapsed(true);
panel.bounds = Rect::new(0.0, 0.0, 20.0, 2.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
assert!(!canvas.texts.is_empty());
}
#[test]
fn test_paint_small_bounds() {
let mut panel = CollapsiblePanel::new("CPU");
panel.bounds = Rect::new(0.0, 0.0, 3.0, 1.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
}
#[test]
fn test_paint_with_indicator_expanded() {
let mut panel = CollapsiblePanel::new("Test");
panel.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
assert!(canvas.texts.iter().any(|(t, _)| t == "▼"));
}
#[test]
fn test_paint_with_indicator_collapsed() {
let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
panel.bounds = Rect::new(0.0, 0.0, 20.0, 2.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
assert!(canvas.texts.iter().any(|(t, _)| t == "▶"));
}
#[test]
fn test_event_enter_toggles() {
let mut panel = CollapsiblePanel::new("Test");
let event = Event::KeyDown { key: Key::Enter };
let result = panel.event(&event);
assert!(result.is_some());
assert!(panel.is_collapsed());
}
#[test]
fn test_event_space_toggles() {
let mut panel = CollapsiblePanel::new("Test");
let event = Event::KeyDown { key: Key::Space };
let result = panel.event(&event);
assert!(result.is_some());
assert!(panel.is_collapsed());
}
#[test]
fn test_event_other_keys_ignored() {
let mut panel = CollapsiblePanel::new("Test");
let event = Event::KeyDown { key: Key::Left };
let result = panel.event(&event);
assert!(result.is_none());
assert!(panel.is_expanded());
}
#[test]
fn test_event_returns_state() {
let mut panel = CollapsiblePanel::new("Test");
let event = Event::KeyDown { key: Key::Enter };
let result = panel.event(&event);
if let Some(boxed) = result {
let state = boxed.downcast_ref::<bool>();
assert_eq!(state, Some(&true));
}
}
#[test]
fn test_long_title_truncated() {
let mut panel = CollapsiblePanel::new("Very Long Title That Should Be Truncated");
panel.bounds = Rect::new(0.0, 0.0, 15.0, 5.0);
let mut canvas = MockCanvas::new();
panel.paint(&mut canvas);
}
}