Skip to main content

datui_lib/widgets/
export.rs

1//! Export modal rendering.
2
3use crate::export_modal::{ExportFocus, ExportFormat, ExportModal};
4use crate::CompressionFormat;
5use ratatui::layout::{Constraint, Direction, Layout, Rect};
6use ratatui::style::{Color, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph, Widget};
9
10/// Render the export modal with format selector on left, options on right.
11pub fn render_export_modal(
12    area: Rect,
13    buf: &mut ratatui::buffer::Buffer,
14    modal: &mut ExportModal,
15    border_color: Color,
16    active_color: Color,
17    text_primary: Color,
18    text_inverse: Color,
19) {
20    Clear.render(area, buf);
21    let block = Block::default()
22        .borders(Borders::ALL)
23        .border_type(BorderType::Rounded)
24        .border_style(Style::default().fg(border_color))
25        .title("Export Data");
26    let inner = block.inner(area);
27    block.render(area, buf);
28
29    // Split into left (format list) and right (options)
30    let chunks = Layout::default()
31        .direction(Direction::Horizontal)
32        .constraints([
33            Constraint::Length(20), // Format list width
34            Constraint::Min(40),    // Options area
35        ])
36        .split(inner);
37
38    // Left: Format selector (list)
39    render_format_list(chunks[0], buf, modal, border_color, active_color);
40
41    // Right: Path input and format-specific options
42    let right_chunks = Layout::default()
43        .direction(Direction::Vertical)
44        .constraints([
45            Constraint::Length(3), // Path input
46            Constraint::Min(10),   // Format-specific options
47            Constraint::Length(3), // Buttons
48        ])
49        .split(chunks[1]);
50
51    // Path input
52    render_path_input(
53        right_chunks[0],
54        buf,
55        modal,
56        border_color,
57        active_color,
58        text_primary,
59        text_inverse,
60    );
61
62    // Format-specific options
63    render_format_options(
64        right_chunks[1],
65        buf,
66        modal,
67        border_color,
68        active_color,
69        text_primary,
70        text_inverse,
71    );
72
73    // Footer buttons
74    render_footer(right_chunks[2], buf, modal, border_color, active_color);
75}
76
77fn render_format_list(
78    area: Rect,
79    buf: &mut ratatui::buffer::Buffer,
80    modal: &mut ExportModal,
81    border_color: Color,
82    active_color: Color,
83) {
84    let is_focused = modal.focus == ExportFocus::FormatSelector;
85    let border_style = if is_focused {
86        Style::default().fg(active_color)
87    } else {
88        Style::default().fg(border_color)
89    };
90
91    let block = Block::default()
92        .borders(Borders::ALL)
93        .border_type(BorderType::Rounded)
94        .border_style(border_style)
95        .title("Format");
96    let inner = block.inner(area);
97    block.render(area, buf);
98
99    let items: Vec<ListItem> = ExportFormat::ALL
100        .iter()
101        .map(|format| {
102            let marker = if modal.selected_format == *format {
103                "●"
104            } else {
105                "○"
106            };
107            let style = if modal.selected_format == *format {
108                Style::default().fg(active_color)
109            } else {
110                Style::default().fg(border_color)
111            };
112            ListItem::new(Line::from(vec![Span::styled(
113                format!("{} {}", marker, format.as_str()),
114                style,
115            )]))
116        })
117        .collect();
118
119    let list = List::new(items).style(if is_focused {
120        Style::default().fg(active_color)
121    } else {
122        Style::default()
123    });
124    list.render(inner, buf);
125}
126
127fn render_path_input(
128    area: Rect,
129    buf: &mut ratatui::buffer::Buffer,
130    modal: &mut ExportModal,
131    border_color: Color,
132    active_color: Color,
133    _text_primary: Color,
134    _text_inverse: Color,
135) {
136    let is_focused = modal.focus == ExportFocus::PathInput;
137    let border_style = if is_focused {
138        Style::default().fg(active_color)
139    } else {
140        Style::default().fg(border_color)
141    };
142
143    let block = Block::default()
144        .borders(Borders::ALL)
145        .border_type(BorderType::Rounded)
146        .border_style(border_style)
147        .title("File Path");
148    let inner = block.inner(area);
149    block.render(area, buf);
150
151    // Render input using TextInput widget
152    modal.path_input.set_focused(is_focused);
153    (&modal.path_input).render(inner, buf);
154}
155
156fn render_format_options(
157    area: Rect,
158    buf: &mut ratatui::buffer::Buffer,
159    modal: &mut ExportModal,
160    border_color: Color,
161    active_color: Color,
162    text_primary: Color,
163    text_inverse: Color,
164) {
165    let block = Block::default()
166        .borders(Borders::ALL)
167        .border_type(BorderType::Rounded)
168        .border_style(Style::default().fg(border_color))
169        .title("Options");
170    let inner = block.inner(area);
171    block.render(area, buf);
172
173    match modal.selected_format {
174        ExportFormat::Csv => render_csv_options(
175            inner,
176            buf,
177            modal,
178            border_color,
179            active_color,
180            text_primary,
181            text_inverse,
182        ),
183        ExportFormat::Json => render_json_options(inner, buf, modal, border_color, active_color),
184        ExportFormat::Ndjson => {
185            render_ndjson_options(inner, buf, modal, border_color, active_color)
186        }
187        ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
188            render_no_format_options(inner, buf, modal, border_color, active_color)
189        }
190    }
191}
192
193fn render_csv_options(
194    area: Rect,
195    buf: &mut ratatui::buffer::Buffer,
196    modal: &mut ExportModal,
197    border_color: Color,
198    active_color: Color,
199    _text_primary: Color,
200    _text_inverse: Color,
201) {
202    // Vertical layout: 3 rows + compression grid
203    // Row 1: Delimiter label + input
204    // Row 2: Include Header label + checkbox
205    // Row 3: Compression label (on its own line)
206    // Row 4+: Compression grid (3 items wide)
207
208    let rows = Layout::default()
209        .direction(Direction::Vertical)
210        .constraints([
211            Constraint::Length(1), // Delimiter row
212            Constraint::Length(1), // Include Header row
213            Constraint::Length(1), // Compression label row
214            Constraint::Min(1),    // Compression grid (flexible)
215        ])
216        .split(area);
217
218    // Row 1: Delimiter label + input
219    // Use fixed width for label to align with other labels
220    let delimiter_row = Layout::default()
221        .direction(Direction::Horizontal)
222        .constraints([
223            Constraint::Length(15), // Fixed width for label alignment ("Delimiter:     ")
224            Constraint::Length(2),  // Padding between label and widget
225            Constraint::Min(1),     // Input widget (fills remaining space)
226        ])
227        .split(rows[0]);
228
229    let is_delimiter_focused = modal.focus == ExportFocus::CsvDelimiter;
230    let delimiter_label_style = if is_delimiter_focused {
231        Style::default().fg(active_color)
232    } else {
233        Style::default().fg(border_color)
234    };
235
236    Paragraph::new("Delimiter:")
237        .style(delimiter_label_style)
238        .render(delimiter_row[0], buf);
239
240    // Render delimiter input using TextInput widget (no border to fit on one row)
241    modal.csv_delimiter_input.set_focused(is_delimiter_focused);
242    (&modal.csv_delimiter_input).render(delimiter_row[2], buf);
243
244    // Row 2: Include Header label + checkbox
245    // Use same label width as delimiter for alignment
246    let header_row = Layout::default()
247        .direction(Direction::Horizontal)
248        .constraints([
249            Constraint::Length(15), // Fixed width for label alignment ("Include Header:")
250            Constraint::Length(2),  // Padding between label and widget
251            Constraint::Min(1),     // Checkbox
252        ])
253        .split(rows[1]);
254
255    let is_header_focused = modal.focus == ExportFocus::CsvIncludeHeader;
256    let header_label_style = if is_header_focused {
257        Style::default().fg(active_color)
258    } else {
259        Style::default().fg(border_color)
260    };
261
262    Paragraph::new("Include Header:")
263        .style(header_label_style)
264        .render(header_row[0], buf);
265
266    // Checkbox
267    let marker = if modal.csv_include_header {
268        "☑"
269    } else {
270        "☐"
271    };
272    let checkbox_style = if is_header_focused {
273        Style::default().fg(active_color)
274    } else {
275        Style::default().fg(border_color)
276    };
277
278    Paragraph::new(Line::from(vec![Span::styled(marker, checkbox_style)]))
279        .render(header_row[2], buf);
280
281    // Row 3: Compression label (on its own line)
282    // Use same label width for alignment
283    let compression_label_row = Layout::default()
284        .direction(Direction::Horizontal)
285        .constraints([
286            Constraint::Length(15), // Fixed width for label alignment ("Compression:    ")
287            Constraint::Min(1),     // Rest of row
288        ])
289        .split(rows[2]);
290
291    let is_compression_focused = modal.focus == ExportFocus::CsvCompression;
292    let compression_label_style = if is_compression_focused {
293        Style::default().fg(active_color)
294    } else {
295        Style::default().fg(border_color)
296    };
297
298    Paragraph::new("Compression:")
299        .style(compression_label_style)
300        .render(compression_label_row[0], buf);
301
302    // Row 4+: Compression grid (3 items wide)
303    if rows.len() > 3 && rows[3].height > 0 {
304        render_compression_grid(
305            rows[3],
306            buf,
307            modal,
308            ExportFocus::CsvCompression,
309            modal.csv_compression,
310            border_color,
311            active_color,
312        );
313    }
314}
315
316fn render_json_options(
317    area: Rect,
318    buf: &mut ratatui::buffer::Buffer,
319    modal: &mut ExportModal,
320    border_color: Color,
321    active_color: Color,
322) {
323    // Vertical layout: Compression label on its own line, then grid
324    let rows = Layout::default()
325        .direction(Direction::Vertical)
326        .constraints([
327            Constraint::Length(1), // Compression label row
328            Constraint::Min(1),    // Compression grid (flexible)
329        ])
330        .split(area);
331
332    // Compression label (on its own line) - use fixed width for alignment
333    let compression_label_row = Layout::default()
334        .direction(Direction::Horizontal)
335        .constraints([
336            Constraint::Length(15), // Fixed width for label alignment
337            Constraint::Min(1),     // Rest of row
338        ])
339        .split(rows[0]);
340
341    let is_compression_focused = modal.focus == ExportFocus::JsonCompression;
342    let compression_label_style = if is_compression_focused {
343        Style::default().fg(active_color)
344    } else {
345        Style::default().fg(border_color)
346    };
347
348    Paragraph::new("Compression:")
349        .style(compression_label_style)
350        .render(compression_label_row[0], buf);
351
352    // Compression grid (3 items wide)
353    if rows.len() > 1 && rows[1].height > 0 {
354        render_compression_grid(
355            rows[1],
356            buf,
357            modal,
358            ExportFocus::JsonCompression,
359            modal.json_compression,
360            border_color,
361            active_color,
362        );
363    }
364}
365
366fn render_ndjson_options(
367    area: Rect,
368    buf: &mut ratatui::buffer::Buffer,
369    modal: &mut ExportModal,
370    border_color: Color,
371    active_color: Color,
372) {
373    // Vertical layout: Compression label on its own line, then grid
374    let rows = Layout::default()
375        .direction(Direction::Vertical)
376        .constraints([
377            Constraint::Length(1), // Compression label row
378            Constraint::Min(1),    // Compression grid (flexible)
379        ])
380        .split(area);
381
382    // Compression label (on its own line) - use fixed width for alignment
383    let compression_label_row = Layout::default()
384        .direction(Direction::Horizontal)
385        .constraints([
386            Constraint::Length(15), // Fixed width for label alignment
387            Constraint::Min(1),     // Rest of row
388        ])
389        .split(rows[0]);
390
391    let is_compression_focused = modal.focus == ExportFocus::NdjsonCompression;
392    let compression_label_style = if is_compression_focused {
393        Style::default().fg(active_color)
394    } else {
395        Style::default().fg(border_color)
396    };
397
398    Paragraph::new("Compression:")
399        .style(compression_label_style)
400        .render(compression_label_row[0], buf);
401
402    // Compression grid (3 items wide)
403    if rows.len() > 1 && rows[1].height > 0 {
404        render_compression_grid(
405            rows[1],
406            buf,
407            modal,
408            ExportFocus::NdjsonCompression,
409            modal.ndjson_compression,
410            border_color,
411            active_color,
412        );
413    }
414}
415
416fn render_no_format_options(
417    area: Rect,
418    buf: &mut ratatui::buffer::Buffer,
419    modal: &mut ExportModal,
420    border_color: Color,
421    _active_color: Color,
422) {
423    let msg = format!(
424        "No additional options for {} format",
425        modal.selected_format.as_str()
426    );
427    Paragraph::new(msg)
428        .style(Style::default().fg(border_color))
429        .centered()
430        .render(area, buf);
431}
432
433/// Render compression options in a grid layout (3 items wide)
434fn render_compression_grid(
435    area: Rect,
436    buf: &mut ratatui::buffer::Buffer,
437    modal: &mut ExportModal,
438    focus: ExportFocus,
439    compression: Option<CompressionFormat>,
440    border_color: Color,
441    active_color: Color,
442) {
443    let is_focused = modal.focus == focus;
444
445    let compression_options = [
446        (None, "None"),
447        (Some(CompressionFormat::Gzip), "Gzip"),
448        (Some(CompressionFormat::Zstd), "Zstd"),
449        (Some(CompressionFormat::Bzip2), "Bzip2"),
450        (Some(CompressionFormat::Xz), "XZ"),
451    ];
452
453    // Grid: 3 items per row
454    const ITEMS_PER_ROW: usize = 3;
455    let num_rows = (compression_options.len() as u16).div_ceil(ITEMS_PER_ROW as u16);
456
457    // Calculate item width (divide area width by 3)
458    let item_width = area.width / ITEMS_PER_ROW as u16;
459    let item_height = 1;
460
461    let mut option_idx = 0;
462    for row in 0..num_rows.min(area.height) {
463        let y = area.y + row;
464        if y >= area.bottom() {
465            break;
466        }
467
468        for col in 0..ITEMS_PER_ROW {
469            if option_idx >= compression_options.len() {
470                break;
471            }
472
473            let x = area.x + (col as u16 * item_width);
474            let item_area = Rect {
475                x,
476                y,
477                width: item_width,
478                height: item_height,
479            };
480
481            let (opt, label) = &compression_options[option_idx];
482            let is_selected = *opt == compression;
483            let is_option_focused = is_focused && option_idx == modal.compression_selection_idx;
484
485            let marker = if is_selected { "●" } else { "○" };
486            let style = if is_selected || is_option_focused {
487                Style::default().fg(active_color)
488            } else {
489                Style::default().fg(border_color)
490            };
491
492            let text = format!("{} {}", marker, label);
493            Paragraph::new(Line::from(vec![Span::styled(text, style)])).render(item_area, buf);
494
495            option_idx += 1;
496        }
497    }
498}
499
500fn render_footer(
501    area: Rect,
502    buf: &mut ratatui::buffer::Buffer,
503    modal: &mut ExportModal,
504    border_color: Color,
505    active_color: Color,
506) {
507    let chunks = Layout::default()
508        .direction(Direction::Horizontal)
509        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
510        .split(area);
511
512    // Export button
513    let is_focused = modal.focus == ExportFocus::ExportButton;
514    let text_style = if is_focused {
515        Style::default().fg(active_color)
516    } else {
517        Style::default().fg(border_color)
518    };
519    let border_style = if is_focused {
520        Style::default().fg(active_color)
521    } else {
522        Style::default().fg(border_color)
523    };
524
525    Paragraph::new("Export")
526        .style(text_style)
527        .block(
528            Block::default()
529                .borders(Borders::ALL)
530                .border_type(BorderType::Rounded)
531                .border_style(border_style),
532        )
533        .centered()
534        .render(chunks[0], buf);
535
536    // Cancel button
537    let is_focused = modal.focus == ExportFocus::CancelButton;
538    let text_style = if is_focused {
539        Style::default().fg(active_color)
540    } else {
541        Style::default().fg(border_color)
542    };
543    let border_style = if is_focused {
544        Style::default().fg(active_color)
545    } else {
546        Style::default().fg(border_color)
547    };
548
549    Paragraph::new("Cancel")
550        .style(text_style)
551        .block(
552            Block::default()
553                .borders(Borders::ALL)
554                .border_type(BorderType::Rounded)
555                .border_style(border_style),
556        )
557        .centered()
558        .render(chunks[1], buf);
559}