Skip to main content

alimentar/tui/
viewer.rs

1//! Dataset viewer widget for TUI display
2//!
3//! Provides a scrollable table view of Arrow datasets.
4
5use super::{adapter::DatasetAdapter, format::truncate_string, scroll::ScrollState};
6
7/// A scrollable table view for displaying Arrow datasets
8///
9/// The viewer provides:
10/// - Scrollable rows with keyboard navigation
11/// - Column headers with field names
12/// - Selected row highlighting
13/// - Automatic column width calculation
14/// - Truncation with ellipsis for long values
15///
16/// # Example
17///
18/// ```ignore
19/// let adapter = DatasetAdapter::from_dataset(&dataset)?;
20/// let viewer = DatasetViewer::new(adapter);
21///
22/// // Handle keyboard input
23/// viewer.scroll_down();
24/// viewer.select_row(5);
25///
26/// // Get visible data for rendering
27/// let headers = viewer.headers();
28/// let rows = viewer.visible_rows();
29/// ```
30#[derive(Debug, Clone)]
31pub struct DatasetViewer {
32    /// The dataset adapter
33    adapter: DatasetAdapter,
34    /// Scroll state
35    scroll: ScrollState,
36    /// Calculated column widths
37    column_widths: Vec<u16>,
38    /// Total display width
39    display_width: u16,
40    /// Number of visible rows (excluding header)
41    visible_rows: u16,
42}
43
44impl DatasetViewer {
45    /// Create a new viewer with default dimensions
46    ///
47    /// # Arguments
48    /// * `adapter` - The dataset adapter to display
49    pub fn new(adapter: DatasetAdapter) -> Self {
50        Self::with_dimensions(adapter, 80, 24)
51    }
52
53    /// Create a new viewer with specific dimensions
54    ///
55    /// # Arguments
56    /// * `adapter` - The dataset adapter to display
57    /// * `width` - Display width in characters
58    /// * `height` - Display height in rows (including header)
59    pub fn with_dimensions(adapter: DatasetAdapter, width: u16, height: u16) -> Self {
60        let visible_rows = height.saturating_sub(1); // -1 for header
61        let column_widths = adapter.calculate_column_widths(width, 20);
62        let scroll = ScrollState::new(adapter.row_count(), visible_rows as usize);
63
64        Self {
65            adapter,
66            scroll,
67            column_widths,
68            display_width: width,
69            visible_rows,
70        }
71    }
72
73    /// Update display dimensions
74    ///
75    /// Recalculates column widths and scroll state.
76    pub fn set_dimensions(&mut self, width: u16, height: u16) {
77        self.display_width = width;
78        self.visible_rows = height.saturating_sub(1);
79        self.column_widths = self.adapter.calculate_column_widths(width, 20);
80        self.scroll.set_visible_rows(self.visible_rows as usize);
81    }
82
83    /// Get the current scroll offset
84    #[inline]
85    pub fn scroll_offset(&self) -> usize {
86        self.scroll.offset()
87    }
88
89    /// Set the scroll offset
90    pub fn set_scroll_offset(&mut self, offset: usize) {
91        self.scroll.set_offset(offset);
92    }
93
94    /// Get total row count
95    #[inline]
96    pub fn row_count(&self) -> usize {
97        self.adapter.row_count()
98    }
99
100    /// Get visible row count
101    #[inline]
102    pub fn visible_row_count(&self) -> u16 {
103        self.visible_rows
104    }
105
106    /// Get the currently selected row
107    #[inline]
108    pub fn selected_row(&self) -> Option<usize> {
109        self.scroll.selected()
110    }
111
112    /// Select a specific row
113    pub fn select_row(&mut self, row: usize) {
114        self.scroll.set_selected(Some(row));
115    }
116
117    /// Clear selection
118    pub fn clear_selection(&mut self) {
119        self.scroll.set_selected(None);
120    }
121
122    /// Check if the dataset is empty
123    #[inline]
124    pub fn is_empty(&self) -> bool {
125        self.adapter.is_empty()
126    }
127
128    /// Get the adapter reference
129    #[inline]
130    pub fn adapter(&self) -> &DatasetAdapter {
131        &self.adapter
132    }
133
134    /// Get column widths
135    #[inline]
136    pub fn column_widths(&self) -> &[u16] {
137        &self.column_widths
138    }
139
140    // Navigation methods
141
142    /// Scroll down by one row
143    pub fn scroll_down(&mut self) {
144        self.scroll.scroll_down();
145    }
146
147    /// Scroll up by one row
148    pub fn scroll_up(&mut self) {
149        self.scroll.scroll_up();
150    }
151
152    /// Scroll down by one page
153    pub fn page_down(&mut self) {
154        self.scroll.page_down();
155    }
156
157    /// Scroll up by one page
158    pub fn page_up(&mut self) {
159        self.scroll.page_up();
160    }
161
162    /// Jump to first row
163    pub fn home(&mut self) {
164        self.scroll.home();
165    }
166
167    /// Jump to last page
168    pub fn end(&mut self) {
169        self.scroll.end();
170    }
171
172    /// Select next row
173    pub fn select_next(&mut self) {
174        self.scroll.select_next();
175    }
176
177    /// Select previous row
178    pub fn select_prev(&mut self) {
179        self.scroll.select_prev();
180    }
181
182    // Rendering helpers
183
184    /// Get column headers
185    pub fn headers(&self) -> Vec<String> {
186        self.adapter
187            .field_names()
188            .into_iter()
189            .enumerate()
190            .map(|(i, name)| {
191                let width = self.column_widths.get(i).copied().unwrap_or(10) as usize;
192                truncate_string(name, width)
193            })
194            .collect()
195    }
196
197    /// Get visible rows as formatted strings
198    ///
199    /// Returns a vector of rows, where each row is a vector of cell strings.
200    pub fn visible_rows_data(&self) -> Vec<Vec<String>> {
201        let start = self.scroll.offset();
202        let end = (start + self.visible_rows as usize).min(self.adapter.row_count());
203
204        (start..end)
205            .map(|row_idx| self.format_row(row_idx))
206            .collect()
207    }
208
209    /// Format a single row
210    fn format_row(&self, row_idx: usize) -> Vec<String> {
211        (0..self.adapter.column_count())
212            .map(|col_idx| {
213                let width = self.column_widths.get(col_idx).copied().unwrap_or(10) as usize;
214                match self.adapter.get_cell(row_idx, col_idx) {
215                    Ok(Some(value)) => truncate_string(&value, width),
216                    Ok(None) => String::new(),
217                    Err(_) => "<error>".to_string(),
218                }
219            })
220            .collect()
221    }
222
223    /// Check if a row is currently selected
224    pub fn is_row_selected(&self, global_row: usize) -> bool {
225        self.scroll.selected() == Some(global_row)
226    }
227
228    /// Check if scrollbar should be shown
229    pub fn needs_scrollbar(&self) -> bool {
230        self.scroll.needs_scrollbar()
231    }
232
233    /// Get scrollbar position (0.0 to 1.0)
234    pub fn scrollbar_position(&self) -> f32 {
235        self.scroll.scrollbar_position()
236    }
237
238    /// Get scrollbar size (0.0 to 1.0)
239    pub fn scrollbar_size(&self) -> f32 {
240        self.scroll.scrollbar_size()
241    }
242
243    /// Render header line as a string
244    pub fn render_header_line(&self) -> String {
245        let headers = self.headers();
246        headers.join(" ")
247    }
248
249    /// Render a data row as a string
250    pub fn render_row_line(&self, viewport_row: usize) -> Option<String> {
251        let global_row = self.scroll.to_global_row(viewport_row);
252        if global_row >= self.adapter.row_count() {
253            return None;
254        }
255
256        let cells = self.format_row(global_row);
257        Some(cells.join(" "))
258    }
259
260    /// Get the data row index for a viewport row
261    pub fn viewport_to_data_row(&self, viewport_row: usize) -> usize {
262        self.scroll.to_global_row(viewport_row)
263    }
264
265    // Search methods
266
267    /// Search for a substring and select the first matching row
268    ///
269    /// Returns the row index if found, None otherwise.
270    /// This is a linear scan suitable for datasets <100k rows (F101).
271    pub fn search(&mut self, query: &str) -> Option<usize> {
272        let result = self.adapter.search(query);
273        if let Some(row) = result {
274            self.select_row(row);
275            self.scroll.ensure_visible(row);
276        }
277        result
278    }
279
280    /// Continue search from current position
281    ///
282    /// Wraps around to beginning if no match found after current row.
283    pub fn search_next(&mut self, query: &str) -> Option<usize> {
284        let start = self.scroll.selected().map(|r| r + 1).unwrap_or(0);
285        let result = self.adapter.search_from(query, start);
286        if let Some(row) = result {
287            self.select_row(row);
288            self.scroll.ensure_visible(row);
289        }
290        result
291    }
292
293    /// Render complete output as lines
294    ///
295    /// Returns header followed by visible data rows.
296    pub fn render_lines(&self) -> Vec<String> {
297        let mut lines = Vec::with_capacity(self.visible_rows as usize + 1);
298
299        // Header
300        lines.push(self.render_header_line());
301
302        // Data rows
303        for vrow in 0..self.visible_rows as usize {
304            if let Some(line) = self.render_row_line(vrow) {
305                lines.push(line);
306            }
307        }
308
309        lines
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use std::sync::Arc;
316
317    use arrow::{
318        array::{Float32Array, Int32Array, RecordBatch, StringArray},
319        datatypes::{DataType, Field, Schema},
320    };
321
322    use super::*;
323
324    fn create_test_adapter(rows: usize) -> DatasetAdapter {
325        let schema = Arc::new(Schema::new(vec![
326            Field::new("id", DataType::Utf8, false),
327            Field::new("value", DataType::Int32, false),
328            Field::new("score", DataType::Float32, false),
329        ]));
330
331        let ids: Vec<String> = (0..rows).map(|i| format!("id_{i}")).collect();
332        let values: Vec<i32> = (0..rows).map(|i| i as i32 * 10).collect();
333        let scores: Vec<f32> = (0..rows).map(|i| i as f32 * 0.1).collect();
334
335        let batch = RecordBatch::try_new(
336            schema.clone(),
337            vec![
338                Arc::new(StringArray::from(ids)),
339                Arc::new(Int32Array::from(values)),
340                Arc::new(Float32Array::from(scores)),
341            ],
342        )
343        .unwrap();
344
345        DatasetAdapter::from_batches(vec![batch], schema).unwrap()
346    }
347
348    fn create_test_viewer() -> DatasetViewer {
349        let adapter = create_test_adapter(80);
350        DatasetViewer::with_dimensions(adapter, 80, 24)
351    }
352
353    #[test]
354    fn f026_viewer_new() {
355        let viewer = create_test_viewer();
356        assert_eq!(viewer.row_count(), 80);
357        assert_eq!(viewer.scroll_offset(), 0);
358    }
359
360    #[test]
361    fn f027_viewer_scroll_down() {
362        let mut viewer = create_test_viewer();
363        viewer.scroll_down();
364        assert_eq!(viewer.scroll_offset(), 1);
365    }
366
367    #[test]
368    fn f028_viewer_scroll_up() {
369        let mut viewer = create_test_viewer();
370        viewer.set_scroll_offset(10);
371        viewer.scroll_up();
372        assert_eq!(viewer.scroll_offset(), 9);
373    }
374
375    #[test]
376    fn f029_viewer_scroll_bounds_top() {
377        let mut viewer = create_test_viewer();
378        viewer.scroll_up();
379        assert_eq!(viewer.scroll_offset(), 0);
380    }
381
382    #[test]
383    fn f030_viewer_page_down() {
384        let mut viewer = create_test_viewer();
385        let visible = viewer.visible_row_count() as usize;
386        viewer.page_down();
387        assert!(viewer.scroll_offset() >= visible / 2);
388    }
389
390    #[test]
391    fn f031_viewer_page_up() {
392        let mut viewer = create_test_viewer();
393        viewer.set_scroll_offset(50);
394        viewer.page_up();
395        assert!(viewer.scroll_offset() < 50);
396    }
397
398    #[test]
399    fn f032_viewer_home() {
400        let mut viewer = create_test_viewer();
401        viewer.set_scroll_offset(50);
402        viewer.home();
403        assert_eq!(viewer.scroll_offset(), 0);
404    }
405
406    #[test]
407    fn f033_viewer_end() {
408        let mut viewer = create_test_viewer();
409        viewer.end();
410        // Should be at position where last row is visible
411        let max_offset = viewer.row_count() - viewer.visible_row_count() as usize;
412        assert_eq!(viewer.scroll_offset(), max_offset);
413    }
414
415    #[test]
416    fn f034_viewer_select_row() {
417        let mut viewer = create_test_viewer();
418        viewer.select_row(5);
419        assert_eq!(viewer.selected_row(), Some(5));
420    }
421
422    #[test]
423    fn f035_viewer_select_next() {
424        let mut viewer = create_test_viewer();
425        viewer.select_row(0);
426        viewer.select_next();
427        assert_eq!(viewer.selected_row(), Some(1));
428    }
429
430    #[test]
431    fn f036_viewer_select_prev() {
432        let mut viewer = create_test_viewer();
433        viewer.select_row(5);
434        viewer.select_prev();
435        assert_eq!(viewer.selected_row(), Some(4));
436    }
437
438    #[test]
439    fn f037_viewer_clear_selection() {
440        let mut viewer = create_test_viewer();
441        viewer.select_row(5);
442        viewer.clear_selection();
443        assert_eq!(viewer.selected_row(), None);
444    }
445
446    #[test]
447    fn f038_viewer_headers() {
448        let viewer = create_test_viewer();
449        let headers = viewer.headers();
450        assert_eq!(headers.len(), 3);
451        assert!(headers[0].contains("id"));
452    }
453
454    #[test]
455    fn f039_viewer_visible_rows() {
456        let viewer = create_test_viewer();
457        let rows = viewer.visible_rows_data();
458        assert!(rows.len() <= viewer.visible_row_count() as usize);
459    }
460
461    #[test]
462    fn f040_viewer_needs_scrollbar() {
463        let viewer = create_test_viewer();
464        assert!(viewer.needs_scrollbar());
465    }
466
467    #[test]
468    fn f041_viewer_no_scrollbar_small() {
469        let adapter = create_test_adapter(5);
470        let viewer = DatasetViewer::with_dimensions(adapter, 80, 24);
471        assert!(!viewer.needs_scrollbar());
472    }
473
474    #[test]
475    fn f042_viewer_scrollbar_position() {
476        let mut viewer = create_test_viewer();
477        viewer.set_scroll_offset(40);
478        let pos = viewer.scrollbar_position();
479        assert!(pos > 0.0 && pos < 1.0);
480    }
481
482    #[test]
483    fn f043_viewer_render_header() {
484        let viewer = create_test_viewer();
485        let header = viewer.render_header_line();
486        assert!(!header.is_empty());
487        assert!(header.contains("id"));
488    }
489
490    #[test]
491    fn f044_viewer_render_row() {
492        let viewer = create_test_viewer();
493        let row = viewer.render_row_line(0);
494        assert!(row.is_some());
495        assert!(row.unwrap().contains("id_0"));
496    }
497
498    #[test]
499    fn f045_viewer_render_row_out_of_bounds() {
500        let viewer = create_test_viewer();
501        let row = viewer.render_row_line(1000);
502        assert!(row.is_none());
503    }
504
505    #[test]
506    fn f046_viewer_render_lines() {
507        let viewer = create_test_viewer();
508        let lines = viewer.render_lines();
509        assert!(!lines.is_empty());
510        // First line is header
511        assert!(lines[0].contains("id"));
512    }
513
514    #[test]
515    fn f047_viewer_column_widths() {
516        let viewer = create_test_viewer();
517        let widths = viewer.column_widths();
518        assert_eq!(widths.len(), 3);
519        for w in widths {
520            assert!(*w >= 3);
521        }
522    }
523
524    #[test]
525    fn f048_viewer_is_row_selected() {
526        let mut viewer = create_test_viewer();
527        viewer.select_row(5);
528        assert!(viewer.is_row_selected(5));
529        assert!(!viewer.is_row_selected(4));
530    }
531
532    #[test]
533    fn f049_viewer_set_dimensions() {
534        let mut viewer = create_test_viewer();
535        viewer.set_dimensions(40, 10);
536        assert_eq!(viewer.visible_row_count(), 9);
537    }
538
539    #[test]
540    fn f050_viewer_empty() {
541        let adapter = DatasetAdapter::empty();
542        let viewer = DatasetViewer::new(adapter);
543        assert!(viewer.is_empty());
544        assert_eq!(viewer.row_count(), 0);
545    }
546
547    #[test]
548    fn f_viewer_viewport_to_data_row() {
549        let mut viewer = create_test_viewer();
550        viewer.set_scroll_offset(10);
551        assert_eq!(viewer.viewport_to_data_row(5), 15);
552    }
553
554    #[test]
555    fn f_viewer_is_clone() {
556        let viewer = create_test_viewer();
557        let cloned = viewer.clone();
558        assert_eq!(viewer.row_count(), cloned.row_count());
559    }
560
561    #[test]
562    fn f_viewer_adapter_access() {
563        let viewer = create_test_viewer();
564        let adapter = viewer.adapter();
565        assert_eq!(adapter.column_count(), 3);
566    }
567
568    #[test]
569    fn f_viewer_scrollbar_size() {
570        let viewer = create_test_viewer();
571        let size = viewer.scrollbar_size();
572        // Should be between 0 and 1
573        assert!(size > 0.0 && size <= 1.0);
574    }
575
576    #[test]
577    fn f_viewer_scrollbar_size_small_dataset() {
578        let adapter = create_test_adapter(5);
579        let viewer = DatasetViewer::with_dimensions(adapter, 80, 24);
580        let size = viewer.scrollbar_size();
581        // When all content fits, size should be 1.0
582        assert!((size - 1.0).abs() < 0.01);
583    }
584
585    #[test]
586    fn f_viewer_format_row_with_null() {
587        // Create a dataset with nullable column containing null
588        use arrow::array::NullArray;
589
590        let schema = Arc::new(Schema::new(vec![
591            Field::new("id", DataType::Utf8, false),
592            Field::new("nullable_col", DataType::Null, true),
593        ]));
594
595        let batch = RecordBatch::try_new(
596            schema.clone(),
597            vec![
598                Arc::new(StringArray::from(vec!["a", "b"])),
599                Arc::new(NullArray::new(2)),
600            ],
601        )
602        .unwrap();
603
604        let adapter = DatasetAdapter::from_batches(vec![batch], schema).unwrap();
605        let viewer = DatasetViewer::new(adapter);
606        let rows = viewer.visible_rows_data();
607
608        // FALSIFIABLE: Assert actual behavior, not just existence
609        assert_eq!(rows.len(), 2, "FALSIFIED: Should have 2 rows");
610        assert_eq!(rows[0].len(), 2, "FALSIFIED: Should have 2 columns");
611        assert_eq!(rows[0][0], "a", "FALSIFIED: First cell should be 'a'");
612        // Null column should render as empty string or "NULL"
613        assert!(
614            rows[0][1].is_empty() || rows[0][1] == "null" || rows[0][1] == "NULL",
615            "FALSIFIED: Null should render as empty or 'null'/'NULL', got: '{}'",
616            rows[0][1]
617        );
618    }
619
620    // === SEARCH TESTS (F101-F103) ===
621
622    #[test]
623    fn f_viewer_search_finds_match() {
624        let mut viewer = create_test_viewer();
625        let result = viewer.search("id_5");
626        assert_eq!(
627            result,
628            Some(5),
629            "FALSIFIED: Search should find 'id_5' at row 5"
630        );
631        assert_eq!(
632            viewer.selected_row(),
633            Some(5),
634            "FALSIFIED: Search should select found row"
635        );
636    }
637
638    #[test]
639    fn f_viewer_search_no_match() {
640        let mut viewer = create_test_viewer();
641        let result = viewer.search("nonexistent_xyz");
642        assert_eq!(result, None, "FALSIFIED: Search should return None");
643        assert_eq!(
644            viewer.selected_row(),
645            None,
646            "FALSIFIED: No selection should change"
647        );
648    }
649
650    #[test]
651    fn f_viewer_search_case_insensitive() {
652        let mut viewer = create_test_viewer();
653        let result = viewer.search("ID_3");
654        assert_eq!(
655            result,
656            Some(3),
657            "FALSIFIED: Search should be case insensitive"
658        );
659    }
660
661    #[test]
662    fn f_viewer_search_next_continues() {
663        let mut viewer = create_test_viewer();
664        // First search
665        viewer.search("id_");
666        let first = viewer.selected_row();
667        // Search next should find a different row
668        viewer.search_next("id_");
669        let second = viewer.selected_row();
670        assert_ne!(first, second, "FALSIFIED: search_next should continue");
671    }
672
673    #[test]
674    fn f_viewer_search_next_wraps() {
675        let mut viewer = create_test_viewer();
676        // Select last row
677        viewer.select_row(9);
678        // Search next should wrap to beginning
679        let result = viewer.search_next("id_0");
680        assert_eq!(result, Some(0), "FALSIFIED: search_next should wrap");
681    }
682
683    #[test]
684    fn f_viewer_search_empty_query() {
685        let mut viewer = create_test_viewer();
686        let result = viewer.search("");
687        assert_eq!(result, None, "FALSIFIED: Empty query should return None");
688    }
689}