Skip to main content

presentar_terminal/widgets/
table.rs

1//! Scrollable table widget.
2
3use 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
10/// Gray color constant.
11const GRAY: Color = Color {
12    r: 0.5,
13    g: 0.5,
14    b: 0.5,
15    a: 1.0,
16};
17
18/// Cyan color constant.
19const CYAN: Color = Color {
20    r: 0.0,
21    g: 1.0,
22    b: 1.0,
23    a: 1.0,
24};
25
26/// Scrollable table widget with headers and rows.
27#[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    /// Create a new table with headers.
42    #[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    /// Set the rows.
58    #[must_use]
59    pub fn with_rows(mut self, rows: Vec<Vec<String>>) -> Self {
60        self.rows = rows;
61        self
62    }
63
64    /// Set the header color.
65    #[must_use]
66    pub fn with_header_color(mut self, color: Color) -> Self {
67        self.header_color = color;
68        self
69    }
70
71    /// Set the selected row highlight color.
72    #[must_use]
73    pub fn with_selected_color(mut self, color: Color) -> Self {
74        self.selected_color = color;
75        self
76    }
77
78    /// Add a row.
79    pub fn add_row(&mut self, row: Vec<String>) {
80        self.rows.push(row);
81    }
82
83    /// Clear all rows.
84    pub fn clear(&mut self) {
85        self.rows.clear();
86        self.selected = 0;
87        self.scroll_offset = 0;
88    }
89
90    /// Set the selected row.
91    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    /// Move selection up.
99    pub fn select_prev(&mut self) {
100        if self.selected > 0 {
101            self.selected -= 1;
102            self.ensure_visible();
103        }
104    }
105
106    /// Move selection down.
107    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    /// Get the selected row index.
115    #[must_use]
116    pub fn selected(&self) -> usize {
117        self.selected
118    }
119
120    /// Get the selected row data.
121    #[must_use]
122    pub fn selected_row(&self) -> Option<&Vec<String>> {
123        self.rows.get(self.selected)
124    }
125
126    /// Sort by column.
127    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        // Check selected is in bounds
223        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        // Draw header
286        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        // Draw separator
307        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        // Draw rows
318        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            // Draw selection background
348            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        // Show "No data" if empty
365        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}