Skip to main content

datui_lib/widgets/
export.rs

1//! Export modal rendering: a FormView, a row 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::app::pointer::FieldId;
7use crate::export::export_modal::{COMPRESSION_OPTIONS, ExportFocus, ExportFormat, ExportModal};
8use crate::render::context::RenderContext;
9use crate::widgets::ui::{FormLine, FormValue, FormView, HintBar};
10use ratatui::layout::Rect;
11use ratatui::style::Style;
12
13/// The value column's offset: past the longest labels, "Compression:" and
14/// "Source file:", plus two cells of air, which the format row's tint uses.
15const LABEL_WIDTH: u16 = 14;
16
17/// The widest the dialog grows: room for every format on its row.
18const MAX_WIDTH: u16 = 70;
19
20/// Rows the dialog takes: the most fields a format shows (format, path,
21/// delimiter, header, compression, source file), a blank and the status line,
22/// then the blank, the footer and the frame.
23const HEIGHT: u16 = 6 + 2 + 4;
24
25/// Shown under the rows of a format that cannot hold lists or structs.
26const NESTED_NOTE: &str = "Lists and structs written as JSON";
27
28/// Shown under the rows of an Avro export whose view has a name Avro refuses.
29const AVRO_NAMES_NOTE: &str = "Column names made valid for Avro";
30
31/// Format names, in the order ←/→ step them.
32const FORMAT_NAMES: [&str; ExportFormat::ALL.len()] = {
33    let mut names = [""; ExportFormat::ALL.len()];
34    let mut i = 0;
35    while i < names.len() {
36        names[i] = ExportFormat::ALL[i].as_str();
37        i += 1;
38    }
39    names
40};
41
42const fn compression_name(compression: Option<CompressionFormat>) -> &'static str {
43    match compression {
44        None => "None",
45        Some(CompressionFormat::Gzip) => "Gzip",
46        Some(CompressionFormat::Zstd) => "Zstd",
47        Some(CompressionFormat::Bzip2) => "Bzip2",
48        Some(CompressionFormat::Xz) => "XZ",
49    }
50}
51
52/// Compression names, in the order ←/→ step them: shown side by side, as the
53/// formats are, so the choices are on screen rather than a bare `None`.
54const COMPRESSION_NAMES: [&str; COMPRESSION_OPTIONS.len()] = {
55    let mut names = [""; COMPRESSION_OPTIONS.len()];
56    let mut i = 0;
57    while i < names.len() {
58        names[i] = compression_name(COMPRESSION_OPTIONS[i]);
59        i += 1;
60    }
61    names
62};
63
64/// Where the dialog sits in `area`: centered and compact, a fixed height so
65/// nothing moves as the format's fields come and go.
66pub fn dialog_area(area: Rect) -> Rect {
67    let width = area.width.saturating_sub(4).min(MAX_WIDTH);
68    let height = HEIGHT.min(area.height);
69    crate::render::layout::centered_rect(area, width, height)
70}
71
72pub fn render_export_modal(
73    area: Rect,
74    buf: &mut ratatui::buffer::Buffer,
75    modal: &mut ExportModal,
76    ctx: &RenderContext,
77) {
78    // Primary first, Esc last, and one chip for what the focused row itself
79    // takes; when the dialog runs out of room, Tab yields first and the way
80    // out goes last.
81    let footer = HintBar::from_ctx(ctx)
82        .screen(datui_cli::keys::Context::Export)
83        .group("Form")
84        .key("Enter")
85        .weight(3);
86    let footer = match modal.focus {
87        ExportFocus::FormatSelector => footer.key_as("← / →", "Format").weight(2),
88        ExportFocus::CsvIncludeHeader | ExportFocus::SourceFile => {
89            footer.key_as("Space", "Toggle").weight(2)
90        }
91        ExportFocus::Compression => footer.key("← / →").weight(2),
92        ExportFocus::PathInput | ExportFocus::CsvDelimiter => footer,
93    };
94    let footer = footer.key("Tab").weight(1).key("Esc").weight(4);
95
96    modal
97        .path_input
98        .set_focused(modal.focus == ExportFocus::PathInput);
99    modal
100        .csv_delimiter_input
101        .set_focused(modal.focus == ExportFocus::CsvDelimiter);
102
103    // One row per field `fields` offers, in its order, so Tab walks what is on
104    // screen. Format stays the first row: stepping it adds and drops the rows
105    // below, never moves it.
106    let selected = ExportFormat::ALL
107        .iter()
108        .position(|f| *f == modal.selected_format)
109        .unwrap_or(0);
110    let compression = COMPRESSION_OPTIONS
111        .iter()
112        .position(|c| *c == modal.compression())
113        .unwrap_or(0);
114    let rows = modal
115        .focus_order()
116        .into_iter()
117        .map(|field| {
118            let (label, value) = match field {
119                ExportFocus::FormatSelector => (
120                    "Format:",
121                    FormValue::Options {
122                        items: &FORMAT_NAMES,
123                        selected,
124                        clicks: Some(FieldId::of::<ExportModal>(field)),
125                    },
126                ),
127                ExportFocus::PathInput => ("Path:", FormValue::Input(&modal.path_input)),
128                ExportFocus::CsvDelimiter => {
129                    ("Delimiter:", FormValue::Input(&modal.csv_delimiter_input))
130                }
131                ExportFocus::CsvIncludeHeader => {
132                    ("Header:", FormValue::Toggle(modal.csv_include_header))
133                }
134                ExportFocus::Compression => (
135                    "Compression:",
136                    FormValue::Options {
137                        items: &COMPRESSION_NAMES,
138                        selected: compression,
139                        clicks: Some(FieldId::of::<ExportModal>(field)),
140                    },
141                ),
142                ExportFocus::SourceFile => ("Source file:", FormValue::Toggle(modal.source_file)),
143            };
144            FormLine::Field(field, label, value)
145        })
146        .collect();
147
148    // The reason the form cannot export yet, inline on the last line: a warning
149    // at most, never a modal. Otherwise the line says how a format without
150    // nesting writes the view's list and struct columns, or that Avro renames.
151    let status = match modal.path_error.as_deref() {
152        Some(message) => (message, ctx.warning),
153        None if modal.nested_columns && !modal.selected_format.holds_nesting() => {
154            (NESTED_NOTE, ctx.dimmed)
155        }
156        None if modal.avro_renames && modal.selected_format == ExportFormat::Avro => {
157            (AVRO_NAMES_NOTE, ctx.dimmed)
158        }
159        None => ("", ctx.dimmed),
160    };
161    FormView {
162        title: "Export Data",
163        screen: datui_cli::keys::Context::Export,
164        footer: Some(footer),
165        label_width: LABEL_WIDTH,
166        rows,
167        focused: Some(modal.focus),
168        picker: None,
169        status: Some((status.0.to_string(), Style::default().fg(status.1))),
170        shields: true,
171    }
172    .render::<ExportModal>(area, buf, ctx);
173}
174
175#[cfg(test)]
176mod tests;