Skip to main content

datui_lib/widgets/
export.rs

1//! Export modal rendering: one Surface, a FormRow per field in one column, the
2//! actions in the footer. Format is a Choice, so it is drawn as one: its values
3//! side by side on its row, where ←/→ visibly step along them.
4
5use crate::CompressionFormat;
6use crate::export_modal::{COMPRESSION_OPTIONS, ExportFocus, ExportFormat, ExportModal};
7use crate::pointer::FieldId;
8use crate::render::context::RenderContext;
9use crate::widgets::ui::{FormRow, FormValue, HintBar, Surface};
10use ratatui::layout::Rect;
11
12/// The value column's offset: past the longest labels, "Compression:" and
13/// "Source file:", plus two cells of air, which the format row's tint uses.
14const LABEL_WIDTH: u16 = 14;
15
16/// The widest the dialog grows: room for every format on its row.
17const MAX_WIDTH: u16 = 70;
18
19/// Rows the dialog takes: the most fields a format shows (format, path,
20/// delimiter, header, compression, source file), a blank and the status line,
21/// then the blank, the footer and the frame.
22const HEIGHT: u16 = 6 + 2 + 4;
23
24/// Shown under the rows of a format that cannot hold lists or structs.
25const NESTED_NOTE: &str = "Lists and structs written as JSON";
26
27/// Shown under the rows of an Avro export whose view has a name Avro refuses.
28const AVRO_NAMES_NOTE: &str = "Column names made valid for Avro";
29
30/// Format names, in the order ←/→ step them.
31const FORMAT_NAMES: [&str; ExportFormat::ALL.len()] = {
32    let mut names = [""; ExportFormat::ALL.len()];
33    let mut i = 0;
34    while i < names.len() {
35        names[i] = ExportFormat::ALL[i].as_str();
36        i += 1;
37    }
38    names
39};
40
41const fn compression_name(compression: Option<CompressionFormat>) -> &'static str {
42    match compression {
43        None => "None",
44        Some(CompressionFormat::Gzip) => "Gzip",
45        Some(CompressionFormat::Zstd) => "Zstd",
46        Some(CompressionFormat::Bzip2) => "Bzip2",
47        Some(CompressionFormat::Xz) => "XZ",
48    }
49}
50
51/// Compression names, in the order ←/→ step them: shown side by side, as the
52/// formats are, so the choices are on screen rather than a bare `None`.
53const COMPRESSION_NAMES: [&str; COMPRESSION_OPTIONS.len()] = {
54    let mut names = [""; COMPRESSION_OPTIONS.len()];
55    let mut i = 0;
56    while i < names.len() {
57        names[i] = compression_name(COMPRESSION_OPTIONS[i]);
58        i += 1;
59    }
60    names
61};
62
63/// Where the dialog sits in `area`: centered and compact, a fixed height so
64/// nothing moves as the format's fields come and go.
65pub fn dialog_area(area: Rect) -> Rect {
66    let width = area.width.saturating_sub(4).min(MAX_WIDTH);
67    let height = HEIGHT.min(area.height);
68    Rect {
69        x: area.x + area.width.saturating_sub(width) / 2,
70        y: area.y + area.height.saturating_sub(height) / 2,
71        width,
72        height,
73    }
74}
75
76pub fn render_export_modal(
77    area: Rect,
78    buf: &mut ratatui::buffer::Buffer,
79    modal: &mut ExportModal,
80    ctx: &RenderContext,
81) {
82    // Primary first, Esc last, and one chip for what the focused row itself
83    // takes; when the dialog runs out of room, Tab yields first and the way
84    // out goes last.
85    let g = crate::glyphs::get();
86    let mut footer = HintBar::from_ctx(ctx).hint_weighted("Enter", "Export", 3);
87    match modal.focus {
88        ExportFocus::FormatSelector => {
89            footer = footer.hint_weighted(g.updown_lr, "Format", 2);
90        }
91        ExportFocus::CsvIncludeHeader | ExportFocus::SourceFile => {
92            footer = footer.hint_weighted("Space", "Toggle", 2);
93        }
94        ExportFocus::Compression => {
95            footer = footer.hint_weighted(g.updown_lr, "Change", 2);
96        }
97        ExportFocus::PathInput | ExportFocus::CsvDelimiter => {}
98    }
99    let footer = footer
100        .hint_weighted("Tab", "Next", 1)
101        .hint_weighted("Esc", "Cancel", 4);
102    crate::pointer::record(area, crate::pointer::Hit::Modal);
103    let content = Surface::new("Export Data")
104        .footer(&footer)
105        .render(area, buf, ctx);
106    if content.height < 1 || content.width < 4 {
107        return;
108    }
109
110    modal
111        .path_input
112        .set_focused(modal.focus == ExportFocus::PathInput);
113    modal
114        .csv_delimiter_input
115        .set_focused(modal.focus == ExportFocus::CsvDelimiter);
116
117    // One row per field `fields` offers, in its order, so Tab walks what is on
118    // screen. Format stays the first row: stepping it adds and drops the rows
119    // below, never moves it.
120    let selected = ExportFormat::ALL
121        .iter()
122        .position(|f| *f == modal.selected_format)
123        .unwrap_or(0);
124    let format_field = FieldId::of::<ExportModal>(ExportFocus::FormatSelector);
125    let compression_field = FieldId::of::<ExportModal>(ExportFocus::Compression);
126    let compression = COMPRESSION_OPTIONS
127        .iter()
128        .position(|c| *c == modal.compression())
129        .unwrap_or(0);
130    let fields = modal.focus_order();
131    for (i, &field) in fields.iter().enumerate() {
132        let y = content.y + i as u16;
133        if y >= content.bottom() {
134            break;
135        }
136        let (label, value) = match field {
137            ExportFocus::FormatSelector => (
138                "Format:",
139                FormValue::Options {
140                    items: &FORMAT_NAMES,
141                    selected,
142                    clicks: Some(format_field.clone()),
143                },
144            ),
145            ExportFocus::PathInput => ("Path:", FormValue::Input(&modal.path_input)),
146            ExportFocus::CsvDelimiter => {
147                ("Delimiter:", FormValue::Input(&modal.csv_delimiter_input))
148            }
149            ExportFocus::CsvIncludeHeader => {
150                ("Header:", FormValue::Toggle(modal.csv_include_header))
151            }
152            ExportFocus::Compression => (
153                "Compression:",
154                FormValue::Options {
155                    items: &COMPRESSION_NAMES,
156                    selected: compression,
157                    clicks: Some(compression_field.clone()),
158                },
159            ),
160            ExportFocus::SourceFile => ("Source file:", FormValue::Toggle(modal.source_file)),
161        };
162        let row = Rect {
163            y,
164            height: 1,
165            ..content
166        };
167        // The row first: the format's values, recorded as drawn, lie on top.
168        crate::pointer::record_field::<ExportModal>(row, field);
169        FormRow {
170            label,
171            value,
172            focused: modal.focus == field,
173            label_width: LABEL_WIDTH,
174        }
175        .render(row, buf, ctx);
176    }
177
178    // The reason the form cannot export yet, inline on the last line: a warning
179    // at most, never a modal. Otherwise the line says how a format without
180    // nesting writes the view's list and struct columns, or that Avro renames.
181    // The last line, not under the rows, so it stays put as rows come and go.
182    let status = match modal.path_error.as_deref() {
183        Some(message) => Some((message, ctx.warning)),
184        None if modal.nested_columns && !modal.selected_format.holds_nesting() => {
185            Some((NESTED_NOTE, ctx.dimmed))
186        }
187        None if modal.avro_renames && modal.selected_format == ExportFormat::Avro => {
188            Some((AVRO_NAMES_NOTE, ctx.dimmed))
189        }
190        None => None,
191    };
192    let Some((message, color)) = status else {
193        return;
194    };
195    // A failed write's reason can run longer than the line: it wraps upward into
196    // the rows the format leaves free, keeping a blank under the last field, and
197    // is cut with an ellipsis past that.
198    let width = content.width.saturating_sub(1) as usize;
199    let first_free = content.y + fields.len() as u16 + 1;
200    let room = content.bottom().saturating_sub(first_free).max(1) as usize;
201    let mut lines = crate::widgets::info::wrap_to(message, width);
202    if lines.len() > room {
203        lines.truncate(room);
204        if let Some(last) = lines.last_mut() {
205            let cut = format!("{last} {}", crate::glyphs::get().ellipsis);
206            *last = crate::widgets::data_quality::fit(&cut, width);
207        }
208    }
209    let top = content.bottom() - lines.len() as u16;
210    if top < content.y + fields.len() as u16 {
211        return;
212    }
213    for (i, line) in lines.iter().enumerate() {
214        // Under the labels, past the rail gutter.
215        ratatui::widgets::Widget::render(
216            ratatui::widgets::Paragraph::new(line.as_str())
217                .style(ratatui::style::Style::default().fg(color)),
218            Rect {
219                x: content.x + 1,
220                y: top + i as u16,
221                width: content.width - 1,
222                height: 1,
223            },
224            buf,
225        );
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use crate::pointer::Hit;
233    use ratatui::buffer::Buffer;
234
235    fn draw(modal: &mut ExportModal, width: u16, height: u16) -> Buffer {
236        let area = Rect::new(0, 0, width, height);
237        let mut buf = Buffer::empty(area);
238        render_export_modal(area, &mut buf, modal, &RenderContext::for_test());
239        buf
240    }
241
242    fn lines(buf: &Buffer) -> Vec<String> {
243        (0..buf.area.height)
244            .map(|y| {
245                (0..buf.area.width)
246                    .map(|x| buf[(x, y)].symbol().to_string())
247                    .collect::<String>()
248            })
249            .collect()
250    }
251
252    fn painted(modal: &mut ExportModal, width: u16, height: u16) -> String {
253        lines(&draw(modal, width, height)).join("\n")
254    }
255
256    /// The dialog's first content row, where Format always sits.
257    const FORMAT_Y: u16 = 1;
258
259    /// Format is one row of its values, the chosen one tinted as the table's
260    /// current row is, the others plain: ←/→ step along what is drawn.
261    #[test]
262    fn format_is_one_row_with_the_chosen_value_tinted() {
263        let ctx = RenderContext::for_test();
264        let tint = ctx.highlight_style().bg.expect("the default theme tints");
265        let mut modal = ExportModal::new();
266        modal.active = true;
267        modal.selected_format = ExportFormat::Tsv;
268        let buf = draw(&mut modal, MAX_WIDTH, HEIGHT);
269        let rows = lines(&buf);
270        let row = &rows[usize::from(FORMAT_Y)];
271        assert!(row.contains("Format:"), "{row}");
272        let mut at = 0;
273        for name in FORMAT_NAMES {
274            let found = row[at..].find(name).map(|i| i + at);
275            assert!(found.is_some(), "{name} after {at} in {row:?}");
276            at = found.unwrap() + name.len();
277        }
278        // Nothing else lists a format: no column of them below.
279        for line in &rows[usize::from(FORMAT_Y) + 1..] {
280            assert!(!line.contains("Parquet"), "a second list: {line:?}");
281        }
282        let x_of = |name: &str| row[..row.find(name).unwrap()].chars().count() as u16;
283        let tsv = x_of(" TSV ");
284        for x in tsv..tsv + 5 {
285            assert_eq!(buf[(x, FORMAT_Y)].bg, tint, "TSV tinted at {x}");
286        }
287        let csv = x_of("CSV");
288        assert_ne!(buf[(csv, FORMAT_Y)].bg, tint, "CSV is not chosen");
289        // The chosen text lines up with the other rows' values.
290        let path_row = &rows[usize::from(FORMAT_Y) + 1];
291        assert!(path_row.contains("Path:"), "{path_row}");
292        assert_eq!(
293            x_of("TSV") - x_of("CSV"),
294            5,
295            "padded a cell each side: {row:?}"
296        );
297        assert_eq!(x_of("CSV"), 2 + 1 + LABEL_WIDTH, "{row:?}");
298    }
299
300    /// Compression shows its choices on its row, as Format does, the chosen one
301    /// tinted: `None` reads as a choice among others, not a bare word.
302    #[test]
303    fn compression_shows_its_choices() {
304        let ctx = RenderContext::for_test();
305        let tint = ctx.highlight_style().bg.expect("the default theme tints");
306        let mut modal = ExportModal::new();
307        modal.active = true;
308        modal.selected_format = ExportFormat::Csv;
309        modal.csv_compression = Some(CompressionFormat::Zstd);
310        let buf = draw(&mut modal, MAX_WIDTH, HEIGHT);
311        let rows = lines(&buf);
312        let (y, row) = rows
313            .iter()
314            .enumerate()
315            .find(|(_, r)| r.contains("Compression:"))
316            .expect("the compression row");
317        for name in COMPRESSION_NAMES {
318            assert!(row.contains(&format!(" {name} ")), "{name} in {row:?}");
319        }
320        let x = row[..row.find(" Zstd ").unwrap()].chars().count() as u16;
321        assert_eq!(buf[(x + 1, y as u16)].bg, tint, "Zstd chosen: {row:?}");
322    }
323
324    /// Too narrow for every value, the row shows the chosen one alone between
325    /// its step marks, and still in the value column.
326    #[test]
327    fn a_narrow_dialog_shows_the_chosen_format_alone() {
328        let g = crate::glyphs::get();
329        let mut modal = ExportModal::new();
330        modal.active = true;
331        modal.selected_format = ExportFormat::Parquet;
332        let rows = lines(&draw(&mut modal, 50, HEIGHT));
333        let row = &rows[usize::from(FORMAT_Y)];
334        let compact = format!("{} Parquet {}", g.choice_prev, g.choice_next);
335        assert!(row.contains(&compact), "{row:?}");
336        assert!(!row.contains("CSV") && !row.contains("Avro"), "{row:?}");
337        let at = row[..row.find("Parquet").unwrap()].chars().count() as u16;
338        assert_eq!(at, 2 + 1 + LABEL_WIDTH, "{row:?}");
339    }
340
341    /// The format's fields come and go below a Format row that stays put; the
342    /// rows are the form's fields, in its order.
343    #[test]
344    fn fields_follow_the_format_under_a_fixed_format_row() {
345        let mut modal = ExportModal::new();
346        modal.active = true;
347        let shown = |modal: &mut ExportModal| {
348            let rows = lines(&draw(modal, MAX_WIDTH, HEIGHT));
349            assert!(rows[usize::from(FORMAT_Y)].contains("Format:"));
350            ["Delimiter:", "Header:", "Compression:", "Source file:"]
351                .into_iter()
352                .filter(|label| rows.iter().any(|row| row.contains(label)))
353                .collect::<Vec<_>>()
354        };
355        assert_eq!(shown(&mut modal), ["Delimiter:", "Header:", "Compression:"]);
356        modal.selected_format = ExportFormat::Tsv;
357        assert_eq!(shown(&mut modal), ["Header:", "Compression:"]);
358        modal.selected_format = ExportFormat::Ndjson;
359        assert_eq!(shown(&mut modal), ["Compression:"]);
360        modal.selected_format = ExportFormat::Parquet;
361        assert!(shown(&mut modal).is_empty());
362        modal.offer_source_file = true;
363        assert_eq!(shown(&mut modal), ["Source file:"]);
364    }
365
366    /// A click on a format's value steps the field to it: each value records
367    /// where it was drawn, over the row's own record.
368    #[test]
369    fn each_format_value_is_a_click_target() {
370        let mut modal = ExportModal::new();
371        modal.active = true;
372        modal.selected_format = ExportFormat::Tsv;
373        let hits = crate::pointer::recording(|| {
374            draw(&mut modal, MAX_WIDTH, HEIGHT);
375        });
376        let field = Some(FieldId::of::<ExportModal>(ExportFocus::FormatSelector));
377        let options: Vec<(u16, usize, usize)> = hits
378            .iter()
379            .filter_map(|(rect, hit)| match hit {
380                Hit::Option {
381                    field: f,
382                    index,
383                    current,
384                } if *f == field => Some((rect.width, *index, *current)),
385                _ => None,
386            })
387            .collect();
388        let expected: Vec<(u16, usize, usize)> = FORMAT_NAMES
389            .iter()
390            .enumerate()
391            .map(|(i, name)| (name.len() as u16 + 2, i, 1))
392            .collect();
393        assert_eq!(options, expected);
394        let row = hits
395            .iter()
396            .position(|(_, hit)| {
397                *hit == Hit::Field(FieldId::of::<ExportModal>(ExportFocus::FormatSelector))
398            })
399            .expect("the row is recorded");
400        let first_option = hits
401            .iter()
402            .position(|(_, hit)| matches!(hit, Hit::Option { .. }))
403            .unwrap();
404        assert!(row < first_option, "the values lie on top of the row");
405
406        // Compact, the step marks step by one.
407        let hits = crate::pointer::recording(|| {
408            draw(&mut modal, 50, HEIGHT);
409        });
410        let steps: Vec<(usize, usize)> = hits
411            .iter()
412            .filter_map(|(_, hit)| match hit {
413                Hit::Option {
414                    field: f,
415                    index,
416                    current,
417                } if *f == field => Some((*index, *current)),
418                _ => None,
419            })
420            .collect();
421        assert_eq!(steps, [(0, 1), (1, 0)]);
422    }
423
424    /// A view with list or struct columns hears how CSV writes them; formats
425    /// that keep the types, and views without such columns, say nothing.
426    #[test]
427    fn csv_says_how_nested_columns_are_written() {
428        let mut modal = ExportModal::new();
429        modal.active = true;
430        for width in [50u16, MAX_WIDTH] {
431            let out = painted(&mut modal, width, HEIGHT);
432            assert!(!out.contains(NESTED_NOTE), "no nested columns: {out}");
433            modal.nested_columns = true;
434            let out = painted(&mut modal, width, HEIGHT);
435            assert!(out.contains(NESTED_NOTE), "missing at {width}: {out}");
436            modal.selected_format = ExportFormat::Parquet;
437            let out = painted(&mut modal, width, HEIGHT);
438            assert!(!out.contains(NESTED_NOTE), "Parquet keeps them: {out}");
439            modal.selected_format = ExportFormat::Csv;
440            modal.nested_columns = false;
441        }
442    }
443
444    /// A view with a name Avro refuses hears it is renamed, and only for Avro.
445    #[test]
446    fn avro_says_it_renames_columns() {
447        let mut modal = ExportModal::new();
448        modal.active = true;
449        modal.selected_format = ExportFormat::Avro;
450        for width in [50u16, MAX_WIDTH] {
451            let out = painted(&mut modal, width, HEIGHT);
452            assert!(!out.contains(AVRO_NAMES_NOTE), "valid names: {out}");
453            modal.avro_renames = true;
454            let out = painted(&mut modal, width, HEIGHT);
455            assert!(out.contains(AVRO_NAMES_NOTE), "missing at {width}: {out}");
456            modal.selected_format = ExportFormat::Parquet;
457            let out = painted(&mut modal, width, HEIGHT);
458            assert!(!out.contains(AVRO_NAMES_NOTE), "Parquet keeps them: {out}");
459            modal.selected_format = ExportFormat::Avro;
460            modal.avro_renames = false;
461        }
462    }
463
464    /// An invalid form says why inline, and never with a modal; the line holds
465    /// its place whatever the format shows.
466    #[test]
467    fn the_blank_path_message_renders_inline() {
468        let mut modal = ExportModal::new();
469        modal.active = true;
470        modal.path_error = Some("Enter a file path.".to_string());
471        for format in [ExportFormat::Csv, ExportFormat::Parquet] {
472            modal.selected_format = format;
473            for width in [50u16, MAX_WIDTH] {
474                let rows = lines(&draw(&mut modal, width, HEIGHT));
475                let at = rows
476                    .iter()
477                    .position(|row| row.contains("Enter a file path."));
478                assert_eq!(at, Some(usize::from(HEIGHT) - 4), "{format:?} at {width}");
479            }
480        }
481    }
482
483    /// At 80 columns the dialog lists every format; at 60 it falls back.
484    #[test]
485    fn the_dialog_fits_every_format_at_eighty_columns() {
486        for (width, full) in [(80u16, true), (60, false)] {
487            let screen = Rect::new(0, 0, width, 24);
488            let dialog = dialog_area(screen);
489            let mut modal = ExportModal::new();
490            modal.active = true;
491            let mut buf = Buffer::empty(screen);
492            render_export_modal(dialog, &mut buf, &mut modal, &RenderContext::for_test());
493            let text = lines(&buf).join("\n");
494            assert_eq!(text.contains("Avro"), full, "at {width}: {text}");
495        }
496    }
497}