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)]
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}