Skip to main content

datui_lib/
lib.rs

1use color_eyre::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use polars::datatypes::AnyValue;
4use polars::datatypes::DataType;
5#[cfg(feature = "cloud")]
6use polars::io::cloud::{AmazonS3ConfigKey, CloudOptions};
7use polars::prelude::{col, len, DataFrame, LazyFrame, Schema};
8#[cfg(feature = "cloud")]
9use polars::prelude::{PlPathRef, ScanArgsParquet};
10use std::path::{Path, PathBuf};
11use std::sync::{mpsc::Sender, Arc};
12use widgets::info::{read_parquet_metadata, InfoFocus, InfoModal, InfoTab, ParquetMetadataCache};
13
14use ratatui::style::{Color, Style};
15use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
16
17use ratatui::widgets::{Block, Clear};
18
19pub mod analysis_modal;
20pub mod cache;
21pub mod chart_data;
22pub mod chart_export;
23pub mod chart_export_modal;
24pub mod chart_modal;
25pub mod cli;
26#[cfg(feature = "cloud")]
27mod cloud_hive;
28pub mod config;
29pub mod error_display;
30pub mod export_modal;
31pub mod filter_modal;
32pub(crate) mod help_strings;
33pub mod pivot_melt_modal;
34mod query;
35mod render;
36pub mod sort_filter_modal;
37pub mod sort_modal;
38mod source;
39pub mod statistics;
40pub mod template;
41pub mod widgets;
42
43pub use cache::CacheManager;
44pub use cli::Args;
45pub use config::{
46    rgb_to_256_color, rgb_to_basic_ansi, AppConfig, ColorParser, ConfigManager, Theme,
47};
48
49use analysis_modal::{AnalysisModal, AnalysisProgress};
50use chart_export::{
51    write_box_plot_eps, write_box_plot_png, write_chart_eps, write_chart_png, write_heatmap_eps,
52    write_heatmap_png, BoxPlotExportBounds, ChartExportBounds, ChartExportFormat,
53    ChartExportSeries,
54};
55use chart_export_modal::{ChartExportFocus, ChartExportModal};
56use chart_modal::{ChartFocus, ChartKind, ChartModal, ChartType};
57pub use error_display::{error_for_python, ErrorKindForPython};
58use export_modal::{ExportFocus, ExportFormat, ExportModal};
59use filter_modal::{FilterFocus, FilterOperator, FilterStatement, LogicalOperator};
60use pivot_melt_modal::{MeltSpec, PivotMeltFocus, PivotMeltModal, PivotMeltTab, PivotSpec};
61use sort_filter_modal::{SortFilterFocus, SortFilterModal, SortFilterTab};
62use sort_modal::{SortColumn, SortFocus};
63pub use template::{Template, TemplateManager};
64use widgets::controls::Controls;
65use widgets::datatable::DataTableState;
66use widgets::debug::DebugState;
67use widgets::template_modal::{CreateFocus, TemplateFocus, TemplateModal, TemplateModalMode};
68use widgets::text_input::{TextInput, TextInputEvent};
69
70/// Application name used for cache directory and other app-specific paths
71pub const APP_NAME: &str = "datui";
72
73/// Re-export compression format and file format from CLI module
74pub use cli::{CompressionFormat, FileFormat};
75
76/// Map FileFormat to ExportFormat for default export. Tsv/Psv map to Csv; Orc/Excel have no export variant.
77fn file_format_to_export_format(f: FileFormat) -> Option<ExportFormat> {
78    match f {
79        FileFormat::Parquet => Some(ExportFormat::Parquet),
80        FileFormat::Csv | FileFormat::Tsv | FileFormat::Psv => Some(ExportFormat::Csv),
81        FileFormat::Json => Some(ExportFormat::Json),
82        FileFormat::Jsonl => Some(ExportFormat::Ndjson),
83        FileFormat::Arrow => Some(ExportFormat::Ipc),
84        FileFormat::Avro => Some(ExportFormat::Avro),
85        FileFormat::Orc | FileFormat::Excel => None,
86    }
87}
88
89#[cfg(test)]
90pub mod tests {
91    use std::path::Path;
92    use std::process::Command;
93    use std::sync::Once;
94
95    static INIT: Once = Once::new();
96
97    /// Ensures that sample data files are generated before tests run.
98    /// This function uses `std::sync::Once` to ensure it only runs once,
99    /// even if called from multiple tests.
100    pub fn ensure_sample_data() {
101        INIT.call_once(|| {
102            // When the lib is in crates/datui-lib, repo root is CARGO_MANIFEST_DIR/../..
103            let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
104            let sample_data_dir = repo_root.join("tests/sample-data");
105
106            // Check if key files exist to determine if we need to generate data
107            // We check for a few representative files that should always be generated
108            let key_files = [
109                "people.parquet",
110                "sales.parquet",
111                "large_dataset.parquet",
112                "empty.parquet",
113                "pivot_long.parquet",
114                "melt_wide.parquet",
115                "infer_schema_length_data.csv",
116            ];
117
118            let needs_generation = !sample_data_dir.exists()
119                || key_files
120                    .iter()
121                    .any(|file| !sample_data_dir.join(file).exists());
122
123            if needs_generation {
124                // Get the path to the Python script (at repo root)
125                let script_path = repo_root.join("scripts/generate_sample_data.py");
126                if !script_path.exists() {
127                    panic!(
128                        "Sample data generation script not found at: {}. \
129                        Please ensure you're running tests from the repository root.",
130                        script_path.display()
131                    );
132                }
133
134                // Try to find Python (python3 or python)
135                let python_cmd = if Command::new("python3").arg("--version").output().is_ok() {
136                    "python3"
137                } else if Command::new("python").arg("--version").output().is_ok() {
138                    "python"
139                } else {
140                    panic!(
141                        "Python not found. Please install Python 3 to generate test data. \
142                        The script requires: polars>=0.20.0 and numpy>=1.24.0"
143                    );
144                };
145
146                // Run the generation script
147                let output = Command::new(python_cmd)
148                    .arg(script_path)
149                    .output()
150                    .unwrap_or_else(|e| {
151                        panic!(
152                            "Failed to run sample data generation script: {}. \
153                            Make sure Python is installed and the script is executable.",
154                            e
155                        );
156                    });
157
158                if !output.status.success() {
159                    let stderr = String::from_utf8_lossy(&output.stderr);
160                    let stdout = String::from_utf8_lossy(&output.stdout);
161                    panic!(
162                        "Sample data generation failed!\n\
163                        Exit code: {:?}\n\
164                        stdout:\n{}\n\
165                        stderr:\n{}",
166                        output.status.code(),
167                        stdout,
168                        stderr
169                    );
170                }
171            }
172        });
173    }
174
175    /// Path to the tests/sample-data directory (at repo root). Call `ensure_sample_data()` first if needed.
176    pub fn sample_data_dir() -> std::path::PathBuf {
177        ensure_sample_data();
178        Path::new(env!("CARGO_MANIFEST_DIR"))
179            .join("../..")
180            .join("tests/sample-data")
181    }
182
183    /// Only one query type is returned; SQL overrides fuzzy over DSL. Used when saving templates.
184    #[test]
185    fn test_active_query_settings_only_one_set() {
186        use super::active_query_settings;
187
188        let (q, sql, fuzzy) = active_query_settings("", "", "");
189        assert!(q.is_none() && sql.is_none() && fuzzy.is_none());
190
191        let (q, sql, fuzzy) = active_query_settings("select a", "SELECT 1", "foo");
192        assert!(q.is_none() && sql.as_deref() == Some("SELECT 1") && fuzzy.is_none());
193
194        let (q, sql, fuzzy) = active_query_settings("select a", "", "foo bar");
195        assert!(q.is_none() && sql.is_none() && fuzzy.as_deref() == Some("foo bar"));
196
197        let (q, sql, fuzzy) = active_query_settings("  select a  ", "", "");
198        assert!(q.as_deref() == Some("select a") && sql.is_none() && fuzzy.is_none());
199    }
200}
201
202/// Which CSV string columns to trim and parse (date/datetime/time/duration/int/float). Default: all. None = disabled (e.g. --no-parse-strings).
203#[derive(Clone, Debug)]
204pub enum ParseStringsTarget {
205    /// Apply to all string columns.
206    All,
207    /// Apply only to these columns (must exist and be string type).
208    Columns(Vec<String>),
209}
210
211#[derive(Clone)]
212pub struct OpenOptions {
213    pub delimiter: Option<u8>,
214    pub has_header: Option<bool>,
215    pub skip_lines: Option<usize>,
216    pub skip_rows: Option<usize>,
217    /// Skip this many rows at the end of the file (e.g. vendor footer or trailing garbage). Applied after load for CSV.
218    pub skip_tail_rows: Option<usize>,
219    pub compression: Option<CompressionFormat>,
220    /// When set, bypass extension-based format detection and use this format (e.g. for URLs or temp files without extension).
221    pub format: Option<FileFormat>,
222    pub pages_lookahead: Option<usize>,
223    pub pages_lookback: Option<usize>,
224    pub max_buffered_rows: Option<usize>,
225    pub max_buffered_mb: Option<usize>,
226    pub row_numbers: bool,
227    pub row_start_index: usize,
228    /// When true, use hive load path for directory/glob; single file uses normal load.
229    pub hive: bool,
230    /// When true (default), infer Hive/partitioned Parquet schema from one file for faster "Caching schema". When false, use Polars collect_schema().
231    pub single_spine_schema: bool,
232    /// When true, CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime).
233    pub parse_dates: bool,
234    /// When set, trim and parse CSV string columns: None = off, Some(true) = all columns, Some(cols) = those columns only.
235    pub parse_strings: Option<ParseStringsTarget>,
236    /// Sample size (rows) for inferring types when parse_strings is enabled; single file or multiple/partitioned.
237    pub parse_strings_sample_rows: usize,
238    /// When true, decompress compressed CSV into memory (eager read). When false (default), decompress to a temp file and use lazy scan.
239    pub decompress_in_memory: bool,
240    /// Directory for decompression temp files. None = system default (e.g. TMPDIR).
241    pub temp_dir: Option<std::path::PathBuf>,
242    /// Excel sheet: 0-based index or sheet name (CLI only).
243    pub excel_sheet: Option<String>,
244    /// S3/compatible overrides (env + CLI). Take precedence over config when building CloudOptions.
245    pub s3_endpoint_url_override: Option<String>,
246    pub s3_access_key_id_override: Option<String>,
247    pub s3_secret_access_key_override: Option<String>,
248    pub s3_region_override: Option<String>,
249    /// When true, use Polars streaming engine for LazyFrame collect when the streaming feature is enabled.
250    pub polars_streaming: bool,
251    /// When true, cast Date/Datetime pivot index columns to Int32 before pivot to avoid Polars 0.52 panic.
252    pub workaround_pivot_date_index: bool,
253    /// Null value specs for CSV: global strings and/or "COL=VAL" for per-column. Empty = use Polars default.
254    pub null_values: Option<Vec<String>>,
255    /// Number of rows to use when inferring CSV schema. None = Polars default (100). Larger values reduce risk of inferring wrong type (e.g. int then N/A).
256    pub infer_schema_length: Option<usize>,
257    /// When true, CSV reader ignores parse errors and continues with the next batch.
258    pub ignore_errors: bool,
259    /// When true, show the debug overlay (session info, performance, query, etc.).
260    pub debug: bool,
261}
262
263impl OpenOptions {
264    pub fn new() -> Self {
265        Self {
266            delimiter: None,
267            has_header: None,
268            skip_lines: None,
269            skip_rows: None,
270            skip_tail_rows: None,
271            compression: None,
272            format: None,
273            pages_lookahead: None,
274            pages_lookback: None,
275            max_buffered_rows: None,
276            max_buffered_mb: None,
277            row_numbers: false,
278            row_start_index: 1,
279            hive: false,
280            single_spine_schema: true,
281            parse_dates: true,
282            parse_strings: None,
283            parse_strings_sample_rows: 1000,
284            decompress_in_memory: false,
285            temp_dir: None,
286            excel_sheet: None,
287            s3_endpoint_url_override: None,
288            s3_access_key_id_override: None,
289            s3_secret_access_key_override: None,
290            s3_region_override: None,
291            polars_streaming: true,
292            workaround_pivot_date_index: true,
293            null_values: None,
294            infer_schema_length: None,
295            ignore_errors: false,
296            debug: false,
297        }
298    }
299}
300
301impl Default for OpenOptions {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307impl OpenOptions {
308    pub fn with_skip_lines(mut self, skip_lines: usize) -> Self {
309        self.skip_lines = Some(skip_lines);
310        self
311    }
312
313    pub fn with_skip_rows(mut self, skip_rows: usize) -> Self {
314        self.skip_rows = Some(skip_rows);
315        self
316    }
317
318    pub fn with_delimiter(mut self, delimiter: u8) -> Self {
319        self.delimiter = Some(delimiter);
320        self
321    }
322
323    pub fn with_has_header(mut self, has_header: bool) -> Self {
324        self.has_header = Some(has_header);
325        self
326    }
327
328    pub fn with_compression(mut self, compression: CompressionFormat) -> Self {
329        self.compression = Some(compression);
330        self
331    }
332
333    pub fn with_workaround_pivot_date_index(mut self, workaround_pivot_date_index: bool) -> Self {
334        self.workaround_pivot_date_index = workaround_pivot_date_index;
335        self
336    }
337
338    /// When loading CSV: use Polars try_parse_dates only if parse_strings is not set.
339    /// When parse_strings is set we do our own date parsing (with strict: false), so we disable
340    /// Polars' try_parse_dates to avoid "could not find an appropriate format" errors.
341    pub fn csv_try_parse_dates(&self) -> bool {
342        self.parse_strings.is_none() && self.parse_dates
343    }
344}
345
346impl OpenOptions {
347    /// Create OpenOptions from CLI args and config, with CLI args taking precedence
348    pub fn from_args_and_config(args: &cli::Args, config: &AppConfig) -> Self {
349        let mut opts = OpenOptions::new();
350
351        // File loading options: CLI args override config
352        opts.delimiter = args.delimiter.or(config.file_loading.delimiter);
353        opts.skip_lines = args.skip_lines.or(config.file_loading.skip_lines);
354        opts.skip_rows = args.skip_rows.or(config.file_loading.skip_rows);
355        opts.skip_tail_rows = args.skip_tail_rows.or(config.file_loading.skip_tail_rows);
356
357        // Handle has_header: CLI no_header flag overrides config
358        opts.has_header = if let Some(no_header) = args.no_header {
359            Some(!no_header)
360        } else {
361            config.file_loading.has_header
362        };
363
364        // Compression: CLI only (auto-detect from extension when not specified)
365        opts.compression = args.compression;
366
367        // Format: CLI only (auto-detect from extension when not specified)
368        opts.format = args.format;
369
370        // Display options: CLI args override config
371        opts.pages_lookahead = args
372            .pages_lookahead
373            .or(Some(config.display.pages_lookahead));
374        opts.pages_lookback = args.pages_lookback.or(Some(config.display.pages_lookback));
375        opts.max_buffered_rows = Some(config.display.max_buffered_rows);
376        opts.max_buffered_mb = Some(config.display.max_buffered_mb);
377
378        // Row numbers: CLI flag overrides config
379        opts.row_numbers = args.row_numbers || config.display.row_numbers;
380
381        // Row start index: CLI arg overrides config
382        opts.row_start_index = args
383            .row_start_index
384            .unwrap_or(config.display.row_start_index);
385
386        // Hive partitioning: CLI only (no config option yet)
387        opts.hive = args.hive;
388
389        // Single-spine schema: CLI overrides config; default true
390        opts.single_spine_schema = args
391            .single_spine_schema
392            .or(config.file_loading.single_spine_schema)
393            .unwrap_or(true);
394
395        // CSV date inference: CLI overrides config; default true
396        opts.parse_dates = args
397            .parse_dates
398            .or(config.file_loading.parse_dates)
399            .unwrap_or(true);
400
401        // Parse strings (trim + type inference). Default: all CSV string columns. --no-parse-strings disables; --parse-strings=COL limits to columns.
402        if args.no_parse_strings {
403            opts.parse_strings = None;
404        } else if !args.parse_strings.is_empty() {
405            let has_all = args.parse_strings.iter().any(|s| s.is_empty());
406            opts.parse_strings = Some(if has_all {
407                ParseStringsTarget::All
408            } else {
409                let cols: Vec<String> = args
410                    .parse_strings
411                    .iter()
412                    .filter(|s| !s.is_empty())
413                    .cloned()
414                    .collect::<std::collections::HashSet<_>>()
415                    .into_iter()
416                    .collect();
417                ParseStringsTarget::Columns(cols)
418            });
419        } else if config.file_loading.parse_strings == Some(false) {
420            opts.parse_strings = None;
421        } else {
422            opts.parse_strings = Some(ParseStringsTarget::All);
423        }
424        opts.parse_strings_sample_rows = config
425            .file_loading
426            .parse_strings_sample_rows
427            .unwrap_or(1000);
428
429        // Decompress-in-memory: CLI overrides config; default false (decompress to temp, use scan)
430        opts.decompress_in_memory = args
431            .decompress_in_memory
432            .or(config.file_loading.decompress_in_memory)
433            .unwrap_or(false);
434
435        // Temp directory for decompression: CLI overrides config; default None (system temp)
436        opts.temp_dir = args.temp_dir.clone().or_else(|| {
437            config
438                .file_loading
439                .temp_dir
440                .as_ref()
441                .map(std::path::PathBuf::from)
442        });
443
444        // Excel sheet (CLI only)
445        opts.excel_sheet = args.excel_sheet.clone();
446
447        // S3/compatible overrides: env then CLI (CLI wins). Env vars match AWS SDK (AWS_ENDPOINT_URL, etc.)
448        opts.s3_endpoint_url_override = args
449            .s3_endpoint_url
450            .clone()
451            .or_else(|| std::env::var("AWS_ENDPOINT_URL_S3").ok())
452            .or_else(|| std::env::var("AWS_ENDPOINT_URL").ok());
453        opts.s3_access_key_id_override = args
454            .s3_access_key_id
455            .clone()
456            .or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok());
457        opts.s3_secret_access_key_override = args
458            .s3_secret_access_key
459            .clone()
460            .or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok());
461        opts.s3_region_override = args
462            .s3_region
463            .clone()
464            .or_else(|| std::env::var("AWS_REGION").ok())
465            .or_else(|| std::env::var("AWS_DEFAULT_REGION").ok());
466
467        opts.polars_streaming = config.performance.polars_streaming;
468
469        opts.workaround_pivot_date_index = args.workaround_pivot_date_index.unwrap_or(true);
470
471        // Debug: CLI flag overrides config
472        opts.debug = args.debug || config.debug.enabled;
473
474        // Null values: merge config list with CLI list (CLI appended); if either is non-empty, set
475        let config_nulls = config.file_loading.null_values.as_deref().unwrap_or(&[]);
476        let cli_nulls = &args.null_value;
477        if config_nulls.is_empty() && cli_nulls.is_empty() {
478            opts.null_values = None;
479        } else {
480            opts.null_values = Some(
481                config_nulls
482                    .iter()
483                    .chain(cli_nulls.iter())
484                    .cloned()
485                    .collect(),
486            );
487        }
488
489        // CSV schema inference: CLI overrides config; default 1000 (Polars default is 100)
490        opts.infer_schema_length = args
491            .infer_schema_length
492            .or(config.file_loading.infer_schema_length)
493            .or(Some(1000));
494
495        // CSV ignore parse errors: CLI overrides config; default false
496        opts.ignore_errors = args
497            .ignore_errors
498            .or(config.file_loading.ignore_errors)
499            .unwrap_or(false);
500
501        opts
502    }
503}
504
505impl From<&cli::Args> for OpenOptions {
506    fn from(args: &cli::Args) -> Self {
507        // Use default config if creating from args alone
508        let config = AppConfig::default();
509        Self::from_args_and_config(args, &config)
510    }
511}
512
513pub enum AppEvent {
514    Key(KeyEvent),
515    Open(Vec<PathBuf>, OpenOptions),
516    /// Open with an existing LazyFrame (e.g. from Python binding); no file load.
517    OpenLazyFrame(Box<LazyFrame>, OpenOptions),
518    DoLoad(Vec<PathBuf>, OpenOptions), // Internal event to actually perform loading after UI update
519    /// Scan paths and build LazyFrame; then emit DoLoadSchema (phased loading).
520    DoLoadScanPaths(Vec<PathBuf>, OpenOptions),
521    /// Build LazyFrame for CSV with --parse-strings (phase already set to "Scanning string columns" so UI shows it).
522    DoLoadCsvWithParseStrings(Vec<PathBuf>, OpenOptions),
523    /// Perform HTTP download (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
524    #[cfg(feature = "http")]
525    DoDownloadHttp(String, OpenOptions),
526    /// Perform S3 download to temp (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
527    #[cfg(feature = "cloud")]
528    DoDownloadS3ToTemp(String, OpenOptions),
529    /// Perform GCS download to temp (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
530    #[cfg(feature = "cloud")]
531    DoDownloadGcsToTemp(String, OpenOptions),
532    /// HTTP, S3, or GCS download finished; temp path is ready. Scan it and continue load.
533    #[cfg(any(feature = "http", feature = "cloud"))]
534    DoLoadFromHttpTemp(PathBuf, OpenOptions),
535    /// Update phase to "Caching schema" and emit DoLoadSchemaBlocking so UI can draw before blocking.
536    DoLoadSchema(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
537    /// Actually run collect_schema() and create state; then emit DoLoadBuffer (phased loading).
538    DoLoadSchemaBlocking(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
539    /// First collect() on state; then emit Collect (phased loading).
540    DoLoadBuffer,
541    DoDecompress(Vec<PathBuf>, OpenOptions), // Internal event to perform decompression after UI shows "Decompressing"
542    DoExport(PathBuf, ExportFormat, ExportOptions), // Internal event to perform export after UI shows progress
543    DoExportCollect(PathBuf, ExportFormat, ExportOptions), // Collect data for export; then emit DoExportWrite
544    DoExportWrite(PathBuf, ExportFormat, ExportOptions),   // Write collected DataFrame to file
545    DoLoadParquetMetadata, // Load Parquet metadata when info panel is opened (deferred from render)
546    Exit,
547    Crash(String),
548    Search(String),
549    SqlSearch(String),
550    FuzzySearch(String),
551    Filter(Vec<FilterStatement>),
552    Sort(Vec<String>, bool),         // Columns, Ascending
553    ColumnOrder(Vec<String>, usize), // Column order, locked columns count
554    Pivot(PivotSpec),
555    Melt(MeltSpec),
556    Export(PathBuf, ExportFormat, ExportOptions), // Path, format, options
557    ChartExport(PathBuf, ChartExportFormat, String, u32, u32), // path, format, title, width, height
558    DoChartExport(PathBuf, ChartExportFormat, String, u32, u32), // Deferred: run chart export
559    Collect,
560    Update,
561    Reset,
562    Resize(u16, u16), // resized (width, height)
563    DoScrollDown,     // Deferred scroll: perform page_down after one frame (throbber)
564    DoScrollUp,       // Deferred scroll: perform page_up
565    DoScrollNext,     // Deferred scroll: perform select_next (one row down)
566    DoScrollPrev,     // Deferred scroll: perform select_previous (one row up)
567    DoScrollEnd,      // Deferred scroll: jump to last page (throbber)
568    DoScrollHalfDown, // Deferred scroll: half page down
569    DoScrollHalfUp,   // Deferred scroll: half page up
570    GoToLine(usize),  // Deferred: jump to line number (when collect needed)
571    /// Run the next chunk of analysis (describe/distribution); drives per-column progress.
572    AnalysisChunk,
573    /// Run distribution analysis (deferred so progress overlay can show first).
574    AnalysisDistributionCompute,
575    /// Run correlation matrix (deferred so progress overlay can show first).
576    AnalysisCorrelationCompute,
577}
578
579/// Input for the shared run loop: open from file paths or from an existing LazyFrame (e.g. Python binding).
580#[derive(Clone)]
581pub enum RunInput {
582    Paths(Vec<PathBuf>, OpenOptions),
583    LazyFrame(Box<LazyFrame>, OpenOptions),
584}
585
586#[derive(Debug, Clone)]
587pub struct ExportOptions {
588    pub csv_delimiter: u8,
589    pub csv_include_header: bool,
590    pub csv_compression: Option<CompressionFormat>,
591    pub json_compression: Option<CompressionFormat>,
592    pub ndjson_compression: Option<CompressionFormat>,
593    pub parquet_compression: Option<CompressionFormat>, // Not used in UI, but kept for API compatibility
594}
595
596#[derive(Debug, Default, PartialEq, Eq)]
597pub enum InputMode {
598    #[default]
599    Normal,
600    SortFilter,
601    PivotMelt,
602    Editing,
603    Export,
604    Info,
605    Chart,
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609pub enum InputType {
610    Search,
611    Filter,
612    GoToLine,
613}
614
615/// Query dialog tab: SQL-Like (current parser), Fuzzy, or SQL (future).
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
617pub enum QueryTab {
618    #[default]
619    SqlLike,
620    Fuzzy,
621    Sql,
622}
623
624impl QueryTab {
625    fn next(self) -> Self {
626        match self {
627            QueryTab::SqlLike => QueryTab::Fuzzy,
628            QueryTab::Fuzzy => QueryTab::Sql,
629            QueryTab::Sql => QueryTab::SqlLike,
630        }
631    }
632    fn prev(self) -> Self {
633        match self {
634            QueryTab::SqlLike => QueryTab::Sql,
635            QueryTab::Fuzzy => QueryTab::SqlLike,
636            QueryTab::Sql => QueryTab::Fuzzy,
637        }
638    }
639    fn index(self) -> usize {
640        match self {
641            QueryTab::SqlLike => 0,
642            QueryTab::Fuzzy => 1,
643            QueryTab::Sql => 2,
644        }
645    }
646}
647
648/// Focus within the query dialog: tab bar or input (SQL-Like only).
649#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
650pub enum QueryFocus {
651    TabBar,
652    #[default]
653    Input,
654}
655
656#[derive(Default)]
657pub struct ErrorModal {
658    pub active: bool,
659    pub message: String,
660}
661
662impl ErrorModal {
663    pub fn new() -> Self {
664        Self::default()
665    }
666
667    pub fn show(&mut self, message: String) {
668        self.active = true;
669        self.message = message;
670    }
671
672    pub fn hide(&mut self) {
673        self.active = false;
674        self.message.clear();
675    }
676}
677
678#[derive(Default)]
679pub struct SuccessModal {
680    pub active: bool,
681    pub message: String,
682}
683
684impl SuccessModal {
685    pub fn new() -> Self {
686        Self::default()
687    }
688
689    pub fn show(&mut self, message: String) {
690        self.active = true;
691        self.message = message;
692    }
693
694    pub fn hide(&mut self) {
695        self.active = false;
696        self.message.clear();
697    }
698}
699
700#[derive(Default)]
701pub struct ConfirmationModal {
702    pub active: bool,
703    pub message: String,
704    pub focus_yes: bool, // true = Yes focused, false = No focused
705}
706
707impl ConfirmationModal {
708    pub fn new() -> Self {
709        Self::default()
710    }
711
712    pub fn show(&mut self, message: String) {
713        self.active = true;
714        self.message = message;
715        self.focus_yes = true; // Default to Yes
716    }
717
718    pub fn hide(&mut self) {
719        self.active = false;
720        self.message.clear();
721        self.focus_yes = true;
722    }
723}
724
725/// Pending remote download; shown in confirmation modal before starting download.
726#[cfg(any(feature = "http", feature = "cloud"))]
727#[derive(Clone)]
728pub enum PendingDownload {
729    #[cfg(feature = "http")]
730    Http {
731        url: String,
732        size: Option<u64>,
733        options: OpenOptions,
734    },
735    #[cfg(feature = "cloud")]
736    S3 {
737        url: String,
738        size: Option<u64>,
739        options: OpenOptions,
740    },
741    #[cfg(feature = "cloud")]
742    Gcs {
743        url: String,
744        size: Option<u64>,
745        options: OpenOptions,
746    },
747}
748
749#[derive(Clone, Debug, Default)]
750pub enum LoadingState {
751    #[default]
752    Idle,
753    Loading {
754        /// None when loading from LazyFrame (e.g. Python binding); Some for file paths.
755        file_path: Option<PathBuf>,
756        file_size: u64,        // Size of compressed file in bytes (0 when no path)
757        current_phase: String, // e.g., "Scanning input", "Caching schema", "Loading buffer"
758        progress_percent: u16, // 0-100
759    },
760    Exporting {
761        file_path: PathBuf,
762        current_phase: String, // e.g., "Collecting data", "Writing file", "Compressing"
763        progress_percent: u16, // 0-100
764    },
765}
766
767impl LoadingState {
768    pub fn is_loading(&self) -> bool {
769        matches!(
770            self,
771            LoadingState::Loading { .. } | LoadingState::Exporting { .. }
772        )
773    }
774}
775
776/// In-progress analysis computation state (orchestration in App; modal only displays progress).
777#[allow(dead_code)]
778struct AnalysisComputationState {
779    df: Option<DataFrame>,
780    schema: Option<Arc<Schema>>,
781    partial_stats: Vec<crate::statistics::ColumnStatistics>,
782    current: usize,
783    total: usize,
784    total_rows: usize,
785    sample_seed: u64,
786    sample_size: Option<usize>,
787}
788
789/// At most one query type can be active. Returns (query, sql_query, fuzzy_query) with only the
790/// active one set (SQL takes precedence over fuzzy over DSL query). Used when saving template settings.
791fn active_query_settings(
792    dsl_query: &str,
793    sql_query: &str,
794    fuzzy_query: &str,
795) -> (Option<String>, Option<String>, Option<String>) {
796    let sql_trimmed = sql_query.trim();
797    let fuzzy_trimmed = fuzzy_query.trim();
798    let dsl_trimmed = dsl_query.trim();
799    if !sql_trimmed.is_empty() {
800        (None, Some(sql_trimmed.to_string()), None)
801    } else if !fuzzy_trimmed.is_empty() {
802        (None, None, Some(fuzzy_trimmed.to_string()))
803    } else if !dsl_trimmed.is_empty() {
804        (Some(dsl_trimmed.to_string()), None, None)
805    } else {
806        (None, None, None)
807    }
808}
809
810// Helper struct to save state before template application
811struct TemplateApplicationState {
812    lf: LazyFrame,
813    schema: Arc<Schema>,
814    active_query: String,
815    active_sql_query: String,
816    active_fuzzy_query: String,
817    filters: Vec<FilterStatement>,
818    sort_columns: Vec<String>,
819    sort_ascending: bool,
820    column_order: Vec<String>,
821    locked_columns_count: usize,
822}
823
824#[derive(Default)]
825pub(crate) struct ChartCache {
826    pub(crate) xy: Option<ChartCacheXY>,
827    pub(crate) x_range: Option<ChartCacheXRange>,
828    pub(crate) histogram: Option<ChartCacheHistogram>,
829    pub(crate) box_plot: Option<ChartCacheBoxPlot>,
830    pub(crate) kde: Option<ChartCacheKde>,
831    pub(crate) heatmap: Option<ChartCacheHeatmap>,
832}
833
834impl ChartCache {
835    fn clear(&mut self) {
836        *self = Self::default();
837    }
838}
839
840pub(crate) struct ChartCacheXY {
841    pub(crate) x_column: String,
842    pub(crate) y_columns: Vec<String>,
843    pub(crate) row_limit: Option<usize>,
844    pub(crate) series: Vec<Vec<(f64, f64)>>,
845    pub(crate) series_log: Option<Vec<Vec<(f64, f64)>>>,
846    pub(crate) x_axis_kind: chart_data::XAxisTemporalKind,
847}
848
849pub(crate) struct ChartCacheXRange {
850    pub(crate) x_column: String,
851    pub(crate) row_limit: Option<usize>,
852    pub(crate) x_min: f64,
853    pub(crate) x_max: f64,
854    pub(crate) x_axis_kind: chart_data::XAxisTemporalKind,
855}
856
857pub(crate) struct ChartCacheHistogram {
858    pub(crate) column: String,
859    pub(crate) bins: usize,
860    pub(crate) row_limit: Option<usize>,
861    pub(crate) data: chart_data::HistogramData,
862}
863
864pub(crate) struct ChartCacheBoxPlot {
865    pub(crate) column: String,
866    pub(crate) row_limit: Option<usize>,
867    pub(crate) data: chart_data::BoxPlotData,
868}
869
870pub(crate) struct ChartCacheKde {
871    pub(crate) column: String,
872    pub(crate) bandwidth_factor: f64,
873    pub(crate) row_limit: Option<usize>,
874    pub(crate) data: chart_data::KdeData,
875}
876
877pub(crate) struct ChartCacheHeatmap {
878    pub(crate) x_column: String,
879    pub(crate) y_column: String,
880    pub(crate) bins: usize,
881    pub(crate) row_limit: Option<usize>,
882    pub(crate) data: chart_data::HeatmapData,
883}
884
885pub struct App {
886    pub data_table_state: Option<DataTableState>,
887    path: Option<PathBuf>,
888    original_file_format: Option<ExportFormat>, // Track original file format for default export
889    original_file_delimiter: Option<u8>, // Track original file delimiter for CSV export default
890    events: Sender<AppEvent>,
891    focus: u32,
892    debug: DebugState,
893    info_modal: InfoModal,
894    parquet_metadata_cache: Option<ParquetMetadataCache>,
895    query_input: TextInput, // Query input widget with history support
896    sql_input: TextInput,   // SQL tab input with its own history (id "sql")
897    fuzzy_input: TextInput, // Fuzzy tab input with its own history (id "fuzzy")
898    pub input_mode: InputMode,
899    input_type: Option<InputType>,
900    query_tab: QueryTab,
901    query_focus: QueryFocus,
902    pub sort_filter_modal: SortFilterModal,
903    pub pivot_melt_modal: PivotMeltModal,
904    pub template_modal: TemplateModal,
905    pub analysis_modal: AnalysisModal,
906    pub chart_modal: ChartModal,
907    pub chart_export_modal: ChartExportModal,
908    pub export_modal: ExportModal,
909    pub(crate) chart_cache: ChartCache,
910    error_modal: ErrorModal,
911    success_modal: SuccessModal,
912    confirmation_modal: ConfirmationModal,
913    pending_export: Option<(PathBuf, ExportFormat, ExportOptions)>, // Store export request while waiting for confirmation
914    /// Collected DataFrame between DoExportCollect and DoExportWrite (two-phase export progress).
915    export_df: Option<DataFrame>,
916    pending_chart_export: Option<(PathBuf, ChartExportFormat, String, u32, u32)>,
917    /// Pending remote file download (HTTP/S3/GCS) while waiting for user confirmation. Size is from HEAD when available.
918    #[cfg(any(feature = "http", feature = "cloud"))]
919    pending_download: Option<PendingDownload>,
920    show_help: bool,
921    help_scroll: usize, // Scroll position for help content
922    cache: CacheManager,
923    template_manager: TemplateManager,
924    active_template_id: Option<String>, // ID of currently applied template
925    loading_state: LoadingState,        // Current loading state for progress indication
926    theme: Theme,                       // Color theme for UI rendering
927    sampling_threshold: Option<usize>, // None = no sampling (full data); Some(n) = sample when rows >= n
928    history_limit: usize, // History limit for all text inputs (from config.query.history_limit)
929    table_cell_padding: u16, // Spaces between columns (from config.display.table_cell_padding)
930    column_colors: bool, // When true, colorize table cells by column type (from config.display.column_colors)
931    busy: bool,          // When true, show throbber and ignore keys
932    throbber_frame: u8,  // Spinner frame index (0..3) for control bar
933    drain_keys_on_next_loop: bool, // Main loop drains crossterm key buffer when true
934    analysis_computation: Option<AnalysisComputationState>,
935    app_config: AppConfig,
936    /// Temp file path for HTTP-downloaded data; removed when user opens different data or exits.
937    #[cfg(feature = "http")]
938    http_temp_path: Option<PathBuf>,
939}
940
941impl App {
942    /// Returns true when the main loop should drain the crossterm key buffer after render.
943    pub fn should_drain_keys(&self) -> bool {
944        self.drain_keys_on_next_loop
945    }
946
947    /// Clears the drain-keys request after the main loop has drained the buffer.
948    pub fn clear_drain_keys_request(&mut self) {
949        self.drain_keys_on_next_loop = false;
950    }
951
952    pub fn send_event(&mut self, event: AppEvent) -> Result<()> {
953        self.events.send(event)?;
954        Ok(())
955    }
956
957    /// Set loading state and phase so the progress dialog is visible. Used by run() to show
958    /// loading UI immediately when launching from LazyFrame (e.g. Python) before sending the open event.
959    pub fn set_loading_phase(&mut self, phase: impl Into<String>, progress_percent: u16) {
960        self.busy = true;
961        self.loading_state = LoadingState::Loading {
962            file_path: None,
963            file_size: 0,
964            current_phase: phase.into(),
965            progress_percent,
966        };
967    }
968
969    /// Ensures file path has an extension when user did not provide one; only adds
970    /// compression suffix (e.g. .gz) when compression is selected. If the user
971    /// provided a path with an extension (e.g. foo.feather), that extension is kept.
972    fn ensure_file_extension(
973        path: &Path,
974        format: ExportFormat,
975        compression: Option<CompressionFormat>,
976    ) -> PathBuf {
977        let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
978        let mut new_path = path.to_path_buf();
979
980        if current_ext.is_empty() {
981            // No extension: use default for format (and add compression if selected)
982            let desired_ext = if let Some(comp) = compression {
983                format!("{}.{}", format.extension(), comp.extension())
984            } else {
985                format.extension().to_string()
986            };
987            new_path.set_extension(&desired_ext);
988        } else {
989            // User provided an extension: keep it. Only add compression suffix when compression is selected.
990            let is_compression_only = matches!(
991                current_ext.to_lowercase().as_str(),
992                "gz" | "zst" | "bz2" | "xz"
993            ) && ExportFormat::from_extension(current_ext).is_none();
994
995            if is_compression_only {
996                // Path has only compression ext (e.g. file.gz); stem may have format (file.csv.gz)
997                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
998                let stem_has_format = stem
999                    .split('.')
1000                    .next_back()
1001                    .and_then(ExportFormat::from_extension)
1002                    .is_some();
1003                if stem_has_format {
1004                    if let Some(comp) = compression {
1005                        if let Some(format_ext) = stem
1006                            .split('.')
1007                            .next_back()
1008                            .and_then(ExportFormat::from_extension)
1009                            .map(|f| f.extension())
1010                        {
1011                            new_path =
1012                                PathBuf::from(stem.rsplit_once('.').map(|x| x.0).unwrap_or(stem));
1013                            new_path.set_extension(format!("{}.{}", format_ext, comp.extension()));
1014                        }
1015                    }
1016                } else if let Some(comp) = compression {
1017                    new_path.set_extension(format!("{}.{}", format.extension(), comp.extension()));
1018                } else {
1019                    new_path.set_extension(format.extension());
1020                }
1021            } else if let Some(comp) = compression {
1022                if format.supports_compression() {
1023                    new_path.set_extension(format!("{}.{}", current_ext, comp.extension()));
1024                }
1025                // else: path stays as-is (e.g. foo.feather stays foo.feather)
1026            }
1027            // else: path with format extension stays as-is
1028        }
1029
1030        new_path
1031    }
1032
1033    pub fn new(events: Sender<AppEvent>) -> App {
1034        // Create default theme for backward compatibility
1035        let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| {
1036            // Create a minimal fallback theme
1037            Theme {
1038                colors: std::collections::HashMap::new(),
1039            }
1040        });
1041
1042        Self::new_with_config(events, theme, AppConfig::default())
1043    }
1044
1045    pub fn new_with_theme(events: Sender<AppEvent>, theme: Theme) -> App {
1046        Self::new_with_config(events, theme, AppConfig::default())
1047    }
1048
1049    pub fn new_with_config(events: Sender<AppEvent>, theme: Theme, app_config: AppConfig) -> App {
1050        let cache = CacheManager::new(APP_NAME).unwrap_or_else(|_| CacheManager {
1051            cache_dir: std::env::temp_dir().join(APP_NAME),
1052        });
1053
1054        let config_manager = ConfigManager::new(APP_NAME).unwrap_or_else(|_| ConfigManager {
1055            config_dir: std::env::temp_dir().join(APP_NAME).join("config"),
1056        });
1057
1058        let template_manager = TemplateManager::new(&config_manager).unwrap_or_else(|_| {
1059            let temp_config = ConfigManager::new("datui").unwrap_or_else(|_| ConfigManager {
1060                config_dir: std::env::temp_dir().join("datui").join("config"),
1061            });
1062            TemplateManager::new(&temp_config).unwrap_or_else(|_| {
1063                let last_resort = ConfigManager {
1064                    config_dir: std::env::temp_dir().join("datui_config"),
1065                };
1066                TemplateManager::new(&last_resort)
1067                    .unwrap_or_else(|_| TemplateManager::empty(&last_resort))
1068            })
1069        });
1070
1071        App {
1072            path: None,
1073            data_table_state: None,
1074            original_file_format: None,
1075            original_file_delimiter: None,
1076            events,
1077            focus: 0,
1078            debug: DebugState::default(),
1079            info_modal: InfoModal::new(),
1080            parquet_metadata_cache: None,
1081            query_input: TextInput::new()
1082                .with_history_limit(app_config.query.history_limit)
1083                .with_theme(&theme)
1084                .with_history("query".to_string()),
1085            sql_input: TextInput::new()
1086                .with_history_limit(app_config.query.history_limit)
1087                .with_theme(&theme)
1088                .with_history("sql".to_string()),
1089            fuzzy_input: TextInput::new()
1090                .with_history_limit(app_config.query.history_limit)
1091                .with_theme(&theme)
1092                .with_history("fuzzy".to_string()),
1093            input_mode: InputMode::Normal,
1094            input_type: None,
1095            query_tab: QueryTab::SqlLike,
1096            query_focus: QueryFocus::Input,
1097            sort_filter_modal: SortFilterModal::new(),
1098            pivot_melt_modal: PivotMeltModal::new(),
1099            template_modal: TemplateModal::new(),
1100            analysis_modal: AnalysisModal::new(),
1101            chart_modal: ChartModal::new(),
1102            chart_export_modal: ChartExportModal::new(),
1103            export_modal: ExportModal::new(),
1104            chart_cache: ChartCache::default(),
1105            error_modal: ErrorModal::new(),
1106            success_modal: SuccessModal::new(),
1107            confirmation_modal: ConfirmationModal::new(),
1108            pending_export: None,
1109            export_df: None,
1110            pending_chart_export: None,
1111            #[cfg(any(feature = "http", feature = "cloud"))]
1112            pending_download: None,
1113            show_help: false,
1114            help_scroll: 0,
1115            cache,
1116            template_manager,
1117            active_template_id: None,
1118            loading_state: LoadingState::Idle,
1119            theme,
1120            sampling_threshold: app_config.performance.sampling_threshold,
1121            history_limit: app_config.query.history_limit,
1122            table_cell_padding: app_config.display.table_cell_padding.min(u16::MAX as usize) as u16,
1123            column_colors: app_config.display.column_colors,
1124            busy: false,
1125            throbber_frame: 0,
1126            drain_keys_on_next_loop: false,
1127            analysis_computation: None,
1128            app_config,
1129            #[cfg(feature = "http")]
1130            http_temp_path: None,
1131        }
1132    }
1133
1134    pub fn enable_debug(&mut self) {
1135        self.debug.enabled = true;
1136    }
1137
1138    /// Get a color from the theme by name
1139    fn color(&self, name: &str) -> Color {
1140        self.theme.get(name)
1141    }
1142
1143    fn load(&mut self, paths: &[PathBuf], options: &OpenOptions) -> Result<()> {
1144        self.parquet_metadata_cache = None;
1145        self.export_df = None;
1146        let path = &paths[0]; // Primary path for format detection and single-path logic
1147                              // Check for compressed CSV files (e.g., file.csv.gz, file.csv.zst, etc.) — only single-file
1148        let compression = options
1149            .compression
1150            .or_else(|| CompressionFormat::from_extension(path));
1151        let is_csv = options.format == Some(FileFormat::Csv)
1152            || path
1153                .file_stem()
1154                .and_then(|stem| stem.to_str())
1155                .map(|stem| {
1156                    stem.ends_with(".csv")
1157                        || path
1158                            .extension()
1159                            .and_then(|e| e.to_str())
1160                            .map(|e| e.eq_ignore_ascii_case("csv"))
1161                            .unwrap_or(false)
1162                })
1163                .unwrap_or(false);
1164        let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
1165
1166        // For compressed files, decompression phase is already set in DoLoad handler
1167        // Now actually perform decompression and CSV reading (this is the slow part)
1168        if is_compressed_csv {
1169            // Phase: Reading data or Scanning string columns (decompressing + parsing CSV; user may see "Decompressing" until we return)
1170            if let LoadingState::Loading {
1171                file_path,
1172                file_size,
1173                ..
1174            } = &self.loading_state
1175            {
1176                self.loading_state = LoadingState::Loading {
1177                    file_path: file_path.clone(),
1178                    file_size: *file_size,
1179                    current_phase: if options.parse_strings.is_some() {
1180                        "Scanning string columns".to_string()
1181                    } else {
1182                        "Reading data".to_string()
1183                    },
1184                    progress_percent: if options.parse_strings.is_some() {
1185                        55
1186                    } else {
1187                        50
1188                    },
1189                };
1190            }
1191            let lf = DataTableState::from_csv(path, options)?; // Already passes pages_lookahead/lookback via options
1192
1193            // Phase: Building lazyframe (after decompression, before rendering)
1194            if let LoadingState::Loading {
1195                file_path,
1196                file_size,
1197                ..
1198            } = &self.loading_state
1199            {
1200                self.loading_state = LoadingState::Loading {
1201                    file_path: file_path.clone(),
1202                    file_size: *file_size,
1203                    current_phase: "Building lazyframe".to_string(),
1204                    progress_percent: 60,
1205                };
1206            }
1207
1208            // Phased loading: set "Loading buffer" so UI can show progress; caller (DoDecompress) will send DoLoadBuffer
1209            if let LoadingState::Loading {
1210                file_path,
1211                file_size,
1212                ..
1213            } = &self.loading_state
1214            {
1215                self.loading_state = LoadingState::Loading {
1216                    file_path: file_path.clone(),
1217                    file_size: *file_size,
1218                    current_phase: "Loading buffer".to_string(),
1219                    progress_percent: 70,
1220                };
1221            }
1222
1223            self.data_table_state = Some(lf);
1224            self.path = Some(path.clone());
1225            let original_format =
1226                path.file_stem()
1227                    .and_then(|stem| stem.to_str())
1228                    .and_then(|stem| {
1229                        if stem.ends_with(".csv") {
1230                            Some(ExportFormat::Csv)
1231                        } else {
1232                            None
1233                        }
1234                    });
1235            self.original_file_format = original_format;
1236            self.original_file_delimiter = Some(options.delimiter.unwrap_or(b','));
1237            self.sort_filter_modal = SortFilterModal::new();
1238            self.pivot_melt_modal = PivotMeltModal::new();
1239            return Ok(());
1240        }
1241
1242        // Hive path: when --hive and single path is directory or glob (not a single file), use hive load.
1243        // Multiple paths or single file with --hive use the normal path below.
1244        if paths.len() == 1 && options.hive {
1245            let path_str = path.as_os_str().to_string_lossy();
1246            let is_single_file = path.exists()
1247                && path.is_file()
1248                && !path_str.contains('*')
1249                && !path_str.contains("**");
1250            if !is_single_file {
1251                // Directory or glob: only Parquet supported for hive in this implementation
1252                let use_parquet_hive = path.is_dir()
1253                    || path_str.contains(".parquet")
1254                    || path_str.contains("*.parquet");
1255                if use_parquet_hive {
1256                    if let LoadingState::Loading {
1257                        file_path,
1258                        file_size,
1259                        ..
1260                    } = &self.loading_state
1261                    {
1262                        self.loading_state = LoadingState::Loading {
1263                            file_path: file_path.clone(),
1264                            file_size: *file_size,
1265                            current_phase: "Scanning partitioned dataset".to_string(),
1266                            progress_percent: 60,
1267                        };
1268                    }
1269                    let lf = DataTableState::from_parquet_hive(
1270                        path,
1271                        options.pages_lookahead,
1272                        options.pages_lookback,
1273                        options.max_buffered_rows,
1274                        options.max_buffered_mb,
1275                        options.row_numbers,
1276                        options.row_start_index,
1277                    )?;
1278                    if let LoadingState::Loading {
1279                        file_path,
1280                        file_size,
1281                        ..
1282                    } = &self.loading_state
1283                    {
1284                        self.loading_state = LoadingState::Loading {
1285                            file_path: file_path.clone(),
1286                            file_size: *file_size,
1287                            current_phase: "Rendering data".to_string(),
1288                            progress_percent: 90,
1289                        };
1290                    }
1291                    self.loading_state = LoadingState::Idle;
1292                    self.data_table_state = Some(lf);
1293                    self.path = Some(path.clone());
1294                    self.original_file_format = Some(ExportFormat::Parquet);
1295                    self.original_file_delimiter = None;
1296                    self.sort_filter_modal = SortFilterModal::new();
1297                    self.pivot_melt_modal = PivotMeltModal::new();
1298                    return Ok(());
1299                }
1300                self.loading_state = LoadingState::Idle;
1301                return Err(color_eyre::eyre::eyre!(
1302                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
1303                ));
1304            }
1305        }
1306
1307        // For non-gzipped files, proceed with normal loading
1308        // Phase 2: Building lazyframe (or Scanning string columns for CSV when --parse-strings)
1309        let effective_format = options.format.or_else(|| FileFormat::from_path(path));
1310        let csv_parse_strings =
1311            effective_format == Some(FileFormat::Csv) && options.parse_strings.is_some();
1312        if let LoadingState::Loading {
1313            file_path,
1314            file_size,
1315            ..
1316        } = &self.loading_state
1317        {
1318            self.loading_state = LoadingState::Loading {
1319                file_path: file_path.clone(),
1320                file_size: *file_size,
1321                current_phase: if csv_parse_strings {
1322                    "Scanning string columns".to_string()
1323                } else {
1324                    "Building lazyframe".to_string()
1325                },
1326                progress_percent: if csv_parse_strings { 55 } else { 60 },
1327            };
1328        }
1329
1330        // Determine and store original file format (from explicit format or first path)
1331        let original_format = effective_format
1332            .and_then(file_format_to_export_format)
1333            .or_else(|| {
1334                path.extension().and_then(|e| e.to_str()).and_then(|ext| {
1335                    if ext.eq_ignore_ascii_case("parquet") {
1336                        Some(ExportFormat::Parquet)
1337                    } else if ext.eq_ignore_ascii_case("csv") {
1338                        Some(ExportFormat::Csv)
1339                    } else if ext.eq_ignore_ascii_case("json") {
1340                        Some(ExportFormat::Json)
1341                    } else if ext.eq_ignore_ascii_case("jsonl")
1342                        || ext.eq_ignore_ascii_case("ndjson")
1343                    {
1344                        Some(ExportFormat::Ndjson)
1345                    } else if ext.eq_ignore_ascii_case("arrow")
1346                        || ext.eq_ignore_ascii_case("ipc")
1347                        || ext.eq_ignore_ascii_case("feather")
1348                    {
1349                        Some(ExportFormat::Ipc)
1350                    } else if ext.eq_ignore_ascii_case("avro") {
1351                        Some(ExportFormat::Avro)
1352                    } else {
1353                        None
1354                    }
1355                })
1356            });
1357
1358        let lf = if paths.len() > 1 {
1359            // Multiple files: same format assumed (from first path or --format), concatenated into one LazyFrame
1360            match effective_format {
1361                Some(FileFormat::Parquet) => DataTableState::from_parquet_paths(
1362                    paths,
1363                    options.pages_lookahead,
1364                    options.pages_lookback,
1365                    options.max_buffered_rows,
1366                    options.max_buffered_mb,
1367                    options.row_numbers,
1368                    options.row_start_index,
1369                )?,
1370                Some(FileFormat::Csv) => DataTableState::from_csv_paths(paths, options)?,
1371                Some(FileFormat::Json) => DataTableState::from_json_paths(
1372                    paths,
1373                    options.pages_lookahead,
1374                    options.pages_lookback,
1375                    options.max_buffered_rows,
1376                    options.max_buffered_mb,
1377                    options.row_numbers,
1378                    options.row_start_index,
1379                )?,
1380                Some(FileFormat::Jsonl) => DataTableState::from_json_lines_paths(
1381                    paths,
1382                    options.pages_lookahead,
1383                    options.pages_lookback,
1384                    options.max_buffered_rows,
1385                    options.max_buffered_mb,
1386                    options.row_numbers,
1387                    options.row_start_index,
1388                )?,
1389                Some(FileFormat::Arrow) => DataTableState::from_ipc_paths(
1390                    paths,
1391                    options.pages_lookahead,
1392                    options.pages_lookback,
1393                    options.max_buffered_rows,
1394                    options.max_buffered_mb,
1395                    options.row_numbers,
1396                    options.row_start_index,
1397                )?,
1398                Some(FileFormat::Avro) => DataTableState::from_avro_paths(
1399                    paths,
1400                    options.pages_lookahead,
1401                    options.pages_lookback,
1402                    options.max_buffered_rows,
1403                    options.max_buffered_mb,
1404                    options.row_numbers,
1405                    options.row_start_index,
1406                )?,
1407                Some(FileFormat::Orc) => DataTableState::from_orc_paths(
1408                    paths,
1409                    options.pages_lookahead,
1410                    options.pages_lookback,
1411                    options.max_buffered_rows,
1412                    options.max_buffered_mb,
1413                    options.row_numbers,
1414                    options.row_start_index,
1415                )?,
1416                Some(FileFormat::Tsv) | Some(FileFormat::Psv) | Some(FileFormat::Excel) | None => {
1417                    self.loading_state = LoadingState::Idle;
1418                    if !paths.is_empty() && !path.exists() {
1419                        return Err(std::io::Error::new(
1420                            std::io::ErrorKind::NotFound,
1421                            format!("File not found: {}", path.display()),
1422                        )
1423                        .into());
1424                    }
1425                    return Err(color_eyre::eyre::eyre!(
1426                        "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
1427                    ));
1428                }
1429            }
1430        } else {
1431            match effective_format {
1432                Some(FileFormat::Parquet) => DataTableState::from_parquet(
1433                    path,
1434                    options.pages_lookahead,
1435                    options.pages_lookback,
1436                    options.max_buffered_rows,
1437                    options.max_buffered_mb,
1438                    options.row_numbers,
1439                    options.row_start_index,
1440                )?,
1441                Some(FileFormat::Csv) => DataTableState::from_csv(path, options)?,
1442                Some(FileFormat::Tsv) => DataTableState::from_delimited(path, b'\t', options)?,
1443                Some(FileFormat::Psv) => DataTableState::from_delimited(path, b'|', options)?,
1444                Some(FileFormat::Json) => DataTableState::from_json(
1445                    path,
1446                    options.pages_lookahead,
1447                    options.pages_lookback,
1448                    options.max_buffered_rows,
1449                    options.max_buffered_mb,
1450                    options.row_numbers,
1451                    options.row_start_index,
1452                )?,
1453                Some(FileFormat::Jsonl) => DataTableState::from_json_lines(
1454                    path,
1455                    options.pages_lookahead,
1456                    options.pages_lookback,
1457                    options.max_buffered_rows,
1458                    options.max_buffered_mb,
1459                    options.row_numbers,
1460                    options.row_start_index,
1461                )?,
1462                Some(FileFormat::Arrow) => DataTableState::from_ipc(
1463                    path,
1464                    options.pages_lookahead,
1465                    options.pages_lookback,
1466                    options.max_buffered_rows,
1467                    options.max_buffered_mb,
1468                    options.row_numbers,
1469                    options.row_start_index,
1470                )?,
1471                Some(FileFormat::Avro) => DataTableState::from_avro(
1472                    path,
1473                    options.pages_lookahead,
1474                    options.pages_lookback,
1475                    options.max_buffered_rows,
1476                    options.max_buffered_mb,
1477                    options.row_numbers,
1478                    options.row_start_index,
1479                )?,
1480                Some(FileFormat::Excel) => DataTableState::from_excel(
1481                    path,
1482                    options.pages_lookahead,
1483                    options.pages_lookback,
1484                    options.max_buffered_rows,
1485                    options.max_buffered_mb,
1486                    options.row_numbers,
1487                    options.row_start_index,
1488                    options.excel_sheet.as_deref(),
1489                )?,
1490                Some(FileFormat::Orc) => DataTableState::from_orc(
1491                    path,
1492                    options.pages_lookahead,
1493                    options.pages_lookback,
1494                    options.max_buffered_rows,
1495                    options.max_buffered_mb,
1496                    options.row_numbers,
1497                    options.row_start_index,
1498                )?,
1499                None => {
1500                    self.loading_state = LoadingState::Idle;
1501                    if paths.len() == 1 && !path.exists() {
1502                        return Err(std::io::Error::new(
1503                            std::io::ErrorKind::NotFound,
1504                            format!("File not found: {}", path.display()),
1505                        )
1506                        .into());
1507                    }
1508                    return Err(color_eyre::eyre::eyre!("Unsupported file type"));
1509                }
1510            }
1511        };
1512
1513        // Phase 3: Rendering data
1514        if let LoadingState::Loading {
1515            file_path,
1516            file_size,
1517            ..
1518        } = &self.loading_state
1519        {
1520            self.loading_state = LoadingState::Loading {
1521                file_path: file_path.clone(),
1522                file_size: *file_size,
1523                current_phase: "Rendering data".to_string(),
1524                progress_percent: 90,
1525            };
1526        }
1527
1528        // Clear loading state after successful load
1529        self.loading_state = LoadingState::Idle;
1530        self.data_table_state = Some(lf);
1531        self.path = Some(path.clone());
1532        self.original_file_format = original_format;
1533        // Store delimiter based on file type (use effective format when set)
1534        self.original_file_delimiter = match effective_format {
1535            Some(FileFormat::Csv) => Some(options.delimiter.unwrap_or(b',')),
1536            Some(FileFormat::Tsv) => Some(b'\t'),
1537            Some(FileFormat::Psv) => Some(b'|'),
1538            _ => path.extension().and_then(|e| e.to_str()).and_then(|ext| {
1539                if ext.eq_ignore_ascii_case("csv") {
1540                    Some(options.delimiter.unwrap_or(b','))
1541                } else if ext.eq_ignore_ascii_case("tsv") {
1542                    Some(b'\t')
1543                } else if ext.eq_ignore_ascii_case("psv") {
1544                    Some(b'|')
1545                } else {
1546                    None
1547                }
1548            }),
1549        };
1550        self.sort_filter_modal = SortFilterModal::new();
1551        self.pivot_melt_modal = PivotMeltModal::new();
1552        Ok(())
1553    }
1554
1555    #[cfg(feature = "cloud")]
1556    fn build_s3_cloud_options(
1557        cloud: &crate::config::CloudConfig,
1558        options: &OpenOptions,
1559    ) -> CloudOptions {
1560        let mut opts = CloudOptions::default();
1561        let mut configs: Vec<(AmazonS3ConfigKey, String)> = Vec::new();
1562        let e = options
1563            .s3_endpoint_url_override
1564            .as_ref()
1565            .or(cloud.s3_endpoint_url.as_ref());
1566        let k = options
1567            .s3_access_key_id_override
1568            .as_ref()
1569            .or(cloud.s3_access_key_id.as_ref());
1570        let s = options
1571            .s3_secret_access_key_override
1572            .as_ref()
1573            .or(cloud.s3_secret_access_key.as_ref());
1574        let r = options
1575            .s3_region_override
1576            .as_ref()
1577            .or(cloud.s3_region.as_ref());
1578        if let Some(e) = e {
1579            configs.push((AmazonS3ConfigKey::Endpoint, e.clone()));
1580        }
1581        if let Some(k) = k {
1582            configs.push((AmazonS3ConfigKey::AccessKeyId, k.clone()));
1583        }
1584        if let Some(s) = s {
1585            configs.push((AmazonS3ConfigKey::SecretAccessKey, s.clone()));
1586        }
1587        if let Some(r) = r {
1588            configs.push((AmazonS3ConfigKey::Region, r.clone()));
1589        }
1590        if !configs.is_empty() {
1591            opts = opts.with_aws(configs);
1592        }
1593        opts
1594    }
1595
1596    #[cfg(feature = "cloud")]
1597    fn build_s3_object_store(
1598        s3_url: &str,
1599        cloud: &crate::config::CloudConfig,
1600        options: &OpenOptions,
1601    ) -> Result<Arc<dyn object_store::ObjectStore>> {
1602        let (path_part, _ext) = source::url_path_extension(s3_url);
1603        let (bucket, _key) = path_part
1604            .split_once('/')
1605            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1606        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1607            .with_url(s3_url)
1608            .with_bucket_name(bucket);
1609        let e = options
1610            .s3_endpoint_url_override
1611            .as_ref()
1612            .or(cloud.s3_endpoint_url.as_ref());
1613        let k = options
1614            .s3_access_key_id_override
1615            .as_ref()
1616            .or(cloud.s3_access_key_id.as_ref());
1617        let s = options
1618            .s3_secret_access_key_override
1619            .as_ref()
1620            .or(cloud.s3_secret_access_key.as_ref());
1621        let r = options
1622            .s3_region_override
1623            .as_ref()
1624            .or(cloud.s3_region.as_ref());
1625        if let Some(e) = e {
1626            builder = builder.with_endpoint(e);
1627        }
1628        if let Some(k) = k {
1629            builder = builder.with_access_key_id(k);
1630        }
1631        if let Some(s) = s {
1632            builder = builder.with_secret_access_key(s);
1633        }
1634        if let Some(r) = r {
1635            builder = builder.with_region(r);
1636        }
1637        let store = builder
1638            .build()
1639            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1640        Ok(Arc::new(store))
1641    }
1642
1643    #[cfg(feature = "cloud")]
1644    fn build_gcs_object_store(gs_url: &str) -> Result<Arc<dyn object_store::ObjectStore>> {
1645        let (path_part, _ext) = source::url_path_extension(gs_url);
1646        let (bucket, _key) = path_part
1647            .split_once('/')
1648            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1649        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1650            .with_url(gs_url)
1651            .with_bucket_name(bucket)
1652            .build()
1653            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1654        Ok(Arc::new(store))
1655    }
1656
1657    /// Human-readable byte size for download confirmation modal.
1658    fn format_bytes(n: u64) -> String {
1659        const KB: u64 = 1024;
1660        const MB: u64 = KB * 1024;
1661        const GB: u64 = MB * 1024;
1662        const TB: u64 = GB * 1024;
1663        if n >= TB {
1664            format!("{:.2} TB", n as f64 / TB as f64)
1665        } else if n >= GB {
1666            format!("{:.2} GB", n as f64 / GB as f64)
1667        } else if n >= MB {
1668            format!("{:.2} MB", n as f64 / MB as f64)
1669        } else if n >= KB {
1670            format!("{:.2} KB", n as f64 / KB as f64)
1671        } else {
1672            format!("{} bytes", n)
1673        }
1674    }
1675
1676    #[cfg(feature = "http")]
1677    fn fetch_remote_size_http(url: &str) -> Result<Option<u64>> {
1678        let response = ureq::request("HEAD", url)
1679            .timeout(std::time::Duration::from_secs(15))
1680            .call();
1681        match response {
1682            Ok(r) => Ok(r
1683                .header("Content-Length")
1684                .and_then(|s| s.parse::<u64>().ok())),
1685            Err(_) => Ok(None),
1686        }
1687    }
1688
1689    #[cfg(feature = "cloud")]
1690    fn fetch_remote_size_s3(
1691        s3_url: &str,
1692        cloud: &crate::config::CloudConfig,
1693        options: &OpenOptions,
1694    ) -> Result<Option<u64>> {
1695        use object_store::path::Path as OsPath;
1696        use object_store::ObjectStore;
1697
1698        let (path_part, _ext) = source::url_path_extension(s3_url);
1699        let (bucket, key) = path_part
1700            .split_once('/')
1701            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1702        if key.is_empty() {
1703            return Ok(None);
1704        }
1705        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1706            .with_url(s3_url)
1707            .with_bucket_name(bucket);
1708        let e = options
1709            .s3_endpoint_url_override
1710            .as_ref()
1711            .or(cloud.s3_endpoint_url.as_ref());
1712        let k = options
1713            .s3_access_key_id_override
1714            .as_ref()
1715            .or(cloud.s3_access_key_id.as_ref());
1716        let s = options
1717            .s3_secret_access_key_override
1718            .as_ref()
1719            .or(cloud.s3_secret_access_key.as_ref());
1720        let r = options
1721            .s3_region_override
1722            .as_ref()
1723            .or(cloud.s3_region.as_ref());
1724        if let Some(e) = e {
1725            builder = builder.with_endpoint(e);
1726        }
1727        if let Some(k) = k {
1728            builder = builder.with_access_key_id(k);
1729        }
1730        if let Some(s) = s {
1731            builder = builder.with_secret_access_key(s);
1732        }
1733        if let Some(r) = r {
1734            builder = builder.with_region(r);
1735        }
1736        let store = builder
1737            .build()
1738            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1739        let rt = tokio::runtime::Runtime::new()
1740            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1741        let path = OsPath::from(key);
1742        match rt.block_on(store.head(&path)) {
1743            Ok(meta) => Ok(Some(meta.size)),
1744            Err(_) => Ok(None),
1745        }
1746    }
1747
1748    #[cfg(feature = "cloud")]
1749    fn fetch_remote_size_gcs(gs_url: &str, _options: &OpenOptions) -> Result<Option<u64>> {
1750        use object_store::path::Path as OsPath;
1751        use object_store::ObjectStore;
1752
1753        let (path_part, _ext) = source::url_path_extension(gs_url);
1754        let (bucket, key) = path_part
1755            .split_once('/')
1756            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1757        if key.is_empty() {
1758            return Ok(None);
1759        }
1760        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1761            .with_url(gs_url)
1762            .with_bucket_name(bucket)
1763            .build()
1764            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1765        let rt = tokio::runtime::Runtime::new()
1766            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1767        let path = OsPath::from(key);
1768        match rt.block_on(store.head(&path)) {
1769            Ok(meta) => Ok(Some(meta.size)),
1770            Err(_) => Ok(None),
1771        }
1772    }
1773
1774    #[cfg(feature = "http")]
1775    fn download_http_to_temp(
1776        url: &str,
1777        temp_dir: Option<&Path>,
1778        extension: Option<&str>,
1779    ) -> Result<PathBuf> {
1780        let dir = temp_dir
1781            .map(Path::to_path_buf)
1782            .unwrap_or_else(std::env::temp_dir);
1783        let suffix = extension
1784            .map(|e| format!(".{e}"))
1785            .unwrap_or_else(|| ".tmp".to_string());
1786        let mut temp = tempfile::Builder::new()
1787            .suffix(&suffix)
1788            .tempfile_in(&dir)
1789            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1790        let response = ureq::get(url)
1791            .timeout(std::time::Duration::from_secs(300))
1792            .call()
1793            .map_err(|e| {
1794                color_eyre::eyre::eyre!("Download failed. Check the URL and your connection: {}", e)
1795            })?;
1796        let status = response.status();
1797        if status >= 400 {
1798            return Err(color_eyre::eyre::eyre!(
1799                "Server returned {} {}. Check the URL.",
1800                status,
1801                response.status_text()
1802            ));
1803        }
1804        std::io::copy(&mut response.into_reader(), &mut temp)
1805            .map_err(|_| color_eyre::eyre::eyre!("Download failed while saving the file."))?;
1806        let (_file, path) = temp
1807            .keep()
1808            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1809        Ok(path)
1810    }
1811
1812    #[cfg(feature = "cloud")]
1813    fn download_s3_to_temp(
1814        s3_url: &str,
1815        cloud: &crate::config::CloudConfig,
1816        options: &OpenOptions,
1817    ) -> Result<PathBuf> {
1818        use object_store::path::Path as OsPath;
1819        use object_store::ObjectStore;
1820
1821        let (path_part, ext) = source::url_path_extension(s3_url);
1822        let (bucket, key) = path_part
1823            .split_once('/')
1824            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1825        if key.is_empty() {
1826            return Err(color_eyre::eyre::eyre!(
1827                "S3 URL must point to an object (e.g. s3://bucket/path/file.csv)"
1828            ));
1829        }
1830
1831        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1832            .with_url(s3_url)
1833            .with_bucket_name(bucket);
1834        let e = options
1835            .s3_endpoint_url_override
1836            .as_ref()
1837            .or(cloud.s3_endpoint_url.as_ref());
1838        let k = options
1839            .s3_access_key_id_override
1840            .as_ref()
1841            .or(cloud.s3_access_key_id.as_ref());
1842        let s = options
1843            .s3_secret_access_key_override
1844            .as_ref()
1845            .or(cloud.s3_secret_access_key.as_ref());
1846        let r = options
1847            .s3_region_override
1848            .as_ref()
1849            .or(cloud.s3_region.as_ref());
1850        if let Some(e) = e {
1851            builder = builder.with_endpoint(e);
1852        }
1853        if let Some(k) = k {
1854            builder = builder.with_access_key_id(k);
1855        }
1856        if let Some(s) = s {
1857            builder = builder.with_secret_access_key(s);
1858        }
1859        if let Some(r) = r {
1860            builder = builder.with_region(r);
1861        }
1862        let store = builder
1863            .build()
1864            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1865
1866        let rt = tokio::runtime::Runtime::new()
1867            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1868        let path = OsPath::from(key);
1869        let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1870            color_eyre::eyre::eyre!("Could not read from S3. Check credentials and URL: {}", e)
1871        })?;
1872        let bytes = rt
1873            .block_on(get_result.bytes())
1874            .map_err(|e| color_eyre::eyre::eyre!("Could not read S3 object body: {}", e))?;
1875
1876        let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1877        let suffix = ext
1878            .as_ref()
1879            .map(|e| format!(".{e}"))
1880            .unwrap_or_else(|| ".tmp".to_string());
1881        let mut temp = tempfile::Builder::new()
1882            .suffix(&suffix)
1883            .tempfile_in(&dir)
1884            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1885        std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1886            .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1887        let (_file, path_buf) = temp
1888            .keep()
1889            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1890        Ok(path_buf)
1891    }
1892
1893    #[cfg(feature = "cloud")]
1894    fn download_gcs_to_temp(gs_url: &str, options: &OpenOptions) -> Result<PathBuf> {
1895        use object_store::path::Path as OsPath;
1896        use object_store::ObjectStore;
1897
1898        let (path_part, ext) = source::url_path_extension(gs_url);
1899        let (bucket, key) = path_part
1900            .split_once('/')
1901            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1902        if key.is_empty() {
1903            return Err(color_eyre::eyre::eyre!(
1904                "GCS URL must point to an object (e.g. gs://bucket/path/file.csv)"
1905            ));
1906        }
1907
1908        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1909            .with_url(gs_url)
1910            .with_bucket_name(bucket)
1911            .build()
1912            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1913
1914        let rt = tokio::runtime::Runtime::new()
1915            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1916        let path = OsPath::from(key);
1917        let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1918            color_eyre::eyre::eyre!("Could not read from GCS. Check credentials and URL: {}", e)
1919        })?;
1920        let bytes = rt
1921            .block_on(get_result.bytes())
1922            .map_err(|e| color_eyre::eyre::eyre!("Could not read GCS object body: {}", e))?;
1923
1924        let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1925        let suffix = ext
1926            .as_ref()
1927            .map(|e| format!(".{e}"))
1928            .unwrap_or_else(|| ".tmp".to_string());
1929        let mut temp = tempfile::Builder::new()
1930            .suffix(&suffix)
1931            .tempfile_in(&dir)
1932            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1933        std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1934            .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1935        let (_file, path_buf) = temp
1936            .keep()
1937            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1938        Ok(path_buf)
1939    }
1940
1941    /// Build LazyFrame from paths for phased loading (non-compressed only). Caller must not use for compressed CSV.
1942    fn build_lazyframe_from_paths(
1943        &mut self,
1944        paths: &[PathBuf],
1945        options: &OpenOptions,
1946    ) -> Result<LazyFrame> {
1947        let path = &paths[0];
1948        match source::input_source(path) {
1949            source::InputSource::Http(_url) => {
1950                #[cfg(feature = "http")]
1951                {
1952                    return Err(color_eyre::eyre::eyre!(
1953                        "HTTP/HTTPS load is handled in the event loop; this path should not be reached."
1954                    ));
1955                }
1956                #[cfg(not(feature = "http"))]
1957                {
1958                    return Err(color_eyre::eyre::eyre!(
1959                        "HTTP/HTTPS URLs are not supported in this build. Rebuild with default features."
1960                    ));
1961                }
1962            }
1963            source::InputSource::S3(url) => {
1964                #[cfg(feature = "cloud")]
1965                {
1966                    let full = format!("s3://{url}");
1967                    let cloud_opts = Self::build_s3_cloud_options(&self.app_config.cloud, options);
1968                    let pl_path = PlPathRef::new(&full).into_owned();
1969                    let is_glob = full.contains('*') || full.ends_with('/');
1970                    let hive_options = if is_glob {
1971                        polars::io::HiveOptions::new_enabled()
1972                    } else {
1973                        polars::io::HiveOptions::default()
1974                    };
1975                    let args = ScanArgsParquet {
1976                        cloud_options: Some(cloud_opts),
1977                        hive_options,
1978                        glob: is_glob,
1979                        ..Default::default()
1980                    };
1981                    let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
1982                        color_eyre::eyre::eyre!(
1983                            "Could not read from S3. Check credentials and URL: {}",
1984                            e
1985                        )
1986                    })?;
1987                    let state = DataTableState::from_lazyframe(lf, options)?;
1988                    return Ok(state.lf);
1989                }
1990                #[cfg(not(feature = "cloud"))]
1991                {
1992                    return Err(color_eyre::eyre::eyre!(
1993                        "S3 is not supported in this build. Rebuild with default features and set AWS credentials (e.g. AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION)."
1994                    ));
1995                }
1996            }
1997            source::InputSource::Gcs(url) => {
1998                #[cfg(feature = "cloud")]
1999                {
2000                    let full = format!("gs://{url}");
2001                    let pl_path = PlPathRef::new(&full).into_owned();
2002                    let is_glob = full.contains('*') || full.ends_with('/');
2003                    let hive_options = if is_glob {
2004                        polars::io::HiveOptions::new_enabled()
2005                    } else {
2006                        polars::io::HiveOptions::default()
2007                    };
2008                    let args = ScanArgsParquet {
2009                        cloud_options: Some(CloudOptions::default()),
2010                        hive_options,
2011                        glob: is_glob,
2012                        ..Default::default()
2013                    };
2014                    let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
2015                        color_eyre::eyre::eyre!(
2016                            "Could not read from GCS. Check credentials and URL: {}",
2017                            e
2018                        )
2019                    })?;
2020                    let state = DataTableState::from_lazyframe(lf, options)?;
2021                    return Ok(state.lf);
2022                }
2023                #[cfg(not(feature = "cloud"))]
2024                {
2025                    return Err(color_eyre::eyre::eyre!(
2026                        "GCS (gs://) is not supported in this build. Rebuild with default features."
2027                    ));
2028                }
2029            }
2030            source::InputSource::Local(_) => {}
2031        }
2032
2033        if paths.len() == 1 && options.hive {
2034            let path_str = path.as_os_str().to_string_lossy();
2035            let is_single_file = path.exists()
2036                && path.is_file()
2037                && !path_str.contains('*')
2038                && !path_str.contains("**");
2039            if !is_single_file {
2040                let use_parquet_hive = path.is_dir()
2041                    || path_str.contains(".parquet")
2042                    || path_str.contains("*.parquet");
2043                if use_parquet_hive {
2044                    // Only build LazyFrame here; schema + partition discovery happen in DoLoadSchema ("Caching schema")
2045                    return DataTableState::scan_parquet_hive(path);
2046                }
2047                return Err(color_eyre::eyre::eyre!(
2048                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
2049                ));
2050            }
2051        }
2052
2053        let effective_format = options.format.or_else(|| FileFormat::from_path(path));
2054
2055        let lf = if paths.len() > 1 {
2056            match effective_format {
2057                Some(FileFormat::Parquet) => DataTableState::from_parquet_paths(
2058                    paths,
2059                    options.pages_lookahead,
2060                    options.pages_lookback,
2061                    options.max_buffered_rows,
2062                    options.max_buffered_mb,
2063                    options.row_numbers,
2064                    options.row_start_index,
2065                )?,
2066                Some(FileFormat::Csv) => DataTableState::from_csv_paths(paths, options)?,
2067                Some(FileFormat::Json) => DataTableState::from_json_paths(
2068                    paths,
2069                    options.pages_lookahead,
2070                    options.pages_lookback,
2071                    options.max_buffered_rows,
2072                    options.max_buffered_mb,
2073                    options.row_numbers,
2074                    options.row_start_index,
2075                )?,
2076                Some(FileFormat::Jsonl) => DataTableState::from_json_lines_paths(
2077                    paths,
2078                    options.pages_lookahead,
2079                    options.pages_lookback,
2080                    options.max_buffered_rows,
2081                    options.max_buffered_mb,
2082                    options.row_numbers,
2083                    options.row_start_index,
2084                )?,
2085                Some(FileFormat::Arrow) => DataTableState::from_ipc_paths(
2086                    paths,
2087                    options.pages_lookahead,
2088                    options.pages_lookback,
2089                    options.max_buffered_rows,
2090                    options.max_buffered_mb,
2091                    options.row_numbers,
2092                    options.row_start_index,
2093                )?,
2094                Some(FileFormat::Avro) => DataTableState::from_avro_paths(
2095                    paths,
2096                    options.pages_lookahead,
2097                    options.pages_lookback,
2098                    options.max_buffered_rows,
2099                    options.max_buffered_mb,
2100                    options.row_numbers,
2101                    options.row_start_index,
2102                )?,
2103                Some(FileFormat::Orc) => DataTableState::from_orc_paths(
2104                    paths,
2105                    options.pages_lookahead,
2106                    options.pages_lookback,
2107                    options.max_buffered_rows,
2108                    options.max_buffered_mb,
2109                    options.row_numbers,
2110                    options.row_start_index,
2111                )?,
2112                Some(FileFormat::Tsv) | Some(FileFormat::Psv) | Some(FileFormat::Excel) | None => {
2113                    if !paths.is_empty() && !path.exists() {
2114                        return Err(std::io::Error::new(
2115                            std::io::ErrorKind::NotFound,
2116                            format!("File not found: {}", path.display()),
2117                        )
2118                        .into());
2119                    }
2120                    return Err(color_eyre::eyre::eyre!(
2121                        "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
2122                    ));
2123                }
2124            }
2125        } else {
2126            match effective_format {
2127                Some(FileFormat::Parquet) => DataTableState::from_parquet(
2128                    path,
2129                    options.pages_lookahead,
2130                    options.pages_lookback,
2131                    options.max_buffered_rows,
2132                    options.max_buffered_mb,
2133                    options.row_numbers,
2134                    options.row_start_index,
2135                )?,
2136                Some(FileFormat::Csv) => DataTableState::from_csv(path, options)?,
2137                Some(FileFormat::Tsv) => DataTableState::from_delimited(path, b'\t', options)?,
2138                Some(FileFormat::Psv) => DataTableState::from_delimited(path, b'|', options)?,
2139                Some(FileFormat::Json) => DataTableState::from_json(
2140                    path,
2141                    options.pages_lookahead,
2142                    options.pages_lookback,
2143                    options.max_buffered_rows,
2144                    options.max_buffered_mb,
2145                    options.row_numbers,
2146                    options.row_start_index,
2147                )?,
2148                Some(FileFormat::Jsonl) => DataTableState::from_json_lines(
2149                    path,
2150                    options.pages_lookahead,
2151                    options.pages_lookback,
2152                    options.max_buffered_rows,
2153                    options.max_buffered_mb,
2154                    options.row_numbers,
2155                    options.row_start_index,
2156                )?,
2157                Some(FileFormat::Arrow) => DataTableState::from_ipc(
2158                    path,
2159                    options.pages_lookahead,
2160                    options.pages_lookback,
2161                    options.max_buffered_rows,
2162                    options.max_buffered_mb,
2163                    options.row_numbers,
2164                    options.row_start_index,
2165                )?,
2166                Some(FileFormat::Avro) => DataTableState::from_avro(
2167                    path,
2168                    options.pages_lookahead,
2169                    options.pages_lookback,
2170                    options.max_buffered_rows,
2171                    options.max_buffered_mb,
2172                    options.row_numbers,
2173                    options.row_start_index,
2174                )?,
2175                Some(FileFormat::Excel) => DataTableState::from_excel(
2176                    path,
2177                    options.pages_lookahead,
2178                    options.pages_lookback,
2179                    options.max_buffered_rows,
2180                    options.max_buffered_mb,
2181                    options.row_numbers,
2182                    options.row_start_index,
2183                    options.excel_sheet.as_deref(),
2184                )?,
2185                Some(FileFormat::Orc) => DataTableState::from_orc(
2186                    path,
2187                    options.pages_lookahead,
2188                    options.pages_lookback,
2189                    options.max_buffered_rows,
2190                    options.max_buffered_mb,
2191                    options.row_numbers,
2192                    options.row_start_index,
2193                )?,
2194                None => {
2195                    if paths.len() == 1 && !path.exists() {
2196                        return Err(std::io::Error::new(
2197                            std::io::ErrorKind::NotFound,
2198                            format!("File not found: {}", path.display()),
2199                        )
2200                        .into());
2201                    }
2202                    return Err(color_eyre::eyre::eyre!("Unsupported file type"));
2203                }
2204            }
2205        };
2206        Ok(lf.lf)
2207    }
2208
2209    /// Set the appropriate help overlay visible (main, template, or analysis). No-op if already visible.
2210    fn open_help_overlay(&mut self) {
2211        let already = self.show_help
2212            || (self.template_modal.active && self.template_modal.show_help)
2213            || (self.analysis_modal.active && self.analysis_modal.show_help);
2214        if already {
2215            return;
2216        }
2217        if self.analysis_modal.active {
2218            self.analysis_modal.show_help = true;
2219        } else if self.template_modal.active {
2220            self.template_modal.show_help = true;
2221        } else {
2222            self.show_help = true;
2223        }
2224    }
2225
2226    fn key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
2227        self.debug.on_key(event);
2228
2229        // F1 opens help first so no other branch (e.g. Editing) can consume it.
2230        if event.code == KeyCode::F(1) {
2231            self.open_help_overlay();
2232            return None;
2233        }
2234
2235        // Handle modals first - they have highest priority
2236        // Confirmation modal (for overwrite)
2237        if self.confirmation_modal.active {
2238            match event.code {
2239                KeyCode::Left | KeyCode::Char('h') => {
2240                    self.confirmation_modal.focus_yes = true;
2241                }
2242                KeyCode::Right | KeyCode::Char('l') => {
2243                    self.confirmation_modal.focus_yes = false;
2244                }
2245                KeyCode::Tab => {
2246                    // Toggle between Yes and No
2247                    self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
2248                }
2249                KeyCode::Enter => {
2250                    if self.confirmation_modal.focus_yes {
2251                        // User confirmed overwrite: chart export first, then dataframe export
2252                        if let Some((path, format, title, width, height)) =
2253                            self.pending_chart_export.take()
2254                        {
2255                            self.confirmation_modal.hide();
2256                            return Some(AppEvent::ChartExport(path, format, title, width, height));
2257                        }
2258                        if let Some((path, format, options)) = self.pending_export.take() {
2259                            self.confirmation_modal.hide();
2260                            return Some(AppEvent::Export(path, format, options));
2261                        }
2262                        #[cfg(any(feature = "http", feature = "cloud"))]
2263                        if let Some(pending) = self.pending_download.take() {
2264                            self.confirmation_modal.hide();
2265                            if let LoadingState::Loading {
2266                                file_path,
2267                                file_size,
2268                                ..
2269                            } = &self.loading_state
2270                            {
2271                                self.loading_state = LoadingState::Loading {
2272                                    file_path: file_path.clone(),
2273                                    file_size: *file_size,
2274                                    current_phase: "Downloading".to_string(),
2275                                    progress_percent: 20,
2276                                };
2277                            }
2278                            return Some(match pending {
2279                                #[cfg(feature = "http")]
2280                                PendingDownload::Http { url, options, .. } => {
2281                                    AppEvent::DoDownloadHttp(url, options)
2282                                }
2283                                #[cfg(feature = "cloud")]
2284                                PendingDownload::S3 { url, options, .. } => {
2285                                    AppEvent::DoDownloadS3ToTemp(url, options)
2286                                }
2287                                #[cfg(feature = "cloud")]
2288                                PendingDownload::Gcs { url, options, .. } => {
2289                                    AppEvent::DoDownloadGcsToTemp(url, options)
2290                                }
2291                            });
2292                        }
2293                    } else {
2294                        // User cancelled: if chart export overwrite, reopen chart export modal with path pre-filled
2295                        if let Some((path, format, _, _, _)) = self.pending_chart_export.take() {
2296                            self.chart_export_modal.reopen_with_path(&path, format);
2297                        }
2298                        self.pending_export = None;
2299                        #[cfg(any(feature = "http", feature = "cloud"))]
2300                        if self.pending_download.take().is_some() {
2301                            self.confirmation_modal.hide();
2302                            return Some(AppEvent::Exit);
2303                        }
2304                        self.confirmation_modal.hide();
2305                    }
2306                }
2307                KeyCode::Esc => {
2308                    // Cancel: if chart export overwrite, reopen chart export modal with path pre-filled
2309                    if let Some((path, format, _, _, _)) = self.pending_chart_export.take() {
2310                        self.chart_export_modal.reopen_with_path(&path, format);
2311                    }
2312                    self.pending_export = None;
2313                    #[cfg(any(feature = "http", feature = "cloud"))]
2314                    if self.pending_download.take().is_some() {
2315                        self.confirmation_modal.hide();
2316                        return Some(AppEvent::Exit);
2317                    }
2318                    self.confirmation_modal.hide();
2319                }
2320                _ => {}
2321            }
2322            return None;
2323        }
2324        // Success modal
2325        if self.success_modal.active {
2326            match event.code {
2327                KeyCode::Esc | KeyCode::Enter => {
2328                    self.success_modal.hide();
2329                }
2330                _ => {}
2331            }
2332            return None;
2333        }
2334        // Error modal
2335        if self.error_modal.active {
2336            match event.code {
2337                KeyCode::Esc | KeyCode::Enter => {
2338                    self.error_modal.hide();
2339                }
2340                _ => {}
2341            }
2342            return None;
2343        }
2344
2345        // Main table: left/right scroll columns (before help/mode blocks so column scroll always works in Normal).
2346        // No is_press()/is_release() check: some terminals do not report key kind correctly.
2347        // Exclude template/analysis modals so they can handle Left/Right themselves.
2348        let in_main_table = !(self.input_mode != InputMode::Normal
2349            || self.show_help
2350            || self.template_modal.active
2351            || self.analysis_modal.active);
2352        if in_main_table {
2353            let did_scroll = match event.code {
2354                KeyCode::Right | KeyCode::Char('l') => {
2355                    if let Some(ref mut state) = self.data_table_state {
2356                        state.scroll_right();
2357                        if self.debug.enabled {
2358                            self.debug.last_action = "scroll_right".to_string();
2359                        }
2360                        true
2361                    } else {
2362                        false
2363                    }
2364                }
2365                KeyCode::Left | KeyCode::Char('h') => {
2366                    if let Some(ref mut state) = self.data_table_state {
2367                        state.scroll_left();
2368                        if self.debug.enabled {
2369                            self.debug.last_action = "scroll_left".to_string();
2370                        }
2371                        true
2372                    } else {
2373                        false
2374                    }
2375                }
2376                _ => false,
2377            };
2378            if did_scroll {
2379                return None;
2380            }
2381        }
2382
2383        if self.show_help
2384            || (self.template_modal.active && self.template_modal.show_help)
2385            || (self.analysis_modal.active && self.analysis_modal.show_help)
2386        {
2387            match event.code {
2388                KeyCode::Esc => {
2389                    if self.analysis_modal.active && self.analysis_modal.show_help {
2390                        self.analysis_modal.show_help = false;
2391                    } else if self.template_modal.active && self.template_modal.show_help {
2392                        self.template_modal.show_help = false;
2393                    } else {
2394                        self.show_help = false;
2395                    }
2396                    self.help_scroll = 0;
2397                }
2398                KeyCode::Char('?') => {
2399                    if self.analysis_modal.active && self.analysis_modal.show_help {
2400                        self.analysis_modal.show_help = false;
2401                    } else if self.template_modal.active && self.template_modal.show_help {
2402                        self.template_modal.show_help = false;
2403                    } else {
2404                        self.show_help = false;
2405                    }
2406                    self.help_scroll = 0;
2407                }
2408                KeyCode::Down | KeyCode::Char('j') => {
2409                    self.help_scroll = self.help_scroll.saturating_add(1);
2410                }
2411                KeyCode::Up | KeyCode::Char('k') => {
2412                    self.help_scroll = self.help_scroll.saturating_sub(1);
2413                }
2414                KeyCode::PageDown => {
2415                    self.help_scroll = self.help_scroll.saturating_add(10);
2416                }
2417                KeyCode::PageUp => {
2418                    self.help_scroll = self.help_scroll.saturating_sub(10);
2419                }
2420                KeyCode::Home => {
2421                    self.help_scroll = 0;
2422                }
2423                KeyCode::End => {
2424                    // Will be set based on content height in render
2425                }
2426                _ => {}
2427            }
2428            return None;
2429        }
2430
2431        if event.code == KeyCode::Char('?') {
2432            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
2433            let in_text_input = match self.input_mode {
2434                InputMode::Editing => true,
2435                InputMode::Export => matches!(
2436                    self.export_modal.focus,
2437                    ExportFocus::PathInput | ExportFocus::CsvDelimiter
2438                ),
2439                InputMode::SortFilter => {
2440                    let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2441                    let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2442                    on_body
2443                        && filter_tab
2444                        && self.sort_filter_modal.filter.focus == FilterFocus::Value
2445                }
2446                InputMode::PivotMelt => matches!(
2447                    self.pivot_melt_modal.focus,
2448                    PivotMeltFocus::PivotFilter
2449                        | PivotMeltFocus::MeltFilter
2450                        | PivotMeltFocus::MeltPattern
2451                        | PivotMeltFocus::MeltVarName
2452                        | PivotMeltFocus::MeltValName
2453                ),
2454                InputMode::Info | InputMode::Chart => false,
2455                InputMode::Normal => {
2456                    if self.template_modal.active
2457                        && self.template_modal.mode != TemplateModalMode::List
2458                    {
2459                        matches!(
2460                            self.template_modal.create_focus,
2461                            CreateFocus::Name
2462                                | CreateFocus::Description
2463                                | CreateFocus::ExactPath
2464                                | CreateFocus::RelativePath
2465                                | CreateFocus::PathPattern
2466                                | CreateFocus::FilenamePattern
2467                        )
2468                    } else {
2469                        false
2470                    }
2471                }
2472            };
2473            // Ctrl-? always opens help; bare ? only when not in a text field
2474            if ctrl_help || !in_text_input {
2475                self.open_help_overlay();
2476                return None;
2477            }
2478        }
2479
2480        if self.input_mode == InputMode::SortFilter {
2481            let on_tab_bar = self.sort_filter_modal.focus == SortFilterFocus::TabBar;
2482            let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2483            let on_apply = self.sort_filter_modal.focus == SortFilterFocus::Apply;
2484            let on_cancel = self.sort_filter_modal.focus == SortFilterFocus::Cancel;
2485            let on_clear = self.sort_filter_modal.focus == SortFilterFocus::Clear;
2486            let sort_tab = self.sort_filter_modal.active_tab == SortFilterTab::Sort;
2487            let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2488
2489            match event.code {
2490                KeyCode::Esc => {
2491                    for col in &mut self.sort_filter_modal.sort.columns {
2492                        col.is_to_be_locked = false;
2493                    }
2494                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2495                    self.sort_filter_modal.close();
2496                    self.input_mode = InputMode::Normal;
2497                }
2498                KeyCode::Tab => self.sort_filter_modal.next_focus(),
2499                KeyCode::BackTab => self.sort_filter_modal.prev_focus(),
2500                KeyCode::Left | KeyCode::Char('h') if on_tab_bar => {
2501                    self.sort_filter_modal.switch_tab();
2502                }
2503                KeyCode::Right | KeyCode::Char('l') if on_tab_bar => {
2504                    self.sort_filter_modal.switch_tab();
2505                }
2506                KeyCode::Enter if event.modifiers.contains(KeyModifiers::CONTROL) && sort_tab => {
2507                    let columns = self.sort_filter_modal.sort.get_sorted_columns();
2508                    let column_order = self.sort_filter_modal.sort.get_column_order();
2509                    let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2510                    let ascending = self.sort_filter_modal.sort.ascending;
2511                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2512                    self.sort_filter_modal.close();
2513                    self.input_mode = InputMode::Normal;
2514                    let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2515                    return Some(AppEvent::Sort(columns, ascending));
2516                }
2517                KeyCode::Enter if on_apply => {
2518                    if sort_tab {
2519                        let columns = self.sort_filter_modal.sort.get_sorted_columns();
2520                        let column_order = self.sort_filter_modal.sort.get_column_order();
2521                        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2522                        let ascending = self.sort_filter_modal.sort.ascending;
2523                        self.sort_filter_modal.sort.has_unapplied_changes = false;
2524                        self.sort_filter_modal.close();
2525                        self.input_mode = InputMode::Normal;
2526                        let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2527                        return Some(AppEvent::Sort(columns, ascending));
2528                    } else {
2529                        let statements = self.sort_filter_modal.filter.statements.clone();
2530                        self.sort_filter_modal.close();
2531                        self.input_mode = InputMode::Normal;
2532                        return Some(AppEvent::Filter(statements));
2533                    }
2534                }
2535                KeyCode::Enter if on_cancel => {
2536                    for col in &mut self.sort_filter_modal.sort.columns {
2537                        col.is_to_be_locked = false;
2538                    }
2539                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2540                    self.sort_filter_modal.close();
2541                    self.input_mode = InputMode::Normal;
2542                }
2543                KeyCode::Enter if on_clear => {
2544                    if sort_tab {
2545                        self.sort_filter_modal.sort.clear_selection();
2546                    } else {
2547                        self.sort_filter_modal.filter.statements.clear();
2548                        self.sort_filter_modal.filter.list_state.select(None);
2549                    }
2550                }
2551                KeyCode::Char(' ')
2552                    if on_body
2553                        && sort_tab
2554                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2555                {
2556                    self.sort_filter_modal.sort.toggle_selection();
2557                }
2558                KeyCode::Char(' ')
2559                    if on_body
2560                        && sort_tab
2561                        && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2562                {
2563                    self.sort_filter_modal.sort.ascending = !self.sort_filter_modal.sort.ascending;
2564                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2565                }
2566                KeyCode::Char(' ') if on_apply && sort_tab => {
2567                    let columns = self.sort_filter_modal.sort.get_sorted_columns();
2568                    let column_order = self.sort_filter_modal.sort.get_column_order();
2569                    let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2570                    let ascending = self.sort_filter_modal.sort.ascending;
2571                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2572                    let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2573                    return Some(AppEvent::Sort(columns, ascending));
2574                }
2575                KeyCode::Enter if on_body && filter_tab => {
2576                    match self.sort_filter_modal.filter.focus {
2577                        FilterFocus::Add => {
2578                            self.sort_filter_modal.filter.add_statement();
2579                        }
2580                        FilterFocus::Statements => {
2581                            let m = &mut self.sort_filter_modal.filter;
2582                            if let Some(idx) = m.list_state.selected() {
2583                                if idx < m.statements.len() {
2584                                    m.statements.remove(idx);
2585                                    if m.statements.is_empty() {
2586                                        m.list_state.select(None);
2587                                        m.focus = FilterFocus::Column;
2588                                    } else {
2589                                        m.list_state
2590                                            .select(Some(m.statements.len().saturating_sub(1)));
2591                                    }
2592                                }
2593                            }
2594                        }
2595                        _ => {}
2596                    }
2597                }
2598                KeyCode::Enter if on_body && sort_tab => match self.sort_filter_modal.sort.focus {
2599                    SortFocus::Filter => {
2600                        self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
2601                    }
2602                    SortFocus::ColumnList => {
2603                        self.sort_filter_modal.sort.toggle_selection();
2604                        let columns = self.sort_filter_modal.sort.get_sorted_columns();
2605                        let column_order = self.sort_filter_modal.sort.get_column_order();
2606                        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2607                        let ascending = self.sort_filter_modal.sort.ascending;
2608                        self.sort_filter_modal.sort.has_unapplied_changes = false;
2609                        let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2610                        return Some(AppEvent::Sort(columns, ascending));
2611                    }
2612                    SortFocus::Order => {
2613                        self.sort_filter_modal.sort.ascending =
2614                            !self.sort_filter_modal.sort.ascending;
2615                        self.sort_filter_modal.sort.has_unapplied_changes = true;
2616                    }
2617                    _ => {}
2618                },
2619                KeyCode::Left
2620                | KeyCode::Right
2621                | KeyCode::Char('h')
2622                | KeyCode::Char('l')
2623                | KeyCode::Up
2624                | KeyCode::Down
2625                | KeyCode::Char('j')
2626                | KeyCode::Char('k')
2627                    if on_body
2628                        && sort_tab
2629                        && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2630                {
2631                    let s = &mut self.sort_filter_modal.sort;
2632                    match event.code {
2633                        KeyCode::Left | KeyCode::Char('h') | KeyCode::Up | KeyCode::Char('k') => {
2634                            s.ascending = true;
2635                        }
2636                        KeyCode::Right
2637                        | KeyCode::Char('l')
2638                        | KeyCode::Down
2639                        | KeyCode::Char('j') => {
2640                            s.ascending = false;
2641                        }
2642                        _ => {}
2643                    }
2644                    s.has_unapplied_changes = true;
2645                }
2646                KeyCode::Down
2647                    if on_body
2648                        && filter_tab
2649                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2650                {
2651                    let m = &mut self.sort_filter_modal.filter;
2652                    let i = match m.list_state.selected() {
2653                        Some(i) => {
2654                            if i >= m.statements.len().saturating_sub(1) {
2655                                0
2656                            } else {
2657                                i + 1
2658                            }
2659                        }
2660                        None => 0,
2661                    };
2662                    m.list_state.select(Some(i));
2663                }
2664                KeyCode::Up
2665                    if on_body
2666                        && filter_tab
2667                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2668                {
2669                    let m = &mut self.sort_filter_modal.filter;
2670                    let i = match m.list_state.selected() {
2671                        Some(i) => {
2672                            if i == 0 {
2673                                m.statements.len().saturating_sub(1)
2674                            } else {
2675                                i - 1
2676                            }
2677                        }
2678                        None => 0,
2679                    };
2680                    m.list_state.select(Some(i));
2681                }
2682                KeyCode::Down | KeyCode::Char('j') if on_body && sort_tab => {
2683                    let s = &mut self.sort_filter_modal.sort;
2684                    if s.focus == SortFocus::ColumnList {
2685                        let i = match s.table_state.selected() {
2686                            Some(i) => {
2687                                if i >= s.filtered_columns().len().saturating_sub(1) {
2688                                    0
2689                                } else {
2690                                    i + 1
2691                                }
2692                            }
2693                            None => 0,
2694                        };
2695                        s.table_state.select(Some(i));
2696                    } else {
2697                        let _ = s.next_body_focus();
2698                    }
2699                }
2700                KeyCode::Up | KeyCode::Char('k') if on_body && sort_tab => {
2701                    let s = &mut self.sort_filter_modal.sort;
2702                    if s.focus == SortFocus::ColumnList {
2703                        let i = match s.table_state.selected() {
2704                            Some(i) => {
2705                                if i == 0 {
2706                                    s.filtered_columns().len().saturating_sub(1)
2707                                } else {
2708                                    i - 1
2709                                }
2710                            }
2711                            None => 0,
2712                        };
2713                        s.table_state.select(Some(i));
2714                    } else {
2715                        let _ = s.prev_body_focus();
2716                    }
2717                }
2718                KeyCode::Char(']')
2719                    if on_body
2720                        && sort_tab
2721                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2722                {
2723                    self.sort_filter_modal.sort.move_selection_down();
2724                }
2725                KeyCode::Char('[')
2726                    if on_body
2727                        && sort_tab
2728                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2729                {
2730                    self.sort_filter_modal.sort.move_selection_up();
2731                }
2732                KeyCode::Char('+') | KeyCode::Char('=')
2733                    if on_body
2734                        && sort_tab
2735                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2736                {
2737                    self.sort_filter_modal.sort.move_column_display_up();
2738                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2739                }
2740                KeyCode::Char('-') | KeyCode::Char('_')
2741                    if on_body
2742                        && sort_tab
2743                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2744                {
2745                    self.sort_filter_modal.sort.move_column_display_down();
2746                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2747                }
2748                KeyCode::Char('L')
2749                    if on_body
2750                        && sort_tab
2751                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2752                {
2753                    self.sort_filter_modal.sort.toggle_lock_at_column();
2754                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2755                }
2756                KeyCode::Char('v')
2757                    if on_body
2758                        && sort_tab
2759                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2760                {
2761                    self.sort_filter_modal.sort.toggle_visibility();
2762                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2763                }
2764                KeyCode::Char(c)
2765                    if on_body
2766                        && sort_tab
2767                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList
2768                        && c.is_ascii_digit() =>
2769                {
2770                    if let Some(digit) = c.to_digit(10) {
2771                        self.sort_filter_modal
2772                            .sort
2773                            .jump_selection_to_order(digit as usize);
2774                    }
2775                }
2776                // Handle filter input field in sort tab
2777                // Only handle keys that the text input should process
2778                // Special keys like Tab, Esc, Enter are handled by other patterns above
2779                _ if on_body
2780                    && sort_tab
2781                    && self.sort_filter_modal.sort.focus == SortFocus::Filter
2782                    && !matches!(
2783                        event.code,
2784                        KeyCode::Tab
2785                            | KeyCode::BackTab
2786                            | KeyCode::Esc
2787                            | KeyCode::Enter
2788                            | KeyCode::Up
2789                            | KeyCode::Down
2790                    ) =>
2791                {
2792                    // Pass key events to the filter input
2793                    let _ = self
2794                        .sort_filter_modal
2795                        .sort
2796                        .filter_input
2797                        .handle_key(event, Some(&self.cache));
2798                }
2799                KeyCode::Char(c)
2800                    if on_body
2801                        && filter_tab
2802                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2803                {
2804                    self.sort_filter_modal.filter.new_value.push(c);
2805                }
2806                KeyCode::Backspace
2807                    if on_body
2808                        && filter_tab
2809                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2810                {
2811                    self.sort_filter_modal.filter.new_value.pop();
2812                }
2813                KeyCode::Right | KeyCode::Char('l') if on_body && filter_tab => {
2814                    let m = &mut self.sort_filter_modal.filter;
2815                    match m.focus {
2816                        FilterFocus::Column => {
2817                            m.new_column_idx =
2818                                (m.new_column_idx + 1) % m.available_columns.len().max(1);
2819                        }
2820                        FilterFocus::Operator => {
2821                            m.new_operator_idx =
2822                                (m.new_operator_idx + 1) % FilterOperator::iterator().count();
2823                        }
2824                        FilterFocus::Logical => {
2825                            m.new_logical_idx =
2826                                (m.new_logical_idx + 1) % LogicalOperator::iterator().count();
2827                        }
2828                        _ => {}
2829                    }
2830                }
2831                KeyCode::Left | KeyCode::Char('h') if on_body && filter_tab => {
2832                    let m = &mut self.sort_filter_modal.filter;
2833                    match m.focus {
2834                        FilterFocus::Column => {
2835                            m.new_column_idx = if m.new_column_idx == 0 {
2836                                m.available_columns.len().saturating_sub(1)
2837                            } else {
2838                                m.new_column_idx - 1
2839                            };
2840                        }
2841                        FilterFocus::Operator => {
2842                            m.new_operator_idx = if m.new_operator_idx == 0 {
2843                                FilterOperator::iterator().count() - 1
2844                            } else {
2845                                m.new_operator_idx - 1
2846                            };
2847                        }
2848                        FilterFocus::Logical => {
2849                            m.new_logical_idx = if m.new_logical_idx == 0 {
2850                                LogicalOperator::iterator().count() - 1
2851                            } else {
2852                                m.new_logical_idx - 1
2853                            };
2854                        }
2855                        _ => {}
2856                    }
2857                }
2858                _ => {}
2859            }
2860            return None;
2861        }
2862
2863        if self.input_mode == InputMode::Export {
2864            match event.code {
2865                KeyCode::Esc => {
2866                    self.export_modal.close();
2867                    self.input_mode = InputMode::Normal;
2868                }
2869                KeyCode::Tab => self.export_modal.next_focus(),
2870                KeyCode::BackTab => self.export_modal.prev_focus(),
2871                KeyCode::Up | KeyCode::Char('k') => {
2872                    match self.export_modal.focus {
2873                        ExportFocus::FormatSelector => {
2874                            // Cycle through formats
2875                            let current_idx = ExportFormat::ALL
2876                                .iter()
2877                                .position(|&f| f == self.export_modal.selected_format)
2878                                .unwrap_or(0);
2879                            let prev_idx = if current_idx == 0 {
2880                                ExportFormat::ALL.len() - 1
2881                            } else {
2882                                current_idx - 1
2883                            };
2884                            self.export_modal.selected_format = ExportFormat::ALL[prev_idx];
2885                        }
2886                        ExportFocus::PathInput => {
2887                            // Pass to text input widget (for history navigation)
2888                            self.export_modal.path_input.handle_key(event, None);
2889                        }
2890                        ExportFocus::CsvDelimiter => {
2891                            // Pass to text input widget (for history navigation)
2892                            self.export_modal
2893                                .csv_delimiter_input
2894                                .handle_key(event, None);
2895                        }
2896                        ExportFocus::CsvCompression
2897                        | ExportFocus::JsonCompression
2898                        | ExportFocus::NdjsonCompression => {
2899                            // Left to move to previous compression option
2900                            self.export_modal.cycle_compression_backward();
2901                        }
2902                        _ => {
2903                            self.export_modal.prev_focus();
2904                        }
2905                    }
2906                }
2907                KeyCode::Down | KeyCode::Char('j') => {
2908                    match self.export_modal.focus {
2909                        ExportFocus::FormatSelector => {
2910                            // Cycle through formats
2911                            let current_idx = ExportFormat::ALL
2912                                .iter()
2913                                .position(|&f| f == self.export_modal.selected_format)
2914                                .unwrap_or(0);
2915                            let next_idx = (current_idx + 1) % ExportFormat::ALL.len();
2916                            self.export_modal.selected_format = ExportFormat::ALL[next_idx];
2917                        }
2918                        ExportFocus::PathInput => {
2919                            // Pass to text input widget (for history navigation)
2920                            self.export_modal.path_input.handle_key(event, None);
2921                        }
2922                        ExportFocus::CsvDelimiter => {
2923                            // Pass to text input widget (for history navigation)
2924                            self.export_modal
2925                                .csv_delimiter_input
2926                                .handle_key(event, None);
2927                        }
2928                        ExportFocus::CsvCompression
2929                        | ExportFocus::JsonCompression
2930                        | ExportFocus::NdjsonCompression => {
2931                            // Right to move to next compression option
2932                            self.export_modal.cycle_compression();
2933                        }
2934                        _ => {
2935                            self.export_modal.next_focus();
2936                        }
2937                    }
2938                }
2939                KeyCode::Left | KeyCode::Char('h') => {
2940                    match self.export_modal.focus {
2941                        ExportFocus::PathInput => {
2942                            self.export_modal.path_input.handle_key(event, None);
2943                        }
2944                        ExportFocus::CsvDelimiter => {
2945                            self.export_modal
2946                                .csv_delimiter_input
2947                                .handle_key(event, None);
2948                        }
2949                        ExportFocus::FormatSelector => {
2950                            // Don't change focus in format selector
2951                        }
2952                        ExportFocus::CsvCompression
2953                        | ExportFocus::JsonCompression
2954                        | ExportFocus::NdjsonCompression => {
2955                            // Move to previous compression option
2956                            self.export_modal.cycle_compression_backward();
2957                        }
2958                        _ => self.export_modal.prev_focus(),
2959                    }
2960                }
2961                KeyCode::Right | KeyCode::Char('l') => {
2962                    match self.export_modal.focus {
2963                        ExportFocus::PathInput => {
2964                            self.export_modal.path_input.handle_key(event, None);
2965                        }
2966                        ExportFocus::CsvDelimiter => {
2967                            self.export_modal
2968                                .csv_delimiter_input
2969                                .handle_key(event, None);
2970                        }
2971                        ExportFocus::FormatSelector => {
2972                            // Don't change focus in format selector
2973                        }
2974                        ExportFocus::CsvCompression
2975                        | ExportFocus::JsonCompression
2976                        | ExportFocus::NdjsonCompression => {
2977                            // Move to next compression option
2978                            self.export_modal.cycle_compression();
2979                        }
2980                        _ => self.export_modal.next_focus(),
2981                    }
2982                }
2983                KeyCode::Enter => {
2984                    match self.export_modal.focus {
2985                        ExportFocus::PathInput => {
2986                            // Enter from path input triggers export (same as Export button)
2987                            let path_str = self.export_modal.path_input.value.trim();
2988                            if !path_str.is_empty() {
2989                                let mut path = PathBuf::from(path_str);
2990                                let format = self.export_modal.selected_format;
2991                                // Get compression format for this export format
2992                                let compression = match format {
2993                                    ExportFormat::Csv => self.export_modal.csv_compression,
2994                                    ExportFormat::Json => self.export_modal.json_compression,
2995                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
2996                                    ExportFormat::Parquet
2997                                    | ExportFormat::Ipc
2998                                    | ExportFormat::Avro => None,
2999                                };
3000                                // Ensure file extension is present (including compression extension if needed)
3001                                let path_with_ext =
3002                                    Self::ensure_file_extension(&path, format, compression);
3003                                // Update the path input to show the extension
3004                                if path_with_ext != path {
3005                                    self.export_modal
3006                                        .path_input
3007                                        .set_value(path_with_ext.display().to_string());
3008                                }
3009                                path = path_with_ext;
3010                                let delimiter =
3011                                    self.export_modal
3012                                        .csv_delimiter_input
3013                                        .value
3014                                        .chars()
3015                                        .next()
3016                                        .unwrap_or(',') as u8;
3017                                let options = ExportOptions {
3018                                    csv_delimiter: delimiter,
3019                                    csv_include_header: self.export_modal.csv_include_header,
3020                                    csv_compression: self.export_modal.csv_compression,
3021                                    json_compression: self.export_modal.json_compression,
3022                                    ndjson_compression: self.export_modal.ndjson_compression,
3023                                    parquet_compression: None,
3024                                };
3025                                // Check if file exists and show confirmation
3026                                if path.exists() {
3027                                    let path_display = path.display().to_string();
3028                                    self.pending_export = Some((path, format, options));
3029                                    self.confirmation_modal.show(format!(
3030                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3031                                        path_display
3032                                    ));
3033                                    self.export_modal.close();
3034                                    self.input_mode = InputMode::Normal;
3035                                } else {
3036                                    // Start export with progress
3037                                    self.export_modal.close();
3038                                    self.input_mode = InputMode::Normal;
3039                                    return Some(AppEvent::Export(path, format, options));
3040                                }
3041                            }
3042                        }
3043                        ExportFocus::ExportButton => {
3044                            if !self.export_modal.path_input.value.is_empty() {
3045                                let mut path = PathBuf::from(&self.export_modal.path_input.value);
3046                                let format = self.export_modal.selected_format;
3047                                // Get compression format for this export format
3048                                let compression = match format {
3049                                    ExportFormat::Csv => self.export_modal.csv_compression,
3050                                    ExportFormat::Json => self.export_modal.json_compression,
3051                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
3052                                    ExportFormat::Parquet
3053                                    | ExportFormat::Ipc
3054                                    | ExportFormat::Avro => None,
3055                                };
3056                                // Ensure file extension is present (including compression extension if needed)
3057                                let path_with_ext =
3058                                    Self::ensure_file_extension(&path, format, compression);
3059                                // Update the path input to show the extension
3060                                if path_with_ext != path {
3061                                    self.export_modal
3062                                        .path_input
3063                                        .set_value(path_with_ext.display().to_string());
3064                                }
3065                                path = path_with_ext;
3066                                let delimiter =
3067                                    self.export_modal
3068                                        .csv_delimiter_input
3069                                        .value
3070                                        .chars()
3071                                        .next()
3072                                        .unwrap_or(',') as u8;
3073                                let options = ExportOptions {
3074                                    csv_delimiter: delimiter,
3075                                    csv_include_header: self.export_modal.csv_include_header,
3076                                    csv_compression: self.export_modal.csv_compression,
3077                                    json_compression: self.export_modal.json_compression,
3078                                    ndjson_compression: self.export_modal.ndjson_compression,
3079                                    parquet_compression: None,
3080                                };
3081                                // Check if file exists and show confirmation
3082                                if path.exists() {
3083                                    let path_display = path.display().to_string();
3084                                    self.pending_export = Some((path, format, options));
3085                                    self.confirmation_modal.show(format!(
3086                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3087                                        path_display
3088                                    ));
3089                                    self.export_modal.close();
3090                                    self.input_mode = InputMode::Normal;
3091                                } else {
3092                                    // Start export with progress
3093                                    self.export_modal.close();
3094                                    self.input_mode = InputMode::Normal;
3095                                    return Some(AppEvent::Export(path, format, options));
3096                                }
3097                            }
3098                        }
3099                        ExportFocus::CancelButton => {
3100                            self.export_modal.close();
3101                            self.input_mode = InputMode::Normal;
3102                        }
3103                        ExportFocus::CsvIncludeHeader => {
3104                            self.export_modal.csv_include_header =
3105                                !self.export_modal.csv_include_header;
3106                        }
3107                        ExportFocus::CsvCompression
3108                        | ExportFocus::JsonCompression
3109                        | ExportFocus::NdjsonCompression => {
3110                            // Enter to select current compression option
3111                            // (Already selected via Left/Right navigation)
3112                        }
3113                        _ => {}
3114                    }
3115                }
3116                KeyCode::Char(' ') => {
3117                    // Space to toggle checkboxes, but pass to text inputs if they're focused
3118                    match self.export_modal.focus {
3119                        ExportFocus::PathInput => {
3120                            // Pass spacebar to text input
3121                            self.export_modal.path_input.handle_key(event, None);
3122                        }
3123                        ExportFocus::CsvDelimiter => {
3124                            // Pass spacebar to text input
3125                            self.export_modal
3126                                .csv_delimiter_input
3127                                .handle_key(event, None);
3128                        }
3129                        ExportFocus::CsvIncludeHeader => {
3130                            // Toggle checkbox
3131                            self.export_modal.csv_include_header =
3132                                !self.export_modal.csv_include_header;
3133                        }
3134                        _ => {}
3135                    }
3136                }
3137                KeyCode::Char(_)
3138                | KeyCode::Backspace
3139                | KeyCode::Delete
3140                | KeyCode::Home
3141                | KeyCode::End => {
3142                    match self.export_modal.focus {
3143                        ExportFocus::PathInput => {
3144                            self.export_modal.path_input.handle_key(event, None);
3145                        }
3146                        ExportFocus::CsvDelimiter => {
3147                            self.export_modal
3148                                .csv_delimiter_input
3149                                .handle_key(event, None);
3150                        }
3151                        ExportFocus::FormatSelector => {
3152                            // Don't input text in format selector
3153                        }
3154                        _ => {}
3155                    }
3156                }
3157                _ => {}
3158            }
3159            return None;
3160        }
3161
3162        if self.input_mode == InputMode::PivotMelt {
3163            let pivot_melt_text_focus = matches!(
3164                self.pivot_melt_modal.focus,
3165                PivotMeltFocus::PivotFilter
3166                    | PivotMeltFocus::MeltFilter
3167                    | PivotMeltFocus::MeltPattern
3168                    | PivotMeltFocus::MeltVarName
3169                    | PivotMeltFocus::MeltValName
3170            );
3171            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
3172            if event.code == KeyCode::Char('?') && (ctrl_help || !pivot_melt_text_focus) {
3173                self.show_help = true;
3174                return None;
3175            }
3176            match event.code {
3177                KeyCode::Esc => {
3178                    self.pivot_melt_modal.close();
3179                    self.input_mode = InputMode::Normal;
3180                }
3181                KeyCode::Tab => self.pivot_melt_modal.next_focus(),
3182                KeyCode::BackTab => self.pivot_melt_modal.prev_focus(),
3183                KeyCode::Left => {
3184                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotAggregation {
3185                        self.pivot_melt_modal.pivot_move_aggregation_step(4, 0, -1);
3186                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3187                        self.pivot_melt_modal
3188                            .pivot_filter_input
3189                            .handle_key(event, None);
3190                        self.pivot_melt_modal.pivot_index_table.select(None);
3191                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3192                        self.pivot_melt_modal
3193                            .melt_filter_input
3194                            .handle_key(event, None);
3195                        self.pivot_melt_modal.melt_index_table.select(None);
3196                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern
3197                        && self.pivot_melt_modal.melt_pattern_cursor > 0
3198                    {
3199                        self.pivot_melt_modal.melt_pattern_cursor -= 1;
3200                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName
3201                        && self.pivot_melt_modal.melt_variable_cursor > 0
3202                    {
3203                        self.pivot_melt_modal.melt_variable_cursor -= 1;
3204                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName
3205                        && self.pivot_melt_modal.melt_value_cursor > 0
3206                    {
3207                        self.pivot_melt_modal.melt_value_cursor -= 1;
3208                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3209                        self.pivot_melt_modal.switch_tab();
3210                    } else {
3211                        self.pivot_melt_modal.prev_focus();
3212                    }
3213                }
3214                KeyCode::Right => {
3215                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotAggregation {
3216                        self.pivot_melt_modal.pivot_move_aggregation_step(4, 0, 1);
3217                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3218                        self.pivot_melt_modal
3219                            .pivot_filter_input
3220                            .handle_key(event, None);
3221                        self.pivot_melt_modal.pivot_index_table.select(None);
3222                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3223                        self.pivot_melt_modal
3224                            .melt_filter_input
3225                            .handle_key(event, None);
3226                        self.pivot_melt_modal.melt_index_table.select(None);
3227                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern {
3228                        let n = self.pivot_melt_modal.melt_pattern.chars().count();
3229                        if self.pivot_melt_modal.melt_pattern_cursor < n {
3230                            self.pivot_melt_modal.melt_pattern_cursor += 1;
3231                        }
3232                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName {
3233                        let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3234                        if self.pivot_melt_modal.melt_variable_cursor < n {
3235                            self.pivot_melt_modal.melt_variable_cursor += 1;
3236                        }
3237                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName {
3238                        let n = self.pivot_melt_modal.melt_value_name.chars().count();
3239                        if self.pivot_melt_modal.melt_value_cursor < n {
3240                            self.pivot_melt_modal.melt_value_cursor += 1;
3241                        }
3242                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3243                        self.pivot_melt_modal.switch_tab();
3244                    } else {
3245                        self.pivot_melt_modal.next_focus();
3246                    }
3247                }
3248                KeyCode::Enter => match self.pivot_melt_modal.focus {
3249                    PivotMeltFocus::Apply => {
3250                        return match self.pivot_melt_modal.active_tab {
3251                            PivotMeltTab::Pivot => {
3252                                if let Some(err) = self.pivot_melt_modal.pivot_validation_error() {
3253                                    self.error_modal.show(err);
3254                                    None
3255                                } else {
3256                                    self.pivot_melt_modal
3257                                        .build_pivot_spec()
3258                                        .map(AppEvent::Pivot)
3259                                }
3260                            }
3261                            PivotMeltTab::Melt => {
3262                                if let Some(err) = self.pivot_melt_modal.melt_validation_error() {
3263                                    self.error_modal.show(err);
3264                                    None
3265                                } else {
3266                                    self.pivot_melt_modal.build_melt_spec().map(AppEvent::Melt)
3267                                }
3268                            }
3269                        };
3270                    }
3271                    PivotMeltFocus::Cancel => {
3272                        self.pivot_melt_modal.close();
3273                        self.input_mode = InputMode::Normal;
3274                    }
3275                    PivotMeltFocus::Clear => {
3276                        self.pivot_melt_modal.reset_form();
3277                    }
3278                    _ => {}
3279                },
3280                KeyCode::Up | KeyCode::Char('k') if !pivot_melt_text_focus => {
3281                    match self.pivot_melt_modal.focus {
3282                        PivotMeltFocus::PivotIndexList => {
3283                            self.pivot_melt_modal.pivot_move_index_selection(false);
3284                        }
3285                        PivotMeltFocus::PivotPivotCol => {
3286                            self.pivot_melt_modal.pivot_move_pivot_selection(false);
3287                        }
3288                        PivotMeltFocus::PivotValueCol => {
3289                            self.pivot_melt_modal.pivot_move_value_selection(false);
3290                        }
3291                        PivotMeltFocus::PivotAggregation => {
3292                            self.pivot_melt_modal.pivot_move_aggregation_step(4, -1, 0);
3293                        }
3294                        PivotMeltFocus::MeltIndexList => {
3295                            self.pivot_melt_modal.melt_move_index_selection(false);
3296                        }
3297                        PivotMeltFocus::MeltStrategy => {
3298                            self.pivot_melt_modal.melt_move_strategy(false);
3299                        }
3300                        PivotMeltFocus::MeltType => {
3301                            self.pivot_melt_modal.melt_move_type_filter(false);
3302                        }
3303                        PivotMeltFocus::MeltExplicitList => {
3304                            self.pivot_melt_modal.melt_move_explicit_selection(false);
3305                        }
3306                        _ => {}
3307                    }
3308                }
3309                KeyCode::Down | KeyCode::Char('j') if !pivot_melt_text_focus => {
3310                    match self.pivot_melt_modal.focus {
3311                        PivotMeltFocus::PivotIndexList => {
3312                            self.pivot_melt_modal.pivot_move_index_selection(true);
3313                        }
3314                        PivotMeltFocus::PivotPivotCol => {
3315                            self.pivot_melt_modal.pivot_move_pivot_selection(true);
3316                        }
3317                        PivotMeltFocus::PivotValueCol => {
3318                            self.pivot_melt_modal.pivot_move_value_selection(true);
3319                        }
3320                        PivotMeltFocus::PivotAggregation => {
3321                            self.pivot_melt_modal.pivot_move_aggregation_step(4, 1, 0);
3322                        }
3323                        PivotMeltFocus::MeltIndexList => {
3324                            self.pivot_melt_modal.melt_move_index_selection(true);
3325                        }
3326                        PivotMeltFocus::MeltStrategy => {
3327                            self.pivot_melt_modal.melt_move_strategy(true);
3328                        }
3329                        PivotMeltFocus::MeltType => {
3330                            self.pivot_melt_modal.melt_move_type_filter(true);
3331                        }
3332                        PivotMeltFocus::MeltExplicitList => {
3333                            self.pivot_melt_modal.melt_move_explicit_selection(true);
3334                        }
3335                        _ => {}
3336                    }
3337                }
3338                KeyCode::Char(' ') if !pivot_melt_text_focus => match self.pivot_melt_modal.focus {
3339                    PivotMeltFocus::PivotIndexList => {
3340                        self.pivot_melt_modal.pivot_toggle_index_at_selection();
3341                    }
3342                    PivotMeltFocus::MeltIndexList => {
3343                        self.pivot_melt_modal.melt_toggle_index_at_selection();
3344                    }
3345                    PivotMeltFocus::MeltExplicitList => {
3346                        self.pivot_melt_modal.melt_toggle_explicit_at_selection();
3347                    }
3348                    _ => {}
3349                },
3350                KeyCode::Home
3351                | KeyCode::End
3352                | KeyCode::Char(_)
3353                | KeyCode::Backspace
3354                | KeyCode::Delete
3355                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter =>
3356                {
3357                    self.pivot_melt_modal
3358                        .pivot_filter_input
3359                        .handle_key(event, None);
3360                    self.pivot_melt_modal.pivot_index_table.select(None);
3361                }
3362                KeyCode::Home
3363                | KeyCode::End
3364                | KeyCode::Char(_)
3365                | KeyCode::Backspace
3366                | KeyCode::Delete
3367                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter =>
3368                {
3369                    self.pivot_melt_modal
3370                        .melt_filter_input
3371                        .handle_key(event, None);
3372                    self.pivot_melt_modal.melt_index_table.select(None);
3373                }
3374                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3375                    self.pivot_melt_modal.melt_pattern_cursor = 0;
3376                }
3377                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3378                    self.pivot_melt_modal.melt_pattern_cursor =
3379                        self.pivot_melt_modal.melt_pattern.chars().count();
3380                }
3381                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3382                    let byte_pos: usize = self
3383                        .pivot_melt_modal
3384                        .melt_pattern
3385                        .chars()
3386                        .take(self.pivot_melt_modal.melt_pattern_cursor)
3387                        .map(|ch| ch.len_utf8())
3388                        .sum();
3389                    self.pivot_melt_modal.melt_pattern.insert(byte_pos, c);
3390                    self.pivot_melt_modal.melt_pattern_cursor += 1;
3391                }
3392                KeyCode::Backspace
3393                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern =>
3394                {
3395                    if self.pivot_melt_modal.melt_pattern_cursor > 0 {
3396                        let prev_byte: usize = self
3397                            .pivot_melt_modal
3398                            .melt_pattern
3399                            .chars()
3400                            .take(self.pivot_melt_modal.melt_pattern_cursor - 1)
3401                            .map(|ch| ch.len_utf8())
3402                            .sum();
3403                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[prev_byte..]
3404                            .chars()
3405                            .next()
3406                        {
3407                            self.pivot_melt_modal
3408                                .melt_pattern
3409                                .drain(prev_byte..prev_byte + ch.len_utf8());
3410                            self.pivot_melt_modal.melt_pattern_cursor -= 1;
3411                        }
3412                    }
3413                }
3414                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3415                    let n = self.pivot_melt_modal.melt_pattern.chars().count();
3416                    if self.pivot_melt_modal.melt_pattern_cursor < n {
3417                        let byte_pos: usize = self
3418                            .pivot_melt_modal
3419                            .melt_pattern
3420                            .chars()
3421                            .take(self.pivot_melt_modal.melt_pattern_cursor)
3422                            .map(|ch| ch.len_utf8())
3423                            .sum();
3424                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[byte_pos..]
3425                            .chars()
3426                            .next()
3427                        {
3428                            self.pivot_melt_modal
3429                                .melt_pattern
3430                                .drain(byte_pos..byte_pos + ch.len_utf8());
3431                        }
3432                    }
3433                }
3434                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3435                    self.pivot_melt_modal.melt_variable_cursor = 0;
3436                }
3437                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3438                    self.pivot_melt_modal.melt_variable_cursor =
3439                        self.pivot_melt_modal.melt_variable_name.chars().count();
3440                }
3441                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3442                    let byte_pos: usize = self
3443                        .pivot_melt_modal
3444                        .melt_variable_name
3445                        .chars()
3446                        .take(self.pivot_melt_modal.melt_variable_cursor)
3447                        .map(|ch| ch.len_utf8())
3448                        .sum();
3449                    self.pivot_melt_modal.melt_variable_name.insert(byte_pos, c);
3450                    self.pivot_melt_modal.melt_variable_cursor += 1;
3451                }
3452                KeyCode::Backspace
3453                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName =>
3454                {
3455                    if self.pivot_melt_modal.melt_variable_cursor > 0 {
3456                        let prev_byte: usize = self
3457                            .pivot_melt_modal
3458                            .melt_variable_name
3459                            .chars()
3460                            .take(self.pivot_melt_modal.melt_variable_cursor - 1)
3461                            .map(|ch| ch.len_utf8())
3462                            .sum();
3463                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[prev_byte..]
3464                            .chars()
3465                            .next()
3466                        {
3467                            self.pivot_melt_modal
3468                                .melt_variable_name
3469                                .drain(prev_byte..prev_byte + ch.len_utf8());
3470                            self.pivot_melt_modal.melt_variable_cursor -= 1;
3471                        }
3472                    }
3473                }
3474                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3475                    let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3476                    if self.pivot_melt_modal.melt_variable_cursor < n {
3477                        let byte_pos: usize = self
3478                            .pivot_melt_modal
3479                            .melt_variable_name
3480                            .chars()
3481                            .take(self.pivot_melt_modal.melt_variable_cursor)
3482                            .map(|ch| ch.len_utf8())
3483                            .sum();
3484                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[byte_pos..]
3485                            .chars()
3486                            .next()
3487                        {
3488                            self.pivot_melt_modal
3489                                .melt_variable_name
3490                                .drain(byte_pos..byte_pos + ch.len_utf8());
3491                        }
3492                    }
3493                }
3494                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3495                    self.pivot_melt_modal.melt_value_cursor = 0;
3496                }
3497                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3498                    self.pivot_melt_modal.melt_value_cursor =
3499                        self.pivot_melt_modal.melt_value_name.chars().count();
3500                }
3501                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3502                    let byte_pos: usize = self
3503                        .pivot_melt_modal
3504                        .melt_value_name
3505                        .chars()
3506                        .take(self.pivot_melt_modal.melt_value_cursor)
3507                        .map(|ch| ch.len_utf8())
3508                        .sum();
3509                    self.pivot_melt_modal.melt_value_name.insert(byte_pos, c);
3510                    self.pivot_melt_modal.melt_value_cursor += 1;
3511                }
3512                KeyCode::Backspace
3513                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName =>
3514                {
3515                    if self.pivot_melt_modal.melt_value_cursor > 0 {
3516                        let prev_byte: usize = self
3517                            .pivot_melt_modal
3518                            .melt_value_name
3519                            .chars()
3520                            .take(self.pivot_melt_modal.melt_value_cursor - 1)
3521                            .map(|ch| ch.len_utf8())
3522                            .sum();
3523                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[prev_byte..]
3524                            .chars()
3525                            .next()
3526                        {
3527                            self.pivot_melt_modal
3528                                .melt_value_name
3529                                .drain(prev_byte..prev_byte + ch.len_utf8());
3530                            self.pivot_melt_modal.melt_value_cursor -= 1;
3531                        }
3532                    }
3533                }
3534                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3535                    let n = self.pivot_melt_modal.melt_value_name.chars().count();
3536                    if self.pivot_melt_modal.melt_value_cursor < n {
3537                        let byte_pos: usize = self
3538                            .pivot_melt_modal
3539                            .melt_value_name
3540                            .chars()
3541                            .take(self.pivot_melt_modal.melt_value_cursor)
3542                            .map(|ch| ch.len_utf8())
3543                            .sum();
3544                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[byte_pos..]
3545                            .chars()
3546                            .next()
3547                        {
3548                            self.pivot_melt_modal
3549                                .melt_value_name
3550                                .drain(byte_pos..byte_pos + ch.len_utf8());
3551                        }
3552                    }
3553                }
3554                _ => {}
3555            }
3556            return None;
3557        }
3558
3559        if self.input_mode == InputMode::Info {
3560            let on_tab_bar = self.info_modal.focus == InfoFocus::TabBar;
3561            let on_body = self.info_modal.focus == InfoFocus::Body;
3562            let schema_tab = self.info_modal.active_tab == InfoTab::Schema;
3563            let total_rows = self
3564                .data_table_state
3565                .as_ref()
3566                .map(|s| s.schema.len())
3567                .unwrap_or(0);
3568            let visible = self.info_modal.schema_visible_height;
3569
3570            match event.code {
3571                KeyCode::Esc | KeyCode::Char('i') if event.is_press() => {
3572                    self.info_modal.close();
3573                    self.input_mode = InputMode::Normal;
3574                }
3575                KeyCode::Tab if event.is_press() => {
3576                    if schema_tab {
3577                        self.info_modal.next_focus();
3578                    }
3579                }
3580                KeyCode::BackTab if event.is_press() => {
3581                    if schema_tab {
3582                        self.info_modal.prev_focus();
3583                    }
3584                }
3585                KeyCode::Left | KeyCode::Char('h') if event.is_press() && on_tab_bar => {
3586                    let has_partitions = self
3587                        .data_table_state
3588                        .as_ref()
3589                        .and_then(|s| s.partition_columns.as_ref())
3590                        .map(|v| !v.is_empty())
3591                        .unwrap_or(false);
3592                    self.info_modal.switch_tab_prev(has_partitions);
3593                }
3594                KeyCode::Right | KeyCode::Char('l') if event.is_press() && on_tab_bar => {
3595                    let has_partitions = self
3596                        .data_table_state
3597                        .as_ref()
3598                        .and_then(|s| s.partition_columns.as_ref())
3599                        .map(|v| !v.is_empty())
3600                        .unwrap_or(false);
3601                    self.info_modal.switch_tab(has_partitions);
3602                }
3603                KeyCode::Down | KeyCode::Char('j') if event.is_press() && on_body && schema_tab => {
3604                    self.info_modal.schema_table_down(total_rows, visible);
3605                }
3606                KeyCode::Up | KeyCode::Char('k') if event.is_press() && on_body && schema_tab => {
3607                    self.info_modal.schema_table_up(total_rows, visible);
3608                }
3609                _ => {}
3610            }
3611            return None;
3612        }
3613
3614        if self.input_mode == InputMode::Chart {
3615            // Chart export modal (sub-dialog within Chart mode)
3616            if self.chart_export_modal.active {
3617                match event.code {
3618                    KeyCode::Esc if event.is_press() => {
3619                        self.chart_export_modal.close();
3620                    }
3621                    KeyCode::Tab if event.is_press() => {
3622                        self.chart_export_modal.next_focus();
3623                    }
3624                    KeyCode::BackTab if event.is_press() => {
3625                        self.chart_export_modal.prev_focus();
3626                    }
3627                    KeyCode::Up | KeyCode::Char('k')
3628                        if event.is_press()
3629                            && self.chart_export_modal.focus
3630                                == ChartExportFocus::FormatSelector =>
3631                    {
3632                        let idx = ChartExportFormat::ALL
3633                            .iter()
3634                            .position(|&f| f == self.chart_export_modal.selected_format)
3635                            .unwrap_or(0);
3636                        let prev = if idx == 0 {
3637                            ChartExportFormat::ALL.len() - 1
3638                        } else {
3639                            idx - 1
3640                        };
3641                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[prev];
3642                    }
3643                    KeyCode::Down | KeyCode::Char('j')
3644                        if event.is_press()
3645                            && self.chart_export_modal.focus
3646                                == ChartExportFocus::FormatSelector =>
3647                    {
3648                        let idx = ChartExportFormat::ALL
3649                            .iter()
3650                            .position(|&f| f == self.chart_export_modal.selected_format)
3651                            .unwrap_or(0);
3652                        let next = (idx + 1) % ChartExportFormat::ALL.len();
3653                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[next];
3654                    }
3655                    KeyCode::Left | KeyCode::Char('h')
3656                        if event.is_press()
3657                            && self.chart_export_modal.focus
3658                                == ChartExportFocus::FormatSelector =>
3659                    {
3660                        let idx = ChartExportFormat::ALL
3661                            .iter()
3662                            .position(|&f| f == self.chart_export_modal.selected_format)
3663                            .unwrap_or(0);
3664                        let prev = if idx == 0 {
3665                            ChartExportFormat::ALL.len() - 1
3666                        } else {
3667                            idx - 1
3668                        };
3669                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[prev];
3670                    }
3671                    KeyCode::Right | KeyCode::Char('l')
3672                        if event.is_press()
3673                            && self.chart_export_modal.focus
3674                                == ChartExportFocus::FormatSelector =>
3675                    {
3676                        let idx = ChartExportFormat::ALL
3677                            .iter()
3678                            .position(|&f| f == self.chart_export_modal.selected_format)
3679                            .unwrap_or(0);
3680                        let next = (idx + 1) % ChartExportFormat::ALL.len();
3681                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[next];
3682                    }
3683                    KeyCode::Enter if event.is_press() => match self.chart_export_modal.focus {
3684                        ChartExportFocus::PathInput | ChartExportFocus::ExportButton => {
3685                            let path_str = self.chart_export_modal.path_input.value.trim();
3686                            if !path_str.is_empty() {
3687                                let title =
3688                                    self.chart_export_modal.title_input.value.trim().to_string();
3689                                let (width, height) = self.chart_export_modal.export_dimensions();
3690                                let mut path = PathBuf::from(path_str);
3691                                let format = self.chart_export_modal.selected_format;
3692                                // Only add default extension when user did not provide one
3693                                if path.extension().is_none() {
3694                                    path.set_extension(format.extension());
3695                                }
3696                                let path_display = path.display().to_string();
3697                                if path.exists() {
3698                                    self.pending_chart_export =
3699                                        Some((path, format, title, width, height));
3700                                    self.chart_export_modal.close();
3701                                    self.confirmation_modal.show(format!(
3702                                            "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3703                                            path_display
3704                                        ));
3705                                } else {
3706                                    self.chart_export_modal.close();
3707                                    return Some(AppEvent::ChartExport(
3708                                        path, format, title, width, height,
3709                                    ));
3710                                }
3711                            }
3712                        }
3713                        ChartExportFocus::CancelButton => {
3714                            self.chart_export_modal.close();
3715                        }
3716                        _ => {}
3717                    },
3718                    _ => {
3719                        if event.is_press() {
3720                            if self.chart_export_modal.focus == ChartExportFocus::TitleInput {
3721                                let _ = self.chart_export_modal.title_input.handle_key(event, None);
3722                            } else if self.chart_export_modal.focus == ChartExportFocus::PathInput {
3723                                let _ = self.chart_export_modal.path_input.handle_key(event, None);
3724                            } else if self.chart_export_modal.focus == ChartExportFocus::WidthInput
3725                            {
3726                                let allow = match event.code {
3727                                    KeyCode::Char(c) if c.is_ascii_digit() => true,
3728                                    KeyCode::Backspace
3729                                    | KeyCode::Delete
3730                                    | KeyCode::Left
3731                                    | KeyCode::Right
3732                                    | KeyCode::Home
3733                                    | KeyCode::End => true,
3734                                    _ => false,
3735                                };
3736                                if allow {
3737                                    let _ =
3738                                        self.chart_export_modal.width_input.handle_key(event, None);
3739                                }
3740                            } else if self.chart_export_modal.focus == ChartExportFocus::HeightInput
3741                            {
3742                                let allow = match event.code {
3743                                    KeyCode::Char(c) if c.is_ascii_digit() => true,
3744                                    KeyCode::Backspace
3745                                    | KeyCode::Delete
3746                                    | KeyCode::Left
3747                                    | KeyCode::Right
3748                                    | KeyCode::Home
3749                                    | KeyCode::End => true,
3750                                    _ => false,
3751                                };
3752                                if allow {
3753                                    let _ = self
3754                                        .chart_export_modal
3755                                        .height_input
3756                                        .handle_key(event, None);
3757                                }
3758                            }
3759                        }
3760                    }
3761                }
3762                return None;
3763            }
3764
3765            match event.code {
3766                KeyCode::Char('e')
3767                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3768                {
3769                    // Open chart export modal when there is something visible to export
3770                    if self.data_table_state.is_some() && self.chart_modal.can_export() {
3771                        self.chart_export_modal
3772                            .open(&self.theme, self.history_limit);
3773                    }
3774                }
3775                // q/Q do nothing in chart view (no exit)
3776                KeyCode::Char('?') if event.is_press() => {
3777                    self.show_help = true;
3778                }
3779                KeyCode::Esc if event.is_press() => {
3780                    self.chart_modal.close();
3781                    self.chart_cache.clear();
3782                    self.input_mode = InputMode::Normal;
3783                }
3784                KeyCode::Tab if event.is_press() => {
3785                    self.chart_modal.next_focus();
3786                }
3787                KeyCode::BackTab if event.is_press() => {
3788                    self.chart_modal.prev_focus();
3789                }
3790                KeyCode::Enter | KeyCode::Char(' ') if event.is_press() => {
3791                    match self.chart_modal.focus {
3792                        ChartFocus::YStartsAtZero => self.chart_modal.toggle_y_starts_at_zero(),
3793                        ChartFocus::LogScale => self.chart_modal.toggle_log_scale(),
3794                        ChartFocus::ShowLegend => self.chart_modal.toggle_show_legend(),
3795                        ChartFocus::XList => self.chart_modal.x_list_toggle(),
3796                        ChartFocus::YList => self.chart_modal.y_list_toggle(),
3797                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3798                        ChartFocus::HistList => self.chart_modal.hist_list_toggle(),
3799                        ChartFocus::BoxList => self.chart_modal.box_list_toggle(),
3800                        ChartFocus::KdeList => self.chart_modal.kde_list_toggle(),
3801                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_toggle(),
3802                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_toggle(),
3803                        _ => {}
3804                    }
3805                }
3806                KeyCode::Char('+') | KeyCode::Char('=')
3807                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3808                {
3809                    match self.chart_modal.focus {
3810                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3811                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3812                        ChartFocus::KdeBandwidth => self
3813                            .chart_modal
3814                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3815                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3816                        _ => {}
3817                    }
3818                }
3819                KeyCode::Char('-')
3820                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3821                {
3822                    match self.chart_modal.focus {
3823                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3824                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3825                        ChartFocus::KdeBandwidth => self
3826                            .chart_modal
3827                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3828                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3829                        _ => {}
3830                    }
3831                }
3832                KeyCode::Left | KeyCode::Char('h')
3833                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3834                {
3835                    match self.chart_modal.focus {
3836                        ChartFocus::TabBar => self.chart_modal.prev_chart_kind(),
3837                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3838                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3839                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3840                        ChartFocus::KdeBandwidth => self
3841                            .chart_modal
3842                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3843                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3844                        _ => {}
3845                    }
3846                }
3847                KeyCode::Right | KeyCode::Char('l')
3848                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3849                {
3850                    match self.chart_modal.focus {
3851                        ChartFocus::TabBar => self.chart_modal.next_chart_kind(),
3852                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3853                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3854                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3855                        ChartFocus::KdeBandwidth => self
3856                            .chart_modal
3857                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3858                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3859                        _ => {}
3860                    }
3861                }
3862                KeyCode::PageUp
3863                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3864                {
3865                    if self.chart_modal.focus == ChartFocus::LimitRows {
3866                        self.chart_modal.adjust_row_limit_page(1);
3867                    }
3868                }
3869                KeyCode::PageDown
3870                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3871                {
3872                    if self.chart_modal.focus == ChartFocus::LimitRows {
3873                        self.chart_modal.adjust_row_limit_page(-1);
3874                    }
3875                }
3876                KeyCode::Up | KeyCode::Char('k')
3877                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3878                {
3879                    match self.chart_modal.focus {
3880                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3881                        ChartFocus::XList => self.chart_modal.x_list_up(),
3882                        ChartFocus::YList => self.chart_modal.y_list_up(),
3883                        ChartFocus::HistList => self.chart_modal.hist_list_up(),
3884                        ChartFocus::BoxList => self.chart_modal.box_list_up(),
3885                        ChartFocus::KdeList => self.chart_modal.kde_list_up(),
3886                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_up(),
3887                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_up(),
3888                        _ => {}
3889                    }
3890                }
3891                KeyCode::Down | KeyCode::Char('j')
3892                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3893                {
3894                    match self.chart_modal.focus {
3895                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3896                        ChartFocus::XList => self.chart_modal.x_list_down(),
3897                        ChartFocus::YList => self.chart_modal.y_list_down(),
3898                        ChartFocus::HistList => self.chart_modal.hist_list_down(),
3899                        ChartFocus::BoxList => self.chart_modal.box_list_down(),
3900                        ChartFocus::KdeList => self.chart_modal.kde_list_down(),
3901                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_down(),
3902                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_down(),
3903                        _ => {}
3904                    }
3905                }
3906                _ => {
3907                    // Pass key to text inputs when focused (including h/j/k/l for typing)
3908                    if event.is_press() {
3909                        if self.chart_modal.focus == ChartFocus::XInput {
3910                            let _ = self.chart_modal.x_input.handle_key(event, None);
3911                        } else if self.chart_modal.focus == ChartFocus::YInput {
3912                            let _ = self.chart_modal.y_input.handle_key(event, None);
3913                        } else if self.chart_modal.focus == ChartFocus::HistInput {
3914                            let _ = self.chart_modal.hist_input.handle_key(event, None);
3915                        } else if self.chart_modal.focus == ChartFocus::BoxInput {
3916                            let _ = self.chart_modal.box_input.handle_key(event, None);
3917                        } else if self.chart_modal.focus == ChartFocus::KdeInput {
3918                            let _ = self.chart_modal.kde_input.handle_key(event, None);
3919                        } else if self.chart_modal.focus == ChartFocus::HeatmapXInput {
3920                            let _ = self.chart_modal.heatmap_x_input.handle_key(event, None);
3921                        } else if self.chart_modal.focus == ChartFocus::HeatmapYInput {
3922                            let _ = self.chart_modal.heatmap_y_input.handle_key(event, None);
3923                        }
3924                    }
3925                }
3926            }
3927            return None;
3928        }
3929
3930        if self.analysis_modal.active {
3931            match event.code {
3932                KeyCode::Esc => {
3933                    if self.analysis_modal.show_help {
3934                        self.analysis_modal.show_help = false;
3935                    } else if self.analysis_modal.view != analysis_modal::AnalysisView::Main {
3936                        // Close detail view
3937                        self.analysis_modal.close_detail();
3938                    } else {
3939                        self.analysis_modal.close();
3940                    }
3941                }
3942                KeyCode::Char('?') => {
3943                    self.analysis_modal.show_help = !self.analysis_modal.show_help;
3944                }
3945                KeyCode::Char('r') => {
3946                    if self.sampling_threshold.is_some() {
3947                        self.analysis_modal.recalculate();
3948                        match self.analysis_modal.selected_tool {
3949                            Some(analysis_modal::AnalysisTool::Describe) => {
3950                                self.analysis_modal.describe_results = None;
3951                                self.analysis_modal.computing = Some(AnalysisProgress {
3952                                    phase: "Describing data".to_string(),
3953                                    current: 0,
3954                                    total: 1,
3955                                });
3956                                self.analysis_computation = Some(AnalysisComputationState {
3957                                    df: None,
3958                                    schema: None,
3959                                    partial_stats: Vec::new(),
3960                                    current: 0,
3961                                    total: 0,
3962                                    total_rows: 0,
3963                                    sample_seed: self.analysis_modal.random_seed,
3964                                    sample_size: None,
3965                                });
3966                                self.busy = true;
3967                                return Some(AppEvent::AnalysisChunk);
3968                            }
3969                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3970                                self.analysis_modal.distribution_results = None;
3971                                self.analysis_modal.computing = Some(AnalysisProgress {
3972                                    phase: "Distribution".to_string(),
3973                                    current: 0,
3974                                    total: 1,
3975                                });
3976                                self.busy = true;
3977                                return Some(AppEvent::AnalysisDistributionCompute);
3978                            }
3979                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3980                                self.analysis_modal.correlation_results = None;
3981                                self.analysis_modal.computing = Some(AnalysisProgress {
3982                                    phase: "Correlation".to_string(),
3983                                    current: 0,
3984                                    total: 1,
3985                                });
3986                                self.busy = true;
3987                                return Some(AppEvent::AnalysisCorrelationCompute);
3988                            }
3989                            None => {}
3990                        }
3991                    }
3992                }
3993                KeyCode::Tab => {
3994                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3995                        // Switch focus between main area and sidebar
3996                        self.analysis_modal.switch_focus();
3997                    } else if self.analysis_modal.view
3998                        == analysis_modal::AnalysisView::DistributionDetail
3999                    {
4000                        // In distribution detail view, only the distribution selector is focusable
4001                        // Tab does nothing - focus stays on the distribution selector
4002                    } else {
4003                        // In other detail views, Tab cycles through sections
4004                        self.analysis_modal.next_detail_section();
4005                    }
4006                }
4007                KeyCode::Enter => {
4008                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4009                        if self.analysis_modal.focus == analysis_modal::AnalysisFocus::Sidebar {
4010                            // Select tool from sidebar
4011                            self.analysis_modal.select_tool();
4012                            // Trigger computation for the selected tool when that tool has no cached results
4013                            match self.analysis_modal.selected_tool {
4014                                Some(analysis_modal::AnalysisTool::Describe)
4015                                    if self.analysis_modal.describe_results.is_none() =>
4016                                {
4017                                    self.analysis_modal.computing = Some(AnalysisProgress {
4018                                        phase: "Describing data".to_string(),
4019                                        current: 0,
4020                                        total: 1,
4021                                    });
4022                                    self.analysis_computation = Some(AnalysisComputationState {
4023                                        df: None,
4024                                        schema: None,
4025                                        partial_stats: Vec::new(),
4026                                        current: 0,
4027                                        total: 0,
4028                                        total_rows: 0,
4029                                        sample_seed: self.analysis_modal.random_seed,
4030                                        sample_size: None,
4031                                    });
4032                                    self.busy = true;
4033                                    return Some(AppEvent::AnalysisChunk);
4034                                }
4035                                Some(analysis_modal::AnalysisTool::DistributionAnalysis)
4036                                    if self.analysis_modal.distribution_results.is_none() =>
4037                                {
4038                                    self.analysis_modal.computing = Some(AnalysisProgress {
4039                                        phase: "Distribution".to_string(),
4040                                        current: 0,
4041                                        total: 1,
4042                                    });
4043                                    self.busy = true;
4044                                    return Some(AppEvent::AnalysisDistributionCompute);
4045                                }
4046                                Some(analysis_modal::AnalysisTool::CorrelationMatrix)
4047                                    if self.analysis_modal.correlation_results.is_none() =>
4048                                {
4049                                    self.analysis_modal.computing = Some(AnalysisProgress {
4050                                        phase: "Correlation".to_string(),
4051                                        current: 0,
4052                                        total: 1,
4053                                    });
4054                                    self.busy = true;
4055                                    return Some(AppEvent::AnalysisCorrelationCompute);
4056                                }
4057                                _ => {}
4058                            }
4059                        } else {
4060                            // Enter in main area opens detail view if applicable
4061                            match self.analysis_modal.selected_tool {
4062                                Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4063                                    self.analysis_modal.open_distribution_detail();
4064                                }
4065                                Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4066                                    self.analysis_modal.open_correlation_detail();
4067                                }
4068                                _ => {}
4069                            }
4070                        }
4071                    }
4072                }
4073                KeyCode::Down | KeyCode::Char('j') => {
4074                    match self.analysis_modal.view {
4075                        analysis_modal::AnalysisView::Main => {
4076                            match self.analysis_modal.focus {
4077                                analysis_modal::AnalysisFocus::Sidebar => {
4078                                    // Navigate sidebar tool list
4079                                    self.analysis_modal.next_tool();
4080                                }
4081                                analysis_modal::AnalysisFocus::Main => {
4082                                    // Navigate in main area based on selected tool
4083                                    match self.analysis_modal.selected_tool {
4084                                        Some(analysis_modal::AnalysisTool::Describe) => {
4085                                            if let Some(state) = &self.data_table_state {
4086                                                let max_rows = state.schema.len();
4087                                                self.analysis_modal.next_row(max_rows);
4088                                            }
4089                                        }
4090                                        Some(
4091                                            analysis_modal::AnalysisTool::DistributionAnalysis,
4092                                        ) => {
4093                                            if let Some(results) =
4094                                                self.analysis_modal.current_results()
4095                                            {
4096                                                let max_rows = results.distribution_analyses.len();
4097                                                self.analysis_modal.next_row(max_rows);
4098                                            }
4099                                        }
4100                                        Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4101                                            if let Some(results) =
4102                                                self.analysis_modal.current_results()
4103                                            {
4104                                                if let Some(corr) = &results.correlation_matrix {
4105                                                    let max_rows = corr.columns.len();
4106                                                    // Calculate visible columns (same logic as horizontal moves)
4107                                                    let row_header_width = 20u16;
4108                                                    let cell_width = 12u16;
4109                                                    let column_spacing = 1u16;
4110                                                    let estimated_width = 80u16;
4111                                                    let available_width = estimated_width
4112                                                        .saturating_sub(row_header_width);
4113                                                    let mut calculated_visible = 0usize;
4114                                                    let mut used = 0u16;
4115                                                    let max_cols = corr.columns.len();
4116                                                    loop {
4117                                                        let needed = if calculated_visible == 0 {
4118                                                            cell_width
4119                                                        } else {
4120                                                            column_spacing + cell_width
4121                                                        };
4122                                                        if used + needed <= available_width
4123                                                            && calculated_visible < max_cols
4124                                                        {
4125                                                            used += needed;
4126                                                            calculated_visible += 1;
4127                                                        } else {
4128                                                            break;
4129                                                        }
4130                                                    }
4131                                                    let visible_cols =
4132                                                        calculated_visible.max(1).min(max_cols);
4133                                                    self.analysis_modal.move_correlation_cell(
4134                                                        (1, 0),
4135                                                        max_rows,
4136                                                        max_rows,
4137                                                        visible_cols,
4138                                                    );
4139                                                }
4140                                            }
4141                                        }
4142                                        None => {}
4143                                    }
4144                                }
4145                                _ => {}
4146                            }
4147                        }
4148                        analysis_modal::AnalysisView::DistributionDetail => {
4149                            if self.analysis_modal.focus
4150                                == analysis_modal::AnalysisFocus::DistributionSelector
4151                            {
4152                                self.analysis_modal.next_distribution();
4153                            }
4154                        }
4155                        _ => {}
4156                    }
4157                }
4158                KeyCode::Char('s') => {
4159                    // Toggle histogram scale (linear/log) in distribution detail view
4160                    if self.analysis_modal.view == analysis_modal::AnalysisView::DistributionDetail
4161                    {
4162                        self.analysis_modal.histogram_scale =
4163                            match self.analysis_modal.histogram_scale {
4164                                analysis_modal::HistogramScale::Linear => {
4165                                    analysis_modal::HistogramScale::Log
4166                                }
4167                                analysis_modal::HistogramScale::Log => {
4168                                    analysis_modal::HistogramScale::Linear
4169                                }
4170                            };
4171                    }
4172                }
4173                KeyCode::Up | KeyCode::Char('k') => {
4174                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4175                        self.analysis_modal.previous_row();
4176                    } else if self.analysis_modal.view
4177                        == analysis_modal::AnalysisView::DistributionDetail
4178                        && self.analysis_modal.focus
4179                            == analysis_modal::AnalysisFocus::DistributionSelector
4180                    {
4181                        self.analysis_modal.previous_distribution();
4182                    }
4183                }
4184                KeyCode::Left | KeyCode::Char('h')
4185                    if !event.modifiers.contains(KeyModifiers::CONTROL) =>
4186                {
4187                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4188                        match self.analysis_modal.focus {
4189                            analysis_modal::AnalysisFocus::Sidebar => {
4190                                // Sidebar navigation handled by Up/Down
4191                            }
4192                            analysis_modal::AnalysisFocus::DistributionSelector => {
4193                                // Distribution selector navigation handled by Up/Down
4194                            }
4195                            analysis_modal::AnalysisFocus::Main => {
4196                                match self.analysis_modal.selected_tool {
4197                                    Some(analysis_modal::AnalysisTool::Describe) => {
4198                                        self.analysis_modal.scroll_left();
4199                                    }
4200                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4201                                        self.analysis_modal.scroll_left();
4202                                    }
4203                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4204                                        if let Some(results) = self.analysis_modal.current_results()
4205                                        {
4206                                            if let Some(corr) = &results.correlation_matrix {
4207                                                let max_cols = corr.columns.len();
4208                                                // Calculate visible columns using same logic as render function
4209                                                // This matches the render_correlation_matrix calculation
4210                                                let row_header_width = 20u16;
4211                                                let cell_width = 12u16;
4212                                                let column_spacing = 1u16;
4213                                                // Use a conservative estimate for available width
4214                                                // In practice, main_area.width would be available, but we don't have access here
4215                                                // Using a reasonable default that works for most terminals
4216                                                let estimated_width = 80u16; // Conservative estimate (most terminals are 80+ wide)
4217                                                let available_width = estimated_width
4218                                                    .saturating_sub(row_header_width);
4219                                                // Match render logic: first column has no spacing, subsequent ones do
4220                                                let mut calculated_visible = 0usize;
4221                                                let mut used = 0u16;
4222                                                loop {
4223                                                    let needed = if calculated_visible == 0 {
4224                                                        cell_width
4225                                                    } else {
4226                                                        column_spacing + cell_width
4227                                                    };
4228                                                    if used + needed <= available_width
4229                                                        && calculated_visible < max_cols
4230                                                    {
4231                                                        used += needed;
4232                                                        calculated_visible += 1;
4233                                                    } else {
4234                                                        break;
4235                                                    }
4236                                                }
4237                                                let visible_cols =
4238                                                    calculated_visible.max(1).min(max_cols);
4239                                                self.analysis_modal.move_correlation_cell(
4240                                                    (0, -1),
4241                                                    max_cols,
4242                                                    max_cols,
4243                                                    visible_cols,
4244                                                );
4245                                            }
4246                                        }
4247                                    }
4248                                    None => {}
4249                                }
4250                            }
4251                        }
4252                    }
4253                }
4254                KeyCode::Right | KeyCode::Char('l') => {
4255                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4256                        match self.analysis_modal.focus {
4257                            analysis_modal::AnalysisFocus::Sidebar => {
4258                                // Sidebar navigation handled by Up/Down
4259                            }
4260                            analysis_modal::AnalysisFocus::DistributionSelector => {
4261                                // Distribution selector navigation handled by Up/Down
4262                            }
4263                            analysis_modal::AnalysisFocus::Main => {
4264                                match self.analysis_modal.selected_tool {
4265                                    Some(analysis_modal::AnalysisTool::Describe) => {
4266                                        // Number of statistics: count, null_count, mean, std, min, 25%, 50%, 75%, max, skewness, kurtosis, distribution
4267                                        let max_stats = 12;
4268                                        // Estimate visible stats based on terminal width (rough estimate)
4269                                        let visible_stats = 8; // Will be calculated more accurately in widget
4270                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4271                                    }
4272                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4273                                        // Number of statistics: Distribution, P-value, Shapiro-Wilk, SW p-value, CV, Outliers, Skewness, Kurtosis
4274                                        let max_stats = 8;
4275                                        // Estimate visible stats based on terminal width (rough estimate)
4276                                        let visible_stats = 6; // Will be calculated more accurately in widget
4277                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4278                                    }
4279                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4280                                        if let Some(results) = self.analysis_modal.current_results()
4281                                        {
4282                                            if let Some(corr) = &results.correlation_matrix {
4283                                                let max_cols = corr.columns.len();
4284                                                // Calculate visible columns using same logic as render function
4285                                                let row_header_width = 20u16;
4286                                                let cell_width = 12u16;
4287                                                let column_spacing = 1u16;
4288                                                let estimated_width = 80u16; // Conservative estimate
4289                                                let available_width = estimated_width
4290                                                    .saturating_sub(row_header_width);
4291                                                let mut calculated_visible = 0usize;
4292                                                let mut used = 0u16;
4293                                                loop {
4294                                                    let needed = if calculated_visible == 0 {
4295                                                        cell_width
4296                                                    } else {
4297                                                        column_spacing + cell_width
4298                                                    };
4299                                                    if used + needed <= available_width
4300                                                        && calculated_visible < max_cols
4301                                                    {
4302                                                        used += needed;
4303                                                        calculated_visible += 1;
4304                                                    } else {
4305                                                        break;
4306                                                    }
4307                                                }
4308                                                let visible_cols =
4309                                                    calculated_visible.max(1).min(max_cols);
4310                                                self.analysis_modal.move_correlation_cell(
4311                                                    (0, 1),
4312                                                    max_cols,
4313                                                    max_cols,
4314                                                    visible_cols,
4315                                                );
4316                                            }
4317                                        }
4318                                    }
4319                                    None => {}
4320                                }
4321                            }
4322                        }
4323                    }
4324                }
4325                KeyCode::PageDown => {
4326                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4327                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4328                    {
4329                        match self.analysis_modal.selected_tool {
4330                            Some(analysis_modal::AnalysisTool::Describe) => {
4331                                if let Some(state) = &self.data_table_state {
4332                                    let max_rows = state.schema.len();
4333                                    let page_size = 10;
4334                                    self.analysis_modal.page_down(max_rows, page_size);
4335                                }
4336                            }
4337                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4338                                if let Some(results) = self.analysis_modal.current_results() {
4339                                    let max_rows = results.distribution_analyses.len();
4340                                    let page_size = 10;
4341                                    self.analysis_modal.page_down(max_rows, page_size);
4342                                }
4343                            }
4344                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4345                                if let Some(results) = self.analysis_modal.current_results() {
4346                                    if let Some(corr) = &results.correlation_matrix {
4347                                        let max_rows = corr.columns.len();
4348                                        let page_size = 10;
4349                                        self.analysis_modal.page_down(max_rows, page_size);
4350                                    }
4351                                }
4352                            }
4353                            None => {}
4354                        }
4355                    }
4356                }
4357                KeyCode::PageUp => {
4358                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4359                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4360                    {
4361                        let page_size = 10;
4362                        self.analysis_modal.page_up(page_size);
4363                    }
4364                }
4365                KeyCode::Home => {
4366                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4367                        match self.analysis_modal.focus {
4368                            analysis_modal::AnalysisFocus::Sidebar => {
4369                                self.analysis_modal.sidebar_state.select(Some(0));
4370                            }
4371                            analysis_modal::AnalysisFocus::DistributionSelector => {
4372                                self.analysis_modal
4373                                    .distribution_selector_state
4374                                    .select(Some(0));
4375                            }
4376                            analysis_modal::AnalysisFocus::Main => {
4377                                match self.analysis_modal.selected_tool {
4378                                    Some(analysis_modal::AnalysisTool::Describe) => {
4379                                        self.analysis_modal.table_state.select(Some(0));
4380                                    }
4381                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4382                                        self.analysis_modal
4383                                            .distribution_table_state
4384                                            .select(Some(0));
4385                                    }
4386                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4387                                        self.analysis_modal.correlation_table_state.select(Some(0));
4388                                        self.analysis_modal.selected_correlation = Some((0, 0));
4389                                    }
4390                                    None => {}
4391                                }
4392                            }
4393                        }
4394                    }
4395                }
4396                KeyCode::End => {
4397                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4398                        match self.analysis_modal.focus {
4399                            analysis_modal::AnalysisFocus::Sidebar => {
4400                                self.analysis_modal.sidebar_state.select(Some(2));
4401                                // Last tool
4402                            }
4403                            analysis_modal::AnalysisFocus::DistributionSelector => {
4404                                self.analysis_modal
4405                                    .distribution_selector_state
4406                                    .select(Some(13)); // Last distribution (Weibull, index 13 of 14 total)
4407                            }
4408                            analysis_modal::AnalysisFocus::Main => {
4409                                match self.analysis_modal.selected_tool {
4410                                    Some(analysis_modal::AnalysisTool::Describe) => {
4411                                        if let Some(state) = &self.data_table_state {
4412                                            let max_rows = state.schema.len();
4413                                            if max_rows > 0 {
4414                                                self.analysis_modal
4415                                                    .table_state
4416                                                    .select(Some(max_rows - 1));
4417                                            }
4418                                        }
4419                                    }
4420                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4421                                        if let Some(results) = self.analysis_modal.current_results()
4422                                        {
4423                                            let max_rows = results.distribution_analyses.len();
4424                                            if max_rows > 0 {
4425                                                self.analysis_modal
4426                                                    .distribution_table_state
4427                                                    .select(Some(max_rows - 1));
4428                                            }
4429                                        }
4430                                    }
4431                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4432                                        if let Some(results) = self.analysis_modal.current_results()
4433                                        {
4434                                            if let Some(corr) = &results.correlation_matrix {
4435                                                let max_rows = corr.columns.len();
4436                                                if max_rows > 0 {
4437                                                    self.analysis_modal
4438                                                        .correlation_table_state
4439                                                        .select(Some(max_rows - 1));
4440                                                    self.analysis_modal.selected_correlation =
4441                                                        Some((max_rows - 1, max_rows - 1));
4442                                                }
4443                                            }
4444                                        }
4445                                    }
4446                                    None => {}
4447                                }
4448                            }
4449                        }
4450                    }
4451                }
4452                _ => {}
4453            }
4454            return None;
4455        }
4456
4457        if self.template_modal.active {
4458            match event.code {
4459                KeyCode::Esc => {
4460                    if self.template_modal.show_score_details {
4461                        // Close score details popup
4462                        self.template_modal.show_score_details = false;
4463                    } else if self.template_modal.delete_confirm {
4464                        // Cancel delete confirmation
4465                        self.template_modal.delete_confirm = false;
4466                    } else if self.template_modal.mode == TemplateModalMode::Create
4467                        || self.template_modal.mode == TemplateModalMode::Edit
4468                    {
4469                        // In create/edit mode, Esc goes back to list mode
4470                        self.template_modal.exit_create_mode();
4471                    } else {
4472                        // In list mode, Esc closes modal
4473                        if self.template_modal.show_help {
4474                            self.template_modal.show_help = false;
4475                        } else {
4476                            self.template_modal.active = false;
4477                            self.template_modal.show_help = false;
4478                            self.template_modal.delete_confirm = false;
4479                        }
4480                    }
4481                }
4482                KeyCode::BackTab if self.template_modal.delete_confirm => {
4483                    self.template_modal.delete_confirm_focus =
4484                        !self.template_modal.delete_confirm_focus;
4485                }
4486                KeyCode::Left
4487                | KeyCode::Right
4488                | KeyCode::Up
4489                | KeyCode::Down
4490                | KeyCode::Char('h')
4491                | KeyCode::Char('l')
4492                | KeyCode::Char('j')
4493                | KeyCode::Char('k')
4494                    if self.template_modal.delete_confirm =>
4495                {
4496                    self.template_modal.delete_confirm_focus =
4497                        !self.template_modal.delete_confirm_focus;
4498                }
4499                KeyCode::Tab if !self.template_modal.delete_confirm => {
4500                    self.template_modal.next_focus();
4501                }
4502                KeyCode::BackTab => {
4503                    self.template_modal.prev_focus();
4504                }
4505                KeyCode::Char('s') if self.template_modal.mode == TemplateModalMode::List => {
4506                    // Switch to create mode from list mode
4507                    self.template_modal
4508                        .enter_create_mode(self.history_limit, &self.theme);
4509                    // Auto-populate fields
4510                    if let Some(ref path) = self.path {
4511                        // Auto-populate name
4512                        self.template_modal.create_name_input.value =
4513                            self.template_manager.generate_next_template_name();
4514                        self.template_modal.create_name_input.cursor =
4515                            self.template_modal.create_name_input.value.chars().count();
4516
4517                        // Auto-populate exact_path (absolute) - canonicalize to ensure absolute path
4518                        let absolute_path = if path.is_absolute() {
4519                            path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4520                        } else {
4521                            // If relative, make it absolute from current dir
4522                            if let Ok(cwd) = std::env::current_dir() {
4523                                let abs = cwd.join(path);
4524                                abs.canonicalize().unwrap_or(abs)
4525                            } else {
4526                                path.to_path_buf()
4527                            }
4528                        };
4529                        self.template_modal.create_exact_path_input.value =
4530                            absolute_path.to_string_lossy().to_string();
4531                        self.template_modal.create_exact_path_input.cursor = self
4532                            .template_modal
4533                            .create_exact_path_input
4534                            .value
4535                            .chars()
4536                            .count();
4537
4538                        // Auto-populate relative_path from current working directory
4539                        if let Ok(cwd) = std::env::current_dir() {
4540                            let abs_path = if path.is_absolute() {
4541                                path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4542                            } else {
4543                                let abs = cwd.join(path);
4544                                abs.canonicalize().unwrap_or(abs)
4545                            };
4546                            if let Ok(canonical_cwd) = cwd.canonicalize() {
4547                                if let Ok(rel_path) = abs_path.strip_prefix(&canonical_cwd) {
4548                                    // Ensure relative path starts with ./ or just the path
4549                                    let rel_str = rel_path.to_string_lossy().to_string();
4550                                    self.template_modal.create_relative_path_input.value =
4551                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4552                                    self.template_modal.create_relative_path_input.cursor = self
4553                                        .template_modal
4554                                        .create_relative_path_input
4555                                        .value
4556                                        .chars()
4557                                        .count();
4558                                } else {
4559                                    // Path is not under CWD, leave empty or use full path
4560                                    self.template_modal.create_relative_path_input.clear();
4561                                }
4562                            } else {
4563                                // Fallback: try without canonicalization
4564                                if let Ok(rel_path) = abs_path.strip_prefix(&cwd) {
4565                                    let rel_str = rel_path.to_string_lossy().to_string();
4566                                    self.template_modal.create_relative_path_input.value =
4567                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4568                                    self.template_modal.create_relative_path_input.cursor = self
4569                                        .template_modal
4570                                        .create_relative_path_input
4571                                        .value
4572                                        .chars()
4573                                        .count();
4574                                } else {
4575                                    self.template_modal.create_relative_path_input.clear();
4576                                }
4577                            }
4578                        } else {
4579                            self.template_modal.create_relative_path_input.clear();
4580                        }
4581
4582                        // Suggest path pattern
4583                        if let Some(parent) = path.parent() {
4584                            if let Some(parent_str) = parent.to_str() {
4585                                if path.file_name().is_some() {
4586                                    if let Some(ext) = path.extension() {
4587                                        self.template_modal.create_path_pattern_input.value =
4588                                            format!("{}/*.{}", parent_str, ext.to_string_lossy());
4589                                        self.template_modal.create_path_pattern_input.cursor = self
4590                                            .template_modal
4591                                            .create_path_pattern_input
4592                                            .value
4593                                            .chars()
4594                                            .count();
4595                                    }
4596                                }
4597                            }
4598                        }
4599
4600                        // Suggest filename pattern
4601                        if let Some(filename) = path.file_name() {
4602                            if let Some(filename_str) = filename.to_str() {
4603                                // Try to create a pattern by replacing numbers/dates with *
4604                                let mut pattern = filename_str.to_string();
4605                                // Simple heuristic: replace sequences of digits with *
4606                                use regex::Regex;
4607                                if let Ok(re) = Regex::new(r"\d+") {
4608                                    pattern = re.replace_all(&pattern, "*").to_string();
4609                                }
4610                                self.template_modal.create_filename_pattern_input.value = pattern;
4611                                self.template_modal.create_filename_pattern_input.cursor = self
4612                                    .template_modal
4613                                    .create_filename_pattern_input
4614                                    .value
4615                                    .chars()
4616                                    .count();
4617                            }
4618                        }
4619                    }
4620
4621                    // Suggest schema match
4622                    if let Some(ref state) = self.data_table_state {
4623                        if !state.schema.is_empty() {
4624                            self.template_modal.create_schema_match_enabled = false;
4625                            // Not auto-enabled, just suggested
4626                        }
4627                    }
4628                }
4629                KeyCode::Char('e') if self.template_modal.mode == TemplateModalMode::List => {
4630                    // Edit selected template
4631                    if let Some(idx) = self.template_modal.table_state.selected() {
4632                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4633                            let template_clone = template.clone();
4634                            self.template_modal.enter_edit_mode(
4635                                &template_clone,
4636                                self.history_limit,
4637                                &self.theme,
4638                            );
4639                        }
4640                    }
4641                }
4642                KeyCode::Char('d')
4643                    if self.template_modal.mode == TemplateModalMode::List
4644                        && !self.template_modal.delete_confirm =>
4645                {
4646                    // Show delete confirmation
4647                    if let Some(_idx) = self.template_modal.table_state.selected() {
4648                        self.template_modal.delete_confirm = true;
4649                        self.template_modal.delete_confirm_focus = false; // Cancel is default
4650                    }
4651                }
4652                KeyCode::Char('?')
4653                    if self.template_modal.mode == TemplateModalMode::List
4654                        && !self.template_modal.delete_confirm =>
4655                {
4656                    // Show score details popup
4657                    self.template_modal.show_score_details = true;
4658                }
4659                KeyCode::Char('D') if self.template_modal.delete_confirm => {
4660                    // Delete with capital D
4661                    if let Some(idx) = self.template_modal.table_state.selected() {
4662                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4663                            if self.template_manager.delete_template(&template.id).is_err() {
4664                                // Delete failed; list will be unchanged
4665                            } else {
4666                                // Reload templates
4667                                if let Some(ref state) = self.data_table_state {
4668                                    if let Some(ref path) = self.path {
4669                                        self.template_modal.templates = self
4670                                            .template_manager
4671                                            .find_relevant_templates(path, &state.schema);
4672                                        if !self.template_modal.templates.is_empty() {
4673                                            let new_idx = idx.min(
4674                                                self.template_modal
4675                                                    .templates
4676                                                    .len()
4677                                                    .saturating_sub(1),
4678                                            );
4679                                            self.template_modal.table_state.select(Some(new_idx));
4680                                        } else {
4681                                            self.template_modal.table_state.select(None);
4682                                        }
4683                                    }
4684                                }
4685                            }
4686                            self.template_modal.delete_confirm = false;
4687                        }
4688                    }
4689                }
4690                KeyCode::Tab if self.template_modal.delete_confirm => {
4691                    // Toggle between Cancel and Delete buttons
4692                    self.template_modal.delete_confirm_focus =
4693                        !self.template_modal.delete_confirm_focus;
4694                }
4695                KeyCode::Enter if self.template_modal.delete_confirm => {
4696                    // Enter cancels by default (Cancel is selected)
4697                    if self.template_modal.delete_confirm_focus {
4698                        // Delete button is selected
4699                        if let Some(idx) = self.template_modal.table_state.selected() {
4700                            if let Some((template, _)) = self.template_modal.templates.get(idx) {
4701                                if self.template_manager.delete_template(&template.id).is_err() {
4702                                    // Delete failed; list will be unchanged
4703                                } else {
4704                                    // Reload templates
4705                                    if let Some(ref state) = self.data_table_state {
4706                                        if let Some(ref path) = self.path {
4707                                            self.template_modal.templates = self
4708                                                .template_manager
4709                                                .find_relevant_templates(path, &state.schema);
4710                                            if !self.template_modal.templates.is_empty() {
4711                                                let new_idx = idx.min(
4712                                                    self.template_modal
4713                                                        .templates
4714                                                        .len()
4715                                                        .saturating_sub(1),
4716                                                );
4717                                                self.template_modal
4718                                                    .table_state
4719                                                    .select(Some(new_idx));
4720                                            } else {
4721                                                self.template_modal.table_state.select(None);
4722                                            }
4723                                        }
4724                                    }
4725                                }
4726                                self.template_modal.delete_confirm = false;
4727                            }
4728                        }
4729                    } else {
4730                        // Cancel button is selected (default)
4731                        self.template_modal.delete_confirm = false;
4732                    }
4733                }
4734                KeyCode::Enter => {
4735                    match self.template_modal.mode {
4736                        TemplateModalMode::List => {
4737                            match self.template_modal.focus {
4738                                TemplateFocus::TemplateList => {
4739                                    // Apply selected template
4740                                    let template_idx = self.template_modal.table_state.selected();
4741                                    if let Some(idx) = template_idx {
4742                                        if let Some((template, _)) =
4743                                            self.template_modal.templates.get(idx)
4744                                        {
4745                                            let template_clone = template.clone();
4746                                            if let Err(e) = self.apply_template(&template_clone) {
4747                                                // Show error modal instead of just printing
4748                                                self.error_modal.show(format!(
4749                                                    "Error applying template: {}",
4750                                                    e
4751                                                ));
4752                                                // Keep template modal open so user can see what failed
4753                                            } else {
4754                                                // Only close template modal on success
4755                                                self.template_modal.active = false;
4756                                            }
4757                                        }
4758                                    }
4759                                }
4760                                TemplateFocus::CreateButton => {
4761                                    // Same as 's' key - enter create mode
4762                                    // (handled by 's' key handler above)
4763                                }
4764                                _ => {}
4765                            }
4766                        }
4767                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4768                            // If in description field, Enter adds a newline instead of moving to next field
4769                            if self.template_modal.create_focus == CreateFocus::Description {
4770                                let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
4771                                self.template_modal
4772                                    .create_description_input
4773                                    .handle_key(&event, None);
4774                                // Auto-scroll to keep cursor visible
4775                                let area_height = 10; // Estimate, will be adjusted in rendering
4776                                self.template_modal
4777                                    .create_description_input
4778                                    .ensure_cursor_visible(area_height, 80);
4779                                return None;
4780                            }
4781                            match self.template_modal.create_focus {
4782                                CreateFocus::SaveButton => {
4783                                    // Validate name
4784                                    self.template_modal.name_error = None;
4785                                    if self
4786                                        .template_modal
4787                                        .create_name_input
4788                                        .value
4789                                        .trim()
4790                                        .is_empty()
4791                                    {
4792                                        self.template_modal.name_error =
4793                                            Some("(required)".to_string());
4794                                        self.template_modal.create_focus = CreateFocus::Name;
4795                                        return None;
4796                                    }
4797
4798                                    // Check for duplicate name (only if creating new, not editing)
4799                                    if self.template_modal.editing_template_id.is_none()
4800                                        && self.template_manager.template_exists(
4801                                            self.template_modal.create_name_input.value.trim(),
4802                                        )
4803                                    {
4804                                        self.template_modal.name_error =
4805                                            Some("(name already exists)".to_string());
4806                                        self.template_modal.create_focus = CreateFocus::Name;
4807                                        return None;
4808                                    }
4809
4810                                    // Create template from current state
4811                                    let match_criteria = template::MatchCriteria {
4812                                        exact_path: if !self
4813                                            .template_modal
4814                                            .create_exact_path_input
4815                                            .value
4816                                            .trim()
4817                                            .is_empty()
4818                                        {
4819                                            Some(std::path::PathBuf::from(
4820                                                self.template_modal
4821                                                    .create_exact_path_input
4822                                                    .value
4823                                                    .trim(),
4824                                            ))
4825                                        } else {
4826                                            None
4827                                        },
4828                                        relative_path: if !self
4829                                            .template_modal
4830                                            .create_relative_path_input
4831                                            .value
4832                                            .trim()
4833                                            .is_empty()
4834                                        {
4835                                            Some(
4836                                                self.template_modal
4837                                                    .create_relative_path_input
4838                                                    .value
4839                                                    .trim()
4840                                                    .to_string(),
4841                                            )
4842                                        } else {
4843                                            None
4844                                        },
4845                                        path_pattern: if !self
4846                                            .template_modal
4847                                            .create_path_pattern_input
4848                                            .value
4849                                            .is_empty()
4850                                        {
4851                                            Some(
4852                                                self.template_modal
4853                                                    .create_path_pattern_input
4854                                                    .value
4855                                                    .clone(),
4856                                            )
4857                                        } else {
4858                                            None
4859                                        },
4860                                        filename_pattern: if !self
4861                                            .template_modal
4862                                            .create_filename_pattern_input
4863                                            .value
4864                                            .is_empty()
4865                                        {
4866                                            Some(
4867                                                self.template_modal
4868                                                    .create_filename_pattern_input
4869                                                    .value
4870                                                    .clone(),
4871                                            )
4872                                        } else {
4873                                            None
4874                                        },
4875                                        schema_columns: if self
4876                                            .template_modal
4877                                            .create_schema_match_enabled
4878                                        {
4879                                            self.data_table_state.as_ref().map(|state| {
4880                                                state
4881                                                    .schema
4882                                                    .iter_names()
4883                                                    .map(|s| s.to_string())
4884                                                    .collect()
4885                                            })
4886                                        } else {
4887                                            None
4888                                        },
4889                                        schema_types: None, // Can be enhanced later
4890                                    };
4891
4892                                    let description = if !self
4893                                        .template_modal
4894                                        .create_description_input
4895                                        .value
4896                                        .is_empty()
4897                                    {
4898                                        Some(
4899                                            self.template_modal
4900                                                .create_description_input
4901                                                .value
4902                                                .clone(),
4903                                        )
4904                                    } else {
4905                                        None
4906                                    };
4907
4908                                    if let Some(ref editing_id) =
4909                                        self.template_modal.editing_template_id
4910                                    {
4911                                        // Update existing template
4912                                        if let Some(mut template) = self
4913                                            .template_manager
4914                                            .get_template_by_id(editing_id)
4915                                            .cloned()
4916                                        {
4917                                            template.name = self
4918                                                .template_modal
4919                                                .create_name_input
4920                                                .value
4921                                                .trim()
4922                                                .to_string();
4923                                            template.description = description;
4924                                            template.match_criteria = match_criteria;
4925                                            // Update settings from current state
4926                                            if let Some(state) = &self.data_table_state {
4927                                                let (query, sql_query, fuzzy_query) =
4928                                                    active_query_settings(
4929                                                        state.get_active_query(),
4930                                                        state.get_active_sql_query(),
4931                                                        state.get_active_fuzzy_query(),
4932                                                    );
4933                                                template.settings = template::TemplateSettings {
4934                                                    query,
4935                                                    sql_query,
4936                                                    fuzzy_query,
4937                                                    filters: state.get_filters().to_vec(),
4938                                                    sort_columns: state.get_sort_columns().to_vec(),
4939                                                    sort_ascending: state.get_sort_ascending(),
4940                                                    column_order: state.get_column_order().to_vec(),
4941                                                    locked_columns_count: state
4942                                                        .locked_columns_count(),
4943                                                    pivot: state.last_pivot_spec().cloned(),
4944                                                    melt: state.last_melt_spec().cloned(),
4945                                                };
4946                                            }
4947
4948                                            match self.template_manager.update_template(&template) {
4949                                                Ok(_) => {
4950                                                    // Reload templates and go back to list mode
4951                                                    if let Some(ref state) = self.data_table_state {
4952                                                        if let Some(ref path) = self.path {
4953                                                            self.template_modal.templates = self
4954                                                                .template_manager
4955                                                                .find_relevant_templates(
4956                                                                    path,
4957                                                                    &state.schema,
4958                                                                );
4959                                                            self.template_modal.table_state.select(
4960                                                                if self
4961                                                                    .template_modal
4962                                                                    .templates
4963                                                                    .is_empty()
4964                                                                {
4965                                                                    None
4966                                                                } else {
4967                                                                    Some(0)
4968                                                                },
4969                                                            );
4970                                                        }
4971                                                    }
4972                                                    self.template_modal.exit_create_mode();
4973                                                }
4974                                                Err(_) => {
4975                                                    // Update failed; stay in edit mode
4976                                                }
4977                                            }
4978                                        }
4979                                    } else {
4980                                        // Create new template
4981                                        match self.create_template_from_current_state(
4982                                            self.template_modal
4983                                                .create_name_input
4984                                                .value
4985                                                .trim()
4986                                                .to_string(),
4987                                            description,
4988                                            match_criteria,
4989                                        ) {
4990                                            Ok(_) => {
4991                                                // Reload templates and go back to list mode
4992                                                if let Some(ref state) = self.data_table_state {
4993                                                    if let Some(ref path) = self.path {
4994                                                        self.template_modal.templates = self
4995                                                            .template_manager
4996                                                            .find_relevant_templates(
4997                                                                path,
4998                                                                &state.schema,
4999                                                            );
5000                                                        self.template_modal.table_state.select(
5001                                                            if self
5002                                                                .template_modal
5003                                                                .templates
5004                                                                .is_empty()
5005                                                            {
5006                                                                None
5007                                                            } else {
5008                                                                Some(0)
5009                                                            },
5010                                                        );
5011                                                    }
5012                                                }
5013                                                self.template_modal.exit_create_mode();
5014                                            }
5015                                            Err(_) => {
5016                                                // Create failed; stay in create mode
5017                                            }
5018                                        }
5019                                    }
5020                                }
5021                                CreateFocus::CancelButton => {
5022                                    self.template_modal.exit_create_mode();
5023                                }
5024                                _ => {
5025                                    // Move to next field
5026                                    self.template_modal.next_focus();
5027                                }
5028                            }
5029                        }
5030                    }
5031                }
5032                KeyCode::Up => {
5033                    match self.template_modal.mode {
5034                        TemplateModalMode::List => {
5035                            if self.template_modal.focus == TemplateFocus::TemplateList {
5036                                let i = match self.template_modal.table_state.selected() {
5037                                    Some(i) => {
5038                                        if i == 0 {
5039                                            self.template_modal.templates.len().saturating_sub(1)
5040                                        } else {
5041                                            i - 1
5042                                        }
5043                                    }
5044                                    None => 0,
5045                                };
5046                                self.template_modal.table_state.select(Some(i));
5047                            }
5048                        }
5049                        TemplateModalMode::Create | TemplateModalMode::Edit => {
5050                            // If in description field, move cursor up one line
5051                            if self.template_modal.create_focus == CreateFocus::Description {
5052                                let event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
5053                                self.template_modal
5054                                    .create_description_input
5055                                    .handle_key(&event, None);
5056                                // Auto-scroll to keep cursor visible
5057                                let area_height = 10; // Estimate, will be adjusted in rendering
5058                                self.template_modal
5059                                    .create_description_input
5060                                    .ensure_cursor_visible(area_height, 80);
5061                            } else {
5062                                // Move to previous field (works for all fields)
5063                                self.template_modal.prev_focus();
5064                            }
5065                        }
5066                    }
5067                }
5068                KeyCode::Down => {
5069                    match self.template_modal.mode {
5070                        TemplateModalMode::List => {
5071                            if self.template_modal.focus == TemplateFocus::TemplateList {
5072                                let i = match self.template_modal.table_state.selected() {
5073                                    Some(i) => {
5074                                        if i >= self
5075                                            .template_modal
5076                                            .templates
5077                                            .len()
5078                                            .saturating_sub(1)
5079                                        {
5080                                            0
5081                                        } else {
5082                                            i + 1
5083                                        }
5084                                    }
5085                                    None => 0,
5086                                };
5087                                self.template_modal.table_state.select(Some(i));
5088                            }
5089                        }
5090                        TemplateModalMode::Create | TemplateModalMode::Edit => {
5091                            // If in description field, move cursor down one line
5092                            if self.template_modal.create_focus == CreateFocus::Description {
5093                                let event = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
5094                                self.template_modal
5095                                    .create_description_input
5096                                    .handle_key(&event, None);
5097                                // Auto-scroll to keep cursor visible
5098                                let area_height = 10; // Estimate, will be adjusted in rendering
5099                                self.template_modal
5100                                    .create_description_input
5101                                    .ensure_cursor_visible(area_height, 80);
5102                            } else {
5103                                // Move to next field (works for all fields)
5104                                self.template_modal.next_focus();
5105                            }
5106                        }
5107                    }
5108                }
5109                KeyCode::Char('j')
5110                    if self.template_modal.mode == TemplateModalMode::List
5111                        && self.template_modal.focus == TemplateFocus::TemplateList
5112                        && !self.template_modal.delete_confirm =>
5113                {
5114                    let i = match self.template_modal.table_state.selected() {
5115                        Some(i) => {
5116                            if i >= self.template_modal.templates.len().saturating_sub(1) {
5117                                0
5118                            } else {
5119                                i + 1
5120                            }
5121                        }
5122                        None => 0,
5123                    };
5124                    self.template_modal.table_state.select(Some(i));
5125                }
5126                KeyCode::Char('k')
5127                    if self.template_modal.mode == TemplateModalMode::List
5128                        && self.template_modal.focus == TemplateFocus::TemplateList
5129                        && !self.template_modal.delete_confirm =>
5130                {
5131                    let i = match self.template_modal.table_state.selected() {
5132                        Some(i) => {
5133                            if i == 0 {
5134                                self.template_modal.templates.len().saturating_sub(1)
5135                            } else {
5136                                i - 1
5137                            }
5138                        }
5139                        None => 0,
5140                    };
5141                    self.template_modal.table_state.select(Some(i));
5142                }
5143                KeyCode::Char(c)
5144                    if self.template_modal.mode == TemplateModalMode::Create
5145                        || self.template_modal.mode == TemplateModalMode::Edit =>
5146                {
5147                    match self.template_modal.create_focus {
5148                        CreateFocus::Name => {
5149                            // Clear error when user starts typing
5150                            self.template_modal.name_error = None;
5151                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5152                            self.template_modal
5153                                .create_name_input
5154                                .handle_key(&event, None);
5155                        }
5156                        CreateFocus::Description => {
5157                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5158                            self.template_modal
5159                                .create_description_input
5160                                .handle_key(&event, None);
5161                            // Auto-scroll to keep cursor visible
5162                            let area_height = 10; // Estimate, will be adjusted in rendering
5163                            self.template_modal
5164                                .create_description_input
5165                                .ensure_cursor_visible(area_height, 80);
5166                        }
5167                        CreateFocus::ExactPath => {
5168                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5169                            self.template_modal
5170                                .create_exact_path_input
5171                                .handle_key(&event, None);
5172                        }
5173                        CreateFocus::RelativePath => {
5174                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5175                            self.template_modal
5176                                .create_relative_path_input
5177                                .handle_key(&event, None);
5178                        }
5179                        CreateFocus::PathPattern => {
5180                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5181                            self.template_modal
5182                                .create_path_pattern_input
5183                                .handle_key(&event, None);
5184                        }
5185                        CreateFocus::FilenamePattern => {
5186                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5187                            self.template_modal
5188                                .create_filename_pattern_input
5189                                .handle_key(&event, None);
5190                        }
5191                        CreateFocus::SchemaMatch => {
5192                            // Space toggles
5193                            if c == ' ' {
5194                                self.template_modal.create_schema_match_enabled =
5195                                    !self.template_modal.create_schema_match_enabled;
5196                            }
5197                        }
5198                        _ => {}
5199                    }
5200                }
5201                KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End
5202                    if self.template_modal.mode == TemplateModalMode::Create
5203                        || self.template_modal.mode == TemplateModalMode::Edit =>
5204                {
5205                    match self.template_modal.create_focus {
5206                        CreateFocus::Name => {
5207                            self.template_modal
5208                                .create_name_input
5209                                .handle_key(event, None);
5210                        }
5211                        CreateFocus::Description => {
5212                            self.template_modal
5213                                .create_description_input
5214                                .handle_key(event, None);
5215                            // Auto-scroll to keep cursor visible
5216                            let area_height = 10;
5217                            self.template_modal
5218                                .create_description_input
5219                                .ensure_cursor_visible(area_height, 80);
5220                        }
5221                        CreateFocus::ExactPath => {
5222                            self.template_modal
5223                                .create_exact_path_input
5224                                .handle_key(event, None);
5225                        }
5226                        CreateFocus::RelativePath => {
5227                            self.template_modal
5228                                .create_relative_path_input
5229                                .handle_key(event, None);
5230                        }
5231                        CreateFocus::PathPattern => {
5232                            self.template_modal
5233                                .create_path_pattern_input
5234                                .handle_key(event, None);
5235                        }
5236                        CreateFocus::FilenamePattern => {
5237                            self.template_modal
5238                                .create_filename_pattern_input
5239                                .handle_key(event, None);
5240                        }
5241                        _ => {}
5242                    }
5243                }
5244                KeyCode::PageUp | KeyCode::PageDown
5245                    if self.template_modal.mode == TemplateModalMode::Create
5246                        || self.template_modal.mode == TemplateModalMode::Edit =>
5247                {
5248                    // PageUp/PageDown for description field - move cursor up/down by 5 lines
5249                    // This is handled manually since MultiLineTextInput doesn't have built-in PageUp/PageDown
5250                    if self.template_modal.create_focus == CreateFocus::Description {
5251                        let lines: Vec<&str> = self
5252                            .template_modal
5253                            .create_description_input
5254                            .value
5255                            .lines()
5256                            .collect();
5257                        let current_line = self.template_modal.create_description_input.cursor_line;
5258                        let current_col = self.template_modal.create_description_input.cursor_col;
5259
5260                        let target_line = if event.code == KeyCode::PageUp {
5261                            current_line.saturating_sub(5)
5262                        } else {
5263                            (current_line + 5).min(lines.len().saturating_sub(1))
5264                        };
5265
5266                        if target_line < lines.len() {
5267                            let target_line_str = lines.get(target_line).unwrap_or(&"");
5268                            let new_col = current_col.min(target_line_str.chars().count());
5269                            self.template_modal.create_description_input.cursor = self
5270                                .template_modal
5271                                .create_description_input
5272                                .line_col_to_cursor(target_line, new_col);
5273                            self.template_modal
5274                                .create_description_input
5275                                .update_line_col_from_cursor();
5276                            // Auto-scroll
5277                            let area_height = 10;
5278                            self.template_modal
5279                                .create_description_input
5280                                .ensure_cursor_visible(area_height, 80);
5281                        }
5282                    }
5283                }
5284                KeyCode::Backspace
5285                | KeyCode::Delete
5286                | KeyCode::Left
5287                | KeyCode::Right
5288                | KeyCode::Home
5289                | KeyCode::End
5290                    if self.template_modal.mode == TemplateModalMode::Create
5291                        || self.template_modal.mode == TemplateModalMode::Edit =>
5292                {
5293                    match self.template_modal.create_focus {
5294                        CreateFocus::Name => {
5295                            self.template_modal
5296                                .create_name_input
5297                                .handle_key(event, None);
5298                        }
5299                        CreateFocus::Description => {
5300                            self.template_modal
5301                                .create_description_input
5302                                .handle_key(event, None);
5303                            // Auto-scroll to keep cursor visible
5304                            let area_height = 10;
5305                            self.template_modal
5306                                .create_description_input
5307                                .ensure_cursor_visible(area_height, 80);
5308                        }
5309                        CreateFocus::ExactPath => {
5310                            self.template_modal
5311                                .create_exact_path_input
5312                                .handle_key(event, None);
5313                        }
5314                        CreateFocus::RelativePath => {
5315                            self.template_modal
5316                                .create_relative_path_input
5317                                .handle_key(event, None);
5318                        }
5319                        CreateFocus::PathPattern => {
5320                            self.template_modal
5321                                .create_path_pattern_input
5322                                .handle_key(event, None);
5323                        }
5324                        CreateFocus::FilenamePattern => {
5325                            self.template_modal
5326                                .create_filename_pattern_input
5327                                .handle_key(event, None);
5328                        }
5329                        _ => {}
5330                    }
5331                }
5332                _ => {}
5333            }
5334            return None;
5335        }
5336
5337        if self.input_mode == InputMode::Editing {
5338            if self.input_type == Some(InputType::Search) {
5339                const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5340                const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5341
5342                if self.query_focus == QueryFocus::TabBar && event.is_press() {
5343                    if event.code == KeyCode::BackTab
5344                        || (event.code == KeyCode::Tab
5345                            && !event.modifiers.contains(KeyModifiers::SHIFT))
5346                    {
5347                        self.query_focus = QueryFocus::Input;
5348                        if let Some(state) = &self.data_table_state {
5349                            if self.query_tab == QueryTab::SqlLike {
5350                                self.query_input.value = state.get_active_query().to_string();
5351                                self.query_input.cursor = self.query_input.value.chars().count();
5352                                self.sql_input.set_focused(false);
5353                                self.fuzzy_input.set_focused(false);
5354                                self.query_input.set_focused(true);
5355                            } else if self.query_tab == QueryTab::Fuzzy {
5356                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5357                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5358                                self.query_input.set_focused(false);
5359                                self.sql_input.set_focused(false);
5360                                self.fuzzy_input.set_focused(true);
5361                            } else if self.query_tab == QueryTab::Sql {
5362                                self.sql_input.value = state.get_active_sql_query().to_string();
5363                                self.sql_input.cursor = self.sql_input.value.chars().count();
5364                                self.query_input.set_focused(false);
5365                                self.fuzzy_input.set_focused(false);
5366                                self.sql_input.set_focused(true);
5367                            }
5368                        }
5369                        return None;
5370                    }
5371                    if RIGHT_KEYS.contains(&event.code) {
5372                        self.query_tab = self.query_tab.next();
5373                        if let Some(state) = &self.data_table_state {
5374                            if self.query_tab == QueryTab::SqlLike {
5375                                self.query_input.value = state.get_active_query().to_string();
5376                                self.query_input.cursor = self.query_input.value.chars().count();
5377                            } else if self.query_tab == QueryTab::Fuzzy {
5378                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5379                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5380                            } else if self.query_tab == QueryTab::Sql {
5381                                self.sql_input.value = state.get_active_sql_query().to_string();
5382                                self.sql_input.cursor = self.sql_input.value.chars().count();
5383                            }
5384                        }
5385                        self.query_input.set_focused(false);
5386                        self.sql_input.set_focused(false);
5387                        self.fuzzy_input.set_focused(false);
5388                        return None;
5389                    }
5390                    if LEFT_KEYS.contains(&event.code) {
5391                        self.query_tab = self.query_tab.prev();
5392                        if let Some(state) = &self.data_table_state {
5393                            if self.query_tab == QueryTab::SqlLike {
5394                                self.query_input.value = state.get_active_query().to_string();
5395                                self.query_input.cursor = self.query_input.value.chars().count();
5396                            } else if self.query_tab == QueryTab::Fuzzy {
5397                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5398                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5399                            } else if self.query_tab == QueryTab::Sql {
5400                                self.sql_input.value = state.get_active_sql_query().to_string();
5401                                self.sql_input.cursor = self.sql_input.value.chars().count();
5402                            }
5403                        }
5404                        self.query_input.set_focused(false);
5405                        self.sql_input.set_focused(false);
5406                        self.fuzzy_input.set_focused(false);
5407                        return None;
5408                    }
5409                    if event.code == KeyCode::Esc {
5410                        self.query_input.clear();
5411                        self.sql_input.clear();
5412                        self.fuzzy_input.clear();
5413                        self.query_input.set_focused(false);
5414                        self.sql_input.set_focused(false);
5415                        self.fuzzy_input.set_focused(false);
5416                        self.input_mode = InputMode::Normal;
5417                        self.input_type = None;
5418                        if let Some(state) = &mut self.data_table_state {
5419                            state.error = None;
5420                            state.suppress_error_display = false;
5421                        }
5422                        return None;
5423                    }
5424                    return None;
5425                }
5426
5427                if event.is_press()
5428                    && event.code == KeyCode::Tab
5429                    && !event.modifiers.contains(KeyModifiers::SHIFT)
5430                {
5431                    self.query_focus = QueryFocus::TabBar;
5432                    self.query_input.set_focused(false);
5433                    self.sql_input.set_focused(false);
5434                    self.fuzzy_input.set_focused(false);
5435                    return None;
5436                }
5437
5438                if self.query_focus != QueryFocus::Input {
5439                    return None;
5440                }
5441
5442                if self.query_tab == QueryTab::Sql {
5443                    self.query_input.set_focused(false);
5444                    self.fuzzy_input.set_focused(false);
5445                    self.sql_input.set_focused(true);
5446                    let result = self.sql_input.handle_key(event, Some(&self.cache));
5447                    match result {
5448                        TextInputEvent::Submit => {
5449                            let _ = self.sql_input.save_to_history(&self.cache);
5450                            let sql = self.sql_input.value.clone();
5451                            self.sql_input.set_focused(false);
5452                            return Some(AppEvent::SqlSearch(sql));
5453                        }
5454                        TextInputEvent::Cancel => {
5455                            self.sql_input.clear();
5456                            self.sql_input.set_focused(false);
5457                            self.input_mode = InputMode::Normal;
5458                            self.input_type = None;
5459                            if let Some(state) = &mut self.data_table_state {
5460                                state.error = None;
5461                                state.suppress_error_display = false;
5462                            }
5463                        }
5464                        TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5465                    }
5466                    return None;
5467                }
5468
5469                if self.query_tab == QueryTab::Fuzzy {
5470                    self.query_input.set_focused(false);
5471                    self.sql_input.set_focused(false);
5472                    self.fuzzy_input.set_focused(true);
5473                    let result = self.fuzzy_input.handle_key(event, Some(&self.cache));
5474                    match result {
5475                        TextInputEvent::Submit => {
5476                            let _ = self.fuzzy_input.save_to_history(&self.cache);
5477                            let query = self.fuzzy_input.value.clone();
5478                            self.fuzzy_input.set_focused(false);
5479                            return Some(AppEvent::FuzzySearch(query));
5480                        }
5481                        TextInputEvent::Cancel => {
5482                            self.fuzzy_input.clear();
5483                            self.fuzzy_input.set_focused(false);
5484                            self.input_mode = InputMode::Normal;
5485                            self.input_type = None;
5486                            if let Some(state) = &mut self.data_table_state {
5487                                state.error = None;
5488                                state.suppress_error_display = false;
5489                            }
5490                        }
5491                        TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5492                    }
5493                    return None;
5494                }
5495
5496                if self.query_tab != QueryTab::SqlLike {
5497                    return None;
5498                }
5499
5500                self.sql_input.set_focused(false);
5501                self.fuzzy_input.set_focused(false);
5502                self.query_input.set_focused(true);
5503                let result = self.query_input.handle_key(event, Some(&self.cache));
5504
5505                match result {
5506                    TextInputEvent::Submit => {
5507                        // Save to history and execute query
5508                        let _ = self.query_input.save_to_history(&self.cache);
5509                        let query = self.query_input.value.clone();
5510                        self.query_input.set_focused(false);
5511                        return Some(AppEvent::Search(query));
5512                    }
5513                    TextInputEvent::Cancel => {
5514                        // Clear and exit input mode
5515                        self.query_input.clear();
5516                        self.query_input.set_focused(false);
5517                        self.input_mode = InputMode::Normal;
5518                        if let Some(state) = &mut self.data_table_state {
5519                            // Clear error and re-enable error display in main view
5520                            state.error = None;
5521                            state.suppress_error_display = false;
5522                        }
5523                    }
5524                    TextInputEvent::HistoryChanged => {
5525                        // History navigation occurred, nothing special needed
5526                    }
5527                    TextInputEvent::None => {
5528                        // Regular input, nothing special needed
5529                    }
5530                }
5531                return None;
5532            }
5533
5534            // Line number input (GoToLine): ":" then type line number, Enter to jump, Esc to cancel
5535            if self.input_type == Some(InputType::GoToLine) {
5536                self.query_input.set_focused(true);
5537                let result = self.query_input.handle_key(event, None);
5538                match result {
5539                    TextInputEvent::Submit => {
5540                        let value = self.query_input.value.trim().to_string();
5541                        self.query_input.clear();
5542                        self.query_input.set_focused(false);
5543                        self.input_mode = InputMode::Normal;
5544                        self.input_type = None;
5545                        if let Some(state) = &mut self.data_table_state {
5546                            if let Ok(display_line) = value.parse::<usize>() {
5547                                let row_index =
5548                                    display_line.saturating_sub(state.row_start_index());
5549                                let would_collect = state.scroll_would_trigger_collect(
5550                                    row_index as i64 - state.start_row as i64,
5551                                );
5552                                if would_collect {
5553                                    self.busy = true;
5554                                    return Some(AppEvent::GoToLine(row_index));
5555                                }
5556                                state.scroll_to_row_centered(row_index);
5557                            }
5558                        }
5559                    }
5560                    TextInputEvent::Cancel => {
5561                        self.query_input.clear();
5562                        self.query_input.set_focused(false);
5563                        self.input_mode = InputMode::Normal;
5564                        self.input_type = None;
5565                    }
5566                    TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5567                }
5568                return None;
5569            }
5570
5571            // For other input types (Filter, etc.), keep old behavior for now
5572            // TODO: Migrate these in later phases
5573            return None;
5574        }
5575
5576        const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5577
5578        const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5579
5580        const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
5581
5582        const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
5583
5584        match event.code {
5585            KeyCode::Char('q') | KeyCode::Char('Q') => Some(AppEvent::Exit),
5586            KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => {
5587                Some(AppEvent::Exit)
5588            }
5589            KeyCode::Char('R') => Some(AppEvent::Reset),
5590            KeyCode::Char('N') => {
5591                if let Some(ref mut state) = self.data_table_state {
5592                    state.toggle_row_numbers();
5593                }
5594                None
5595            }
5596            KeyCode::Esc => {
5597                // First check if we're in drill-down mode
5598                if let Some(ref mut state) = self.data_table_state {
5599                    if state.is_drilled_down() {
5600                        let _ = state.drill_up();
5601                        return None;
5602                    }
5603                }
5604                // Escape no longer exits - use 'q' or Ctrl-C to exit
5605                // (Info modal handles Esc in its own block)
5606                None
5607            }
5608            code if RIGHT_KEYS.contains(&code) => {
5609                if let Some(ref mut state) = self.data_table_state {
5610                    state.scroll_right();
5611                    if self.debug.enabled {
5612                        self.debug.last_action = "scroll_right".to_string();
5613                    }
5614                }
5615                None
5616            }
5617            code if LEFT_KEYS.contains(&code) => {
5618                if let Some(ref mut state) = self.data_table_state {
5619                    state.scroll_left();
5620                    if self.debug.enabled {
5621                        self.debug.last_action = "scroll_left".to_string();
5622                    }
5623                }
5624                None
5625            }
5626            code if event.is_press() && DOWN_KEYS.contains(&code) => {
5627                let would_collect = self
5628                    .data_table_state
5629                    .as_ref()
5630                    .map(|s| s.scroll_would_trigger_collect(1))
5631                    .unwrap_or(false);
5632                if would_collect {
5633                    self.busy = true;
5634                    Some(AppEvent::DoScrollNext)
5635                } else {
5636                    if let Some(ref mut s) = self.data_table_state {
5637                        s.select_next();
5638                    }
5639                    None
5640                }
5641            }
5642            code if event.is_press() && UP_KEYS.contains(&code) => {
5643                let would_collect = self
5644                    .data_table_state
5645                    .as_ref()
5646                    .map(|s| s.scroll_would_trigger_collect(-1))
5647                    .unwrap_or(false);
5648                if would_collect {
5649                    self.busy = true;
5650                    Some(AppEvent::DoScrollPrev)
5651                } else {
5652                    if let Some(ref mut s) = self.data_table_state {
5653                        s.select_previous();
5654                    }
5655                    None
5656                }
5657            }
5658            KeyCode::PageDown if event.is_press() => {
5659                let would_collect = self
5660                    .data_table_state
5661                    .as_ref()
5662                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5663                    .unwrap_or(false);
5664                if would_collect {
5665                    self.busy = true;
5666                    Some(AppEvent::DoScrollDown)
5667                } else {
5668                    if let Some(ref mut s) = self.data_table_state {
5669                        s.page_down();
5670                    }
5671                    None
5672                }
5673            }
5674            KeyCode::Home if event.is_press() => {
5675                if let Some(ref mut state) = self.data_table_state {
5676                    if state.start_row > 0 {
5677                        state.scroll_to(0);
5678                    }
5679                    state.table_state.select(Some(0));
5680                }
5681                None
5682            }
5683            KeyCode::End if event.is_press() => {
5684                if self.data_table_state.is_some() {
5685                    self.busy = true;
5686                    Some(AppEvent::DoScrollEnd)
5687                } else {
5688                    None
5689                }
5690            }
5691            KeyCode::Char('G') if event.is_press() => {
5692                if self.data_table_state.is_some() {
5693                    self.busy = true;
5694                    Some(AppEvent::DoScrollEnd)
5695                } else {
5696                    None
5697                }
5698            }
5699            KeyCode::Char('f')
5700                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5701            {
5702                let would_collect = self
5703                    .data_table_state
5704                    .as_ref()
5705                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5706                    .unwrap_or(false);
5707                if would_collect {
5708                    self.busy = true;
5709                    Some(AppEvent::DoScrollDown)
5710                } else {
5711                    if let Some(ref mut s) = self.data_table_state {
5712                        s.page_down();
5713                    }
5714                    None
5715                }
5716            }
5717            KeyCode::Char('b')
5718                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5719            {
5720                let would_collect = self
5721                    .data_table_state
5722                    .as_ref()
5723                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5724                    .unwrap_or(false);
5725                if would_collect {
5726                    self.busy = true;
5727                    Some(AppEvent::DoScrollUp)
5728                } else {
5729                    if let Some(ref mut s) = self.data_table_state {
5730                        s.page_up();
5731                    }
5732                    None
5733                }
5734            }
5735            KeyCode::Char('d')
5736                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5737            {
5738                let half = self
5739                    .data_table_state
5740                    .as_ref()
5741                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5742                    .unwrap_or(1);
5743                let would_collect = self
5744                    .data_table_state
5745                    .as_ref()
5746                    .map(|s| s.scroll_would_trigger_collect(half))
5747                    .unwrap_or(false);
5748                if would_collect {
5749                    self.busy = true;
5750                    Some(AppEvent::DoScrollHalfDown)
5751                } else {
5752                    if let Some(ref mut s) = self.data_table_state {
5753                        s.half_page_down();
5754                    }
5755                    None
5756                }
5757            }
5758            KeyCode::Char('u')
5759                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5760            {
5761                let half = self
5762                    .data_table_state
5763                    .as_ref()
5764                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5765                    .unwrap_or(1);
5766                let would_collect = self
5767                    .data_table_state
5768                    .as_ref()
5769                    .map(|s| s.scroll_would_trigger_collect(-half))
5770                    .unwrap_or(false);
5771                if would_collect {
5772                    self.busy = true;
5773                    Some(AppEvent::DoScrollHalfUp)
5774                } else {
5775                    if let Some(ref mut s) = self.data_table_state {
5776                        s.half_page_up();
5777                    }
5778                    None
5779                }
5780            }
5781            KeyCode::PageUp if event.is_press() => {
5782                let would_collect = self
5783                    .data_table_state
5784                    .as_ref()
5785                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5786                    .unwrap_or(false);
5787                if would_collect {
5788                    self.busy = true;
5789                    Some(AppEvent::DoScrollUp)
5790                } else {
5791                    if let Some(ref mut s) = self.data_table_state {
5792                        s.page_up();
5793                    }
5794                    None
5795                }
5796            }
5797            KeyCode::Enter if event.is_press() => {
5798                // Only drill down if not in a modal and viewing grouped data
5799                if self.input_mode == InputMode::Normal {
5800                    if let Some(ref mut state) = self.data_table_state {
5801                        if state.is_grouped() && !state.is_drilled_down() {
5802                            if let Some(selected) = state.table_state.selected() {
5803                                let group_index = state.start_row + selected;
5804                                let _ = state.drill_down_into_group(group_index);
5805                            }
5806                        }
5807                    }
5808                }
5809                None
5810            }
5811            KeyCode::Tab if event.is_press() => {
5812                self.focus = (self.focus + 1) % 2;
5813                None
5814            }
5815            KeyCode::BackTab if event.is_press() => {
5816                self.focus = (self.focus + 1) % 2;
5817                None
5818            }
5819            KeyCode::Char('i') if event.is_press() => {
5820                if self.data_table_state.is_some() {
5821                    self.info_modal.open();
5822                    self.input_mode = InputMode::Info;
5823                    // Defer Parquet metadata load so UI can show throbber; avoid blocking in render
5824                    if self.path.is_some()
5825                        && self.original_file_format == Some(ExportFormat::Parquet)
5826                        && self.parquet_metadata_cache.is_none()
5827                    {
5828                        self.busy = true;
5829                        return Some(AppEvent::DoLoadParquetMetadata);
5830                    }
5831                }
5832                None
5833            }
5834            KeyCode::Char('/') => {
5835                self.input_mode = InputMode::Editing;
5836                self.input_type = Some(InputType::Search);
5837                self.query_tab = QueryTab::SqlLike;
5838                self.query_focus = QueryFocus::Input;
5839                if let Some(state) = &mut self.data_table_state {
5840                    self.query_input.value = state.active_query.clone();
5841                    self.query_input.cursor = self.query_input.value.chars().count();
5842                    self.sql_input.value = state.get_active_sql_query().to_string();
5843                    self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5844                    self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5845                    self.sql_input.cursor = self.sql_input.value.chars().count();
5846                    state.suppress_error_display = true;
5847                } else {
5848                    self.query_input.clear();
5849                    self.sql_input.clear();
5850                    self.fuzzy_input.clear();
5851                }
5852                self.sql_input.set_focused(false);
5853                self.fuzzy_input.set_focused(false);
5854                self.query_input.set_focused(true);
5855                None
5856            }
5857            KeyCode::Char(':') if event.is_press() => {
5858                if self.data_table_state.is_some() {
5859                    self.input_mode = InputMode::Editing;
5860                    self.input_type = Some(InputType::GoToLine);
5861                    self.query_input.value.clear();
5862                    self.query_input.cursor = 0;
5863                    self.query_input.set_focused(true);
5864                }
5865                None
5866            }
5867            KeyCode::Char('T') => {
5868                // Apply most relevant template immediately (no modal)
5869                if let Some(ref state) = self.data_table_state {
5870                    if let Some(ref path) = self.path {
5871                        if let Some(template) =
5872                            self.template_manager.get_most_relevant(path, &state.schema)
5873                        {
5874                            // Apply template settings
5875                            if let Err(e) = self.apply_template(&template) {
5876                                // Show error modal instead of just printing
5877                                self.error_modal
5878                                    .show(format!("Error applying template: {}", e));
5879                            }
5880                        }
5881                    }
5882                }
5883                None
5884            }
5885            KeyCode::Char('t') => {
5886                // Open template modal
5887                if let Some(ref state) = self.data_table_state {
5888                    if let Some(ref path) = self.path {
5889                        // Load relevant templates
5890                        self.template_modal.templates = self
5891                            .template_manager
5892                            .find_relevant_templates(path, &state.schema);
5893                        self.template_modal.table_state.select(
5894                            if self.template_modal.templates.is_empty() {
5895                                None
5896                            } else {
5897                                Some(0)
5898                            },
5899                        );
5900                        self.template_modal.active = true;
5901                        self.template_modal.mode = TemplateModalMode::List;
5902                        self.template_modal.focus = TemplateFocus::TemplateList;
5903                    }
5904                }
5905                None
5906            }
5907            KeyCode::Char('s') => {
5908                if let Some(state) = &self.data_table_state {
5909                    let headers: Vec<String> =
5910                        state.schema.iter_names().map(|s| s.to_string()).collect();
5911                    let locked_count = state.locked_columns_count();
5912
5913                    // Populate sort tab
5914                    let mut existing_columns: std::collections::HashMap<String, SortColumn> = self
5915                        .sort_filter_modal
5916                        .sort
5917                        .columns
5918                        .iter()
5919                        .map(|c| (c.name.clone(), c.clone()))
5920                        .collect();
5921                    self.sort_filter_modal.sort.columns = headers
5922                        .iter()
5923                        .enumerate()
5924                        .map(|(i, h)| {
5925                            if let Some(mut col) = existing_columns.remove(h) {
5926                                col.display_order = i;
5927                                col.is_locked = i < locked_count;
5928                                col.is_to_be_locked = false;
5929                                col
5930                            } else {
5931                                SortColumn {
5932                                    name: h.clone(),
5933                                    sort_order: None,
5934                                    display_order: i,
5935                                    is_locked: i < locked_count,
5936                                    is_to_be_locked: false,
5937                                    is_visible: true,
5938                                }
5939                            }
5940                        })
5941                        .collect();
5942                    self.sort_filter_modal.sort.filter_input.clear();
5943                    self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
5944
5945                    // Populate filter tab
5946                    self.sort_filter_modal.filter.available_columns = state.headers();
5947                    if !self.sort_filter_modal.filter.available_columns.is_empty() {
5948                        self.sort_filter_modal.filter.new_column_idx =
5949                            self.sort_filter_modal.filter.new_column_idx.min(
5950                                self.sort_filter_modal
5951                                    .filter
5952                                    .available_columns
5953                                    .len()
5954                                    .saturating_sub(1),
5955                            );
5956                    } else {
5957                        self.sort_filter_modal.filter.new_column_idx = 0;
5958                    }
5959
5960                    self.sort_filter_modal.open(self.history_limit, &self.theme);
5961                    self.input_mode = InputMode::SortFilter;
5962                }
5963                None
5964            }
5965            KeyCode::Char('r') => {
5966                if let Some(state) = &mut self.data_table_state {
5967                    state.reverse();
5968                }
5969                None
5970            }
5971            KeyCode::Char('a') => {
5972                // Open analysis modal; no computation until user selects a tool from the sidebar (Enter)
5973                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5974                    self.analysis_modal.open();
5975                }
5976                None
5977            }
5978            KeyCode::Char('c') => {
5979                if let Some(state) = &self.data_table_state {
5980                    if self.input_mode == InputMode::Normal {
5981                        let numeric_columns: Vec<String> = state
5982                            .schema
5983                            .iter()
5984                            .filter(|(_, dtype)| dtype.is_numeric())
5985                            .map(|(name, _)| name.to_string())
5986                            .collect();
5987                        let datetime_columns: Vec<String> = state
5988                            .schema
5989                            .iter()
5990                            .filter(|(_, dtype)| {
5991                                matches!(
5992                                    dtype,
5993                                    DataType::Datetime(_, _) | DataType::Date | DataType::Time
5994                                )
5995                            })
5996                            .map(|(name, _)| name.to_string())
5997                            .collect();
5998                        self.chart_modal.open(
5999                            &numeric_columns,
6000                            &datetime_columns,
6001                            self.app_config.chart.row_limit,
6002                        );
6003                        self.chart_modal.x_input =
6004                            std::mem::take(&mut self.chart_modal.x_input).with_theme(&self.theme);
6005                        self.chart_modal.y_input =
6006                            std::mem::take(&mut self.chart_modal.y_input).with_theme(&self.theme);
6007                        self.chart_modal.hist_input =
6008                            std::mem::take(&mut self.chart_modal.hist_input)
6009                                .with_theme(&self.theme);
6010                        self.chart_modal.box_input =
6011                            std::mem::take(&mut self.chart_modal.box_input).with_theme(&self.theme);
6012                        self.chart_modal.kde_input =
6013                            std::mem::take(&mut self.chart_modal.kde_input).with_theme(&self.theme);
6014                        self.chart_modal.heatmap_x_input =
6015                            std::mem::take(&mut self.chart_modal.heatmap_x_input)
6016                                .with_theme(&self.theme);
6017                        self.chart_modal.heatmap_y_input =
6018                            std::mem::take(&mut self.chart_modal.heatmap_y_input)
6019                                .with_theme(&self.theme);
6020                        self.chart_cache.clear();
6021                        self.input_mode = InputMode::Chart;
6022                    }
6023                }
6024                None
6025            }
6026            KeyCode::Char('p') => {
6027                if let Some(state) = &self.data_table_state {
6028                    if self.input_mode == InputMode::Normal {
6029                        self.pivot_melt_modal.available_columns =
6030                            state.schema.iter_names().map(|s| s.to_string()).collect();
6031                        self.pivot_melt_modal.column_dtypes = state
6032                            .schema
6033                            .iter()
6034                            .map(|(n, d)| (n.to_string(), d.clone()))
6035                            .collect();
6036                        self.pivot_melt_modal.open(self.history_limit, &self.theme);
6037                        self.input_mode = InputMode::PivotMelt;
6038                    }
6039                }
6040                None
6041            }
6042            KeyCode::Char('e') => {
6043                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
6044                    // Load config to get delimiter preference
6045                    let config_delimiter = AppConfig::load(APP_NAME)
6046                        .ok()
6047                        .and_then(|config| config.file_loading.delimiter);
6048                    self.export_modal.open(
6049                        self.original_file_format,
6050                        self.history_limit,
6051                        &self.theme,
6052                        self.original_file_delimiter,
6053                        config_delimiter,
6054                    );
6055                    self.input_mode = InputMode::Export;
6056                }
6057                None
6058            }
6059            _ => None,
6060        }
6061    }
6062
6063    pub fn event(&mut self, event: &AppEvent) -> Option<AppEvent> {
6064        self.debug.num_events += 1;
6065
6066        match event {
6067            AppEvent::Key(key) => {
6068                let is_column_scroll = matches!(
6069                    key.code,
6070                    KeyCode::Left | KeyCode::Right | KeyCode::Char('h') | KeyCode::Char('l')
6071                );
6072                let is_help_key = key.code == KeyCode::F(1);
6073                // When busy (e.g. loading), still process column scroll, F1, and confirmation modal keys.
6074                if self.busy && !is_column_scroll && !is_help_key && !self.confirmation_modal.active
6075                {
6076                    return None;
6077                }
6078                self.key(key)
6079            }
6080            AppEvent::Open(paths, options) => {
6081                if paths.is_empty() {
6082                    return Some(AppEvent::Crash("No paths provided".to_string()));
6083                }
6084                #[cfg(feature = "http")]
6085                if let Some(ref p) = self.http_temp_path.take() {
6086                    let _ = std::fs::remove_file(p);
6087                }
6088                self.busy = true;
6089                let first = &paths[0];
6090                let file_size = match source::input_source(first) {
6091                    source::InputSource::Local(_) => {
6092                        std::fs::metadata(first).map(|m| m.len()).unwrap_or(0)
6093                    }
6094                    source::InputSource::S3(_)
6095                    | source::InputSource::Gcs(_)
6096                    | source::InputSource::Http(_) => 0,
6097                };
6098                let path_str = first.as_os_str().to_string_lossy();
6099                let _is_partitioned_path = paths.len() == 1
6100                    && options.hive
6101                    && (first.is_dir() || path_str.contains('*') || path_str.contains("**"));
6102                let phase = "Scanning input";
6103
6104                self.loading_state = LoadingState::Loading {
6105                    file_path: Some(first.clone()),
6106                    file_size,
6107                    current_phase: phase.to_string(),
6108                    progress_percent: 10,
6109                };
6110
6111                Some(AppEvent::DoLoadScanPaths(paths.clone(), options.clone()))
6112            }
6113            AppEvent::OpenLazyFrame(lf, options) => {
6114                self.busy = true;
6115                self.loading_state = LoadingState::Loading {
6116                    file_path: None,
6117                    file_size: 0,
6118                    current_phase: "Scanning input".to_string(),
6119                    progress_percent: 10,
6120                };
6121                Some(AppEvent::DoLoadSchema(lf.clone(), None, options.clone()))
6122            }
6123            AppEvent::DoLoadScanPaths(paths, options) => {
6124                let first = &paths[0];
6125                let src = source::input_source(first);
6126                if paths.len() > 1 {
6127                    match &src {
6128                        source::InputSource::S3(_) => {
6129                            return Some(AppEvent::Crash(
6130                                "Only one S3 URL at a time. Open a single s3:// path.".to_string(),
6131                            ));
6132                        }
6133                        source::InputSource::Gcs(_) => {
6134                            return Some(AppEvent::Crash(
6135                                "Only one GCS URL at a time. Open a single gs:// path.".to_string(),
6136                            ));
6137                        }
6138                        source::InputSource::Http(_) => {
6139                            return Some(AppEvent::Crash(
6140                                "Only one HTTP/HTTPS URL at a time. Open a single URL.".to_string(),
6141                            ));
6142                        }
6143                        source::InputSource::Local(_) => {}
6144                    }
6145                }
6146                let compression = options
6147                    .compression
6148                    .or_else(|| CompressionFormat::from_extension(first));
6149                let is_csv = options.format == Some(FileFormat::Csv)
6150                    || first
6151                        .file_stem()
6152                        .and_then(|stem| stem.to_str())
6153                        .map(|stem| {
6154                            stem.ends_with(".csv")
6155                                || first
6156                                    .extension()
6157                                    .and_then(|e| e.to_str())
6158                                    .map(|e| e.eq_ignore_ascii_case("csv"))
6159                                    .unwrap_or(false)
6160                        })
6161                        .unwrap_or(false);
6162                let is_compressed_csv = matches!(src, source::InputSource::Local(_))
6163                    && paths.len() == 1
6164                    && compression.is_some()
6165                    && is_csv;
6166                if is_compressed_csv {
6167                    if let LoadingState::Loading {
6168                        file_path,
6169                        file_size,
6170                        ..
6171                    } = &self.loading_state
6172                    {
6173                        self.loading_state = LoadingState::Loading {
6174                            file_path: file_path.clone(),
6175                            file_size: *file_size,
6176                            current_phase: "Decompressing".to_string(),
6177                            progress_percent: 30,
6178                        };
6179                    }
6180                    Some(AppEvent::DoLoad(paths.clone(), options.clone()))
6181                } else {
6182                    #[cfg(feature = "http")]
6183                    if let source::InputSource::Http(ref url) = src {
6184                        let size = Self::fetch_remote_size_http(url).unwrap_or(None);
6185                        let size_str = size
6186                            .map(Self::format_bytes)
6187                            .unwrap_or_else(|| "unknown".to_string());
6188                        let dest_dir = options
6189                            .temp_dir
6190                            .as_deref()
6191                            .map(|p| p.display().to_string())
6192                            .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6193                        let message = format!(
6194                            "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6195                            url, size_str, dest_dir
6196                        );
6197                        self.pending_download = Some(PendingDownload::Http {
6198                            url: url.clone(),
6199                            size,
6200                            options: options.clone(),
6201                        });
6202                        self.confirmation_modal.show(message);
6203                        return None;
6204                    }
6205                    #[cfg(feature = "cloud")]
6206                    if let source::InputSource::S3(ref url) = src {
6207                        let full = format!("s3://{url}");
6208                        let (_, ext) = source::url_path_extension(&full);
6209                        let is_glob = full.contains('*') || full.ends_with('/');
6210                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6211                            let size =
6212                                Self::fetch_remote_size_s3(&full, &self.app_config.cloud, options)
6213                                    .unwrap_or(None);
6214                            let size_str = size
6215                                .map(Self::format_bytes)
6216                                .unwrap_or_else(|| "unknown".to_string());
6217                            let dest_dir = options
6218                                .temp_dir
6219                                .as_deref()
6220                                .map(|p| p.display().to_string())
6221                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6222                            let message = format!(
6223                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6224                                full, size_str, dest_dir
6225                            );
6226                            self.pending_download = Some(PendingDownload::S3 {
6227                                url: full,
6228                                size,
6229                                options: options.clone(),
6230                            });
6231                            self.confirmation_modal.show(message);
6232                            return None;
6233                        }
6234                    }
6235                    #[cfg(feature = "cloud")]
6236                    if let source::InputSource::Gcs(ref url) = src {
6237                        let full = format!("gs://{url}");
6238                        let (_, ext) = source::url_path_extension(&full);
6239                        let is_glob = full.contains('*') || full.ends_with('/');
6240                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6241                            let size = Self::fetch_remote_size_gcs(&full, options).unwrap_or(None);
6242                            let size_str = size
6243                                .map(Self::format_bytes)
6244                                .unwrap_or_else(|| "unknown".to_string());
6245                            let dest_dir = options
6246                                .temp_dir
6247                                .as_deref()
6248                                .map(|p| p.display().to_string())
6249                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6250                            let message = format!(
6251                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6252                                full, size_str, dest_dir
6253                            );
6254                            self.pending_download = Some(PendingDownload::Gcs {
6255                                url: full,
6256                                size,
6257                                options: options.clone(),
6258                            });
6259                            self.confirmation_modal.show(message);
6260                            return None;
6261                        }
6262                    }
6263                    let first = paths[0].clone();
6264                    // When CSV with --parse-strings, set "Scanning string columns" and defer build so UI can show it before blocking.
6265                    if paths.len() == 1 && is_csv && options.parse_strings.is_some() {
6266                        if let LoadingState::Loading {
6267                            file_path,
6268                            file_size,
6269                            ..
6270                        } = &self.loading_state
6271                        {
6272                            self.loading_state = LoadingState::Loading {
6273                                file_path: file_path.clone(),
6274                                file_size: *file_size,
6275                                current_phase: "Scanning string columns".to_string(),
6276                                progress_percent: 55,
6277                            };
6278                        }
6279                        return Some(AppEvent::DoLoadCsvWithParseStrings(
6280                            paths.clone(),
6281                            options.clone(),
6282                        ));
6283                    }
6284                    #[allow(clippy::needless_borrow)]
6285                    match self.build_lazyframe_from_paths(&paths, options) {
6286                        Ok(lf) => {
6287                            if let LoadingState::Loading {
6288                                file_path,
6289                                file_size,
6290                                ..
6291                            } = &self.loading_state
6292                            {
6293                                self.loading_state = LoadingState::Loading {
6294                                    file_path: file_path.clone(),
6295                                    file_size: *file_size,
6296                                    current_phase: "Caching schema".to_string(),
6297                                    progress_percent: 40,
6298                                };
6299                            }
6300                            Some(AppEvent::DoLoadSchema(
6301                                Box::new(lf),
6302                                Some(first),
6303                                options.clone(),
6304                            ))
6305                        }
6306                        Err(e) => {
6307                            self.loading_state = LoadingState::Idle;
6308                            self.busy = false;
6309                            self.drain_keys_on_next_loop = true;
6310                            let msg = crate::error_display::user_message_from_report(
6311                                &e,
6312                                paths.first().map(|p| p.as_path()),
6313                            );
6314                            Some(AppEvent::Crash(msg))
6315                        }
6316                    }
6317                }
6318            }
6319            AppEvent::DoLoadCsvWithParseStrings(paths, options) => {
6320                let first = paths[0].clone();
6321                #[allow(clippy::needless_borrow)]
6322                match self.build_lazyframe_from_paths(&paths, options) {
6323                    Ok(lf) => {
6324                        if let LoadingState::Loading {
6325                            file_path,
6326                            file_size,
6327                            ..
6328                        } = &self.loading_state
6329                        {
6330                            self.loading_state = LoadingState::Loading {
6331                                file_path: file_path.clone(),
6332                                file_size: *file_size,
6333                                current_phase: "Caching schema".to_string(),
6334                                progress_percent: 40,
6335                            };
6336                        }
6337                        Some(AppEvent::DoLoadSchema(
6338                            Box::new(lf),
6339                            Some(first),
6340                            options.clone(),
6341                        ))
6342                    }
6343                    Err(e) => {
6344                        self.loading_state = LoadingState::Idle;
6345                        self.busy = false;
6346                        self.drain_keys_on_next_loop = true;
6347                        let msg = crate::error_display::user_message_from_report(
6348                            &e,
6349                            paths.first().map(|p| p.as_path()),
6350                        );
6351                        Some(AppEvent::Crash(msg))
6352                    }
6353                }
6354            }
6355            #[cfg(feature = "http")]
6356            AppEvent::DoDownloadHttp(url, options) => {
6357                let (_, ext) = source::url_path_extension(url.as_str());
6358                match Self::download_http_to_temp(
6359                    url.as_str(),
6360                    options.temp_dir.as_deref(),
6361                    ext.as_deref(),
6362                ) {
6363                    Ok(temp_path) => {
6364                        self.http_temp_path = Some(temp_path.clone());
6365                        if let LoadingState::Loading {
6366                            file_path,
6367                            file_size,
6368                            ..
6369                        } = &self.loading_state
6370                        {
6371                            self.loading_state = LoadingState::Loading {
6372                                file_path: file_path.clone(),
6373                                file_size: *file_size,
6374                                current_phase: "Scanning".to_string(),
6375                                progress_percent: 30,
6376                            };
6377                        }
6378                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6379                    }
6380                    Err(e) => {
6381                        self.loading_state = LoadingState::Idle;
6382                        self.busy = false;
6383                        self.drain_keys_on_next_loop = true;
6384                        let msg = crate::error_display::user_message_from_report(&e, None);
6385                        Some(AppEvent::Crash(msg))
6386                    }
6387                }
6388            }
6389            #[cfg(feature = "cloud")]
6390            AppEvent::DoDownloadS3ToTemp(s3_url, options) => {
6391                match Self::download_s3_to_temp(s3_url, &self.app_config.cloud, options) {
6392                    Ok(temp_path) => {
6393                        self.http_temp_path = Some(temp_path.clone());
6394                        if let LoadingState::Loading {
6395                            file_path,
6396                            file_size,
6397                            ..
6398                        } = &self.loading_state
6399                        {
6400                            self.loading_state = LoadingState::Loading {
6401                                file_path: file_path.clone(),
6402                                file_size: *file_size,
6403                                current_phase: "Scanning".to_string(),
6404                                progress_percent: 30,
6405                            };
6406                        }
6407                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6408                    }
6409                    Err(e) => {
6410                        self.loading_state = LoadingState::Idle;
6411                        self.busy = false;
6412                        self.drain_keys_on_next_loop = true;
6413                        let msg = crate::error_display::user_message_from_report(&e, None);
6414                        Some(AppEvent::Crash(msg))
6415                    }
6416                }
6417            }
6418            #[cfg(feature = "cloud")]
6419            AppEvent::DoDownloadGcsToTemp(gs_url, options) => {
6420                match Self::download_gcs_to_temp(gs_url, options) {
6421                    Ok(temp_path) => {
6422                        self.http_temp_path = Some(temp_path.clone());
6423                        if let LoadingState::Loading {
6424                            file_path,
6425                            file_size,
6426                            ..
6427                        } = &self.loading_state
6428                        {
6429                            self.loading_state = LoadingState::Loading {
6430                                file_path: file_path.clone(),
6431                                file_size: *file_size,
6432                                current_phase: "Scanning".to_string(),
6433                                progress_percent: 30,
6434                            };
6435                        }
6436                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6437                    }
6438                    Err(e) => {
6439                        self.loading_state = LoadingState::Idle;
6440                        self.busy = false;
6441                        self.drain_keys_on_next_loop = true;
6442                        let msg = crate::error_display::user_message_from_report(&e, None);
6443                        Some(AppEvent::Crash(msg))
6444                    }
6445                }
6446            }
6447            #[cfg(any(feature = "http", feature = "cloud"))]
6448            AppEvent::DoLoadFromHttpTemp(temp_path, options) => {
6449                self.http_temp_path = Some(temp_path.clone());
6450                let display_path = match &self.loading_state {
6451                    LoadingState::Loading { file_path, .. } => file_path.clone(),
6452                    _ => None,
6453                };
6454                if let LoadingState::Loading {
6455                    file_path,
6456                    file_size,
6457                    ..
6458                } = &self.loading_state
6459                {
6460                    self.loading_state = LoadingState::Loading {
6461                        file_path: file_path.clone(),
6462                        file_size: *file_size,
6463                        current_phase: "Scanning".to_string(),
6464                        progress_percent: 30,
6465                    };
6466                }
6467                #[allow(clippy::cloned_ref_to_slice_refs)]
6468                match self.build_lazyframe_from_paths(&[temp_path.clone()], options) {
6469                    Ok(lf) => {
6470                        if let LoadingState::Loading {
6471                            file_path,
6472                            file_size,
6473                            ..
6474                        } = &self.loading_state
6475                        {
6476                            self.loading_state = LoadingState::Loading {
6477                                file_path: file_path.clone(),
6478                                file_size: *file_size,
6479                                current_phase: "Caching schema".to_string(),
6480                                progress_percent: 40,
6481                            };
6482                        }
6483                        Some(AppEvent::DoLoadSchema(
6484                            Box::new(lf),
6485                            display_path,
6486                            options.clone(),
6487                        ))
6488                    }
6489                    Err(e) => {
6490                        self.loading_state = LoadingState::Idle;
6491                        self.busy = false;
6492                        self.drain_keys_on_next_loop = true;
6493                        let msg = crate::error_display::user_message_from_report(
6494                            &e,
6495                            Some(temp_path.as_path()),
6496                        );
6497                        Some(AppEvent::Crash(msg))
6498                    }
6499                }
6500            }
6501            AppEvent::DoLoadSchema(lf, path, options) => {
6502                // Set "Caching schema" and return so the UI draws this phase before we block in DoLoadSchemaBlocking
6503                if let LoadingState::Loading {
6504                    file_path,
6505                    file_size,
6506                    ..
6507                } = &self.loading_state
6508                {
6509                    self.loading_state = LoadingState::Loading {
6510                        file_path: file_path.clone(),
6511                        file_size: *file_size,
6512                        current_phase: "Caching schema".to_string(),
6513                        progress_percent: 40,
6514                    };
6515                }
6516                Some(AppEvent::DoLoadSchemaBlocking(
6517                    lf.clone(),
6518                    path.clone(),
6519                    options.clone(),
6520                ))
6521            }
6522            AppEvent::DoLoadSchemaBlocking(lf, path, options) => {
6523                self.debug.schema_load = None;
6524                // Fast path for hive directory: infer schema from one parquet file instead of collect_schema() over all files.
6525                if options.single_spine_schema
6526                    && path.as_ref().is_some_and(|p| p.is_dir() && options.hive)
6527                {
6528                    let p = path.as_ref().expect("path set by caller");
6529                    if let Ok((merged_schema, partition_columns)) =
6530                        DataTableState::schema_from_one_hive_parquet(p)
6531                    {
6532                        if let Ok(lf_owned) =
6533                            DataTableState::scan_parquet_hive_with_schema(p, merged_schema.clone())
6534                        {
6535                            match DataTableState::from_schema_and_lazyframe(
6536                                merged_schema,
6537                                lf_owned,
6538                                options,
6539                                Some(partition_columns),
6540                            ) {
6541                                Ok(state) => {
6542                                    self.debug.schema_load = Some("one-file (local)".to_string());
6543                                    self.parquet_metadata_cache = None;
6544                                    self.export_df = None;
6545                                    self.data_table_state = Some(state);
6546                                    self.path = path.clone();
6547                                    if let Some(ref path_p) = path {
6548                                        self.original_file_format = path_p
6549                                            .extension()
6550                                            .and_then(|e| e.to_str())
6551                                            .and_then(|ext| {
6552                                                if ext.eq_ignore_ascii_case("parquet") {
6553                                                    Some(ExportFormat::Parquet)
6554                                                } else if ext.eq_ignore_ascii_case("csv") {
6555                                                    Some(ExportFormat::Csv)
6556                                                } else if ext.eq_ignore_ascii_case("json") {
6557                                                    Some(ExportFormat::Json)
6558                                                } else if ext.eq_ignore_ascii_case("jsonl")
6559                                                    || ext.eq_ignore_ascii_case("ndjson")
6560                                                {
6561                                                    Some(ExportFormat::Ndjson)
6562                                                } else if ext.eq_ignore_ascii_case("arrow")
6563                                                    || ext.eq_ignore_ascii_case("ipc")
6564                                                    || ext.eq_ignore_ascii_case("feather")
6565                                                {
6566                                                    Some(ExportFormat::Ipc)
6567                                                } else if ext.eq_ignore_ascii_case("avro") {
6568                                                    Some(ExportFormat::Avro)
6569                                                } else {
6570                                                    None
6571                                                }
6572                                            });
6573                                        self.original_file_delimiter =
6574                                            Some(options.delimiter.unwrap_or(b','));
6575                                    } else {
6576                                        self.original_file_format = None;
6577                                        self.original_file_delimiter = None;
6578                                    }
6579                                    self.sort_filter_modal = SortFilterModal::new();
6580                                    self.pivot_melt_modal = PivotMeltModal::new();
6581                                    if let LoadingState::Loading {
6582                                        file_path,
6583                                        file_size,
6584                                        ..
6585                                    } = &self.loading_state
6586                                    {
6587                                        self.loading_state = LoadingState::Loading {
6588                                            file_path: file_path.clone(),
6589                                            file_size: *file_size,
6590                                            current_phase: "Loading buffer".to_string(),
6591                                            progress_percent: 70,
6592                                        };
6593                                    }
6594                                    return Some(AppEvent::DoLoadBuffer);
6595                                }
6596                                Err(e) => {
6597                                    self.loading_state = LoadingState::Idle;
6598                                    self.busy = false;
6599                                    self.drain_keys_on_next_loop = true;
6600                                    let msg =
6601                                        crate::error_display::user_message_from_report(&e, None);
6602                                    return Some(AppEvent::Crash(msg));
6603                                }
6604                            }
6605                        }
6606                    }
6607                }
6608
6609                #[cfg(feature = "cloud")]
6610                {
6611                    // Use fast path for directory/glob cloud URLs (same as build_lazyframe_from_paths).
6612                    // Don't require --hive: path shape already implies hive scan.
6613                    if options.single_spine_schema
6614                        && path.as_ref().is_some_and(|p| {
6615                            let s = p.as_os_str().to_string_lossy();
6616                            let is_cloud = s.starts_with("s3://") || s.starts_with("gs://");
6617                            let looks_like_hive = s.ends_with('/') || s.contains('*');
6618                            is_cloud && (options.hive || looks_like_hive)
6619                        })
6620                    {
6621                        self.debug.schema_load = Some("trying one-file (cloud)".to_string());
6622                        let src = source::input_source(path.as_ref().expect("path set by caller"));
6623                        let try_cloud = match &src {
6624                            source::InputSource::S3(url) => {
6625                                let full = format!("s3://{url}");
6626                                let (path_part, _) = source::url_path_extension(&full);
6627                                let key = path_part
6628                                    .split_once('/')
6629                                    .map(|(_, k)| k.trim_end_matches('/'))
6630                                    .unwrap_or("");
6631                                let cloud_opts =
6632                                    Self::build_s3_cloud_options(&self.app_config.cloud, options);
6633                                Self::build_s3_object_store(&full, &self.app_config.cloud, options)
6634                                    .ok()
6635                                    .and_then(|store| {
6636                                        let rt = tokio::runtime::Runtime::new().ok()?;
6637                                        let (merged_schema, partition_columns) = rt
6638                                            .block_on(cloud_hive::schema_from_one_cloud_hive(
6639                                                store, key,
6640                                            ))
6641                                            .ok()?;
6642                                        let pl_path = PlPathRef::new(&full).into_owned();
6643                                        let args = ScanArgsParquet {
6644                                            schema: Some(merged_schema.clone()),
6645                                            cloud_options: Some(cloud_opts),
6646                                            hive_options: polars::io::HiveOptions::new_enabled(),
6647                                            glob: true,
6648                                            ..Default::default()
6649                                        };
6650                                        let mut lf_owned =
6651                                            LazyFrame::scan_parquet(pl_path, args).ok()?;
6652                                        if !partition_columns.is_empty() {
6653                                            let exprs: Vec<_> = partition_columns
6654                                                .iter()
6655                                                .map(|s| col(s.as_str()))
6656                                                .chain(
6657                                                    merged_schema
6658                                                        .iter_names()
6659                                                        .map(|s| s.to_string())
6660                                                        .filter(|c| !partition_columns.contains(c))
6661                                                        .map(|s| col(s.as_str())),
6662                                                )
6663                                                .collect();
6664                                            lf_owned = lf_owned.select(exprs);
6665                                        }
6666                                        DataTableState::from_schema_and_lazyframe(
6667                                            merged_schema,
6668                                            lf_owned,
6669                                            options,
6670                                            Some(partition_columns),
6671                                        )
6672                                        .ok()
6673                                    })
6674                            }
6675                            source::InputSource::Gcs(url) => {
6676                                let full = format!("gs://{url}");
6677                                let (path_part, _) = source::url_path_extension(&full);
6678                                let key = path_part
6679                                    .split_once('/')
6680                                    .map(|(_, k)| k.trim_end_matches('/'))
6681                                    .unwrap_or("");
6682                                Self::build_gcs_object_store(&full).ok().and_then(|store| {
6683                                    let rt = tokio::runtime::Runtime::new().ok()?;
6684                                    let (merged_schema, partition_columns) = rt
6685                                        .block_on(cloud_hive::schema_from_one_cloud_hive(
6686                                            store, key,
6687                                        ))
6688                                        .ok()?;
6689                                    let pl_path = PlPathRef::new(&full).into_owned();
6690                                    let args = ScanArgsParquet {
6691                                        schema: Some(merged_schema.clone()),
6692                                        cloud_options: Some(CloudOptions::default()),
6693                                        hive_options: polars::io::HiveOptions::new_enabled(),
6694                                        glob: true,
6695                                        ..Default::default()
6696                                    };
6697                                    let mut lf_owned =
6698                                        LazyFrame::scan_parquet(pl_path, args).ok()?;
6699                                    if !partition_columns.is_empty() {
6700                                        let exprs: Vec<_> = partition_columns
6701                                            .iter()
6702                                            .map(|s| col(s.as_str()))
6703                                            .chain(
6704                                                merged_schema
6705                                                    .iter_names()
6706                                                    .map(|s| s.to_string())
6707                                                    .filter(|c| !partition_columns.contains(c))
6708                                                    .map(|s| col(s.as_str())),
6709                                            )
6710                                            .collect();
6711                                        lf_owned = lf_owned.select(exprs);
6712                                    }
6713                                    DataTableState::from_schema_and_lazyframe(
6714                                        merged_schema,
6715                                        lf_owned,
6716                                        options,
6717                                        Some(partition_columns),
6718                                    )
6719                                    .ok()
6720                                })
6721                            }
6722                            _ => None,
6723                        };
6724                        if let Some(state) = try_cloud {
6725                            self.debug.schema_load = Some("one-file (cloud)".to_string());
6726                            self.parquet_metadata_cache = None;
6727                            self.export_df = None;
6728                            self.data_table_state = Some(state);
6729                            self.path = path.clone();
6730                            if let Some(ref path_p) = path {
6731                                self.original_file_format =
6732                                    path_p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6733                                        if ext.eq_ignore_ascii_case("parquet") {
6734                                            Some(ExportFormat::Parquet)
6735                                        } else if ext.eq_ignore_ascii_case("csv") {
6736                                            Some(ExportFormat::Csv)
6737                                        } else if ext.eq_ignore_ascii_case("json") {
6738                                            Some(ExportFormat::Json)
6739                                        } else if ext.eq_ignore_ascii_case("jsonl")
6740                                            || ext.eq_ignore_ascii_case("ndjson")
6741                                        {
6742                                            Some(ExportFormat::Ndjson)
6743                                        } else if ext.eq_ignore_ascii_case("arrow")
6744                                            || ext.eq_ignore_ascii_case("ipc")
6745                                            || ext.eq_ignore_ascii_case("feather")
6746                                        {
6747                                            Some(ExportFormat::Ipc)
6748                                        } else if ext.eq_ignore_ascii_case("avro") {
6749                                            Some(ExportFormat::Avro)
6750                                        } else {
6751                                            None
6752                                        }
6753                                    });
6754                                self.original_file_delimiter =
6755                                    Some(options.delimiter.unwrap_or(b','));
6756                            } else {
6757                                self.original_file_format = None;
6758                                self.original_file_delimiter = None;
6759                            }
6760                            self.sort_filter_modal = SortFilterModal::new();
6761                            self.pivot_melt_modal = PivotMeltModal::new();
6762                            if let LoadingState::Loading {
6763                                file_path,
6764                                file_size,
6765                                ..
6766                            } = &self.loading_state
6767                            {
6768                                self.loading_state = LoadingState::Loading {
6769                                    file_path: file_path.clone(),
6770                                    file_size: *file_size,
6771                                    current_phase: "Loading buffer".to_string(),
6772                                    progress_percent: 70,
6773                                };
6774                            }
6775                            return Some(AppEvent::DoLoadBuffer);
6776                        } else {
6777                            self.debug.schema_load = Some("fallback (cloud)".to_string());
6778                        }
6779                    }
6780                }
6781
6782                if self.debug.schema_load.is_none() {
6783                    self.debug.schema_load = Some("full scan".to_string());
6784                }
6785                let mut lf_owned = (**lf).clone();
6786                match lf_owned.collect_schema() {
6787                    Ok(schema) => {
6788                        let partition_columns = if path.as_ref().is_some_and(|p| {
6789                            options.hive
6790                                && (p.is_dir() || p.as_os_str().to_string_lossy().contains('*'))
6791                        }) {
6792                            let discovered = DataTableState::discover_hive_partition_columns(
6793                                path.as_ref().expect("path set by caller"),
6794                            );
6795                            discovered
6796                                .into_iter()
6797                                .filter(|c| schema.contains(c.as_str()))
6798                                .collect::<Vec<_>>()
6799                        } else {
6800                            Vec::new()
6801                        };
6802                        if !partition_columns.is_empty() {
6803                            let exprs: Vec<_> = partition_columns
6804                                .iter()
6805                                .map(|s| col(s.as_str()))
6806                                .chain(
6807                                    schema
6808                                        .iter_names()
6809                                        .map(|s| s.to_string())
6810                                        .filter(|c| !partition_columns.contains(c))
6811                                        .map(|s| col(s.as_str())),
6812                                )
6813                                .collect();
6814                            lf_owned = lf_owned.select(exprs);
6815                        }
6816                        let part_cols_opt = if partition_columns.is_empty() {
6817                            None
6818                        } else {
6819                            Some(partition_columns)
6820                        };
6821                        match DataTableState::from_schema_and_lazyframe(
6822                            schema,
6823                            lf_owned,
6824                            options,
6825                            part_cols_opt,
6826                        ) {
6827                            Ok(state) => {
6828                                self.parquet_metadata_cache = None;
6829                                self.export_df = None;
6830                                self.data_table_state = Some(state);
6831                                self.path = path.clone();
6832                                if let Some(ref p) = path {
6833                                    self.original_file_format =
6834                                        p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6835                                            if ext.eq_ignore_ascii_case("parquet") {
6836                                                Some(ExportFormat::Parquet)
6837                                            } else if ext.eq_ignore_ascii_case("csv") {
6838                                                Some(ExportFormat::Csv)
6839                                            } else if ext.eq_ignore_ascii_case("json") {
6840                                                Some(ExportFormat::Json)
6841                                            } else if ext.eq_ignore_ascii_case("jsonl")
6842                                                || ext.eq_ignore_ascii_case("ndjson")
6843                                            {
6844                                                Some(ExportFormat::Ndjson)
6845                                            } else if ext.eq_ignore_ascii_case("arrow")
6846                                                || ext.eq_ignore_ascii_case("ipc")
6847                                                || ext.eq_ignore_ascii_case("feather")
6848                                            {
6849                                                Some(ExportFormat::Ipc)
6850                                            } else if ext.eq_ignore_ascii_case("avro") {
6851                                                Some(ExportFormat::Avro)
6852                                            } else {
6853                                                None
6854                                            }
6855                                        });
6856                                    self.original_file_delimiter =
6857                                        Some(options.delimiter.unwrap_or(b','));
6858                                } else {
6859                                    self.original_file_format = None;
6860                                    self.original_file_delimiter = None;
6861                                }
6862                                self.sort_filter_modal = SortFilterModal::new();
6863                                self.pivot_melt_modal = PivotMeltModal::new();
6864                                if let LoadingState::Loading {
6865                                    file_path,
6866                                    file_size,
6867                                    ..
6868                                } = &self.loading_state
6869                                {
6870                                    self.loading_state = LoadingState::Loading {
6871                                        file_path: file_path.clone(),
6872                                        file_size: *file_size,
6873                                        current_phase: "Loading buffer".to_string(),
6874                                        progress_percent: 70,
6875                                    };
6876                                }
6877                                Some(AppEvent::DoLoadBuffer)
6878                            }
6879                            Err(e) => {
6880                                self.loading_state = LoadingState::Idle;
6881                                self.busy = false;
6882                                self.drain_keys_on_next_loop = true;
6883                                let msg = crate::error_display::user_message_from_report(&e, None);
6884                                Some(AppEvent::Crash(msg))
6885                            }
6886                        }
6887                    }
6888                    Err(e) => {
6889                        self.loading_state = LoadingState::Idle;
6890                        self.busy = false;
6891                        self.drain_keys_on_next_loop = true;
6892                        let report = color_eyre::eyre::Report::from(e);
6893                        let msg = crate::error_display::user_message_from_report(&report, None);
6894                        Some(AppEvent::Crash(msg))
6895                    }
6896                }
6897            }
6898            AppEvent::DoLoadBuffer => {
6899                if let Some(state) = &mut self.data_table_state {
6900                    state.collect();
6901                    if let Some(e) = state.error.take() {
6902                        self.loading_state = LoadingState::Idle;
6903                        self.busy = false;
6904                        self.drain_keys_on_next_loop = true;
6905                        let msg = crate::error_display::user_message_from_polars(&e);
6906                        return Some(AppEvent::Crash(msg));
6907                    }
6908                }
6909                self.loading_state = LoadingState::Idle;
6910                self.busy = false;
6911                self.drain_keys_on_next_loop = true;
6912                Some(AppEvent::Collect)
6913            }
6914            AppEvent::DoLoad(paths, options) => {
6915                let first = &paths[0];
6916                // Check if file is compressed (only single-file compressed CSV supported for now)
6917                let compression = options
6918                    .compression
6919                    .or_else(|| CompressionFormat::from_extension(first));
6920                let is_csv = first
6921                    .file_stem()
6922                    .and_then(|stem| stem.to_str())
6923                    .map(|stem| {
6924                        stem.ends_with(".csv")
6925                            || first
6926                                .extension()
6927                                .and_then(|e| e.to_str())
6928                                .map(|e| e.eq_ignore_ascii_case("csv"))
6929                                .unwrap_or(false)
6930                    })
6931                    .unwrap_or(false);
6932                let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
6933
6934                if is_compressed_csv {
6935                    // Set "Decompressing" phase and return event to trigger render
6936                    if let LoadingState::Loading {
6937                        file_path,
6938                        file_size,
6939                        ..
6940                    } = &self.loading_state
6941                    {
6942                        self.loading_state = LoadingState::Loading {
6943                            file_path: file_path.clone(),
6944                            file_size: *file_size,
6945                            current_phase: "Decompressing".to_string(),
6946                            progress_percent: 30,
6947                        };
6948                    }
6949                    // Return DoDecompress to allow UI to render "Decompressing" before blocking
6950                    Some(AppEvent::DoDecompress(paths.clone(), options.clone()))
6951                } else {
6952                    // For non-compressed files, proceed with normal loading
6953                    match self.load(paths, options) {
6954                        Ok(_) => {
6955                            self.busy = false;
6956                            self.drain_keys_on_next_loop = true;
6957                            Some(AppEvent::Collect)
6958                        }
6959                        Err(e) => {
6960                            self.loading_state = LoadingState::Idle;
6961                            self.busy = false;
6962                            self.drain_keys_on_next_loop = true;
6963                            let msg = crate::error_display::user_message_from_report(
6964                                &e,
6965                                paths.first().map(|p| p.as_path()),
6966                            );
6967                            Some(AppEvent::Crash(msg))
6968                        }
6969                    }
6970                }
6971            }
6972            AppEvent::DoDecompress(paths, options) => {
6973                // Actually perform decompression now (after UI has rendered "Decompressing")
6974                match self.load(paths, options) {
6975                    Ok(_) => Some(AppEvent::DoLoadBuffer),
6976                    Err(e) => {
6977                        self.loading_state = LoadingState::Idle;
6978                        self.busy = false;
6979                        self.drain_keys_on_next_loop = true;
6980                        let msg = crate::error_display::user_message_from_report(
6981                            &e,
6982                            paths.first().map(|p| p.as_path()),
6983                        );
6984                        Some(AppEvent::Crash(msg))
6985                    }
6986                }
6987            }
6988            AppEvent::Resize(_cols, rows) => {
6989                self.busy = true;
6990                if let Some(state) = &mut self.data_table_state {
6991                    state.visible_rows = *rows as usize;
6992                    state.collect();
6993                }
6994                self.busy = false;
6995                self.drain_keys_on_next_loop = true;
6996                None
6997            }
6998            AppEvent::Collect => {
6999                self.busy = true;
7000                if let Some(ref mut state) = self.data_table_state {
7001                    state.collect();
7002                }
7003                self.busy = false;
7004                self.drain_keys_on_next_loop = true;
7005                None
7006            }
7007            AppEvent::DoScrollDown => {
7008                if let Some(state) = &mut self.data_table_state {
7009                    state.page_down();
7010                }
7011                self.busy = false;
7012                self.drain_keys_on_next_loop = true;
7013                None
7014            }
7015            AppEvent::DoScrollUp => {
7016                if let Some(state) = &mut self.data_table_state {
7017                    state.page_up();
7018                }
7019                self.busy = false;
7020                self.drain_keys_on_next_loop = true;
7021                None
7022            }
7023            AppEvent::DoScrollNext => {
7024                if let Some(state) = &mut self.data_table_state {
7025                    state.select_next();
7026                }
7027                self.busy = false;
7028                self.drain_keys_on_next_loop = true;
7029                None
7030            }
7031            AppEvent::DoScrollPrev => {
7032                if let Some(state) = &mut self.data_table_state {
7033                    state.select_previous();
7034                }
7035                self.busy = false;
7036                self.drain_keys_on_next_loop = true;
7037                None
7038            }
7039            AppEvent::DoScrollEnd => {
7040                if let Some(state) = &mut self.data_table_state {
7041                    state.scroll_to_end();
7042                }
7043                self.busy = false;
7044                self.drain_keys_on_next_loop = true;
7045                None
7046            }
7047            AppEvent::DoScrollHalfDown => {
7048                if let Some(state) = &mut self.data_table_state {
7049                    state.half_page_down();
7050                }
7051                self.busy = false;
7052                self.drain_keys_on_next_loop = true;
7053                None
7054            }
7055            AppEvent::DoScrollHalfUp => {
7056                if let Some(state) = &mut self.data_table_state {
7057                    state.half_page_up();
7058                }
7059                self.busy = false;
7060                self.drain_keys_on_next_loop = true;
7061                None
7062            }
7063            AppEvent::GoToLine(n) => {
7064                if let Some(state) = &mut self.data_table_state {
7065                    state.scroll_to_row_centered(*n);
7066                }
7067                self.busy = false;
7068                self.drain_keys_on_next_loop = true;
7069                None
7070            }
7071            AppEvent::AnalysisChunk => {
7072                let lf = match &self.data_table_state {
7073                    Some(state) => state.lf.clone(),
7074                    None => {
7075                        self.analysis_computation = None;
7076                        self.analysis_modal.computing = None;
7077                        self.busy = false;
7078                        return None;
7079                    }
7080                };
7081                let comp = self.analysis_computation.take()?;
7082                if comp.df.is_none() {
7083                    // First chunk: get row count then run describe (lazy aggregation, no full collect)
7084                    // Reuse cached row count from control bar when valid to avoid extra full scan.
7085                    let total_rows = match self
7086                        .data_table_state
7087                        .as_ref()
7088                        .and_then(|s| s.num_rows_if_valid())
7089                    {
7090                        Some(n) => n,
7091                        None => match crate::statistics::collect_lazy(
7092                            lf.clone().select([len()]),
7093                            self.app_config.performance.polars_streaming,
7094                        ) {
7095                            Ok(count_df) => {
7096                                if let Some(col) = count_df.get(0) {
7097                                    match col.first() {
7098                                        Some(AnyValue::UInt32(n)) => *n as usize,
7099                                        _ => 0,
7100                                    }
7101                                } else {
7102                                    0
7103                                }
7104                            }
7105                            Err(_e) => {
7106                                self.analysis_modal.computing = None;
7107                                self.busy = false;
7108                                self.drain_keys_on_next_loop = true;
7109                                return None;
7110                            }
7111                        },
7112                    };
7113                    match crate::statistics::compute_describe_from_lazy(
7114                        &lf,
7115                        total_rows,
7116                        self.sampling_threshold,
7117                        comp.sample_seed,
7118                        self.app_config.performance.polars_streaming,
7119                    ) {
7120                        Ok(results) => {
7121                            self.analysis_modal.describe_results = Some(results);
7122                            self.analysis_modal.computing = None;
7123                            self.busy = false;
7124                            self.drain_keys_on_next_loop = true;
7125                            None
7126                        }
7127                        Err(_e) => {
7128                            self.analysis_modal.computing = None;
7129                            self.busy = false;
7130                            self.drain_keys_on_next_loop = true;
7131                            None
7132                        }
7133                    }
7134                } else {
7135                    None
7136                }
7137            }
7138            AppEvent::AnalysisDistributionCompute => {
7139                if let Some(state) = &self.data_table_state {
7140                    let options = crate::statistics::ComputeOptions {
7141                        include_distribution_info: true,
7142                        include_distribution_analyses: true,
7143                        include_correlation_matrix: false,
7144                        include_skewness_kurtosis_outliers: true,
7145                        polars_streaming: self.app_config.performance.polars_streaming,
7146                    };
7147                    if let Ok(results) = crate::statistics::compute_statistics_with_options(
7148                        &state.lf,
7149                        self.sampling_threshold,
7150                        self.analysis_modal.random_seed,
7151                        options,
7152                    ) {
7153                        self.analysis_modal.distribution_results = Some(results);
7154                    }
7155                }
7156                self.analysis_modal.computing = None;
7157                self.busy = false;
7158                self.drain_keys_on_next_loop = true;
7159                None
7160            }
7161            AppEvent::AnalysisCorrelationCompute => {
7162                if let Some(state) = &self.data_table_state {
7163                    if let Ok(df) =
7164                        crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7165                    {
7166                        if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
7167                            self.analysis_modal.correlation_results =
7168                                Some(crate::statistics::AnalysisResults {
7169                                    column_statistics: vec![],
7170                                    total_rows: df.height(),
7171                                    sample_size: None,
7172                                    sample_seed: self.analysis_modal.random_seed,
7173                                    correlation_matrix: Some(matrix),
7174                                    distribution_analyses: vec![],
7175                                });
7176                        }
7177                    }
7178                }
7179                self.analysis_modal.computing = None;
7180                self.busy = false;
7181                self.drain_keys_on_next_loop = true;
7182                None
7183            }
7184            AppEvent::Search(query) => {
7185                let query_succeeded = if let Some(state) = &mut self.data_table_state {
7186                    state.query(query.clone());
7187                    state.error.is_none()
7188                } else {
7189                    false
7190                };
7191
7192                // Only close input mode if query succeeded (no error after execution)
7193                if query_succeeded {
7194                    // History was already saved in TextInputEvent::Submit handler
7195                    self.input_mode = InputMode::Normal;
7196                    self.query_input.set_focused(false);
7197                    // Re-enable error display in main view when closing query input
7198                    if let Some(state) = &mut self.data_table_state {
7199                        state.suppress_error_display = false;
7200                    }
7201                }
7202                // If there's an error, keep input mode open so user can fix the query
7203                // suppress_error_display remains true to keep main view clean
7204                None
7205            }
7206            AppEvent::SqlSearch(sql) => {
7207                let sql_succeeded = if let Some(state) = &mut self.data_table_state {
7208                    state.sql_query(sql.clone());
7209                    state.error.is_none()
7210                } else {
7211                    false
7212                };
7213                if sql_succeeded {
7214                    self.input_mode = InputMode::Normal;
7215                    self.sql_input.set_focused(false);
7216                    if let Some(state) = &mut self.data_table_state {
7217                        state.suppress_error_display = false;
7218                    }
7219                    Some(AppEvent::Collect)
7220                } else {
7221                    None
7222                }
7223            }
7224            AppEvent::FuzzySearch(query) => {
7225                let fuzzy_succeeded = if let Some(state) = &mut self.data_table_state {
7226                    state.fuzzy_search(query.clone());
7227                    state.error.is_none()
7228                } else {
7229                    false
7230                };
7231                if fuzzy_succeeded {
7232                    self.input_mode = InputMode::Normal;
7233                    self.fuzzy_input.set_focused(false);
7234                    if let Some(state) = &mut self.data_table_state {
7235                        state.suppress_error_display = false;
7236                    }
7237                    Some(AppEvent::Collect)
7238                } else {
7239                    None
7240                }
7241            }
7242            AppEvent::Filter(statements) => {
7243                if let Some(state) = &mut self.data_table_state {
7244                    state.filter(statements.clone());
7245                }
7246                None
7247            }
7248            AppEvent::Sort(columns, ascending) => {
7249                if let Some(state) = &mut self.data_table_state {
7250                    state.sort(columns.clone(), *ascending);
7251                }
7252                None
7253            }
7254            AppEvent::Reset => {
7255                if let Some(state) = &mut self.data_table_state {
7256                    state.reset();
7257                }
7258                // Clear active template when resetting
7259                self.active_template_id = None;
7260                None
7261            }
7262            AppEvent::ColumnOrder(order, locked_count) => {
7263                if let Some(state) = &mut self.data_table_state {
7264                    state.set_column_order(order.clone());
7265                    state.set_locked_columns(*locked_count);
7266                }
7267                None
7268            }
7269            AppEvent::Pivot(spec) => {
7270                self.busy = true;
7271                if let Some(state) = &mut self.data_table_state {
7272                    match state.pivot(spec) {
7273                        Ok(()) => {
7274                            self.pivot_melt_modal.close();
7275                            self.input_mode = InputMode::Normal;
7276                            Some(AppEvent::Collect)
7277                        }
7278                        Err(e) => {
7279                            self.busy = false;
7280                            self.error_modal
7281                                .show(crate::error_display::user_message_from_report(&e, None));
7282                            None
7283                        }
7284                    }
7285                } else {
7286                    self.busy = false;
7287                    None
7288                }
7289            }
7290            AppEvent::Melt(spec) => {
7291                self.busy = true;
7292                if let Some(state) = &mut self.data_table_state {
7293                    match state.melt(spec) {
7294                        Ok(()) => {
7295                            self.pivot_melt_modal.close();
7296                            self.input_mode = InputMode::Normal;
7297                            Some(AppEvent::Collect)
7298                        }
7299                        Err(e) => {
7300                            self.busy = false;
7301                            self.error_modal
7302                                .show(crate::error_display::user_message_from_report(&e, None));
7303                            None
7304                        }
7305                    }
7306                } else {
7307                    self.busy = false;
7308                    None
7309                }
7310            }
7311            AppEvent::ChartExport(path, format, title, width, height) => {
7312                self.busy = true;
7313                self.loading_state = LoadingState::Exporting {
7314                    file_path: path.clone(),
7315                    current_phase: "Exporting chart".to_string(),
7316                    progress_percent: 0,
7317                };
7318                Some(AppEvent::DoChartExport(
7319                    path.clone(),
7320                    *format,
7321                    title.clone(),
7322                    *width,
7323                    *height,
7324                ))
7325            }
7326            AppEvent::DoChartExport(path, format, title, width, height) => {
7327                let result = self.do_chart_export(path, *format, title, *width, *height);
7328                self.loading_state = LoadingState::Idle;
7329                self.busy = false;
7330                self.drain_keys_on_next_loop = true;
7331                match result {
7332                    Ok(()) => {
7333                        self.success_modal.show(format!(
7334                            "Chart exported successfully to\n{}",
7335                            path.display()
7336                        ));
7337                        self.chart_export_modal.close();
7338                    }
7339                    Err(e) => {
7340                        self.error_modal
7341                            .show(crate::error_display::user_message_from_report(
7342                                &e,
7343                                Some(path),
7344                            ));
7345                        self.chart_export_modal.reopen_with_path(path, *format);
7346                    }
7347                }
7348                None
7349            }
7350            AppEvent::Export(path, format, options) => {
7351                if let Some(_state) = &self.data_table_state {
7352                    self.busy = true;
7353                    // Show progress immediately
7354                    self.loading_state = LoadingState::Exporting {
7355                        file_path: path.clone(),
7356                        current_phase: "Preparing export".to_string(),
7357                        progress_percent: 0,
7358                    };
7359                    // Return DoExport to allow UI to render progress before blocking
7360                    Some(AppEvent::DoExport(path.clone(), *format, options.clone()))
7361                } else {
7362                    None
7363                }
7364            }
7365            AppEvent::DoExport(path, format, options) => {
7366                if let Some(_state) = &self.data_table_state {
7367                    // Phase 1: show "Collecting data" so UI can redraw before blocking collect
7368                    self.loading_state = LoadingState::Exporting {
7369                        file_path: path.clone(),
7370                        current_phase: "Collecting data".to_string(),
7371                        progress_percent: 10,
7372                    };
7373                    Some(AppEvent::DoExportCollect(
7374                        path.clone(),
7375                        *format,
7376                        options.clone(),
7377                    ))
7378                } else {
7379                    self.busy = false;
7380                    None
7381                }
7382            }
7383            AppEvent::DoExportCollect(path, format, options) => {
7384                if let Some(state) = &self.data_table_state {
7385                    match crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7386                    {
7387                        Ok(df) => {
7388                            self.export_df = Some(df);
7389                            let has_compression = match format {
7390                                ExportFormat::Csv => options.csv_compression.is_some(),
7391                                ExportFormat::Json => options.json_compression.is_some(),
7392                                ExportFormat::Ndjson => options.ndjson_compression.is_some(),
7393                                ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
7394                                    false
7395                                }
7396                            };
7397                            let phase = if has_compression {
7398                                "Writing and compressing file"
7399                            } else {
7400                                "Writing file"
7401                            };
7402                            self.loading_state = LoadingState::Exporting {
7403                                file_path: path.clone(),
7404                                current_phase: phase.to_string(),
7405                                progress_percent: 50,
7406                            };
7407                            Some(AppEvent::DoExportWrite(
7408                                path.clone(),
7409                                *format,
7410                                options.clone(),
7411                            ))
7412                        }
7413                        Err(e) => {
7414                            self.loading_state = LoadingState::Idle;
7415                            self.busy = false;
7416                            self.drain_keys_on_next_loop = true;
7417                            self.error_modal.show(format!(
7418                                "Export failed: {}",
7419                                crate::error_display::user_message_from_polars(&e)
7420                            ));
7421                            None
7422                        }
7423                    }
7424                } else {
7425                    self.busy = false;
7426                    None
7427                }
7428            }
7429            AppEvent::DoExportWrite(path, format, options) => {
7430                let result = self
7431                    .export_df
7432                    .take()
7433                    .map(|mut df| Self::export_data_from_df(&mut df, path, *format, options));
7434                self.loading_state = LoadingState::Idle;
7435                self.busy = false;
7436                self.drain_keys_on_next_loop = true;
7437                match result {
7438                    Some(Ok(())) => {
7439                        self.success_modal
7440                            .show(format!("Data exported successfully to\n{}", path.display()));
7441                    }
7442                    Some(Err(e)) => {
7443                        let error_msg = Self::format_export_error(&e, path);
7444                        self.error_modal.show(error_msg);
7445                    }
7446                    None => {}
7447                }
7448                None
7449            }
7450            AppEvent::DoLoadParquetMetadata => {
7451                let path = self.path.clone();
7452                if let Some(p) = &path {
7453                    if let Some(meta) = read_parquet_metadata(p) {
7454                        self.parquet_metadata_cache = Some(meta);
7455                    }
7456                }
7457                self.busy = false;
7458                self.drain_keys_on_next_loop = true;
7459                None
7460            }
7461            _ => None,
7462        }
7463    }
7464
7465    /// Perform chart export to file. Exports what is currently visible (effective x + y).
7466    /// Title is optional; blank or whitespace means no chart title on export.
7467    /// Width and height are used for PNG output (pixels); EPS uses fixed logical size.
7468    fn do_chart_export(
7469        &self,
7470        path: &Path,
7471        format: ChartExportFormat,
7472        title: &str,
7473        width: u32,
7474        height: u32,
7475    ) -> color_eyre::Result<()> {
7476        let state = self
7477            .data_table_state
7478            .as_ref()
7479            .ok_or_else(|| color_eyre::eyre::eyre!("No data loaded"))?;
7480        let chart_title = title.trim();
7481        let chart_title = if chart_title.is_empty() {
7482            None
7483        } else {
7484            Some(chart_title.to_string())
7485        };
7486
7487        match self.chart_modal.chart_kind {
7488            ChartKind::XY => {
7489                let x_column = self
7490                    .chart_modal
7491                    .effective_x_column()
7492                    .ok_or_else(|| color_eyre::eyre::eyre!("No X axis column selected"))?;
7493                let y_columns = self.chart_modal.effective_y_columns();
7494                if y_columns.is_empty() {
7495                    return Err(color_eyre::eyre::eyre!("No Y axis columns selected"));
7496                }
7497
7498                let row_limit_opt = self.chart_modal.row_limit;
7499                let row_limit = self.chart_modal.effective_row_limit();
7500                let cache_matches = self.chart_cache.xy.as_ref().is_some_and(|c| {
7501                    c.x_column == *x_column
7502                        && c.y_columns == y_columns
7503                        && c.row_limit == row_limit_opt
7504                });
7505
7506                let (series_vec, x_axis_kind_export, from_cache) = if cache_matches {
7507                    if let Some(cache) = self.chart_cache.xy.as_ref() {
7508                        let pts = if self.chart_modal.log_scale {
7509                            cache.series_log.as_ref().cloned().unwrap_or_else(|| {
7510                                cache
7511                                    .series
7512                                    .iter()
7513                                    .map(|s| {
7514                                        s.iter().map(|&(x, y)| (x, y.max(0.0).ln_1p())).collect()
7515                                    })
7516                                    .collect()
7517                            })
7518                        } else {
7519                            cache.series.clone()
7520                        };
7521                        (pts, cache.x_axis_kind, true)
7522                    } else {
7523                        let r = chart_data::prepare_chart_data(
7524                            &state.lf,
7525                            &state.schema,
7526                            x_column,
7527                            &y_columns,
7528                            row_limit,
7529                        )?;
7530                        (r.series, r.x_axis_kind, false)
7531                    }
7532                } else {
7533                    let r = chart_data::prepare_chart_data(
7534                        &state.lf,
7535                        &state.schema,
7536                        x_column,
7537                        &y_columns,
7538                        row_limit,
7539                    )?;
7540                    (r.series, r.x_axis_kind, false)
7541                };
7542
7543                let log_scale = self.chart_modal.log_scale;
7544                let series: Vec<ChartExportSeries> = series_vec
7545                    .iter()
7546                    .zip(y_columns.iter())
7547                    .filter(|(points, _)| !points.is_empty())
7548                    .map(|(points, name)| {
7549                        let pts = if log_scale && !from_cache {
7550                            points
7551                                .iter()
7552                                .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
7553                                .collect()
7554                        } else {
7555                            points.clone()
7556                        };
7557                        ChartExportSeries {
7558                            name: name.clone(),
7559                            points: pts,
7560                        }
7561                    })
7562                    .collect();
7563
7564                if series.is_empty() {
7565                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7566                }
7567
7568                let mut all_x_min = f64::INFINITY;
7569                let mut all_x_max = f64::NEG_INFINITY;
7570                let mut all_y_min = f64::INFINITY;
7571                let mut all_y_max = f64::NEG_INFINITY;
7572                for s in &series {
7573                    for &(x, y) in &s.points {
7574                        all_x_min = all_x_min.min(x);
7575                        all_x_max = all_x_max.max(x);
7576                        all_y_min = all_y_min.min(y);
7577                        all_y_max = all_y_max.max(y);
7578                    }
7579                }
7580
7581                let chart_type = self.chart_modal.chart_type;
7582                let y_starts_at_zero = self.chart_modal.y_starts_at_zero;
7583                let y_min_bounds = if chart_type == ChartType::Bar {
7584                    0.0_f64.min(all_y_min)
7585                } else if y_starts_at_zero {
7586                    0.0
7587                } else {
7588                    all_y_min
7589                };
7590                let y_max_bounds = if all_y_max > y_min_bounds {
7591                    all_y_max
7592                } else {
7593                    y_min_bounds + 1.0
7594                };
7595                let x_min_bounds = if all_x_max > all_x_min {
7596                    all_x_min
7597                } else {
7598                    all_x_min - 0.5
7599                };
7600                let x_max_bounds = if all_x_max > all_x_min {
7601                    all_x_max
7602                } else {
7603                    all_x_min + 0.5
7604                };
7605
7606                let x_label = x_column.to_string();
7607                let y_label = y_columns.join(", ");
7608                let bounds = ChartExportBounds {
7609                    x_min: x_min_bounds,
7610                    x_max: x_max_bounds,
7611                    y_min: y_min_bounds,
7612                    y_max: y_max_bounds,
7613                    x_label: x_label.clone(),
7614                    y_label: y_label.clone(),
7615                    x_axis_kind: x_axis_kind_export,
7616                    log_scale: self.chart_modal.log_scale,
7617                    chart_title,
7618                };
7619
7620                match format {
7621                    ChartExportFormat::Png => {
7622                        write_chart_png(path, &series, chart_type, &bounds, (width, height))
7623                    }
7624                    ChartExportFormat::Eps => write_chart_eps(path, &series, chart_type, &bounds),
7625                }
7626            }
7627            ChartKind::Histogram => {
7628                let column = self
7629                    .chart_modal
7630                    .effective_hist_column()
7631                    .ok_or_else(|| color_eyre::eyre::eyre!("No histogram column selected"))?;
7632                let row_limit = self.chart_modal.effective_row_limit();
7633                let data = if let Some(c) = self.chart_cache.histogram.as_ref().filter(|c| {
7634                    c.column == column
7635                        && c.bins == self.chart_modal.hist_bins
7636                        && c.row_limit == self.chart_modal.row_limit
7637                }) {
7638                    c.data.clone()
7639                } else {
7640                    chart_data::prepare_histogram_data(
7641                        &state.lf,
7642                        &column,
7643                        self.chart_modal.hist_bins,
7644                        row_limit,
7645                    )?
7646                };
7647                if data.bins.is_empty() {
7648                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7649                }
7650                let points: Vec<(f64, f64)> =
7651                    data.bins.iter().map(|b| (b.center, b.count)).collect();
7652                let series = vec![ChartExportSeries {
7653                    name: column.clone(),
7654                    points,
7655                }];
7656                let x_max = if data.x_max > data.x_min {
7657                    data.x_max
7658                } else {
7659                    data.x_min + 1.0
7660                };
7661                let y_max = if data.max_count > 0.0 {
7662                    data.max_count
7663                } else {
7664                    1.0
7665                };
7666                let bounds = ChartExportBounds {
7667                    x_min: data.x_min,
7668                    x_max,
7669                    y_min: 0.0,
7670                    y_max,
7671                    x_label: column.clone(),
7672                    y_label: "Count".to_string(),
7673                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7674                    log_scale: false,
7675                    chart_title,
7676                };
7677                match format {
7678                    ChartExportFormat::Png => {
7679                        write_chart_png(path, &series, ChartType::Bar, &bounds, (width, height))
7680                    }
7681                    ChartExportFormat::Eps => {
7682                        write_chart_eps(path, &series, ChartType::Bar, &bounds)
7683                    }
7684                }
7685            }
7686            ChartKind::BoxPlot => {
7687                let column = self
7688                    .chart_modal
7689                    .effective_box_column()
7690                    .ok_or_else(|| color_eyre::eyre::eyre!("No box plot column selected"))?;
7691                let row_limit = self.chart_modal.effective_row_limit();
7692                let data = if let Some(c) = self
7693                    .chart_cache
7694                    .box_plot
7695                    .as_ref()
7696                    .filter(|c| c.column == column && c.row_limit == self.chart_modal.row_limit)
7697                {
7698                    c.data.clone()
7699                } else {
7700                    chart_data::prepare_box_plot_data(
7701                        &state.lf,
7702                        std::slice::from_ref(&column),
7703                        row_limit,
7704                    )?
7705                };
7706                if data.stats.is_empty() {
7707                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7708                }
7709                let bounds = BoxPlotExportBounds {
7710                    y_min: data.y_min,
7711                    y_max: data.y_max,
7712                    x_labels: vec![column.clone()],
7713                    x_label: "Columns".to_string(),
7714                    y_label: "Value".to_string(),
7715                    chart_title,
7716                };
7717                match format {
7718                    ChartExportFormat::Png => {
7719                        write_box_plot_png(path, &data, &bounds, (width, height))
7720                    }
7721                    ChartExportFormat::Eps => write_box_plot_eps(path, &data, &bounds),
7722                }
7723            }
7724            ChartKind::Kde => {
7725                let column = self
7726                    .chart_modal
7727                    .effective_kde_column()
7728                    .ok_or_else(|| color_eyre::eyre::eyre!("No KDE column selected"))?;
7729                let row_limit = self.chart_modal.effective_row_limit();
7730                let data = if let Some(c) = self.chart_cache.kde.as_ref().filter(|c| {
7731                    c.column == column
7732                        && c.bandwidth_factor == self.chart_modal.kde_bandwidth_factor
7733                        && c.row_limit == self.chart_modal.row_limit
7734                }) {
7735                    c.data.clone()
7736                } else {
7737                    chart_data::prepare_kde_data(
7738                        &state.lf,
7739                        std::slice::from_ref(&column),
7740                        self.chart_modal.kde_bandwidth_factor,
7741                        row_limit,
7742                    )?
7743                };
7744                if data.series.is_empty() {
7745                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7746                }
7747                let series: Vec<ChartExportSeries> = data
7748                    .series
7749                    .iter()
7750                    .map(|s| ChartExportSeries {
7751                        name: s.name.clone(),
7752                        points: s.points.clone(),
7753                    })
7754                    .collect();
7755                let bounds = ChartExportBounds {
7756                    x_min: data.x_min,
7757                    x_max: data.x_max,
7758                    y_min: 0.0,
7759                    y_max: data.y_max,
7760                    x_label: column.clone(),
7761                    y_label: "Density".to_string(),
7762                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7763                    log_scale: false,
7764                    chart_title,
7765                };
7766                match format {
7767                    ChartExportFormat::Png => {
7768                        write_chart_png(path, &series, ChartType::Line, &bounds, (width, height))
7769                    }
7770                    ChartExportFormat::Eps => {
7771                        write_chart_eps(path, &series, ChartType::Line, &bounds)
7772                    }
7773                }
7774            }
7775            ChartKind::Heatmap => {
7776                let x_column = self
7777                    .chart_modal
7778                    .effective_heatmap_x_column()
7779                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap X column selected"))?;
7780                let y_column = self
7781                    .chart_modal
7782                    .effective_heatmap_y_column()
7783                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap Y column selected"))?;
7784                let row_limit = self.chart_modal.effective_row_limit();
7785                let data = if let Some(c) = self.chart_cache.heatmap.as_ref().filter(|c| {
7786                    c.x_column == *x_column
7787                        && c.y_column == *y_column
7788                        && c.bins == self.chart_modal.heatmap_bins
7789                        && c.row_limit == self.chart_modal.row_limit
7790                }) {
7791                    c.data.clone()
7792                } else {
7793                    chart_data::prepare_heatmap_data(
7794                        &state.lf,
7795                        &x_column,
7796                        &y_column,
7797                        self.chart_modal.heatmap_bins,
7798                        row_limit,
7799                    )?
7800                };
7801                if data.counts.is_empty() || data.max_count <= 0.0 {
7802                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7803                }
7804                let bounds = ChartExportBounds {
7805                    x_min: data.x_min,
7806                    x_max: data.x_max,
7807                    y_min: data.y_min,
7808                    y_max: data.y_max,
7809                    x_label: x_column.clone(),
7810                    y_label: y_column.clone(),
7811                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7812                    log_scale: false,
7813                    chart_title,
7814                };
7815                match format {
7816                    ChartExportFormat::Png => {
7817                        write_heatmap_png(path, &data, &bounds, (width, height))
7818                    }
7819                    ChartExportFormat::Eps => write_heatmap_eps(path, &data, &bounds),
7820                }
7821            }
7822        }
7823    }
7824
7825    fn apply_template(&mut self, template: &Template) -> Result<()> {
7826        // Save state before applying template so we can restore on failure
7827        let saved_state = self
7828            .data_table_state
7829            .as_ref()
7830            .map(|state| TemplateApplicationState {
7831                lf: state.lf.clone(),
7832                schema: state.schema.clone(),
7833                active_query: state.active_query.clone(),
7834                active_sql_query: state.get_active_sql_query().to_string(),
7835                active_fuzzy_query: state.get_active_fuzzy_query().to_string(),
7836                filters: state.get_filters().to_vec(),
7837                sort_columns: state.get_sort_columns().to_vec(),
7838                sort_ascending: state.get_sort_ascending(),
7839                column_order: state.get_column_order().to_vec(),
7840                locked_columns_count: state.locked_columns_count(),
7841            });
7842        let saved_active_template_id = self.active_template_id.clone();
7843
7844        if let Some(state) = &mut self.data_table_state {
7845            state.error = None;
7846
7847            // At most one of SQL or DSL query is stored per template; then fuzzy. Apply in that order.
7848            let sql_trimmed = template.settings.sql_query.as_deref().unwrap_or("").trim();
7849            let query_opt = template.settings.query.as_deref().filter(|s| !s.is_empty());
7850            let fuzzy_trimmed = template
7851                .settings
7852                .fuzzy_query
7853                .as_deref()
7854                .unwrap_or("")
7855                .trim();
7856
7857            if !sql_trimmed.is_empty() {
7858                state.sql_query(template.settings.sql_query.clone().unwrap_or_default());
7859            } else if let Some(q) = query_opt {
7860                state.query(q.to_string());
7861            }
7862            if let Some(error) = state.error.clone() {
7863                if let Some(saved) = saved_state {
7864                    self.restore_state(saved);
7865                }
7866                self.active_template_id = saved_active_template_id;
7867                return Err(color_eyre::eyre::eyre!(
7868                    "{}",
7869                    crate::error_display::user_message_from_polars(&error)
7870                ));
7871            }
7872
7873            if !fuzzy_trimmed.is_empty() {
7874                state.fuzzy_search(template.settings.fuzzy_query.clone().unwrap_or_default());
7875                if let Some(error) = state.error.clone() {
7876                    if let Some(saved) = saved_state {
7877                        self.restore_state(saved);
7878                    }
7879                    self.active_template_id = saved_active_template_id;
7880                    return Err(color_eyre::eyre::eyre!(
7881                        "{}",
7882                        crate::error_display::user_message_from_polars(&error)
7883                    ));
7884                }
7885            }
7886
7887            // Apply filters
7888            if !template.settings.filters.is_empty() {
7889                state.filter(template.settings.filters.clone());
7890                // Check for errors after filter
7891                let error_opt = state.error.clone();
7892                if let Some(error) = error_opt {
7893                    // End the if let block to drop the borrow
7894                    if let Some(saved) = saved_state {
7895                        self.restore_state(saved);
7896                    }
7897                    self.active_template_id = saved_active_template_id;
7898                    return Err(color_eyre::eyre::eyre!("{}", error));
7899                }
7900            }
7901
7902            // Apply sort
7903            if !template.settings.sort_columns.is_empty() {
7904                state.sort(
7905                    template.settings.sort_columns.clone(),
7906                    template.settings.sort_ascending,
7907                );
7908                // Check for errors after sort
7909                let error_opt = state.error.clone();
7910                if let Some(error) = error_opt {
7911                    // End the if let block to drop the borrow
7912                    if let Some(saved) = saved_state {
7913                        self.restore_state(saved);
7914                    }
7915                    self.active_template_id = saved_active_template_id;
7916                    return Err(color_eyre::eyre::eyre!("{}", error));
7917                }
7918            }
7919
7920            // Apply pivot or melt (reshape) if present. Order: query → filters → sort → reshape → column_order.
7921            if let Some(ref spec) = template.settings.pivot {
7922                if let Err(e) = state.pivot(spec) {
7923                    if let Some(saved) = saved_state {
7924                        self.restore_state(saved);
7925                    }
7926                    self.active_template_id = saved_active_template_id;
7927                    return Err(color_eyre::eyre::eyre!(
7928                        "{}",
7929                        crate::error_display::user_message_from_report(&e, None)
7930                    ));
7931                }
7932            } else if let Some(ref spec) = template.settings.melt {
7933                if let Err(e) = state.melt(spec) {
7934                    if let Some(saved) = saved_state {
7935                        self.restore_state(saved);
7936                    }
7937                    self.active_template_id = saved_active_template_id;
7938                    return Err(color_eyre::eyre::eyre!(
7939                        "{}",
7940                        crate::error_display::user_message_from_report(&e, None)
7941                    ));
7942                }
7943            }
7944
7945            // Apply column order and locks
7946            if !template.settings.column_order.is_empty() {
7947                state.set_column_order(template.settings.column_order.clone());
7948                // Check for errors after set_column_order
7949                let error_opt = state.error.clone();
7950                if let Some(error) = error_opt {
7951                    // End the if let block to drop the borrow
7952                    if let Some(saved) = saved_state {
7953                        self.restore_state(saved);
7954                    }
7955                    self.active_template_id = saved_active_template_id;
7956                    return Err(color_eyre::eyre::eyre!("{}", error));
7957                }
7958                state.set_locked_columns(template.settings.locked_columns_count);
7959                // Check for errors after set_locked_columns
7960                let error_opt = state.error.clone();
7961                if let Some(error) = error_opt {
7962                    // End the if let block to drop the borrow
7963                    if let Some(saved) = saved_state {
7964                        self.restore_state(saved);
7965                    }
7966                    self.active_template_id = saved_active_template_id;
7967                    return Err(color_eyre::eyre::eyre!("{}", error));
7968                }
7969            }
7970        }
7971
7972        // Update template usage statistics
7973        // Note: We need to clone and update the template, then save it
7974        // For now, we'll update the template manager's internal state
7975        // A more complete implementation would reload templates after saving
7976        if let Some(path) = &self.path {
7977            let mut updated_template = template.clone();
7978            updated_template.last_used = Some(std::time::SystemTime::now());
7979            updated_template.usage_count += 1;
7980            updated_template.last_matched_file = Some(path.clone());
7981
7982            // Save updated template
7983            let _ = self.template_manager.save_template(&updated_template);
7984        }
7985
7986        // Track active template
7987        self.active_template_id = Some(template.id.clone());
7988
7989        Ok(())
7990    }
7991
7992    /// Format export error messages to be more user-friendly using type-based handling.
7993    fn format_export_error(error: &color_eyre::eyre::Report, path: &Path) -> String {
7994        use std::io;
7995
7996        for cause in error.chain() {
7997            if let Some(io_err) = cause.downcast_ref::<io::Error>() {
7998                let msg = crate::error_display::user_message_from_io(io_err, None);
7999                return format!("Cannot write to {}: {}", path.display(), msg);
8000            }
8001            if let Some(pe) = cause.downcast_ref::<polars::prelude::PolarsError>() {
8002                let msg = crate::error_display::user_message_from_polars(pe);
8003                return format!("Export failed: {}", msg);
8004            }
8005        }
8006        let error_str = error.to_string();
8007        let first_line = error_str.lines().next().unwrap_or("Unknown error").trim();
8008        format!("Export failed: {}", first_line)
8009    }
8010
8011    /// Write an already-collected DataFrame to file. Used by two-phase export (DoExportWrite).
8012    fn export_data_from_df(
8013        df: &mut DataFrame,
8014        path: &Path,
8015        format: ExportFormat,
8016        options: &ExportOptions,
8017    ) -> Result<()> {
8018        use polars::prelude::*;
8019        use std::fs::File;
8020        use std::io::{BufWriter, Write};
8021
8022        match format {
8023            ExportFormat::Csv => {
8024                use polars::prelude::CsvWriter;
8025                if let Some(compression) = options.csv_compression {
8026                    // Write to compressed file
8027                    let file = File::create(path)?;
8028                    let writer: Box<dyn Write> = match compression {
8029                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
8030                            file,
8031                            flate2::Compression::default(),
8032                        )),
8033                        CompressionFormat::Zstd => {
8034                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
8035                        }
8036                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
8037                            file,
8038                            bzip2::Compression::default(),
8039                        )),
8040                        CompressionFormat::Xz => {
8041                            Box::new(xz2::write::XzEncoder::new(
8042                                file, 6, // compression level
8043                            ))
8044                        }
8045                    };
8046                    CsvWriter::new(writer)
8047                        .with_separator(options.csv_delimiter)
8048                        .include_header(options.csv_include_header)
8049                        .finish(df)?;
8050                } else {
8051                    // Write uncompressed
8052                    let file = File::create(path)?;
8053                    CsvWriter::new(file)
8054                        .with_separator(options.csv_delimiter)
8055                        .include_header(options.csv_include_header)
8056                        .finish(df)?;
8057                }
8058            }
8059            ExportFormat::Parquet => {
8060                use polars::prelude::ParquetWriter;
8061                let file = File::create(path)?;
8062                let mut writer = BufWriter::new(file);
8063                ParquetWriter::new(&mut writer).finish(df)?;
8064            }
8065            ExportFormat::Json => {
8066                use polars::prelude::JsonWriter;
8067                if let Some(compression) = options.json_compression {
8068                    // Write to compressed file
8069                    let file = File::create(path)?;
8070                    let writer: Box<dyn Write> = match compression {
8071                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
8072                            file,
8073                            flate2::Compression::default(),
8074                        )),
8075                        CompressionFormat::Zstd => {
8076                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
8077                        }
8078                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
8079                            file,
8080                            bzip2::Compression::default(),
8081                        )),
8082                        CompressionFormat::Xz => {
8083                            Box::new(xz2::write::XzEncoder::new(
8084                                file, 6, // compression level
8085                            ))
8086                        }
8087                    };
8088                    JsonWriter::new(writer)
8089                        .with_json_format(JsonFormat::Json)
8090                        .finish(df)?;
8091                } else {
8092                    // Write uncompressed
8093                    let file = File::create(path)?;
8094                    JsonWriter::new(file)
8095                        .with_json_format(JsonFormat::Json)
8096                        .finish(df)?;
8097                }
8098            }
8099            ExportFormat::Ndjson => {
8100                use polars::prelude::{JsonFormat, JsonWriter};
8101                if let Some(compression) = options.ndjson_compression {
8102                    // Write to compressed file
8103                    let file = File::create(path)?;
8104                    let writer: Box<dyn Write> = match compression {
8105                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
8106                            file,
8107                            flate2::Compression::default(),
8108                        )),
8109                        CompressionFormat::Zstd => {
8110                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
8111                        }
8112                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
8113                            file,
8114                            bzip2::Compression::default(),
8115                        )),
8116                        CompressionFormat::Xz => {
8117                            Box::new(xz2::write::XzEncoder::new(
8118                                file, 6, // compression level
8119                            ))
8120                        }
8121                    };
8122                    JsonWriter::new(writer)
8123                        .with_json_format(JsonFormat::JsonLines)
8124                        .finish(df)?;
8125                } else {
8126                    // Write uncompressed
8127                    let file = File::create(path)?;
8128                    JsonWriter::new(file)
8129                        .with_json_format(JsonFormat::JsonLines)
8130                        .finish(df)?;
8131                }
8132            }
8133            ExportFormat::Ipc => {
8134                use polars::prelude::IpcWriter;
8135                let file = File::create(path)?;
8136                let mut writer = BufWriter::new(file);
8137                IpcWriter::new(&mut writer).finish(df)?;
8138            }
8139            ExportFormat::Avro => {
8140                use polars::io::avro::AvroWriter;
8141                let file = File::create(path)?;
8142                let mut writer = BufWriter::new(file);
8143                AvroWriter::new(&mut writer).finish(df)?;
8144            }
8145        }
8146
8147        Ok(())
8148    }
8149
8150    #[allow(dead_code)] // Used only when not using two-phase export; kept for tests/single-shot use
8151    fn export_data(
8152        state: &DataTableState,
8153        path: &Path,
8154        format: ExportFormat,
8155        options: &ExportOptions,
8156    ) -> Result<()> {
8157        let mut df = crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)?;
8158        Self::export_data_from_df(&mut df, path, format, options)
8159    }
8160
8161    fn restore_state(&mut self, saved: TemplateApplicationState) {
8162        if let Some(state) = &mut self.data_table_state {
8163            // Clone saved lf and schema so we can restore them after applying methods
8164            let saved_lf = saved.lf.clone();
8165            let saved_schema = saved.schema.clone();
8166
8167            // Restore lf and schema directly (these are public fields)
8168            // This preserves the exact LazyFrame state from before template application
8169            state.lf = saved.lf;
8170            state.schema = saved.schema;
8171            state.active_query = saved.active_query;
8172            state.active_sql_query = saved.active_sql_query;
8173            state.active_fuzzy_query = saved.active_fuzzy_query;
8174            // Clear error
8175            state.error = None;
8176            // Restore private fields using public methods
8177            // Note: These methods will modify lf by applying transformations, but since
8178            // we've already restored lf to the saved state, we need to restore it again after
8179            state.filter(saved.filters.clone());
8180            if state.error.is_none() {
8181                state.sort(saved.sort_columns.clone(), saved.sort_ascending);
8182            }
8183            if state.error.is_none() {
8184                state.set_column_order(saved.column_order.clone());
8185            }
8186            if state.error.is_none() {
8187                state.set_locked_columns(saved.locked_columns_count);
8188            }
8189            // Restore the exact saved lf and schema (in case filter/sort modified them)
8190            state.lf = saved_lf;
8191            state.schema = saved_schema;
8192            state.collect();
8193        }
8194    }
8195
8196    pub fn create_template_from_current_state(
8197        &mut self,
8198        name: String,
8199        description: Option<String>,
8200        match_criteria: template::MatchCriteria,
8201    ) -> Result<template::Template> {
8202        let settings = if let Some(state) = &self.data_table_state {
8203            let (query, sql_query, fuzzy_query) = active_query_settings(
8204                state.get_active_query(),
8205                state.get_active_sql_query(),
8206                state.get_active_fuzzy_query(),
8207            );
8208            template::TemplateSettings {
8209                query,
8210                sql_query,
8211                fuzzy_query,
8212                filters: state.get_filters().to_vec(),
8213                sort_columns: state.get_sort_columns().to_vec(),
8214                sort_ascending: state.get_sort_ascending(),
8215                column_order: state.get_column_order().to_vec(),
8216                locked_columns_count: state.locked_columns_count(),
8217                pivot: state.last_pivot_spec().cloned(),
8218                melt: state.last_melt_spec().cloned(),
8219            }
8220        } else {
8221            template::TemplateSettings {
8222                query: None,
8223                sql_query: None,
8224                fuzzy_query: None,
8225                filters: Vec::new(),
8226                sort_columns: Vec::new(),
8227                sort_ascending: true,
8228                column_order: Vec::new(),
8229                locked_columns_count: 0,
8230                pivot: None,
8231                melt: None,
8232            }
8233        };
8234
8235        self.template_manager
8236            .create_template(name, description, match_criteria, settings)
8237    }
8238
8239    fn get_help_info(&self) -> (String, String) {
8240        let (title, content) = match self.input_mode {
8241            InputMode::Normal => ("Main View Help", help_strings::main_view()),
8242            InputMode::Editing => match self.input_type {
8243                Some(InputType::Search) => ("Query Help", help_strings::query()),
8244                _ => ("Editing Help", help_strings::editing()),
8245            },
8246            InputMode::SortFilter => ("Sort & Filter Help", help_strings::sort_filter()),
8247            InputMode::PivotMelt => ("Pivot / Melt Help", help_strings::pivot_melt()),
8248            InputMode::Export => ("Export Help", help_strings::export()),
8249            InputMode::Info => ("Info Panel Help", help_strings::info_panel()),
8250            InputMode::Chart => ("Chart Help", help_strings::chart()),
8251        };
8252        (title.to_string(), content.to_string())
8253    }
8254}
8255
8256impl Widget for &mut App {
8257    fn render(self, area: Rect, buf: &mut Buffer) {
8258        self.debug.num_frames += 1;
8259        if self.debug.enabled {
8260            self.debug.show_help_at_render = self.show_help;
8261        }
8262
8263        use crate::render::context::RenderContext;
8264        use crate::render::layout::{app_layout, centered_rect_loading};
8265        use crate::render::main_view::MainViewContent;
8266
8267        let ctx = RenderContext::from_theme_and_config(
8268            &self.theme,
8269            self.table_cell_padding,
8270            self.column_colors,
8271        );
8272
8273        let main_view_content = MainViewContent::from_app_state(
8274            self.analysis_modal.active,
8275            self.input_mode == InputMode::Chart,
8276        );
8277
8278        Clear.render(area, buf);
8279        let background_color = self.color("background");
8280        Block::default()
8281            .style(Style::default().bg(background_color))
8282            .render(area, buf);
8283
8284        let app_layout = app_layout(area, self.debug.enabled);
8285        let main_area = app_layout.main_view;
8286        Clear.render(main_area, buf);
8287
8288        crate::render::main_view_render::render_main_view(area, main_area, buf, self, &ctx);
8289
8290        // Render loading progress popover (min 25 chars wide, max 25% of area; throbber spins via busy in controls)
8291        if matches!(self.loading_state, LoadingState::Loading { .. }) {
8292            if let LoadingState::Loading {
8293                current_phase,
8294                progress_percent,
8295                ..
8296            } = &self.loading_state
8297            {
8298                let popover_rect = centered_rect_loading(area);
8299                crate::render::overlays::render_loading_gauge(
8300                    popover_rect,
8301                    buf,
8302                    "Loading",
8303                    current_phase,
8304                    *progress_percent,
8305                    ctx.modal_border,
8306                    ctx.primary_chart_series_color,
8307                );
8308            }
8309        }
8310        if matches!(self.loading_state, LoadingState::Exporting { .. }) {
8311            if let LoadingState::Exporting {
8312                file_path,
8313                current_phase,
8314                progress_percent,
8315            } = &self.loading_state
8316            {
8317                let label = format!("{}: {}", current_phase, file_path.display());
8318                crate::render::overlays::render_loading_gauge(
8319                    area,
8320                    buf,
8321                    "Exporting",
8322                    &label,
8323                    *progress_percent,
8324                    ctx.modal_border,
8325                    ctx.primary_chart_series_color,
8326                );
8327            }
8328        }
8329
8330        if self.confirmation_modal.active {
8331            crate::render::overlays::render_confirmation_modal(
8332                area,
8333                buf,
8334                &self.confirmation_modal,
8335                &ctx,
8336            );
8337        }
8338        if self.success_modal.active {
8339            crate::render::overlays::render_success_modal(area, buf, &self.success_modal, &ctx);
8340        }
8341        if self.error_modal.active {
8342            crate::render::overlays::render_error_modal(area, buf, &self.error_modal, &ctx);
8343        }
8344        if self.show_help
8345            || (self.template_modal.active && self.template_modal.show_help)
8346            || (self.analysis_modal.active && self.analysis_modal.show_help)
8347        {
8348            let (title, text): (String, String) =
8349                if self.analysis_modal.active && self.analysis_modal.show_help {
8350                    crate::render::analysis_view::help_title_and_text(&self.analysis_modal)
8351                } else if self.template_modal.active {
8352                    (
8353                        "Template Help".to_string(),
8354                        help_strings::template().to_string(),
8355                    )
8356                } else {
8357                    let (t, txt) = self.get_help_info();
8358                    (t.to_string(), txt.to_string())
8359                };
8360            crate::render::overlays::render_help_overlay(
8361                area,
8362                buf,
8363                &title,
8364                &text,
8365                &mut self.help_scroll,
8366                &ctx,
8367            );
8368        }
8369
8370        let row_count = self.data_table_state.as_ref().map(|s| s.num_rows);
8371        let use_unicode_throbber = std::env::var("LANG")
8372            .map(|l| l.to_uppercase().contains("UTF-8"))
8373            .unwrap_or(false);
8374        let mut controls = Controls::from_context(row_count.unwrap_or(0), &ctx)
8375            .with_unicode_throbber(use_unicode_throbber);
8376
8377        match crate::render::main_view::control_bar_spec(self, main_view_content) {
8378            crate::render::main_view::ControlBarSpec::Datatable {
8379                dimmed,
8380                query_active,
8381            } => {
8382                controls = controls.with_dimmed(dimmed).with_query_active(query_active);
8383            }
8384            crate::render::main_view::ControlBarSpec::Custom(pairs) => {
8385                controls = controls.with_custom_controls(pairs);
8386            }
8387        }
8388
8389        if self.busy {
8390            self.throbber_frame = self.throbber_frame.wrapping_add(1);
8391        }
8392        controls = controls.with_busy(self.busy, self.throbber_frame);
8393        controls.render(app_layout.control_bar, buf);
8394        if let Some(debug_area) = app_layout.debug {
8395            self.debug.render(debug_area, buf);
8396        }
8397    }
8398}
8399
8400/// Run the TUI with either file paths or an existing LazyFrame. Single event loop used by CLI and Python binding.
8401pub fn run(input: RunInput, config: Option<AppConfig>) -> Result<()> {
8402    use std::io::Write;
8403    use std::sync::{mpsc, Mutex, Once};
8404
8405    let config = match config {
8406        Some(c) => c,
8407        None => AppConfig::load(APP_NAME)?,
8408    };
8409
8410    let opts = match &input {
8411        RunInput::Paths(_, o) => o.clone(),
8412        RunInput::LazyFrame(_, o) => o.clone(),
8413    };
8414
8415    let theme = Theme::from_config(&config.theme)
8416        .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
8417
8418    // Install color_eyre at most once per process (e.g. first datui.view() in Python).
8419    // Subsequent run() calls skip install and reuse the result; no error-message detection.
8420    static COLOR_EYRE_INIT: Once = Once::new();
8421    static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
8422    COLOR_EYRE_INIT.call_once(|| {
8423        *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
8424    });
8425    if let Some(Err(e)) = INSTALL_RESULT
8426        .lock()
8427        .unwrap_or_else(|e| e.into_inner())
8428        .as_ref()
8429    {
8430        return Err(color_eyre::eyre::eyre!(e.to_string()));
8431    }
8432    // Require at least one path so event handlers can safely use paths[0].
8433    if let RunInput::Paths(ref paths, _) = input {
8434        if paths.is_empty() {
8435            return Err(color_eyre::eyre::eyre!("At least one path is required"));
8436        }
8437        for path in paths {
8438            let s = path.to_string_lossy();
8439            let is_remote = s.starts_with("s3://")
8440                || s.starts_with("gs://")
8441                || s.starts_with("http://")
8442                || s.starts_with("https://");
8443            let is_glob = s.contains('*');
8444            if !is_remote && !is_glob && !path.exists() {
8445                return Err(std::io::Error::new(
8446                    std::io::ErrorKind::NotFound,
8447                    format!("File not found: {}", path.display()),
8448                )
8449                .into());
8450            }
8451        }
8452    }
8453    let mut terminal = ratatui::try_init().map_err(|e| {
8454        color_eyre::eyre::eyre!(
8455            "datui requires an interactive terminal (TTY). No terminal detected: {}. \
8456             Run from a terminal or ensure stdout is connected to a TTY.",
8457            e
8458        )
8459    })?;
8460    let (tx, rx) = mpsc::channel::<AppEvent>();
8461    let mut app = App::new_with_config(tx.clone(), theme, config.clone());
8462    if opts.debug {
8463        app.enable_debug();
8464    }
8465
8466    terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8467
8468    match input {
8469        RunInput::Paths(paths, opts) => {
8470            tx.send(AppEvent::Open(paths, opts))?;
8471        }
8472        RunInput::LazyFrame(lf, opts) => {
8473            // Show loading dialog immediately so it is visible when launch is from Python/LazyFrame
8474            // (before sending the event and before any blocking work in the event handler).
8475            app.set_loading_phase("Scanning input", 10);
8476            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8477            let _ = std::io::stdout().flush();
8478            // Brief pause so the terminal can display the frame when run from Python (e.g. maturin).
8479            std::thread::sleep(std::time::Duration::from_millis(150));
8480            tx.send(AppEvent::OpenLazyFrame(lf, opts))?;
8481        }
8482    }
8483
8484    // Process load events and draw so the loading progress dialog updates (e.g. "Caching schema")
8485    // before any blocking work. Keeps processing until no event is received (timeout).
8486    loop {
8487        let event = match rx.recv_timeout(std::time::Duration::from_millis(50)) {
8488            Ok(ev) => ev,
8489            Err(mpsc::RecvTimeoutError::Timeout) => break,
8490            Err(mpsc::RecvTimeoutError::Disconnected) => break,
8491        };
8492        match event {
8493            AppEvent::Exit => break,
8494            AppEvent::Crash(msg) => {
8495                ratatui::restore();
8496                return Err(color_eyre::eyre::eyre!(msg));
8497            }
8498            ev => {
8499                if let Some(next) = app.event(&ev) {
8500                    let _ = tx.send(next);
8501                }
8502                terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8503                let _ = std::io::stdout().flush();
8504                // After processing DoLoadSchema we've drawn "Caching schema"; next event is DoLoadSchemaBlocking (blocking).
8505                // Leave it for the main loop so we don't block here.
8506                if matches!(ev, AppEvent::DoLoadSchema(..)) {
8507                    break;
8508                }
8509            }
8510        }
8511    }
8512
8513    loop {
8514        if crossterm::event::poll(std::time::Duration::from_millis(
8515            config.performance.event_poll_interval_ms,
8516        ))? {
8517            match crossterm::event::read()? {
8518                crossterm::event::Event::Key(key) => {
8519                    if key.is_press() {
8520                        tx.send(AppEvent::Key(key))?
8521                    }
8522                }
8523                crossterm::event::Event::Resize(cols, rows) => {
8524                    tx.send(AppEvent::Resize(cols, rows))?
8525                }
8526                _ => {}
8527            }
8528        }
8529
8530        let updated = match rx.recv_timeout(std::time::Duration::from_millis(0)) {
8531            Ok(event) => {
8532                match event {
8533                    AppEvent::Exit => break,
8534                    AppEvent::Crash(msg) => {
8535                        ratatui::restore();
8536                        return Err(color_eyre::eyre::eyre!(msg));
8537                    }
8538                    event => {
8539                        if let Some(next) = app.event(&event) {
8540                            tx.send(next)?;
8541                        }
8542                    }
8543                }
8544                true
8545            }
8546            Err(mpsc::RecvTimeoutError::Timeout) => false,
8547            Err(mpsc::RecvTimeoutError::Disconnected) => break,
8548        };
8549
8550        if updated {
8551            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
8552            if app.should_drain_keys() {
8553                while crossterm::event::poll(std::time::Duration::from_millis(0))? {
8554                    let _ = crossterm::event::read();
8555                }
8556                app.clear_drain_keys_request();
8557            }
8558        }
8559    }
8560
8561    ratatui::restore();
8562    Ok(())
8563}