Skip to main content

datui_lib/
export_modal.rs

1//! Export modal state and focus management.
2
3use crate::widgets::text_input::TextInput;
4use crate::CompressionFormat;
5
6#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
7pub enum ExportFormat {
8    #[default]
9    Csv,
10    Parquet,
11    Json,
12    Ndjson,
13    /// Arrow IPC / Feather v2
14    Ipc,
15    Avro,
16}
17
18impl ExportFormat {
19    pub const ALL: [Self; 6] = [
20        Self::Csv,
21        Self::Parquet,
22        Self::Json,
23        Self::Ndjson,
24        Self::Ipc,
25        Self::Avro,
26    ];
27
28    pub fn as_str(self) -> &'static str {
29        match self {
30            Self::Csv => "CSV",
31            Self::Parquet => "Parquet",
32            Self::Json => "JSON",
33            Self::Ndjson => "NDJSON",
34            Self::Ipc => "Arrow",
35            Self::Avro => "Avro",
36        }
37    }
38
39    pub fn extension(self) -> &'static str {
40        match self {
41            Self::Csv => "csv",
42            Self::Parquet => "parquet",
43            Self::Json => "json",
44            Self::Ndjson => "jsonl",
45            Self::Ipc => "arrow",
46            Self::Avro => "avro",
47        }
48    }
49
50    pub fn from_extension(ext: &str) -> Option<Self> {
51        match ext.to_lowercase().as_str() {
52            "csv" => Some(Self::Csv),
53            "parquet" => Some(Self::Parquet),
54            "json" => Some(Self::Json),
55            "ndjson" | "jsonl" => Some(Self::Ndjson),
56            "arrow" | "ipc" | "feather" => Some(Self::Ipc),
57            "avro" => Some(Self::Avro),
58            _ => None,
59        }
60    }
61
62    pub fn supports_compression(self) -> bool {
63        matches!(self, Self::Csv | Self::Json | Self::Ndjson)
64    }
65}
66
67#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
68pub enum ExportFocus {
69    #[default]
70    FormatSelector,
71    PathInput,
72    // CSV options
73    CsvDelimiter,
74    CsvIncludeHeader,
75    CsvCompression,
76    // JSON options
77    JsonCompression,
78    // NDJSON options
79    NdjsonCompression,
80    // Footer buttons
81    ExportButton,
82    CancelButton,
83}
84
85pub struct ExportModal {
86    pub active: bool,
87    pub focus: ExportFocus,
88    pub selected_format: ExportFormat,
89    pub path_input: TextInput,
90    // CSV options
91    pub csv_delimiter_input: TextInput,
92    pub csv_include_header: bool,
93    pub csv_compression: Option<CompressionFormat>,
94    // JSON options
95    pub json_compression: Option<CompressionFormat>,
96    // NDJSON options
97    pub ndjson_compression: Option<CompressionFormat>,
98    // Compression selection index (for horizontal radio buttons)
99    pub compression_selection_idx: usize,
100    pub history_limit: usize,
101}
102
103impl ExportModal {
104    pub fn new() -> Self {
105        Self::default()
106    }
107
108    pub fn open(
109        &mut self,
110        default_format: Option<ExportFormat>,
111        history_limit: usize,
112        theme: &crate::config::Theme,
113        file_delimiter: Option<u8>,
114        config_delimiter: Option<u8>,
115    ) {
116        self.active = true;
117        self.focus = ExportFocus::PathInput;
118        self.history_limit = history_limit;
119        if let Some(format) = default_format {
120            self.selected_format = format;
121        }
122        self.path_input = TextInput::new()
123            .with_history_limit(history_limit)
124            .with_theme(theme);
125        self.path_input.clear();
126        self.csv_delimiter_input = TextInput::new()
127            .with_history_limit(history_limit)
128            .with_theme(theme);
129        // Priority: 1) Config delimiter (user preference), 2) File delimiter (what was used/autodetected), 3) Comma (default)
130        let delimiter_char = config_delimiter.or(file_delimiter).unwrap_or(b',');
131        // Use set_value to properly sync to textarea
132        self.csv_delimiter_input
133            .set_value(format!("{}", delimiter_char as char));
134        self.csv_include_header = true;
135        self.csv_compression = None;
136        self.json_compression = None;
137        self.ndjson_compression = None;
138        self.compression_selection_idx = 0;
139    }
140
141    pub fn close(&mut self) {
142        self.active = false;
143        self.focus = ExportFocus::FormatSelector;
144        self.path_input.clear();
145    }
146
147    pub fn next_focus(&mut self) {
148        let new_focus = match self.focus {
149            ExportFocus::FormatSelector => ExportFocus::PathInput,
150            ExportFocus::PathInput => match self.selected_format {
151                ExportFormat::Csv => ExportFocus::CsvDelimiter,
152                ExportFormat::Json => ExportFocus::JsonCompression,
153                ExportFormat::Ndjson => ExportFocus::NdjsonCompression,
154                ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
155                    ExportFocus::ExportButton
156                }
157            },
158            ExportFocus::CsvDelimiter => ExportFocus::CsvIncludeHeader,
159            ExportFocus::CsvIncludeHeader => ExportFocus::CsvCompression,
160            ExportFocus::CsvCompression => ExportFocus::ExportButton,
161            ExportFocus::JsonCompression => ExportFocus::ExportButton,
162            ExportFocus::NdjsonCompression => ExportFocus::ExportButton,
163            ExportFocus::ExportButton => ExportFocus::CancelButton,
164            ExportFocus::CancelButton => ExportFocus::FormatSelector,
165        };
166        self.focus = new_focus;
167        // Initialize compression selection index when focusing on compression
168        if matches!(
169            self.focus,
170            ExportFocus::CsvCompression
171                | ExportFocus::JsonCompression
172                | ExportFocus::NdjsonCompression
173        ) {
174            self.init_compression_selection();
175        }
176    }
177
178    pub fn prev_focus(&mut self) {
179        let new_focus = match self.focus {
180            ExportFocus::FormatSelector => ExportFocus::CancelButton,
181            ExportFocus::PathInput => ExportFocus::FormatSelector,
182            ExportFocus::CsvDelimiter => ExportFocus::PathInput,
183            ExportFocus::CsvIncludeHeader => ExportFocus::CsvDelimiter,
184            ExportFocus::CsvCompression => ExportFocus::CsvIncludeHeader,
185            ExportFocus::JsonCompression => ExportFocus::PathInput,
186            ExportFocus::NdjsonCompression => ExportFocus::PathInput,
187            ExportFocus::ExportButton => match self.selected_format {
188                ExportFormat::Csv => ExportFocus::CsvCompression,
189                ExportFormat::Json => ExportFocus::JsonCompression,
190                ExportFormat::Ndjson => ExportFocus::NdjsonCompression,
191                ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
192                    ExportFocus::PathInput
193                }
194            },
195            ExportFocus::CancelButton => ExportFocus::ExportButton,
196        };
197        self.focus = new_focus;
198        // Initialize compression selection index when focusing on compression
199        if matches!(
200            self.focus,
201            ExportFocus::CsvCompression
202                | ExportFocus::JsonCompression
203                | ExportFocus::NdjsonCompression
204        ) {
205            self.init_compression_selection();
206        }
207    }
208
209    pub fn init_compression_selection(&mut self) {
210        const COMPRESSION_OPTIONS: [Option<CompressionFormat>; 5] = [
211            None,
212            Some(CompressionFormat::Gzip),
213            Some(CompressionFormat::Zstd),
214            Some(CompressionFormat::Bzip2),
215            Some(CompressionFormat::Xz),
216        ];
217
218        let compression = match self.focus {
219            ExportFocus::CsvCompression => self.csv_compression,
220            ExportFocus::JsonCompression => self.json_compression,
221            ExportFocus::NdjsonCompression => self.ndjson_compression,
222            _ => return,
223        };
224
225        // Find current index based on selected compression
226        self.compression_selection_idx = COMPRESSION_OPTIONS
227            .iter()
228            .position(|&opt| opt == compression)
229            .unwrap_or(0);
230    }
231
232    pub fn cycle_compression(&mut self) {
233        const COMPRESSION_OPTIONS: [Option<CompressionFormat>; 5] = [
234            None,
235            Some(CompressionFormat::Gzip),
236            Some(CompressionFormat::Zstd),
237            Some(CompressionFormat::Bzip2),
238            Some(CompressionFormat::Xz),
239        ];
240
241        let compression = match self.focus {
242            ExportFocus::CsvCompression => &mut self.csv_compression,
243            ExportFocus::JsonCompression => &mut self.json_compression,
244            ExportFocus::NdjsonCompression => &mut self.ndjson_compression,
245            _ => return,
246        };
247
248        // Move to next
249        self.compression_selection_idx =
250            (self.compression_selection_idx + 1) % COMPRESSION_OPTIONS.len();
251        *compression = COMPRESSION_OPTIONS[self.compression_selection_idx];
252    }
253
254    pub fn cycle_compression_backward(&mut self) {
255        const COMPRESSION_OPTIONS: [Option<CompressionFormat>; 5] = [
256            None,
257            Some(CompressionFormat::Gzip),
258            Some(CompressionFormat::Zstd),
259            Some(CompressionFormat::Bzip2),
260            Some(CompressionFormat::Xz),
261        ];
262
263        let compression = match self.focus {
264            ExportFocus::CsvCompression => &mut self.csv_compression,
265            ExportFocus::JsonCompression => &mut self.json_compression,
266            ExportFocus::NdjsonCompression => &mut self.ndjson_compression,
267            _ => return,
268        };
269
270        // Move to previous
271        self.compression_selection_idx = if self.compression_selection_idx == 0 {
272            COMPRESSION_OPTIONS.len() - 1
273        } else {
274            self.compression_selection_idx - 1
275        };
276        *compression = COMPRESSION_OPTIONS[self.compression_selection_idx];
277    }
278
279    pub fn select_compression(&mut self, compression: Option<CompressionFormat>) {
280        match self.focus {
281            ExportFocus::CsvCompression => {
282                self.csv_compression = compression;
283            }
284            ExportFocus::JsonCompression => {
285                self.json_compression = compression;
286            }
287            ExportFocus::NdjsonCompression => {
288                self.ndjson_compression = compression;
289            }
290            _ => {}
291        }
292    }
293}
294
295impl Default for ExportModal {
296    fn default() -> Self {
297        Self {
298            active: false,
299            focus: ExportFocus::FormatSelector,
300            selected_format: ExportFormat::Csv,
301            path_input: TextInput::new(),
302            csv_delimiter_input: TextInput::new(),
303            csv_include_header: true,
304            csv_compression: None,
305            json_compression: None,
306            ndjson_compression: None,
307            compression_selection_idx: 0,
308            history_limit: 1000,
309        }
310    }
311}