use crate::core::{Color, Font, ObjectId, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use std::cell::RefCell;
use std::rc::Rc;
pub struct CollapsiblePane {
base: BaseWidget,
title: String,
collapsed: bool,
animation_progress: f32,
content_child: Option<ObjectId>,
header_height: u32,
pub toggled: Signal1<bool>,
registry: Option<Rc<RefCell<SimpleRegistry>>>,
}
impl CollapsiblePane {
pub fn new(geometry: Rect, title: String) -> Self {
let mut base = BaseWidget::new(WidgetKind::CollapsiblePane, geometry, "CollapsiblePane");
base.visible = true;
Self {
base,
title,
collapsed: false,
animation_progress: 1.0,
content_child: None,
header_height: 24,
toggled: Signal1::new(),
registry: None,
}
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: String) {
self.title = title;
}
pub fn is_collapsed(&self) -> bool {
self.collapsed
}
pub fn set_collapsed(&mut self, collapsed: bool) {
if self.collapsed == collapsed {
return;
}
self.collapsed = collapsed;
self.animation_progress = if collapsed { 0.0 } else { 1.0 };
self.toggled.emit(collapsed);
self.base.request_redraw();
}
pub fn toggle(&mut self) {
self.set_collapsed(!self.collapsed);
}
pub fn set_content(&mut self, child: ObjectId) {
if let Some(existing) = self.content_child {
self.base.remove_child(existing);
}
self.content_child = Some(child);
self.base.add_child(child);
}
pub fn content(&self) -> Option<ObjectId> {
self.content_child
}
pub fn header_height(&self) -> u32 {
self.header_height
}
pub fn set_header_height(&mut self, height: u32) {
self.header_height = height;
}
pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
self.registry = Some(registry);
}
fn header_rect(&self) -> Rect {
let rect = self.geometry();
Rect::new(rect.x, rect.y, rect.width, self.header_height)
}
fn content_rect(&self) -> Rect {
let rect = self.geometry();
let y_offset = rect.y + self.header_height as i32;
let height = rect.height.saturating_sub(self.header_height);
Rect::new(rect.x, y_offset, rect.width, height)
}
}
impl Widget for CollapsiblePane {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn remove_child(&mut self, child: ObjectId) {
self.base.remove_child(child);
if self.content_child == Some(child) {
self.content_child = None;
}
}
}
impl EventHandler for CollapsiblePane {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if let Event::MousePress { pos, button } = event {
if *button == 1 {
let hdr = self.header_rect();
if hdr.contains(*pos) {
self.toggle();
}
}
}
if self.base.is_enabled() {
if let Some(content) = self.content_child {
if let Some(ref reg) = self.registry {
let _ = reg.borrow_mut().forward_event(content, event);
}
}
}
}
}
impl Draw for CollapsiblePane {
fn draw(&mut self, context: &mut RenderContext) {
let hdr = self.header_rect();
let header_bg = if self.base.is_enabled() {
Color::from_rgb(220, 220, 220)
} else {
Color::from_rgb(240, 240, 240)
};
context.fill_rect(hdr, header_bg);
let border_color = Color::from_rgb(180, 180, 180);
context.draw_line(
Point::from_f32(hdr.x as f32, (hdr.y + hdr.height as i32) as f32),
Point::from_f32((hdr.x + hdr.width as i32) as f32, (hdr.y + hdr.height as i32) as f32),
border_color,
);
let arrow_x = hdr.x + 6;
let arrow_y = hdr.y + (hdr.height as i32 / 2) - 4;
let arrow_color = if self.base.is_enabled() {
Color::from_rgb(80, 80, 80)
} else {
Color::from_rgb(180, 180, 180)
};
let arrow_char = if self.collapsed { "▶" } else { "▼" };
context.draw_text(
Point::from_f32(arrow_x as f32, arrow_y as f32),
arrow_char,
&Font::default(),
arrow_color,
);
if !self.title.is_empty() {
let text_x = hdr.x + 20;
let text_y = hdr.y + (hdr.height as i32 / 2) - 6;
let text_color = if self.base.is_enabled() {
Color::from_rgb(0, 0, 0)
} else {
Color::from_rgb(150, 150, 150)
};
context.draw_text(
Point::from_f32(text_x as f32, text_y as f32),
&self.title,
&Font::default(),
text_color,
);
}
if !self.collapsed {
let content_rect = self.content_rect();
context.fill_rect(content_rect, Color::from_rgb(248, 248, 248));
context.draw_line(
Point::from_f32(content_rect.x as f32, content_rect.y as f32),
Point::from_f32(
content_rect.x as f32,
(content_rect.y + content_rect.height as i32) as f32,
),
border_color,
);
context.draw_line(
Point::from_f32(
(content_rect.x + content_rect.width as i32) as f32,
content_rect.y as f32,
),
Point::from_f32(
(content_rect.x + content_rect.width as i32) as f32,
(content_rect.y + content_rect.height as i32) as f32,
),
border_color,
);
context.draw_line(
Point::from_f32(
content_rect.x as f32,
(content_rect.y + content_rect.height as i32) as f32,
),
Point::from_f32(
(content_rect.x + content_rect.width as i32) as f32,
(content_rect.y + content_rect.height as i32) as f32,
),
border_color,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Point, Rect, Size};
use crate::render::{PaintBackend, RenderContext, SvgPaintBackend};
use std::sync::Arc;
fn make_pane() -> CollapsiblePane {
CollapsiblePane::new(Rect::new(0, 0, 200, 100), "Test".to_string())
}
#[test]
fn collapsible_pane_creation_defaults() {
let cp = CollapsiblePane::new(Rect::new(0, 0, 200, 100), "Title".to_string());
assert_eq!(cp.title(), "Title");
assert!(!cp.is_collapsed(), "pane should start expanded");
assert_eq!(cp.header_height(), 24, "default header height should be 24");
assert!(cp.content().is_none(), "no content child by default");
}
#[test]
fn collapsible_pane_set_title() {
let mut cp = make_pane();
assert_eq!(cp.title(), "Test");
cp.set_title("Updated Title".to_string());
assert_eq!(cp.title(), "Updated Title");
cp.set_title(String::new());
assert_eq!(cp.title(), "");
}
#[test]
fn collapsible_pane_toggle_collapsed() {
let mut cp = make_pane();
assert!(!cp.is_collapsed());
cp.set_collapsed(true);
assert!(cp.is_collapsed());
cp.set_collapsed(false);
assert!(!cp.is_collapsed());
cp.set_collapsed(false);
assert!(!cp.is_collapsed());
cp.set_collapsed(true);
assert!(cp.is_collapsed());
cp.set_collapsed(true);
assert!(cp.is_collapsed());
cp.toggle();
assert!(!cp.is_collapsed());
cp.toggle();
assert!(cp.is_collapsed());
}
#[test]
fn collapsible_pane_toggle_emits_signal() {
let mut cp = make_pane();
let emitted = Arc::new(std::sync::Mutex::new(Vec::new()));
let e = Arc::clone(&emitted);
cp.toggled.connect(move |v| {
e.lock().unwrap().push(*v);
});
cp.set_collapsed(true);
assert_eq!(emitted.lock().unwrap().as_slice(), &[true], "should emit true when collapsing");
cp.set_collapsed(false);
assert_eq!(
emitted.lock().unwrap().as_slice(),
&[true, false],
"should emit false when expanding"
);
cp.set_collapsed(false);
assert_eq!(
emitted.lock().unwrap().as_slice(),
&[true, false],
"no-op must not emit signal"
);
cp.toggle();
assert_eq!(emitted.lock().unwrap().as_slice(), &[true, false, true], "toggle should emit");
}
#[test]
fn collapsible_pane_header_height() {
let mut cp = make_pane();
assert_eq!(cp.header_height(), 24);
cp.set_header_height(32);
assert_eq!(cp.header_height(), 32);
cp.set_header_height(0);
assert_eq!(cp.header_height(), 0);
}
#[test]
fn collapsible_pane_content_child() {
let mut cp = make_pane();
assert!(cp.content().is_none());
cp.set_content(42);
assert_eq!(cp.content(), Some(42));
assert!(cp.base().children().contains(&42), "content child must be in base children");
cp.set_content(99);
assert_eq!(cp.content(), Some(99));
assert!(!cp.base().children().contains(&42), "old child must be removed");
assert!(cp.base().children().contains(&99), "new child must be in base children");
cp.remove_child(99);
assert!(cp.content().is_none(), "content should be cleared after remove_child");
assert!(!cp.base().children().contains(&99));
}
#[test]
fn collapsible_pane_geometry_delegation() {
let mut cp = CollapsiblePane::new(Rect::new(10, 20, 300, 150), "Geo".to_string());
assert_eq!(cp.geometry(), Rect::new(10, 20, 300, 150));
cp.base_mut().set_geometry(Rect::new(0, 0, 400, 200));
assert_eq!(cp.geometry(), Rect::new(0, 0, 400, 200));
assert_eq!(cp.base().kind(), WidgetKind::CollapsiblePane);
}
#[test]
fn collapsible_pane_visibility() {
let mut cp = make_pane();
assert!(cp.base().is_visible(), "should be visible by default");
cp.base_mut().hide();
assert!(!cp.base().is_visible());
cp.base_mut().show();
assert!(cp.base().is_visible());
}
#[test]
fn collapsible_pane_id_kind() {
let cp1 = make_pane();
let cp2 = make_pane();
assert_ne!(cp1.base().id(), cp2.base().id(), "each pane must have a unique ObjectId");
assert_eq!(cp1.base().kind(), WidgetKind::CollapsiblePane);
assert_eq!(cp2.base().kind(), WidgetKind::CollapsiblePane);
let id: ObjectId = cp1.base().id();
assert!(id > 0, "ObjectId should be positive");
}
#[test]
fn collapsible_pane_draw_produces_svg() {
let mut cp = make_pane();
let mut svg_backend = SvgPaintBackend::new(Size::new(200, 100));
svg_backend.begin_frame(Color::from_rgb(255, 255, 255));
{
let mut ctx = RenderContext::new(&mut svg_backend);
cp.draw(&mut ctx);
}
svg_backend.end_frame();
let svg_output = svg_backend.finish();
assert!(svg_output.starts_with("<svg"), "output must start with <svg");
assert!(svg_output.ends_with("</svg>"), "output must end with </svg>");
assert!(svg_output.contains("248,248,248"), "should contain content area background");
assert!(svg_output.contains("220,220,220"), "should contain header background");
let mut cp2 = CollapsiblePane::new(Rect::new(0, 0, 200, 100), "Collapsed".to_string());
cp2.set_collapsed(true);
let mut svg_backend2 = SvgPaintBackend::new(Size::new(200, 100));
svg_backend2.begin_frame(Color::from_rgb(255, 255, 255));
{
let mut ctx = RenderContext::new(&mut svg_backend2);
cp2.draw(&mut ctx);
}
svg_backend2.end_frame();
let svg_collapsed = svg_backend2.finish();
assert!(
!svg_collapsed.contains("248,248,248"),
"collapsed pane must not draw content area"
);
assert!(svg_collapsed.contains("220,220,220"), "collapsed pane must still draw header");
}
#[test]
fn collapsible_pane_mouse_click_toggles() {
let mut cp = make_pane();
assert!(!cp.is_collapsed());
let click_event = Event::MousePress { pos: Point { x: 10, y: 12 }, button: 1 };
cp.handle_event(&click_event);
assert!(cp.is_collapsed(), "click on header should collapse the pane");
cp.handle_event(&click_event);
assert!(!cp.is_collapsed(), "second click on header should expand the pane");
cp.handle_event(&click_event); assert!(cp.is_collapsed());
let outside_event = Event::MousePress {
pos: Point { x: 10, y: 50 }, button: 1,
};
cp.handle_event(&outside_event);
assert!(cp.is_collapsed(), "click outside header must not toggle");
let right_click = Event::MousePress { pos: Point { x: 10, y: 12 }, button: 2 };
cp.handle_event(&right_click);
assert!(cp.is_collapsed(), "right-click on header must not toggle");
}
}