Skip to main content

presentar_terminal/widgets/
collapsible_panel.rs

1//! `CollapsiblePanel` widget for expandable/collapsible content sections.
2//!
3//! Provides a panel with header that can be collapsed to save space.
4//! Based on btop panel patterns with toggle indicators.
5
6use crate::widgets::border::BorderStyle;
7use presentar_core::{
8    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
9    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
10};
11use std::any::Any;
12use std::time::Duration;
13
14/// Direction for collapse animation/behavior.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum CollapseDirection {
17    /// Collapses upward (content below header disappears).
18    #[default]
19    Up,
20    /// Collapses downward (content above header disappears).
21    Down,
22    /// Collapses leftward.
23    Left,
24    /// Collapses rightward.
25    Right,
26}
27
28/// Indicator characters for expand/collapse state.
29#[derive(Debug, Clone)]
30pub struct CollapseIndicators {
31    /// Character shown when expanded.
32    pub expanded: char,
33    /// Character shown when collapsed.
34    pub collapsed: char,
35}
36
37impl Default for CollapseIndicators {
38    fn default() -> Self {
39        Self::triangle()
40    }
41}
42
43impl CollapseIndicators {
44    /// Triangle indicators (▼/▶).
45    #[must_use]
46    pub fn triangle() -> Self {
47        Self {
48            expanded: '▼',
49            collapsed: '▶',
50        }
51    }
52
53    /// Plus/minus indicators (−/+).
54    #[must_use]
55    pub fn plus_minus() -> Self {
56        Self {
57            expanded: '−',
58            collapsed: '+',
59        }
60    }
61
62    /// Chevron indicators (˅/˃).
63    #[must_use]
64    pub fn chevron() -> Self {
65        Self {
66            expanded: '˅',
67            collapsed: '˃',
68        }
69    }
70
71    /// Arrow indicators (↓/→).
72    #[must_use]
73    pub fn arrow() -> Self {
74        Self {
75            expanded: '↓',
76            collapsed: '→',
77        }
78    }
79
80    /// Get the current indicator based on collapsed state.
81    #[must_use]
82    pub fn current(&self, collapsed: bool) -> char {
83        if collapsed {
84            self.collapsed
85        } else {
86            self.expanded
87        }
88    }
89}
90
91/// Collapsible panel widget with header and toggle.
92#[derive(Debug, Clone)]
93pub struct CollapsiblePanel {
94    /// Panel title.
95    title: String,
96    /// Collapsed state.
97    collapsed: bool,
98    /// Collapse direction.
99    direction: CollapseDirection,
100    /// Indicator characters.
101    indicators: CollapseIndicators,
102    /// Border style.
103    border_style: BorderStyle,
104    /// Title color.
105    title_color: Color,
106    /// Border color.
107    border_color: Color,
108    /// Indicator color.
109    indicator_color: Color,
110    /// Content height when expanded (in lines).
111    content_height: usize,
112    /// Cached bounds.
113    bounds: Rect,
114}
115
116impl Default for CollapsiblePanel {
117    fn default() -> Self {
118        Self::new("Panel")
119    }
120}
121
122impl CollapsiblePanel {
123    /// Create a new collapsible panel.
124    #[must_use]
125    pub fn new(title: impl Into<String>) -> Self {
126        Self {
127            title: title.into(),
128            collapsed: false,
129            direction: CollapseDirection::default(),
130            indicators: CollapseIndicators::default(),
131            border_style: BorderStyle::Rounded,
132            title_color: Color::WHITE,
133            border_color: Color::new(0.4, 0.5, 0.6, 1.0),
134            indicator_color: Color::new(0.8, 0.8, 0.3, 1.0),
135            content_height: 3,
136            bounds: Rect::default(),
137        }
138    }
139
140    /// Set collapsed state.
141    #[must_use]
142    pub fn with_collapsed(mut self, collapsed: bool) -> Self {
143        self.collapsed = collapsed;
144        self
145    }
146
147    /// Set collapse direction.
148    #[must_use]
149    pub fn with_direction(mut self, direction: CollapseDirection) -> Self {
150        self.direction = direction;
151        self
152    }
153
154    /// Set indicator style.
155    #[must_use]
156    pub fn with_indicators(mut self, indicators: CollapseIndicators) -> Self {
157        self.indicators = indicators;
158        self
159    }
160
161    /// Set border style.
162    #[must_use]
163    pub fn with_border_style(mut self, style: BorderStyle) -> Self {
164        self.border_style = style;
165        self
166    }
167
168    /// Set title color.
169    #[must_use]
170    pub fn with_title_color(mut self, color: Color) -> Self {
171        self.title_color = color;
172        self
173    }
174
175    /// Set border color.
176    #[must_use]
177    pub fn with_border_color(mut self, color: Color) -> Self {
178        self.border_color = color;
179        self
180    }
181
182    /// Set indicator color.
183    #[must_use]
184    pub fn with_indicator_color(mut self, color: Color) -> Self {
185        self.indicator_color = color;
186        self
187    }
188
189    /// Set content height when expanded.
190    #[must_use]
191    pub fn with_content_height(mut self, height: usize) -> Self {
192        self.content_height = height;
193        self
194    }
195
196    // ================= State =================
197
198    /// Get the title.
199    #[must_use]
200    pub fn title(&self) -> &str {
201        &self.title
202    }
203
204    /// Check if panel is collapsed.
205    #[must_use]
206    pub fn is_collapsed(&self) -> bool {
207        self.collapsed
208    }
209
210    /// Check if panel is expanded.
211    #[must_use]
212    pub fn is_expanded(&self) -> bool {
213        !self.collapsed
214    }
215
216    /// Get collapse direction.
217    #[must_use]
218    pub fn direction(&self) -> CollapseDirection {
219        self.direction
220    }
221
222    /// Get the inner content area (excluding border).
223    #[must_use]
224    pub fn inner_rect(&self) -> Rect {
225        if self.collapsed {
226            Rect::new(self.bounds.x + 1.0, self.bounds.y + 1.0, 0.0, 0.0)
227        } else {
228            Rect::new(
229                self.bounds.x + 1.0,
230                self.bounds.y + 1.0,
231                (self.bounds.width - 2.0).max(0.0),
232                (self.bounds.height - 2.0).max(0.0),
233            )
234        }
235    }
236
237    /// Get effective height based on collapsed state.
238    #[must_use]
239    pub fn effective_height(&self) -> usize {
240        if self.collapsed {
241            2 // Just header + bottom border
242        } else {
243            2 + self.content_height // Header + content + bottom border
244        }
245    }
246
247    // ================= Actions =================
248
249    /// Toggle collapsed state.
250    pub fn toggle(&mut self) {
251        self.collapsed = !self.collapsed;
252    }
253
254    /// Expand the panel.
255    pub fn expand(&mut self) {
256        self.collapsed = false;
257    }
258
259    /// Collapse the panel.
260    pub fn collapse(&mut self) {
261        self.collapsed = true;
262    }
263
264    /// Set title.
265    pub fn set_title(&mut self, title: impl Into<String>) {
266        self.title = title.into();
267    }
268}
269
270impl Brick for CollapsiblePanel {
271    fn brick_name(&self) -> &'static str {
272        "collapsible_panel"
273    }
274
275    fn assertions(&self) -> &[BrickAssertion] {
276        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
277        ASSERTIONS
278    }
279
280    fn budget(&self) -> BrickBudget {
281        BrickBudget::uniform(8)
282    }
283
284    fn verify(&self) -> BrickVerification {
285        BrickVerification {
286            passed: self.assertions().to_vec(),
287            failed: vec![],
288            verification_time: Duration::from_micros(5),
289        }
290    }
291
292    fn to_html(&self) -> String {
293        String::new()
294    }
295
296    fn to_css(&self) -> String {
297        String::new()
298    }
299}
300
301impl Widget for CollapsiblePanel {
302    fn type_id(&self) -> TypeId {
303        TypeId::of::<Self>()
304    }
305
306    fn measure(&self, constraints: Constraints) -> Size {
307        let width = constraints.max_width.clamp(10.0, 40.0);
308        let height = self.effective_height() as f32;
309        constraints.constrain(Size::new(width, height))
310    }
311
312    fn layout(&mut self, bounds: Rect) -> LayoutResult {
313        self.bounds = bounds;
314        LayoutResult {
315            size: Size::new(bounds.width, bounds.height),
316        }
317    }
318
319    fn paint(&self, canvas: &mut dyn Canvas) {
320        let width = self.bounds.width as usize;
321        let height = self.bounds.height as usize;
322
323        if width < 4 || height < 2 {
324            return;
325        }
326
327        let (tl, top, tr, left, right, bl, bottom, br) = self.border_style.chars();
328        let border_style = TextStyle {
329            color: self.border_color,
330            ..Default::default()
331        };
332        let indicator_style = TextStyle {
333            color: self.indicator_color,
334            ..Default::default()
335        };
336        let title_style = TextStyle {
337            color: self.title_color,
338            ..Default::default()
339        };
340
341        // Draw top border with indicator and title
342        let indicator = self.indicators.current(self.collapsed);
343
344        // Top-left corner
345        canvas.draw_text(
346            &tl.to_string(),
347            Point::new(self.bounds.x, self.bounds.y),
348            &border_style,
349        );
350
351        // Indicator after corner
352        canvas.draw_text(
353            &indicator.to_string(),
354            Point::new(self.bounds.x + 1.0, self.bounds.y),
355            &indicator_style,
356        );
357
358        // Space + title
359        let title_start = 3;
360        let available = width.saturating_sub(title_start + 2);
361        let displayed_title: String = self.title.chars().take(available).collect();
362
363        canvas.draw_text(
364            &format!(" {displayed_title} "),
365            Point::new(self.bounds.x + 2.0, self.bounds.y),
366            &title_style,
367        );
368
369        // Rest of top border
370        let title_len = displayed_title.chars().count() + 2; // +2 for spaces
371        let rest_start = title_start + title_len;
372        if rest_start < width - 1 {
373            let rest: String = std::iter::repeat_n(top, width - rest_start - 1).collect();
374            canvas.draw_text(
375                &rest,
376                Point::new(self.bounds.x + rest_start as f32, self.bounds.y),
377                &border_style,
378            );
379        }
380
381        // Top-right corner
382        canvas.draw_text(
383            &tr.to_string(),
384            Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y),
385            &border_style,
386        );
387
388        // If collapsed, just draw bottom border
389        if self.collapsed {
390            // Bottom border (immediately after header)
391            let mut bottom_line = String::with_capacity(width);
392            bottom_line.push(bl);
393            for _ in 0..(width - 2) {
394                bottom_line.push(bottom);
395            }
396            bottom_line.push(br);
397            canvas.draw_text(
398                &bottom_line,
399                Point::new(self.bounds.x, self.bounds.y + 1.0),
400                &border_style,
401            );
402        } else {
403            // Draw side borders for content area
404            for y in 1..(height - 1) {
405                canvas.draw_text(
406                    &left.to_string(),
407                    Point::new(self.bounds.x, self.bounds.y + y as f32),
408                    &border_style,
409                );
410                canvas.draw_text(
411                    &right.to_string(),
412                    Point::new(self.bounds.x + (width - 1) as f32, self.bounds.y + y as f32),
413                    &border_style,
414                );
415            }
416
417            // Bottom border
418            let mut bottom_line = String::with_capacity(width);
419            bottom_line.push(bl);
420            for _ in 0..(width - 2) {
421                bottom_line.push(bottom);
422            }
423            bottom_line.push(br);
424            canvas.draw_text(
425                &bottom_line,
426                Point::new(self.bounds.x, self.bounds.y + (height - 1) as f32),
427                &border_style,
428            );
429        }
430    }
431
432    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
433        match event {
434            Event::KeyDown {
435                key: Key::Enter | Key::Space,
436                ..
437            } => {
438                self.toggle();
439                Some(Box::new(self.collapsed))
440            }
441            _ => None,
442        }
443    }
444
445    fn children(&self) -> &[Box<dyn Widget>] {
446        &[]
447    }
448
449    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
450        &mut []
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    struct MockCanvas {
459        texts: Vec<(String, Point)>,
460    }
461
462    impl MockCanvas {
463        fn new() -> Self {
464            Self { texts: vec![] }
465        }
466    }
467
468    impl Canvas for MockCanvas {
469        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
470        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
471        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
472            self.texts.push((text.to_string(), position));
473        }
474        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
475        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
476        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
477        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
478        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
479        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
480        fn push_clip(&mut self, _rect: Rect) {}
481        fn pop_clip(&mut self) {}
482        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
483        fn pop_transform(&mut self) {}
484    }
485
486    // =====================================================
487    // Construction Tests
488    // =====================================================
489
490    #[test]
491    fn test_new() {
492        let panel = CollapsiblePanel::new("CPU");
493        assert_eq!(panel.title(), "CPU");
494        assert!(!panel.is_collapsed());
495    }
496
497    #[test]
498    fn test_default() {
499        let panel = CollapsiblePanel::default();
500        assert_eq!(panel.title(), "Panel");
501        assert!(!panel.is_collapsed());
502    }
503
504    #[test]
505    fn test_with_collapsed() {
506        let panel = CollapsiblePanel::new("Test").with_collapsed(true);
507        assert!(panel.is_collapsed());
508    }
509
510    #[test]
511    fn test_with_direction() {
512        let panel = CollapsiblePanel::new("Test").with_direction(CollapseDirection::Left);
513        assert_eq!(panel.direction(), CollapseDirection::Left);
514    }
515
516    #[test]
517    fn test_with_indicators() {
518        let panel = CollapsiblePanel::new("Test").with_indicators(CollapseIndicators::plus_minus());
519        assert_eq!(panel.indicators.expanded, '−');
520    }
521
522    #[test]
523    fn test_with_border_style() {
524        let panel = CollapsiblePanel::new("Test").with_border_style(BorderStyle::Double);
525        assert_eq!(panel.border_style, BorderStyle::Double);
526    }
527
528    #[test]
529    fn test_with_title_color() {
530        let panel = CollapsiblePanel::new("Test").with_title_color(Color::RED);
531        assert_eq!(panel.title_color, Color::RED);
532    }
533
534    #[test]
535    fn test_with_border_color() {
536        let panel = CollapsiblePanel::new("Test").with_border_color(Color::GREEN);
537        assert_eq!(panel.border_color, Color::GREEN);
538    }
539
540    #[test]
541    fn test_with_indicator_color() {
542        let panel = CollapsiblePanel::new("Test").with_indicator_color(Color::BLUE);
543        assert_eq!(panel.indicator_color, Color::BLUE);
544    }
545
546    #[test]
547    fn test_with_content_height() {
548        let panel = CollapsiblePanel::new("Test").with_content_height(5);
549        assert_eq!(panel.content_height, 5);
550    }
551
552    // =====================================================
553    // CollapseIndicators Tests
554    // =====================================================
555
556    #[test]
557    fn test_indicators_default() {
558        let ind = CollapseIndicators::default();
559        assert_eq!(ind.expanded, '▼');
560        assert_eq!(ind.collapsed, '▶');
561    }
562
563    #[test]
564    fn test_indicators_triangle() {
565        let ind = CollapseIndicators::triangle();
566        assert_eq!(ind.expanded, '▼');
567        assert_eq!(ind.collapsed, '▶');
568    }
569
570    #[test]
571    fn test_indicators_plus_minus() {
572        let ind = CollapseIndicators::plus_minus();
573        assert_eq!(ind.expanded, '−');
574        assert_eq!(ind.collapsed, '+');
575    }
576
577    #[test]
578    fn test_indicators_chevron() {
579        let ind = CollapseIndicators::chevron();
580        assert_eq!(ind.expanded, '˅');
581        assert_eq!(ind.collapsed, '˃');
582    }
583
584    #[test]
585    fn test_indicators_arrow() {
586        let ind = CollapseIndicators::arrow();
587        assert_eq!(ind.expanded, '↓');
588        assert_eq!(ind.collapsed, '→');
589    }
590
591    #[test]
592    fn test_indicators_current_expanded() {
593        let ind = CollapseIndicators::triangle();
594        assert_eq!(ind.current(false), '▼');
595    }
596
597    #[test]
598    fn test_indicators_current_collapsed() {
599        let ind = CollapseIndicators::triangle();
600        assert_eq!(ind.current(true), '▶');
601    }
602
603    // =====================================================
604    // CollapseDirection Tests
605    // =====================================================
606
607    #[test]
608    fn test_direction_default() {
609        assert_eq!(CollapseDirection::default(), CollapseDirection::Up);
610    }
611
612    #[test]
613    fn test_direction_variants() {
614        let _ = CollapseDirection::Up;
615        let _ = CollapseDirection::Down;
616        let _ = CollapseDirection::Left;
617        let _ = CollapseDirection::Right;
618    }
619
620    // =====================================================
621    // State Tests
622    // =====================================================
623
624    #[test]
625    fn test_is_expanded() {
626        let panel = CollapsiblePanel::new("Test");
627        assert!(panel.is_expanded());
628    }
629
630    #[test]
631    fn test_is_expanded_when_collapsed() {
632        let panel = CollapsiblePanel::new("Test").with_collapsed(true);
633        assert!(!panel.is_expanded());
634    }
635
636    #[test]
637    fn test_effective_height_expanded() {
638        let panel = CollapsiblePanel::new("Test").with_content_height(5);
639        assert_eq!(panel.effective_height(), 7); // 2 + 5
640    }
641
642    #[test]
643    fn test_effective_height_collapsed() {
644        let panel = CollapsiblePanel::new("Test").with_collapsed(true);
645        assert_eq!(panel.effective_height(), 2);
646    }
647
648    #[test]
649    fn test_inner_rect_expanded() {
650        let mut panel = CollapsiblePanel::new("Test");
651        panel.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
652        let inner = panel.inner_rect();
653        assert_eq!(inner.x, 1.0);
654        assert_eq!(inner.y, 1.0);
655        assert_eq!(inner.width, 18.0);
656        assert_eq!(inner.height, 8.0);
657    }
658
659    #[test]
660    fn test_inner_rect_collapsed() {
661        let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
662        panel.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
663        let inner = panel.inner_rect();
664        assert_eq!(inner.width, 0.0);
665        assert_eq!(inner.height, 0.0);
666    }
667
668    // =====================================================
669    // Action Tests
670    // =====================================================
671
672    #[test]
673    fn test_toggle_expand_to_collapse() {
674        let mut panel = CollapsiblePanel::new("Test");
675        panel.toggle();
676        assert!(panel.is_collapsed());
677    }
678
679    #[test]
680    fn test_toggle_collapse_to_expand() {
681        let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
682        panel.toggle();
683        assert!(panel.is_expanded());
684    }
685
686    #[test]
687    fn test_expand() {
688        let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
689        panel.expand();
690        assert!(panel.is_expanded());
691    }
692
693    #[test]
694    fn test_collapse() {
695        let mut panel = CollapsiblePanel::new("Test");
696        panel.collapse();
697        assert!(panel.is_collapsed());
698    }
699
700    #[test]
701    fn test_set_title() {
702        let mut panel = CollapsiblePanel::new("Old");
703        panel.set_title("New");
704        assert_eq!(panel.title(), "New");
705    }
706
707    // =====================================================
708    // Brick Trait Tests
709    // =====================================================
710
711    #[test]
712    fn test_brick_name() {
713        let panel = CollapsiblePanel::new("Test");
714        assert_eq!(panel.brick_name(), "collapsible_panel");
715    }
716
717    #[test]
718    fn test_assertions_not_empty() {
719        let panel = CollapsiblePanel::new("Test");
720        assert!(!panel.assertions().is_empty());
721    }
722
723    #[test]
724    fn test_budget() {
725        let panel = CollapsiblePanel::new("Test");
726        assert!(panel.budget().paint_ms > 0);
727    }
728
729    #[test]
730    fn test_verify() {
731        let panel = CollapsiblePanel::new("Test");
732        assert!(panel.verify().is_valid());
733    }
734
735    #[test]
736    fn test_to_html() {
737        let panel = CollapsiblePanel::new("Test");
738        assert!(panel.to_html().is_empty());
739    }
740
741    #[test]
742    fn test_to_css() {
743        let panel = CollapsiblePanel::new("Test");
744        assert!(panel.to_css().is_empty());
745    }
746
747    // =====================================================
748    // Widget Trait Tests
749    // =====================================================
750
751    #[test]
752    fn test_type_id() {
753        let panel = CollapsiblePanel::new("Test");
754        assert_eq!(Widget::type_id(&panel), TypeId::of::<CollapsiblePanel>());
755    }
756
757    #[test]
758    fn test_measure_expanded() {
759        let panel = CollapsiblePanel::new("Test").with_content_height(5);
760        let size = panel.measure(Constraints::loose(Size::new(100.0, 100.0)));
761        assert!(size.width >= 10.0);
762        assert_eq!(size.height, 7.0); // 2 + 5
763    }
764
765    #[test]
766    fn test_measure_collapsed() {
767        let panel = CollapsiblePanel::new("Test").with_collapsed(true);
768        let size = panel.measure(Constraints::loose(Size::new(100.0, 100.0)));
769        assert_eq!(size.height, 2.0);
770    }
771
772    #[test]
773    fn test_layout() {
774        let mut panel = CollapsiblePanel::new("Test");
775        let bounds = Rect::new(5.0, 10.0, 30.0, 8.0);
776        let result = panel.layout(bounds);
777        assert_eq!(result.size.width, 30.0);
778        assert_eq!(panel.bounds, bounds);
779    }
780
781    #[test]
782    fn test_children() {
783        let panel = CollapsiblePanel::new("Test");
784        assert!(panel.children().is_empty());
785    }
786
787    #[test]
788    fn test_children_mut() {
789        let mut panel = CollapsiblePanel::new("Test");
790        assert!(panel.children_mut().is_empty());
791    }
792
793    // =====================================================
794    // Paint Tests
795    // =====================================================
796
797    #[test]
798    fn test_paint_expanded() {
799        let mut panel = CollapsiblePanel::new("CPU");
800        panel.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
801        let mut canvas = MockCanvas::new();
802        panel.paint(&mut canvas);
803        assert!(!canvas.texts.is_empty());
804        // Should contain title
805        assert!(canvas.texts.iter().any(|(t, _)| t.contains("CPU")));
806    }
807
808    #[test]
809    fn test_paint_collapsed() {
810        let mut panel = CollapsiblePanel::new("CPU").with_collapsed(true);
811        panel.bounds = Rect::new(0.0, 0.0, 20.0, 2.0);
812        let mut canvas = MockCanvas::new();
813        panel.paint(&mut canvas);
814        assert!(!canvas.texts.is_empty());
815    }
816
817    #[test]
818    fn test_paint_small_bounds() {
819        let mut panel = CollapsiblePanel::new("CPU");
820        panel.bounds = Rect::new(0.0, 0.0, 3.0, 1.0);
821        let mut canvas = MockCanvas::new();
822        panel.paint(&mut canvas);
823        // Should handle gracefully (skip painting)
824    }
825
826    #[test]
827    fn test_paint_with_indicator_expanded() {
828        let mut panel = CollapsiblePanel::new("Test");
829        panel.bounds = Rect::new(0.0, 0.0, 20.0, 5.0);
830        let mut canvas = MockCanvas::new();
831        panel.paint(&mut canvas);
832        // Should show expanded indicator
833        assert!(canvas.texts.iter().any(|(t, _)| t == "▼"));
834    }
835
836    #[test]
837    fn test_paint_with_indicator_collapsed() {
838        let mut panel = CollapsiblePanel::new("Test").with_collapsed(true);
839        panel.bounds = Rect::new(0.0, 0.0, 20.0, 2.0);
840        let mut canvas = MockCanvas::new();
841        panel.paint(&mut canvas);
842        // Should show collapsed indicator
843        assert!(canvas.texts.iter().any(|(t, _)| t == "▶"));
844    }
845
846    // =====================================================
847    // Event Tests
848    // =====================================================
849
850    #[test]
851    fn test_event_enter_toggles() {
852        let mut panel = CollapsiblePanel::new("Test");
853        let event = Event::key_down(Key::Enter);
854        let result = panel.event(&event);
855        assert!(result.is_some());
856        assert!(panel.is_collapsed());
857    }
858
859    #[test]
860    fn test_event_space_toggles() {
861        let mut panel = CollapsiblePanel::new("Test");
862        let event = Event::key_down(Key::Space);
863        let result = panel.event(&event);
864        assert!(result.is_some());
865        assert!(panel.is_collapsed());
866    }
867
868    #[test]
869    fn test_event_other_keys_ignored() {
870        let mut panel = CollapsiblePanel::new("Test");
871        let event = Event::key_down(Key::Left);
872        let result = panel.event(&event);
873        assert!(result.is_none());
874        assert!(panel.is_expanded());
875    }
876
877    #[test]
878    fn test_event_returns_state() {
879        let mut panel = CollapsiblePanel::new("Test");
880        let event = Event::key_down(Key::Enter);
881        let result = panel.event(&event);
882        // Result should be the new collapsed state
883        if let Some(boxed) = result {
884            let state = boxed.downcast_ref::<bool>();
885            assert_eq!(state, Some(&true));
886        }
887    }
888
889    // =====================================================
890    // Long Title Tests
891    // =====================================================
892
893    #[test]
894    fn test_long_title_truncated() {
895        let mut panel = CollapsiblePanel::new("Very Long Title That Should Be Truncated");
896        panel.bounds = Rect::new(0.0, 0.0, 15.0, 5.0);
897        let mut canvas = MockCanvas::new();
898        panel.paint(&mut canvas);
899        // Should not overflow bounds
900    }
901}