Skip to main content

alimentar/tui/
row_detail.rs

1//! Row detail view widget for displaying a single record
2//!
3//! Provides an expanded view of all fields in a single row.
4
5use super::{adapter::DatasetAdapter, scroll::ScrollState};
6
7/// Row detail view widget for displaying a single record
8///
9/// Shows all fields and their values for a selected row,
10/// with scrolling support for large text values.
11///
12/// # Example
13///
14/// ```ignore
15/// let adapter = DatasetAdapter::from_dataset(&dataset)?;
16/// let detail = RowDetailView::new(&adapter, 5); // Row 5
17///
18/// for line in detail.render_lines() {
19///     println!("{}", line);
20/// }
21/// ```
22#[derive(Debug, Clone)]
23pub struct RowDetailView {
24    /// Row index being displayed
25    row_index: usize,
26    /// Field values: (name, value)
27    fields: Vec<(String, String)>,
28    /// Scroll state for navigating fields
29    scroll: ScrollState,
30    /// Display width
31    display_width: u16,
32    /// Display height
33    display_height: u16,
34}
35
36impl RowDetailView {
37    /// Create a new row detail view
38    ///
39    /// # Arguments
40    /// * `adapter` - Dataset adapter
41    /// * `row_index` - Index of the row to display
42    ///
43    /// # Returns
44    /// The detail view, or None if row is out of bounds
45    pub fn new(adapter: &DatasetAdapter, row_index: usize) -> Option<Self> {
46        Self::with_dimensions(adapter, row_index, 80, 24)
47    }
48
49    /// Create a new row detail view with specific dimensions
50    pub fn with_dimensions(
51        adapter: &DatasetAdapter,
52        row_index: usize,
53        width: u16,
54        height: u16,
55    ) -> Option<Self> {
56        if row_index >= adapter.row_count() {
57            return None;
58        }
59
60        // Collect field values
61        let fields: Vec<(String, String)> = (0..adapter.column_count())
62            .filter_map(|col| {
63                let name = adapter.field_name(col)?.to_string();
64                let value = adapter
65                    .get_cell(row_index, col)
66                    .ok()
67                    .flatten()
68                    .unwrap_or_else(|| "NULL".to_string());
69                Some((name, value))
70            })
71            .collect();
72
73        // Calculate total lines needed (each field may span multiple lines)
74        let total_lines = calculate_total_lines(&fields, width);
75        let visible_lines = height.saturating_sub(2) as usize; // -2 for title and border
76
77        let scroll = ScrollState::new(total_lines, visible_lines);
78
79        Some(Self {
80            row_index,
81            fields,
82            scroll,
83            display_width: width,
84            display_height: height,
85        })
86    }
87
88    /// Get the row index being displayed
89    pub fn row_index(&self) -> usize {
90        self.row_index
91    }
92
93    /// Get the number of fields
94    pub fn field_count(&self) -> usize {
95        self.fields.len()
96    }
97
98    /// Check if empty
99    pub fn is_empty(&self) -> bool {
100        self.fields.is_empty()
101    }
102
103    /// Get a field value by name
104    pub fn field_value(&self, name: &str) -> Option<&str> {
105        self.fields
106            .iter()
107            .find(|(n, _)| n == name)
108            .map(|(_, v)| v.as_str())
109    }
110
111    /// Get a field value by index
112    pub fn field_by_index(&self, index: usize) -> Option<(&str, &str)> {
113        self.fields
114            .get(index)
115            .map(|(n, v)| (n.as_str(), v.as_str()))
116    }
117
118    // Navigation
119
120    /// Scroll down
121    pub fn scroll_down(&mut self) {
122        self.scroll.scroll_down();
123    }
124
125    /// Scroll up
126    pub fn scroll_up(&mut self) {
127        self.scroll.scroll_up();
128    }
129
130    /// Page down
131    pub fn page_down(&mut self) {
132        self.scroll.page_down();
133    }
134
135    /// Page up
136    pub fn page_up(&mut self) {
137        self.scroll.page_up();
138    }
139
140    /// Get scroll offset
141    pub fn scroll_offset(&self) -> usize {
142        self.scroll.offset()
143    }
144
145    /// Render the detail view as lines
146    pub fn render_lines(&self) -> Vec<String> {
147        let max_width = self.display_width.saturating_sub(4) as usize; // margins
148        let mut all_lines = Vec::new();
149
150        // Title
151        all_lines.push(format!("Row {}", self.row_index));
152        all_lines.push(String::new());
153
154        // Fields
155        for (name, value) in &self.fields {
156            // Field name
157            all_lines.push(format!("{name}:"));
158
159            // Wrap value if needed
160            let wrapped = wrap_text(value, max_width);
161            for line in wrapped {
162                all_lines.push(format!("  {line}"));
163            }
164
165            // Blank line between fields
166            all_lines.push(String::new());
167        }
168
169        // Apply scroll offset
170        let start = self.scroll.offset();
171        let visible = self.display_height.saturating_sub(2) as usize;
172        let end = (start + visible).min(all_lines.len());
173
174        all_lines[start..end].to_vec()
175    }
176
177    /// Render as a single string
178    pub fn render(&self) -> String {
179        self.render_lines().join("\n")
180    }
181}
182
183/// Calculate total lines needed for all fields
184fn calculate_total_lines(fields: &[(String, String)], width: u16) -> usize {
185    let max_width = width.saturating_sub(4) as usize;
186
187    fields
188        .iter()
189        .map(|(_, value)| {
190            // Name line + wrapped value lines + blank line
191            1 + wrap_text(value, max_width).len() + 1
192        })
193        .sum::<usize>()
194        .saturating_add(2) // Title + blank
195}
196
197/// Wrap text to fit within a maximum width
198fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
199    if max_width == 0 {
200        return vec![text.to_string()];
201    }
202
203    let mut lines = Vec::new();
204
205    for line in text.lines() {
206        if line.is_empty() {
207            lines.push(String::new());
208            continue;
209        }
210
211        let chars: Vec<char> = line.chars().collect();
212        let mut start = 0;
213
214        while start < chars.len() {
215            let end = (start + max_width).min(chars.len());
216            let segment: String = chars[start..end].iter().collect();
217            lines.push(segment);
218            start = end;
219        }
220    }
221
222    if lines.is_empty() {
223        lines.push(String::new());
224    }
225
226    lines
227}
228
229#[cfg(test)]
230mod tests {
231    use std::sync::Arc;
232
233    use arrow::{
234        array::{Float32Array, Int32Array, RecordBatch, StringArray},
235        datatypes::{DataType, Field, Schema},
236    };
237
238    use super::*;
239
240    fn create_test_adapter() -> DatasetAdapter {
241        let schema = Arc::new(Schema::new(vec![
242            Field::new("id", DataType::Utf8, false),
243            Field::new("description", DataType::Utf8, false),
244            Field::new("value", DataType::Int32, false),
245            Field::new("score", DataType::Float32, false),
246        ]));
247
248        let ids = vec!["row_0", "row_1", "row_2"];
249        let descriptions = vec![
250            "Short description",
251            "This is a much longer description that will need to be wrapped across multiple lines when displayed in the detail view",
252            "Another row",
253        ];
254        let values = vec![100, 200, 300];
255        let scores = vec![0.95_f32, 0.87, 0.99];
256
257        let batch = RecordBatch::try_new(
258            schema.clone(),
259            vec![
260                Arc::new(StringArray::from(ids)),
261                Arc::new(StringArray::from(descriptions)),
262                Arc::new(Int32Array::from(values)),
263                Arc::new(Float32Array::from(scores)),
264            ],
265        )
266        .unwrap();
267
268        DatasetAdapter::from_batches(vec![batch], schema).unwrap()
269    }
270
271    #[test]
272    fn f_detail_new() {
273        let adapter = create_test_adapter();
274        let detail = RowDetailView::new(&adapter, 0);
275        assert!(detail.is_some());
276    }
277
278    #[test]
279    fn f_detail_out_of_bounds() {
280        let adapter = create_test_adapter();
281        let detail = RowDetailView::new(&adapter, 100);
282        assert!(detail.is_none());
283    }
284
285    #[test]
286    fn f_detail_row_index() {
287        let adapter = create_test_adapter();
288        let detail = RowDetailView::new(&adapter, 1).unwrap();
289        assert_eq!(detail.row_index(), 1);
290    }
291
292    #[test]
293    fn f_detail_field_count() {
294        let adapter = create_test_adapter();
295        let detail = RowDetailView::new(&adapter, 0).unwrap();
296        assert_eq!(detail.field_count(), 4);
297    }
298
299    #[test]
300    fn f_detail_field_value_by_name() {
301        let adapter = create_test_adapter();
302        let detail = RowDetailView::new(&adapter, 0).unwrap();
303        let value = detail.field_value("id");
304        assert_eq!(value, Some("row_0"));
305    }
306
307    #[test]
308    fn f_detail_field_value_not_found() {
309        let adapter = create_test_adapter();
310        let detail = RowDetailView::new(&adapter, 0).unwrap();
311        let value = detail.field_value("nonexistent");
312        assert!(value.is_none());
313    }
314
315    #[test]
316    fn f_detail_field_by_index() {
317        let adapter = create_test_adapter();
318        let detail = RowDetailView::new(&adapter, 0).unwrap();
319        let (name, value) = detail.field_by_index(0).unwrap();
320        assert_eq!(name, "id");
321        assert_eq!(value, "row_0");
322    }
323
324    #[test]
325    fn f_detail_field_by_index_out_of_bounds() {
326        let adapter = create_test_adapter();
327        let detail = RowDetailView::new(&adapter, 0).unwrap();
328        assert!(detail.field_by_index(100).is_none());
329    }
330
331    #[test]
332    fn f_detail_render_lines() {
333        let adapter = create_test_adapter();
334        let detail = RowDetailView::new(&adapter, 0).unwrap();
335        let lines = detail.render_lines();
336
337        assert!(!lines.is_empty());
338        assert!(lines[0].contains("Row 0"));
339    }
340
341    #[test]
342    fn f_detail_render() {
343        let adapter = create_test_adapter();
344        let detail = RowDetailView::new(&adapter, 0).unwrap();
345        let rendered = detail.render();
346
347        assert!(rendered.contains("Row 0"));
348        assert!(rendered.contains("id:"));
349        assert!(rendered.contains("row_0"));
350    }
351
352    #[test]
353    fn f_detail_scroll_down() {
354        let adapter = create_test_adapter();
355        let mut detail = RowDetailView::new(&adapter, 1).unwrap();
356        let initial = detail.scroll_offset();
357        detail.scroll_down();
358        // May or may not change depending on content size
359        assert!(detail.scroll_offset() >= initial);
360    }
361
362    #[test]
363    fn f_detail_scroll_up() {
364        let adapter = create_test_adapter();
365        let mut detail = RowDetailView::with_dimensions(&adapter, 1, 40, 10).unwrap();
366        detail.scroll_down();
367        detail.scroll_down();
368        detail.scroll_down();
369        let after_down = detail.scroll_offset();
370        detail.scroll_up();
371        assert!(detail.scroll_offset() <= after_down);
372    }
373
374    #[test]
375    fn f_detail_is_empty() {
376        let adapter = create_test_adapter();
377        let detail = RowDetailView::new(&adapter, 0).unwrap();
378        assert!(!detail.is_empty());
379    }
380
381    #[test]
382    fn f_detail_clone() {
383        let adapter = create_test_adapter();
384        let detail = RowDetailView::new(&adapter, 0).unwrap();
385        let cloned = detail.clone();
386        assert_eq!(detail.row_index(), cloned.row_index());
387        assert_eq!(detail.field_count(), cloned.field_count());
388    }
389
390    #[test]
391    fn f_wrap_text_short() {
392        let wrapped = wrap_text("hello", 20);
393        assert_eq!(wrapped, vec!["hello"]);
394    }
395
396    #[test]
397    fn f_wrap_text_long() {
398        let text = "This is a long line that needs wrapping";
399        let wrapped = wrap_text(text, 10);
400        assert!(wrapped.len() > 1);
401        for line in &wrapped {
402            assert!(line.chars().count() <= 10);
403        }
404    }
405
406    #[test]
407    fn f_wrap_text_multiline() {
408        let text = "Line one\nLine two";
409        let wrapped = wrap_text(text, 50);
410        assert_eq!(wrapped.len(), 2);
411    }
412
413    #[test]
414    fn f_wrap_text_empty() {
415        let wrapped = wrap_text("", 20);
416        assert_eq!(wrapped.len(), 1);
417    }
418
419    #[test]
420    fn f_wrap_text_zero_width() {
421        let wrapped = wrap_text("hello", 0);
422        assert_eq!(wrapped, vec!["hello"]);
423    }
424
425    #[test]
426    fn f_calculate_total_lines() {
427        let fields = vec![
428            ("name".to_string(), "value".to_string()),
429            ("other".to_string(), "data".to_string()),
430        ];
431        let total = calculate_total_lines(&fields, 80);
432        // Title + blank + (field_name + value + blank) * 2
433        assert!(total >= 8);
434    }
435
436    #[test]
437    fn f_detail_page_down() {
438        let adapter = create_test_adapter();
439        let mut detail = RowDetailView::with_dimensions(&adapter, 1, 40, 5).unwrap();
440        let initial = detail.scroll_offset();
441        detail.page_down();
442        // Page down should increase offset (or stay same if at end)
443        assert!(detail.scroll_offset() >= initial);
444    }
445
446    #[test]
447    fn f_detail_page_up() {
448        let adapter = create_test_adapter();
449        let mut detail = RowDetailView::with_dimensions(&adapter, 1, 40, 5).unwrap();
450        // Scroll down first
451        detail.page_down();
452        detail.page_down();
453        let after_down = detail.scroll_offset();
454        // Now page up
455        detail.page_up();
456        // Should decrease or stay same
457        assert!(detail.scroll_offset() <= after_down);
458    }
459
460    #[test]
461    fn f_wrap_text_with_empty_line() {
462        let text = "First\n\nThird";
463        let wrapped = wrap_text(text, 50);
464        assert_eq!(wrapped.len(), 3);
465        assert_eq!(wrapped[1], "");
466    }
467}