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