1use presentar_core::{
4 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event, Key,
5 LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
6};
7use std::any::Any;
8use std::time::Duration;
9
10const GRAY: Color = Color {
12 r: 0.5,
13 g: 0.5,
14 b: 0.5,
15 a: 1.0,
16};
17
18const CYAN: Color = Color {
20 r: 0.0,
21 g: 1.0,
22 b: 1.0,
23 a: 1.0,
24};
25
26#[derive(Debug, Clone)]
28pub struct Table {
29 headers: Vec<String>,
30 rows: Vec<Vec<String>>,
31 selected: usize,
32 scroll_offset: usize,
33 sort_column: Option<usize>,
34 sort_ascending: bool,
35 header_color: Color,
36 selected_color: Color,
37 bounds: Rect,
38}
39
40impl Table {
41 #[must_use]
43 pub fn new(headers: Vec<String>) -> Self {
44 Self {
45 headers,
46 rows: Vec::new(),
47 selected: 0,
48 scroll_offset: 0,
49 sort_column: None,
50 sort_ascending: true,
51 header_color: CYAN,
52 selected_color: Color::BLUE,
53 bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
54 }
55 }
56
57 #[must_use]
59 pub fn with_rows(mut self, rows: Vec<Vec<String>>) -> Self {
60 self.rows = rows;
61 self
62 }
63
64 #[must_use]
66 pub fn with_header_color(mut self, color: Color) -> Self {
67 self.header_color = color;
68 self
69 }
70
71 #[must_use]
73 pub fn with_selected_color(mut self, color: Color) -> Self {
74 self.selected_color = color;
75 self
76 }
77
78 pub fn add_row(&mut self, row: Vec<String>) {
80 self.rows.push(row);
81 }
82
83 pub fn clear(&mut self) {
85 self.rows.clear();
86 self.selected = 0;
87 self.scroll_offset = 0;
88 }
89
90 pub fn select(&mut self, row: usize) {
92 if !self.rows.is_empty() {
93 self.selected = row.min(self.rows.len() - 1);
94 self.ensure_visible();
95 }
96 }
97
98 pub fn select_prev(&mut self) {
100 if self.selected > 0 {
101 self.selected -= 1;
102 self.ensure_visible();
103 }
104 }
105
106 pub fn select_next(&mut self) {
108 if !self.rows.is_empty() && self.selected < self.rows.len() - 1 {
109 self.selected += 1;
110 self.ensure_visible();
111 }
112 }
113
114 #[must_use]
116 pub fn selected(&self) -> usize {
117 self.selected
118 }
119
120 #[must_use]
122 pub fn selected_row(&self) -> Option<&Vec<String>> {
123 self.rows.get(self.selected)
124 }
125
126 pub fn sort_by(&mut self, column: usize) {
128 if column >= self.headers.len() {
129 return;
130 }
131
132 if self.sort_column == Some(column) {
133 self.sort_ascending = !self.sort_ascending;
134 } else {
135 self.sort_column = Some(column);
136 self.sort_ascending = true;
137 }
138
139 let ascending = self.sort_ascending;
140 self.rows.sort_by(|a, b| {
141 let val_a = a.get(column).map_or("", String::as_str);
142 let val_b = b.get(column).map_or("", String::as_str);
143 if ascending {
144 val_a.cmp(val_b)
145 } else {
146 val_b.cmp(val_a)
147 }
148 });
149 }
150
151 fn ensure_visible(&mut self) {
152 let visible_rows = (self.bounds.height as usize).saturating_sub(1);
153 if visible_rows == 0 {
154 return;
155 }
156
157 if self.selected < self.scroll_offset {
158 self.scroll_offset = self.selected;
159 } else if self.selected >= self.scroll_offset + visible_rows {
160 self.scroll_offset = self.selected - visible_rows + 1;
161 }
162 }
163
164 fn column_widths(&self, total_width: usize) -> Vec<usize> {
165 if self.headers.is_empty() {
166 return vec![];
167 }
168
169 let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
170
171 for row in &self.rows {
172 for (i, cell) in row.iter().enumerate() {
173 if i < widths.len() {
174 widths[i] = widths[i].max(cell.len());
175 }
176 }
177 }
178
179 let total_content: usize = widths.iter().sum();
180 let separators = (self.headers.len() - 1) * 3;
181 let available = total_width.saturating_sub(separators);
182
183 if total_content > available {
184 let ratio = available as f64 / total_content as f64;
185 for w in &mut widths {
186 *w = ((*w as f64) * ratio).max(3.0) as usize;
187 }
188 }
189
190 widths
191 }
192
193 fn truncate(s: &str, width: usize) -> String {
194 if s.len() <= width {
195 format!("{s:width$}")
196 } else if width > 3 {
197 format!("{}...", &s[..width - 3])
198 } else {
199 s[..width].to_string()
200 }
201 }
202}
203
204impl Brick for Table {
205 fn brick_name(&self) -> &'static str {
206 "table"
207 }
208
209 fn assertions(&self) -> &[BrickAssertion] {
210 static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
211 ASSERTIONS
212 }
213
214 fn budget(&self) -> BrickBudget {
215 BrickBudget::uniform(16)
216 }
217
218 fn verify(&self) -> BrickVerification {
219 let mut passed = Vec::new();
220 let mut failed = Vec::new();
221
222 if self.rows.is_empty() || self.selected < self.rows.len() {
224 passed.push(BrickAssertion::max_latency_ms(16));
225 } else {
226 failed.push((
227 BrickAssertion::max_latency_ms(16),
228 format!(
229 "Selected {} >= row count {}",
230 self.selected,
231 self.rows.len()
232 ),
233 ));
234 }
235
236 BrickVerification {
237 passed,
238 failed,
239 verification_time: Duration::from_micros(10),
240 }
241 }
242
243 fn to_html(&self) -> String {
244 String::new()
245 }
246
247 fn to_css(&self) -> String {
248 String::new()
249 }
250}
251
252impl Widget for Table {
253 fn type_id(&self) -> TypeId {
254 TypeId::of::<Self>()
255 }
256
257 fn measure(&self, constraints: Constraints) -> Size {
258 let width = constraints.max_width.max(20.0);
259 let min_height = 3.0;
260 let preferred_height = (self.rows.len() + 1) as f32;
261 let height = constraints
262 .max_height
263 .max(min_height)
264 .min(preferred_height.max(min_height));
265 constraints.constrain(Size::new(width, height))
266 }
267
268 fn layout(&mut self, bounds: Rect) -> LayoutResult {
269 self.bounds = bounds;
270 self.ensure_visible();
271 LayoutResult {
272 size: Size::new(bounds.width, bounds.height),
273 }
274 }
275
276 fn paint(&self, canvas: &mut dyn Canvas) {
277 let width = self.bounds.width as usize;
278 let height = self.bounds.height as usize;
279 if width == 0 || height == 0 {
280 return;
281 }
282
283 let col_widths = self.column_widths(width);
284
285 let header_style = TextStyle {
287 color: self.header_color,
288 weight: presentar_core::FontWeight::Bold,
289 ..Default::default()
290 };
291
292 let mut header_line = String::new();
293 for (i, header) in self.headers.iter().enumerate() {
294 if i > 0 {
295 header_line.push_str(" │ ");
296 }
297 let w = col_widths.get(i).copied().unwrap_or(10);
298 header_line.push_str(&Self::truncate(header, w));
299 }
300 canvas.draw_text(
301 &header_line,
302 Point::new(self.bounds.x, self.bounds.y),
303 &header_style,
304 );
305
306 let sep_y = self.bounds.y + 1.0;
308 if height > 1 {
309 let sep: String = "─".repeat(width);
310 canvas.draw_text(
311 &sep,
312 Point::new(self.bounds.x, sep_y),
313 &TextStyle::default(),
314 );
315 }
316
317 let visible_rows = height.saturating_sub(2);
319 let default_style = TextStyle::default();
320 let selected_style = TextStyle {
321 color: self.selected_color,
322 ..Default::default()
323 };
324
325 for (i, row_idx) in (self.scroll_offset..self.rows.len())
326 .take(visible_rows)
327 .enumerate()
328 {
329 let row = &self.rows[row_idx];
330 let y = self.bounds.y + 2.0 + i as f32;
331
332 let style = if row_idx == self.selected {
333 &selected_style
334 } else {
335 &default_style
336 };
337
338 let mut row_line = String::new();
339 for (j, cell) in row.iter().enumerate() {
340 if j > 0 {
341 row_line.push_str(" │ ");
342 }
343 let w = col_widths.get(j).copied().unwrap_or(10);
344 row_line.push_str(&Self::truncate(cell, w));
345 }
346
347 if row_idx == self.selected {
349 let bg_color = Color::new(
350 self.selected_color.r,
351 self.selected_color.g,
352 self.selected_color.b,
353 0.3,
354 );
355 canvas.fill_rect(
356 Rect::new(self.bounds.x, y, self.bounds.width, 1.0),
357 bg_color,
358 );
359 }
360
361 canvas.draw_text(&row_line, Point::new(self.bounds.x, y), style);
362 }
363
364 if self.rows.is_empty() && height > 2 {
366 canvas.draw_text(
367 "No data",
368 Point::new(self.bounds.x + 1.0, self.bounds.y + 2.0),
369 &TextStyle {
370 color: GRAY,
371 ..Default::default()
372 },
373 );
374 }
375 }
376
377 fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
378 match event {
379 Event::KeyDown { key, .. } => {
380 match key {
381 Key::Up | Key::K => self.select_prev(),
382 Key::Down | Key::J => self.select_next(),
383 _ => {}
384 }
385 None
386 }
387 _ => None,
388 }
389 }
390
391 fn children(&self) -> &[Box<dyn Widget>] {
392 &[]
393 }
394
395 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
396 &mut []
397 }
398}
399
400#[cfg(test)]
401#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
402mod tests {
403 use super::*;
404 use presentar_core::{Canvas, TextStyle};
405
406 struct MockCanvas {
407 texts: Vec<(String, Point)>,
408 rects: Vec<Rect>,
409 }
410
411 impl MockCanvas {
412 fn new() -> Self {
413 Self {
414 texts: vec![],
415 rects: vec![],
416 }
417 }
418 }
419
420 impl Canvas for MockCanvas {
421 fn fill_rect(&mut self, rect: Rect, _color: Color) {
422 self.rects.push(rect);
423 }
424 fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
425 fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
426 self.texts.push((text.to_string(), position));
427 }
428 fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
429 fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
430 fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
431 fn fill_arc(
432 &mut self,
433 _center: Point,
434 _radius: f32,
435 _start: f32,
436 _end: f32,
437 _color: Color,
438 ) {
439 }
440 fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
441 fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
442 fn push_clip(&mut self, _rect: Rect) {}
443 fn pop_clip(&mut self) {}
444 fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
445 fn pop_transform(&mut self) {}
446 }
447
448 fn sample_table() -> Table {
449 Table::new(vec!["Name".into(), "Value".into()]).with_rows(vec![
450 vec!["CPU".into(), "45%".into()],
451 vec!["Memory".into(), "62%".into()],
452 vec!["Disk".into(), "78%".into()],
453 ])
454 }
455
456 #[test]
457 fn test_table_creation() {
458 let table = sample_table();
459 assert_eq!(table.headers.len(), 2);
460 assert_eq!(table.rows.len(), 3);
461 }
462
463 #[test]
464 fn test_table_assertions_not_empty() {
465 let table = sample_table();
466 assert!(!table.assertions().is_empty());
467 }
468
469 #[test]
470 fn test_table_verify_pass() {
471 let table = sample_table();
472 assert!(table.verify().is_valid());
473 }
474
475 #[test]
476 fn test_table_selection() {
477 let mut table = sample_table();
478 assert_eq!(table.selected(), 0);
479
480 table.select_next();
481 assert_eq!(table.selected(), 1);
482
483 table.select_prev();
484 assert_eq!(table.selected(), 0);
485 }
486
487 #[test]
488 fn test_table_with_header_color() {
489 let table = Table::new(vec!["A".into()]).with_header_color(Color::RED);
490 assert_eq!(table.header_color, Color::RED);
491 }
492
493 #[test]
494 fn test_table_with_selected_color() {
495 let table = Table::new(vec!["A".into()]).with_selected_color(Color::GREEN);
496 assert_eq!(table.selected_color, Color::GREEN);
497 }
498
499 #[test]
500 fn test_table_add_row() {
501 let mut table = Table::new(vec!["A".into(), "B".into()]);
502 table.add_row(vec!["1".into(), "2".into()]);
503 assert_eq!(table.rows.len(), 1);
504 }
505
506 #[test]
507 fn test_table_clear() {
508 let mut table = sample_table();
509 table.select_next();
510 table.clear();
511 assert_eq!(table.rows.len(), 0);
512 assert_eq!(table.selected(), 0);
513 assert_eq!(table.scroll_offset, 0);
514 }
515
516 #[test]
517 fn test_table_select() {
518 let mut table = sample_table();
519 table.select(2);
520 assert_eq!(table.selected(), 2);
521
522 table.select(10);
523 assert_eq!(table.selected(), 2);
524 }
525
526 #[test]
527 fn test_table_select_prev_at_start() {
528 let mut table = sample_table();
529 table.select_prev();
530 assert_eq!(table.selected(), 0);
531 }
532
533 #[test]
534 fn test_table_select_next_at_end() {
535 let mut table = sample_table();
536 table.select(2);
537 table.select_next();
538 assert_eq!(table.selected(), 2);
539 }
540
541 #[test]
542 fn test_table_selected_row() {
543 let table = sample_table();
544 let row = table.selected_row().unwrap();
545 assert_eq!(row[0], "CPU");
546 }
547
548 #[test]
549 fn test_table_sort_by() {
550 let mut table = sample_table();
551 table.sort_by(0);
552 assert_eq!(table.rows[0][0], "CPU");
553 assert_eq!(table.rows[1][0], "Disk");
554 assert_eq!(table.rows[2][0], "Memory");
555
556 table.sort_by(0);
557 assert_eq!(table.rows[0][0], "Memory");
558 }
559
560 #[test]
561 fn test_table_sort_by_invalid_column() {
562 let mut table = sample_table();
563 table.sort_by(10);
564 assert_eq!(table.rows[0][0], "CPU");
565 }
566
567 #[test]
568 fn test_table_column_widths() {
569 let table = sample_table();
570 let widths = table.column_widths(80);
571 assert!(!widths.is_empty());
572 }
573
574 #[test]
575 fn test_table_column_widths_empty_headers() {
576 let table = Table::new(vec![]);
577 let widths = table.column_widths(80);
578 assert!(widths.is_empty());
579 }
580
581 #[test]
582 fn test_table_truncate() {
583 assert_eq!(Table::truncate("Hello", 10), "Hello ");
584 assert_eq!(Table::truncate("Hello World", 5), "He...");
585 assert_eq!(Table::truncate("Hi", 2), "Hi");
586 }
587
588 #[test]
589 fn test_table_truncate_very_short() {
590 assert_eq!(Table::truncate("Hello", 3), "Hel");
591 }
592
593 #[test]
594 fn test_table_measure() {
595 let table = sample_table();
596 let constraints = Constraints::new(0.0, 100.0, 0.0, 50.0);
597 let size = table.measure(constraints);
598 assert!(size.width >= 20.0);
599 assert!(size.height >= 3.0);
600 }
601
602 #[test]
603 fn test_table_layout() {
604 let mut table = sample_table();
605 let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
606 let result = table.layout(bounds);
607 assert_eq!(result.size.width, 80.0);
608 assert_eq!(result.size.height, 20.0);
609 }
610
611 #[test]
612 fn test_table_paint() {
613 let mut table = sample_table();
614 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
615 let mut canvas = MockCanvas::new();
616 table.paint(&mut canvas);
617 assert!(!canvas.texts.is_empty());
618 }
619
620 #[test]
621 fn test_table_paint_empty() {
622 let mut table = Table::new(vec!["A".into(), "B".into()]);
623 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
624 let mut canvas = MockCanvas::new();
625 table.paint(&mut canvas);
626 assert!(canvas.texts.iter().any(|(t, _)| t.contains("No data")));
627 }
628
629 #[test]
630 fn test_table_paint_zero_size() {
631 let mut table = sample_table();
632 table.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
633 let mut canvas = MockCanvas::new();
634 table.paint(&mut canvas);
635 assert!(canvas.texts.is_empty());
636 }
637
638 #[test]
639 fn test_table_paint_with_selected() {
640 let mut table = sample_table();
641 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
642 table.select(1);
643 let mut canvas = MockCanvas::new();
644 table.paint(&mut canvas);
645 assert!(!canvas.rects.is_empty());
646 }
647
648 #[test]
649 fn test_table_event_up() {
650 let mut table = sample_table();
651 table.select(1);
652 let event = Event::key_down(Key::Up);
653 table.event(&event);
654 assert_eq!(table.selected(), 0);
655 }
656
657 #[test]
658 fn test_table_event_down() {
659 let mut table = sample_table();
660 let event = Event::key_down(Key::Down);
661 table.event(&event);
662 assert_eq!(table.selected(), 1);
663 }
664
665 #[test]
666 fn test_table_event_k() {
667 let mut table = sample_table();
668 table.select(1);
669 let event = Event::key_down(Key::K);
670 table.event(&event);
671 assert_eq!(table.selected(), 0);
672 }
673
674 #[test]
675 fn test_table_event_j() {
676 let mut table = sample_table();
677 let event = Event::key_down(Key::J);
678 table.event(&event);
679 assert_eq!(table.selected(), 1);
680 }
681
682 #[test]
683 fn test_table_event_other() {
684 let mut table = sample_table();
685 let event = Event::key_down(Key::Enter);
686 assert!(table.event(&event).is_none());
687 }
688
689 #[test]
690 fn test_table_event_non_keydown() {
691 let mut table = sample_table();
692 let event = Event::FocusIn;
693 assert!(table.event(&event).is_none());
694 }
695
696 #[test]
697 fn test_table_children() {
698 let table = sample_table();
699 assert!(table.children().is_empty());
700 }
701
702 #[test]
703 fn test_table_children_mut() {
704 let mut table = sample_table();
705 assert!(table.children_mut().is_empty());
706 }
707
708 #[test]
709 fn test_table_type_id() {
710 let table = sample_table();
711 assert_eq!(Widget::type_id(&table), TypeId::of::<Table>());
712 }
713
714 #[test]
715 fn test_table_brick_name() {
716 let table = sample_table();
717 assert_eq!(table.brick_name(), "table");
718 }
719
720 #[test]
721 fn test_table_budget() {
722 let table = sample_table();
723 let budget = table.budget();
724 assert!(budget.measure_ms > 0);
725 }
726
727 #[test]
728 fn test_table_to_html() {
729 let table = sample_table();
730 assert!(table.to_html().is_empty());
731 }
732
733 #[test]
734 fn test_table_to_css() {
735 let table = sample_table();
736 assert!(table.to_css().is_empty());
737 }
738
739 #[test]
740 fn test_table_scroll() {
741 let mut table = Table::new(vec!["Name".into()])
742 .with_rows((0..100).map(|i| vec![format!("Item {}", i)]).collect());
743 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
744 table.layout(table.bounds);
745
746 table.select(50);
747 assert!(table.scroll_offset > 0);
748 }
749
750 #[test]
751 fn test_table_ensure_visible_no_visible_rows() {
752 let mut table = sample_table();
753 table.bounds = Rect::new(0.0, 0.0, 40.0, 1.0);
754 table.select(2);
755 }
756
757 #[test]
758 fn test_table_verify_invalid_selection() {
759 let mut table = sample_table();
760 table.selected = 10;
761 assert!(!table.verify().is_valid());
762 }
763
764 #[test]
765 fn test_table_empty_verify() {
766 let table = Table::new(vec!["A".into()]);
767 assert!(table.verify().is_valid());
768 }
769
770 #[test]
771 fn test_table_select_empty() {
772 let mut table = Table::new(vec!["A".into()]);
773 table.select(5);
774 assert_eq!(table.selected(), 0);
775 }
776
777 #[test]
778 fn test_table_narrow_columns() {
779 let table = Table::new(vec!["A".into(), "B".into(), "C".into()]).with_rows(vec![vec![
780 "VeryLongValue1".into(),
781 "VeryLongValue2".into(),
782 "VeryLongValue3".into(),
783 ]]);
784 let widths = table.column_widths(30);
785 let total: usize = widths.iter().sum();
786 assert!(total <= 30);
787 }
788}