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)]
401mod tests {
402 use super::*;
403 use presentar_core::{Canvas, TextStyle};
404
405 struct MockCanvas {
406 texts: Vec<(String, Point)>,
407 rects: Vec<Rect>,
408 }
409
410 impl MockCanvas {
411 fn new() -> Self {
412 Self {
413 texts: vec![],
414 rects: vec![],
415 }
416 }
417 }
418
419 impl Canvas for MockCanvas {
420 fn fill_rect(&mut self, rect: Rect, _color: Color) {
421 self.rects.push(rect);
422 }
423 fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
424 fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
425 self.texts.push((text.to_string(), position));
426 }
427 fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
428 fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
429 fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
430 fn fill_arc(
431 &mut self,
432 _center: Point,
433 _radius: f32,
434 _start: f32,
435 _end: f32,
436 _color: Color,
437 ) {
438 }
439 fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
440 fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
441 fn push_clip(&mut self, _rect: Rect) {}
442 fn pop_clip(&mut self) {}
443 fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
444 fn pop_transform(&mut self) {}
445 }
446
447 fn sample_table() -> Table {
448 Table::new(vec!["Name".into(), "Value".into()]).with_rows(vec![
449 vec!["CPU".into(), "45%".into()],
450 vec!["Memory".into(), "62%".into()],
451 vec!["Disk".into(), "78%".into()],
452 ])
453 }
454
455 #[test]
456 fn test_table_creation() {
457 let table = sample_table();
458 assert_eq!(table.headers.len(), 2);
459 assert_eq!(table.rows.len(), 3);
460 }
461
462 #[test]
463 fn test_table_assertions_not_empty() {
464 let table = sample_table();
465 assert!(!table.assertions().is_empty());
466 }
467
468 #[test]
469 fn test_table_verify_pass() {
470 let table = sample_table();
471 assert!(table.verify().is_valid());
472 }
473
474 #[test]
475 fn test_table_selection() {
476 let mut table = sample_table();
477 assert_eq!(table.selected(), 0);
478
479 table.select_next();
480 assert_eq!(table.selected(), 1);
481
482 table.select_prev();
483 assert_eq!(table.selected(), 0);
484 }
485
486 #[test]
487 fn test_table_with_header_color() {
488 let table = Table::new(vec!["A".into()]).with_header_color(Color::RED);
489 assert_eq!(table.header_color, Color::RED);
490 }
491
492 #[test]
493 fn test_table_with_selected_color() {
494 let table = Table::new(vec!["A".into()]).with_selected_color(Color::GREEN);
495 assert_eq!(table.selected_color, Color::GREEN);
496 }
497
498 #[test]
499 fn test_table_add_row() {
500 let mut table = Table::new(vec!["A".into(), "B".into()]);
501 table.add_row(vec!["1".into(), "2".into()]);
502 assert_eq!(table.rows.len(), 1);
503 }
504
505 #[test]
506 fn test_table_clear() {
507 let mut table = sample_table();
508 table.select_next();
509 table.clear();
510 assert_eq!(table.rows.len(), 0);
511 assert_eq!(table.selected(), 0);
512 assert_eq!(table.scroll_offset, 0);
513 }
514
515 #[test]
516 fn test_table_select() {
517 let mut table = sample_table();
518 table.select(2);
519 assert_eq!(table.selected(), 2);
520
521 table.select(10);
522 assert_eq!(table.selected(), 2);
523 }
524
525 #[test]
526 fn test_table_select_prev_at_start() {
527 let mut table = sample_table();
528 table.select_prev();
529 assert_eq!(table.selected(), 0);
530 }
531
532 #[test]
533 fn test_table_select_next_at_end() {
534 let mut table = sample_table();
535 table.select(2);
536 table.select_next();
537 assert_eq!(table.selected(), 2);
538 }
539
540 #[test]
541 fn test_table_selected_row() {
542 let table = sample_table();
543 let row = table.selected_row().unwrap();
544 assert_eq!(row[0], "CPU");
545 }
546
547 #[test]
548 fn test_table_sort_by() {
549 let mut table = sample_table();
550 table.sort_by(0);
551 assert_eq!(table.rows[0][0], "CPU");
552 assert_eq!(table.rows[1][0], "Disk");
553 assert_eq!(table.rows[2][0], "Memory");
554
555 table.sort_by(0);
556 assert_eq!(table.rows[0][0], "Memory");
557 }
558
559 #[test]
560 fn test_table_sort_by_invalid_column() {
561 let mut table = sample_table();
562 table.sort_by(10);
563 assert_eq!(table.rows[0][0], "CPU");
564 }
565
566 #[test]
567 fn test_table_column_widths() {
568 let table = sample_table();
569 let widths = table.column_widths(80);
570 assert!(!widths.is_empty());
571 }
572
573 #[test]
574 fn test_table_column_widths_empty_headers() {
575 let table = Table::new(vec![]);
576 let widths = table.column_widths(80);
577 assert!(widths.is_empty());
578 }
579
580 #[test]
581 fn test_table_truncate() {
582 assert_eq!(Table::truncate("Hello", 10), "Hello ");
583 assert_eq!(Table::truncate("Hello World", 5), "He...");
584 assert_eq!(Table::truncate("Hi", 2), "Hi");
585 }
586
587 #[test]
588 fn test_table_truncate_very_short() {
589 assert_eq!(Table::truncate("Hello", 3), "Hel");
590 }
591
592 #[test]
593 fn test_table_measure() {
594 let table = sample_table();
595 let constraints = Constraints::new(0.0, 100.0, 0.0, 50.0);
596 let size = table.measure(constraints);
597 assert!(size.width >= 20.0);
598 assert!(size.height >= 3.0);
599 }
600
601 #[test]
602 fn test_table_layout() {
603 let mut table = sample_table();
604 let bounds = Rect::new(0.0, 0.0, 80.0, 20.0);
605 let result = table.layout(bounds);
606 assert_eq!(result.size.width, 80.0);
607 assert_eq!(result.size.height, 20.0);
608 }
609
610 #[test]
611 fn test_table_paint() {
612 let mut table = sample_table();
613 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
614 let mut canvas = MockCanvas::new();
615 table.paint(&mut canvas);
616 assert!(!canvas.texts.is_empty());
617 }
618
619 #[test]
620 fn test_table_paint_empty() {
621 let mut table = Table::new(vec!["A".into(), "B".into()]);
622 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
623 let mut canvas = MockCanvas::new();
624 table.paint(&mut canvas);
625 assert!(canvas.texts.iter().any(|(t, _)| t.contains("No data")));
626 }
627
628 #[test]
629 fn test_table_paint_zero_size() {
630 let mut table = sample_table();
631 table.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
632 let mut canvas = MockCanvas::new();
633 table.paint(&mut canvas);
634 assert!(canvas.texts.is_empty());
635 }
636
637 #[test]
638 fn test_table_paint_with_selected() {
639 let mut table = sample_table();
640 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
641 table.select(1);
642 let mut canvas = MockCanvas::new();
643 table.paint(&mut canvas);
644 assert!(!canvas.rects.is_empty());
645 }
646
647 #[test]
648 fn test_table_event_up() {
649 let mut table = sample_table();
650 table.select(1);
651 let event = Event::KeyDown { key: Key::Up };
652 table.event(&event);
653 assert_eq!(table.selected(), 0);
654 }
655
656 #[test]
657 fn test_table_event_down() {
658 let mut table = sample_table();
659 let event = Event::KeyDown { key: Key::Down };
660 table.event(&event);
661 assert_eq!(table.selected(), 1);
662 }
663
664 #[test]
665 fn test_table_event_k() {
666 let mut table = sample_table();
667 table.select(1);
668 let event = Event::KeyDown { key: Key::K };
669 table.event(&event);
670 assert_eq!(table.selected(), 0);
671 }
672
673 #[test]
674 fn test_table_event_j() {
675 let mut table = sample_table();
676 let event = Event::KeyDown { key: Key::J };
677 table.event(&event);
678 assert_eq!(table.selected(), 1);
679 }
680
681 #[test]
682 fn test_table_event_other() {
683 let mut table = sample_table();
684 let event = Event::KeyDown { key: Key::Enter };
685 assert!(table.event(&event).is_none());
686 }
687
688 #[test]
689 fn test_table_event_non_keydown() {
690 let mut table = sample_table();
691 let event = Event::FocusIn;
692 assert!(table.event(&event).is_none());
693 }
694
695 #[test]
696 fn test_table_children() {
697 let table = sample_table();
698 assert!(table.children().is_empty());
699 }
700
701 #[test]
702 fn test_table_children_mut() {
703 let mut table = sample_table();
704 assert!(table.children_mut().is_empty());
705 }
706
707 #[test]
708 fn test_table_type_id() {
709 let table = sample_table();
710 assert_eq!(Widget::type_id(&table), TypeId::of::<Table>());
711 }
712
713 #[test]
714 fn test_table_brick_name() {
715 let table = sample_table();
716 assert_eq!(table.brick_name(), "table");
717 }
718
719 #[test]
720 fn test_table_budget() {
721 let table = sample_table();
722 let budget = table.budget();
723 assert!(budget.measure_ms > 0);
724 }
725
726 #[test]
727 fn test_table_to_html() {
728 let table = sample_table();
729 assert!(table.to_html().is_empty());
730 }
731
732 #[test]
733 fn test_table_to_css() {
734 let table = sample_table();
735 assert!(table.to_css().is_empty());
736 }
737
738 #[test]
739 fn test_table_scroll() {
740 let mut table = Table::new(vec!["Name".into()])
741 .with_rows((0..100).map(|i| vec![format!("Item {}", i)]).collect());
742 table.bounds = Rect::new(0.0, 0.0, 40.0, 10.0);
743 table.layout(table.bounds);
744
745 table.select(50);
746 assert!(table.scroll_offset > 0);
747 }
748
749 #[test]
750 fn test_table_ensure_visible_no_visible_rows() {
751 let mut table = sample_table();
752 table.bounds = Rect::new(0.0, 0.0, 40.0, 1.0);
753 table.select(2);
754 }
755
756 #[test]
757 fn test_table_verify_invalid_selection() {
758 let mut table = sample_table();
759 table.selected = 10;
760 assert!(!table.verify().is_valid());
761 }
762
763 #[test]
764 fn test_table_empty_verify() {
765 let table = Table::new(vec!["A".into()]);
766 assert!(table.verify().is_valid());
767 }
768
769 #[test]
770 fn test_table_select_empty() {
771 let mut table = Table::new(vec!["A".into()]);
772 table.select(5);
773 assert_eq!(table.selected(), 0);
774 }
775
776 #[test]
777 fn test_table_narrow_columns() {
778 let table = Table::new(vec!["A".into(), "B".into(), "C".into()]).with_rows(vec![vec![
779 "VeryLongValue1".into(),
780 "VeryLongValue2".into(),
781 "VeryLongValue3".into(),
782 ]]);
783 let widths = table.column_widths(30);
784 let total: usize = widths.iter().sum();
785 assert!(total <= 30);
786 }
787}