1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum CollapseDirection {
17 #[default]
19 Up,
20 Down,
22 Left,
24 Right,
26}
27
28#[derive(Debug, Clone)]
30pub struct CollapseIndicators {
31 pub expanded: char,
33 pub collapsed: char,
35}
36
37impl Default for CollapseIndicators {
38 fn default() -> Self {
39 Self::triangle()
40 }
41}
42
43impl CollapseIndicators {
44 #[must_use]
46 pub fn triangle() -> Self {
47 Self {
48 expanded: '▼',
49 collapsed: '▶',
50 }
51 }
52
53 #[must_use]
55 pub fn plus_minus() -> Self {
56 Self {
57 expanded: '−',
58 collapsed: '+',
59 }
60 }
61
62 #[must_use]
64 pub fn chevron() -> Self {
65 Self {
66 expanded: '˅',
67 collapsed: '˃',
68 }
69 }
70
71 #[must_use]
73 pub fn arrow() -> Self {
74 Self {
75 expanded: '↓',
76 collapsed: '→',
77 }
78 }
79
80 #[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#[derive(Debug, Clone)]
93pub struct CollapsiblePanel {
94 title: String,
96 collapsed: bool,
98 direction: CollapseDirection,
100 indicators: CollapseIndicators,
102 border_style: BorderStyle,
104 title_color: Color,
106 border_color: Color,
108 indicator_color: Color,
110 content_height: usize,
112 bounds: Rect,
114}
115
116impl Default for CollapsiblePanel {
117 fn default() -> Self {
118 Self::new("Panel")
119 }
120}
121
122impl CollapsiblePanel {
123 #[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 #[must_use]
142 pub fn with_collapsed(mut self, collapsed: bool) -> Self {
143 self.collapsed = collapsed;
144 self
145 }
146
147 #[must_use]
149 pub fn with_direction(mut self, direction: CollapseDirection) -> Self {
150 self.direction = direction;
151 self
152 }
153
154 #[must_use]
156 pub fn with_indicators(mut self, indicators: CollapseIndicators) -> Self {
157 self.indicators = indicators;
158 self
159 }
160
161 #[must_use]
163 pub fn with_border_style(mut self, style: BorderStyle) -> Self {
164 self.border_style = style;
165 self
166 }
167
168 #[must_use]
170 pub fn with_title_color(mut self, color: Color) -> Self {
171 self.title_color = color;
172 self
173 }
174
175 #[must_use]
177 pub fn with_border_color(mut self, color: Color) -> Self {
178 self.border_color = color;
179 self
180 }
181
182 #[must_use]
184 pub fn with_indicator_color(mut self, color: Color) -> Self {
185 self.indicator_color = color;
186 self
187 }
188
189 #[must_use]
191 pub fn with_content_height(mut self, height: usize) -> Self {
192 self.content_height = height;
193 self
194 }
195
196 #[must_use]
200 pub fn title(&self) -> &str {
201 &self.title
202 }
203
204 #[must_use]
206 pub fn is_collapsed(&self) -> bool {
207 self.collapsed
208 }
209
210 #[must_use]
212 pub fn is_expanded(&self) -> bool {
213 !self.collapsed
214 }
215
216 #[must_use]
218 pub fn direction(&self) -> CollapseDirection {
219 self.direction
220 }
221
222 #[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 #[must_use]
239 pub fn effective_height(&self) -> usize {
240 if self.collapsed {
241 2 } else {
243 2 + self.content_height }
245 }
246
247 pub fn toggle(&mut self) {
251 self.collapsed = !self.collapsed;
252 }
253
254 pub fn expand(&mut self) {
256 self.collapsed = false;
257 }
258
259 pub fn collapse(&mut self) {
261 self.collapsed = true;
262 }
263
264 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 let indicator = self.indicators.current(self.collapsed);
343
344 canvas.draw_text(
346 &tl.to_string(),
347 Point::new(self.bounds.x, self.bounds.y),
348 &border_style,
349 );
350
351 canvas.draw_text(
353 &indicator.to_string(),
354 Point::new(self.bounds.x + 1.0, self.bounds.y),
355 &indicator_style,
356 );
357
358 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 let title_len = displayed_title.chars().count() + 2; 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 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 self.collapsed {
390 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 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 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 #[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 #[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 #[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 #[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); }
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 #[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 #[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 #[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); }
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 #[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 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 }
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 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 assert!(canvas.texts.iter().any(|(t, _)| t == "▶"));
844 }
845
846 #[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 if let Some(boxed) = result {
884 let state = boxed.downcast_ref::<bool>();
885 assert_eq!(state, Some(&true));
886 }
887 }
888
889 #[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 }
901}