1use 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
12const LABEL_WIDTH: u16 = 14;
15
16const MAX_WIDTH: u16 = 70;
18
19const HEIGHT: u16 = 6 + 2 + 4;
23
24const NESTED_NOTE: &str = "Lists and structs written as JSON";
26
27const AVRO_NAMES_NOTE: &str = "Column names made valid for Avro";
29
30const 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
51const 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
63pub 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 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 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 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 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 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 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 const FORMAT_Y: u16 = 1;
258
259 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}