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::{
13    read_parquet_metadata, DataTableInfo, InfoContext, InfoFocus, InfoModal, InfoTab,
14    ParquetMetadataCache,
15};
16
17use ratatui::layout::{Alignment, Constraint, Direction, Layout};
18use ratatui::style::{Color, Modifier, Style};
19use ratatui::{buffer::Buffer, layout::Rect, widgets::Widget};
20
21use ratatui::widgets::{
22    Block, BorderType, Borders, Cell, Clear, Gauge, List, ListItem, Paragraph, Row, StatefulWidget,
23    Table, Tabs,
24};
25
26pub mod analysis_modal;
27pub mod cache;
28pub mod chart_data;
29pub mod chart_export;
30pub mod chart_export_modal;
31pub mod chart_modal;
32pub mod cli;
33#[cfg(feature = "cloud")]
34mod cloud_hive;
35pub mod config;
36pub mod error_display;
37pub mod export_modal;
38pub mod filter_modal;
39mod help_strings;
40pub mod pivot_melt_modal;
41mod query;
42pub mod sort_filter_modal;
43pub mod sort_modal;
44mod source;
45pub mod statistics;
46pub mod template;
47pub mod widgets;
48
49pub use cache::CacheManager;
50pub use cli::Args;
51pub use config::{
52    rgb_to_256_color, rgb_to_basic_ansi, AppConfig, ColorParser, ConfigManager, Theme,
53};
54
55use analysis_modal::{AnalysisModal, AnalysisProgress};
56use chart_export::{
57    write_box_plot_eps, write_box_plot_png, write_chart_eps, write_chart_png, write_heatmap_eps,
58    write_heatmap_png, BoxPlotExportBounds, ChartExportBounds, ChartExportFormat,
59    ChartExportSeries,
60};
61use chart_export_modal::{ChartExportFocus, ChartExportModal};
62use chart_modal::{ChartFocus, ChartKind, ChartModal, ChartType};
63pub use error_display::{error_for_python, ErrorKindForPython};
64use export_modal::{ExportFocus, ExportFormat, ExportModal};
65use filter_modal::{FilterFocus, FilterOperator, FilterStatement, LogicalOperator};
66use pivot_melt_modal::{MeltSpec, PivotMeltFocus, PivotMeltModal, PivotMeltTab, PivotSpec};
67use sort_filter_modal::{SortFilterFocus, SortFilterModal, SortFilterTab};
68use sort_modal::{SortColumn, SortFocus};
69pub use template::{Template, TemplateManager};
70use widgets::controls::Controls;
71use widgets::datatable::{DataTable, DataTableState};
72use widgets::debug::DebugState;
73use widgets::export;
74use widgets::pivot_melt;
75use widgets::template_modal::{CreateFocus, TemplateFocus, TemplateModal, TemplateModalMode};
76use widgets::text_input::{TextInput, TextInputEvent};
77
78/// Application name used for cache directory and other app-specific paths
79pub const APP_NAME: &str = "datui";
80
81/// Re-export compression format from CLI module
82pub use cli::CompressionFormat;
83
84#[cfg(test)]
85pub mod tests {
86    use std::path::Path;
87    use std::process::Command;
88    use std::sync::Once;
89
90    static INIT: Once = Once::new();
91
92    /// Ensures that sample data files are generated before tests run.
93    /// This function uses `std::sync::Once` to ensure it only runs once,
94    /// even if called from multiple tests.
95    pub fn ensure_sample_data() {
96        INIT.call_once(|| {
97            // When the lib is in crates/datui-lib, repo root is CARGO_MANIFEST_DIR/../..
98            let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
99            let sample_data_dir = repo_root.join("tests/sample-data");
100
101            // Check if key files exist to determine if we need to generate data
102            // We check for a few representative files that should always be generated
103            let key_files = [
104                "people.parquet",
105                "sales.parquet",
106                "large_dataset.parquet",
107                "empty.parquet",
108                "pivot_long.parquet",
109                "melt_wide.parquet",
110            ];
111
112            let needs_generation = !sample_data_dir.exists()
113                || key_files
114                    .iter()
115                    .any(|file| !sample_data_dir.join(file).exists());
116
117            if needs_generation {
118                // Get the path to the Python script (at repo root)
119                let script_path = repo_root.join("scripts/generate_sample_data.py");
120                if !script_path.exists() {
121                    panic!(
122                        "Sample data generation script not found at: {}. \
123                        Please ensure you're running tests from the repository root.",
124                        script_path.display()
125                    );
126                }
127
128                // Try to find Python (python3 or python)
129                let python_cmd = if Command::new("python3").arg("--version").output().is_ok() {
130                    "python3"
131                } else if Command::new("python").arg("--version").output().is_ok() {
132                    "python"
133                } else {
134                    panic!(
135                        "Python not found. Please install Python 3 to generate test data. \
136                        The script requires: polars>=0.20.0 and numpy>=1.24.0"
137                    );
138                };
139
140                // Run the generation script
141                let output = Command::new(python_cmd)
142                    .arg(script_path)
143                    .output()
144                    .unwrap_or_else(|e| {
145                        panic!(
146                            "Failed to run sample data generation script: {}. \
147                            Make sure Python is installed and the script is executable.",
148                            e
149                        );
150                    });
151
152                if !output.status.success() {
153                    let stderr = String::from_utf8_lossy(&output.stderr);
154                    let stdout = String::from_utf8_lossy(&output.stdout);
155                    panic!(
156                        "Sample data generation failed!\n\
157                        Exit code: {:?}\n\
158                        stdout:\n{}\n\
159                        stderr:\n{}",
160                        output.status.code(),
161                        stdout,
162                        stderr
163                    );
164                }
165            }
166        });
167    }
168
169    /// Path to the tests/sample-data directory (at repo root). Call `ensure_sample_data()` first if needed.
170    pub fn sample_data_dir() -> std::path::PathBuf {
171        ensure_sample_data();
172        Path::new(env!("CARGO_MANIFEST_DIR"))
173            .join("../..")
174            .join("tests/sample-data")
175    }
176
177    /// Only one query type is returned; SQL overrides fuzzy over DSL. Used when saving templates.
178    #[test]
179    fn test_active_query_settings_only_one_set() {
180        use super::active_query_settings;
181
182        let (q, sql, fuzzy) = active_query_settings("", "", "");
183        assert!(q.is_none() && sql.is_none() && fuzzy.is_none());
184
185        let (q, sql, fuzzy) = active_query_settings("select a", "SELECT 1", "foo");
186        assert!(q.is_none() && sql.as_deref() == Some("SELECT 1") && fuzzy.is_none());
187
188        let (q, sql, fuzzy) = active_query_settings("select a", "", "foo bar");
189        assert!(q.is_none() && sql.is_none() && fuzzy.as_deref() == Some("foo bar"));
190
191        let (q, sql, fuzzy) = active_query_settings("  select a  ", "", "");
192        assert!(q.as_deref() == Some("select a") && sql.is_none() && fuzzy.is_none());
193    }
194}
195
196#[derive(Clone)]
197pub struct OpenOptions {
198    pub delimiter: Option<u8>,
199    pub has_header: Option<bool>,
200    pub skip_lines: Option<usize>,
201    pub skip_rows: Option<usize>,
202    pub compression: Option<CompressionFormat>,
203    pub pages_lookahead: Option<usize>,
204    pub pages_lookback: Option<usize>,
205    pub max_buffered_rows: Option<usize>,
206    pub max_buffered_mb: Option<usize>,
207    pub row_numbers: bool,
208    pub row_start_index: usize,
209    /// When true, use hive load path for directory/glob; single file uses normal load.
210    pub hive: bool,
211    /// When true (default), infer Hive/partitioned Parquet schema from one file for faster "Caching schema". When false, use Polars collect_schema().
212    pub single_spine_schema: bool,
213    /// When true, CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime).
214    pub parse_dates: bool,
215    /// When true, decompress compressed CSV into memory (eager read). When false (default), decompress to a temp file and use lazy scan.
216    pub decompress_in_memory: bool,
217    /// Directory for decompression temp files. None = system default (e.g. TMPDIR).
218    pub temp_dir: Option<std::path::PathBuf>,
219    /// Excel sheet: 0-based index or sheet name (CLI only).
220    pub excel_sheet: Option<String>,
221    /// S3/compatible overrides (env + CLI). Take precedence over config when building CloudOptions.
222    pub s3_endpoint_url_override: Option<String>,
223    pub s3_access_key_id_override: Option<String>,
224    pub s3_secret_access_key_override: Option<String>,
225    pub s3_region_override: Option<String>,
226    /// When true, use Polars streaming engine for LazyFrame collect when the streaming feature is enabled.
227    pub polars_streaming: bool,
228}
229
230impl OpenOptions {
231    pub fn new() -> Self {
232        Self {
233            delimiter: None,
234            has_header: None,
235            skip_lines: None,
236            skip_rows: None,
237            compression: None,
238            pages_lookahead: None,
239            pages_lookback: None,
240            max_buffered_rows: None,
241            max_buffered_mb: None,
242            row_numbers: false,
243            row_start_index: 1,
244            hive: false,
245            single_spine_schema: true,
246            parse_dates: true,
247            decompress_in_memory: false,
248            temp_dir: None,
249            excel_sheet: None,
250            s3_endpoint_url_override: None,
251            s3_access_key_id_override: None,
252            s3_secret_access_key_override: None,
253            s3_region_override: None,
254            polars_streaming: true,
255        }
256    }
257}
258
259impl Default for OpenOptions {
260    fn default() -> Self {
261        Self::new()
262    }
263}
264
265impl OpenOptions {
266    pub fn with_skip_lines(mut self, skip_lines: usize) -> Self {
267        self.skip_lines = Some(skip_lines);
268        self
269    }
270
271    pub fn with_skip_rows(mut self, skip_rows: usize) -> Self {
272        self.skip_rows = Some(skip_rows);
273        self
274    }
275
276    pub fn with_delimiter(mut self, delimiter: u8) -> Self {
277        self.delimiter = Some(delimiter);
278        self
279    }
280
281    pub fn with_has_header(mut self, has_header: bool) -> Self {
282        self.has_header = Some(has_header);
283        self
284    }
285
286    pub fn with_compression(mut self, compression: CompressionFormat) -> Self {
287        self.compression = Some(compression);
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
384    }
385}
386
387impl From<&cli::Args> for OpenOptions {
388    fn from(args: &cli::Args) -> Self {
389        // Use default config if creating from args alone
390        let config = AppConfig::default();
391        Self::from_args_and_config(args, &config)
392    }
393}
394
395pub enum AppEvent {
396    Key(KeyEvent),
397    Open(Vec<PathBuf>, OpenOptions),
398    /// Open with an existing LazyFrame (e.g. from Python binding); no file load.
399    OpenLazyFrame(Box<LazyFrame>, OpenOptions),
400    DoLoad(Vec<PathBuf>, OpenOptions), // Internal event to actually perform loading after UI update
401    /// Scan paths and build LazyFrame; then emit DoLoadSchema (phased loading).
402    DoLoadScanPaths(Vec<PathBuf>, OpenOptions),
403    /// Perform HTTP download (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
404    #[cfg(feature = "http")]
405    DoDownloadHttp(String, OpenOptions),
406    /// Perform S3 download to temp (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
407    #[cfg(feature = "cloud")]
408    DoDownloadS3ToTemp(String, OpenOptions),
409    /// Perform GCS download to temp (next loop so "Downloading" can render first). Then emit DoLoadFromHttpTemp.
410    #[cfg(feature = "cloud")]
411    DoDownloadGcsToTemp(String, OpenOptions),
412    /// HTTP, S3, or GCS download finished; temp path is ready. Scan it and continue load.
413    #[cfg(any(feature = "http", feature = "cloud"))]
414    DoLoadFromHttpTemp(PathBuf, OpenOptions),
415    /// Update phase to "Caching schema" and emit DoLoadSchemaBlocking so UI can draw before blocking.
416    DoLoadSchema(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
417    /// Actually run collect_schema() and create state; then emit DoLoadBuffer (phased loading).
418    DoLoadSchemaBlocking(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
419    /// First collect() on state; then emit Collect (phased loading).
420    DoLoadBuffer,
421    DoDecompress(Vec<PathBuf>, OpenOptions), // Internal event to perform decompression after UI shows "Decompressing"
422    DoExport(PathBuf, ExportFormat, ExportOptions), // Internal event to perform export after UI shows progress
423    DoExportCollect(PathBuf, ExportFormat, ExportOptions), // Collect data for export; then emit DoExportWrite
424    DoExportWrite(PathBuf, ExportFormat, ExportOptions),   // Write collected DataFrame to file
425    DoLoadParquetMetadata, // Load Parquet metadata when info panel is opened (deferred from render)
426    Exit,
427    Crash(String),
428    Search(String),
429    SqlSearch(String),
430    FuzzySearch(String),
431    Filter(Vec<FilterStatement>),
432    Sort(Vec<String>, bool),         // Columns, Ascending
433    ColumnOrder(Vec<String>, usize), // Column order, locked columns count
434    Pivot(PivotSpec),
435    Melt(MeltSpec),
436    Export(PathBuf, ExportFormat, ExportOptions), // Path, format, options
437    ChartExport(PathBuf, ChartExportFormat, String), // Chart export: path, format, optional title
438    DoChartExport(PathBuf, ChartExportFormat, String), // Deferred: show progress bar then run chart export
439    Collect,
440    Update,
441    Reset,
442    Resize(u16, u16), // resized (width, height)
443    DoScrollDown,     // Deferred scroll: perform page_down after one frame (throbber)
444    DoScrollUp,       // Deferred scroll: perform page_up
445    DoScrollNext,     // Deferred scroll: perform select_next (one row down)
446    DoScrollPrev,     // Deferred scroll: perform select_previous (one row up)
447    DoScrollEnd,      // Deferred scroll: jump to last page (throbber)
448    DoScrollHalfDown, // Deferred scroll: half page down
449    DoScrollHalfUp,   // Deferred scroll: half page up
450    GoToLine(usize),  // Deferred: jump to line number (when collect needed)
451    /// Run the next chunk of analysis (describe/distribution); drives per-column progress.
452    AnalysisChunk,
453    /// Run distribution analysis (deferred so progress overlay can show first).
454    AnalysisDistributionCompute,
455    /// Run correlation matrix (deferred so progress overlay can show first).
456    AnalysisCorrelationCompute,
457}
458
459/// Input for the shared run loop: open from file paths or from an existing LazyFrame (e.g. Python binding).
460#[derive(Clone)]
461pub enum RunInput {
462    Paths(Vec<PathBuf>, OpenOptions),
463    LazyFrame(Box<LazyFrame>, OpenOptions),
464}
465
466#[derive(Debug, Clone)]
467pub struct ExportOptions {
468    pub csv_delimiter: u8,
469    pub csv_include_header: bool,
470    pub csv_compression: Option<CompressionFormat>,
471    pub json_compression: Option<CompressionFormat>,
472    pub ndjson_compression: Option<CompressionFormat>,
473    pub parquet_compression: Option<CompressionFormat>, // Not used in UI, but kept for API compatibility
474}
475
476#[derive(Debug, Default, PartialEq, Eq)]
477pub enum InputMode {
478    #[default]
479    Normal,
480    SortFilter,
481    PivotMelt,
482    Editing,
483    Export,
484    Info,
485    Chart,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub enum InputType {
490    Search,
491    Filter,
492    GoToLine,
493}
494
495/// Query dialog tab: SQL-Like (current parser), Fuzzy, or SQL (future).
496#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
497pub enum QueryTab {
498    #[default]
499    SqlLike,
500    Fuzzy,
501    Sql,
502}
503
504impl QueryTab {
505    fn next(self) -> Self {
506        match self {
507            QueryTab::SqlLike => QueryTab::Fuzzy,
508            QueryTab::Fuzzy => QueryTab::Sql,
509            QueryTab::Sql => QueryTab::SqlLike,
510        }
511    }
512    fn prev(self) -> Self {
513        match self {
514            QueryTab::SqlLike => QueryTab::Sql,
515            QueryTab::Fuzzy => QueryTab::SqlLike,
516            QueryTab::Sql => QueryTab::Fuzzy,
517        }
518    }
519    fn index(self) -> usize {
520        match self {
521            QueryTab::SqlLike => 0,
522            QueryTab::Fuzzy => 1,
523            QueryTab::Sql => 2,
524        }
525    }
526}
527
528/// Focus within the query dialog: tab bar or input (SQL-Like only).
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
530pub enum QueryFocus {
531    TabBar,
532    #[default]
533    Input,
534}
535
536#[derive(Default)]
537pub struct ErrorModal {
538    pub active: bool,
539    pub message: String,
540}
541
542impl ErrorModal {
543    pub fn new() -> Self {
544        Self::default()
545    }
546
547    pub fn show(&mut self, message: String) {
548        self.active = true;
549        self.message = message;
550    }
551
552    pub fn hide(&mut self) {
553        self.active = false;
554        self.message.clear();
555    }
556}
557
558#[derive(Default)]
559pub struct SuccessModal {
560    pub active: bool,
561    pub message: String,
562}
563
564impl SuccessModal {
565    pub fn new() -> Self {
566        Self::default()
567    }
568
569    pub fn show(&mut self, message: String) {
570        self.active = true;
571        self.message = message;
572    }
573
574    pub fn hide(&mut self) {
575        self.active = false;
576        self.message.clear();
577    }
578}
579
580#[derive(Default)]
581pub struct ConfirmationModal {
582    pub active: bool,
583    pub message: String,
584    pub focus_yes: bool, // true = Yes focused, false = No focused
585}
586
587impl ConfirmationModal {
588    pub fn new() -> Self {
589        Self::default()
590    }
591
592    pub fn show(&mut self, message: String) {
593        self.active = true;
594        self.message = message;
595        self.focus_yes = true; // Default to Yes
596    }
597
598    pub fn hide(&mut self) {
599        self.active = false;
600        self.message.clear();
601        self.focus_yes = true;
602    }
603}
604
605/// Pending remote download; shown in confirmation modal before starting download.
606#[cfg(any(feature = "http", feature = "cloud"))]
607#[derive(Clone)]
608pub enum PendingDownload {
609    #[cfg(feature = "http")]
610    Http {
611        url: String,
612        size: Option<u64>,
613        options: OpenOptions,
614    },
615    #[cfg(feature = "cloud")]
616    S3 {
617        url: String,
618        size: Option<u64>,
619        options: OpenOptions,
620    },
621    #[cfg(feature = "cloud")]
622    Gcs {
623        url: String,
624        size: Option<u64>,
625        options: OpenOptions,
626    },
627}
628
629#[derive(Clone, Debug, Default)]
630pub enum LoadingState {
631    #[default]
632    Idle,
633    Loading {
634        /// None when loading from LazyFrame (e.g. Python binding); Some for file paths.
635        file_path: Option<PathBuf>,
636        file_size: u64,        // Size of compressed file in bytes (0 when no path)
637        current_phase: String, // e.g., "Scanning input", "Caching schema", "Loading buffer"
638        progress_percent: u16, // 0-100
639    },
640    Exporting {
641        file_path: PathBuf,
642        current_phase: String, // e.g., "Collecting data", "Writing file", "Compressing"
643        progress_percent: u16, // 0-100
644    },
645}
646
647impl LoadingState {
648    pub fn is_loading(&self) -> bool {
649        matches!(
650            self,
651            LoadingState::Loading { .. } | LoadingState::Exporting { .. }
652        )
653    }
654}
655
656/// In-progress analysis computation state (orchestration in App; modal only displays progress).
657#[allow(dead_code)]
658struct AnalysisComputationState {
659    df: Option<DataFrame>,
660    schema: Option<Arc<Schema>>,
661    partial_stats: Vec<crate::statistics::ColumnStatistics>,
662    current: usize,
663    total: usize,
664    total_rows: usize,
665    sample_seed: u64,
666    sample_size: Option<usize>,
667}
668
669/// At most one query type can be active. Returns (query, sql_query, fuzzy_query) with only the
670/// active one set (SQL takes precedence over fuzzy over DSL query). Used when saving template settings.
671fn active_query_settings(
672    dsl_query: &str,
673    sql_query: &str,
674    fuzzy_query: &str,
675) -> (Option<String>, Option<String>, Option<String>) {
676    let sql_trimmed = sql_query.trim();
677    let fuzzy_trimmed = fuzzy_query.trim();
678    let dsl_trimmed = dsl_query.trim();
679    if !sql_trimmed.is_empty() {
680        (None, Some(sql_trimmed.to_string()), None)
681    } else if !fuzzy_trimmed.is_empty() {
682        (None, None, Some(fuzzy_trimmed.to_string()))
683    } else if !dsl_trimmed.is_empty() {
684        (Some(dsl_trimmed.to_string()), None, None)
685    } else {
686        (None, None, None)
687    }
688}
689
690// Helper struct to save state before template application
691struct TemplateApplicationState {
692    lf: LazyFrame,
693    schema: Arc<Schema>,
694    active_query: String,
695    active_sql_query: String,
696    active_fuzzy_query: String,
697    filters: Vec<FilterStatement>,
698    sort_columns: Vec<String>,
699    sort_ascending: bool,
700    column_order: Vec<String>,
701    locked_columns_count: usize,
702}
703
704#[derive(Default)]
705struct ChartCache {
706    xy: Option<ChartCacheXY>,
707    x_range: Option<ChartCacheXRange>,
708    histogram: Option<ChartCacheHistogram>,
709    box_plot: Option<ChartCacheBoxPlot>,
710    kde: Option<ChartCacheKde>,
711    heatmap: Option<ChartCacheHeatmap>,
712}
713
714impl ChartCache {
715    fn clear(&mut self) {
716        *self = Self::default();
717    }
718}
719
720struct ChartCacheXY {
721    x_column: String,
722    y_columns: Vec<String>,
723    row_limit: Option<usize>,
724    series: Vec<Vec<(f64, f64)>>,
725    /// Log-scaled series when log_scale was requested; filled on first use to avoid per-frame clone.
726    series_log: Option<Vec<Vec<(f64, f64)>>>,
727    x_axis_kind: chart_data::XAxisTemporalKind,
728}
729
730struct ChartCacheXRange {
731    x_column: String,
732    row_limit: Option<usize>,
733    x_min: f64,
734    x_max: f64,
735    x_axis_kind: chart_data::XAxisTemporalKind,
736}
737
738struct ChartCacheHistogram {
739    column: String,
740    bins: usize,
741    row_limit: Option<usize>,
742    data: chart_data::HistogramData,
743}
744
745struct ChartCacheBoxPlot {
746    column: String,
747    row_limit: Option<usize>,
748    data: chart_data::BoxPlotData,
749}
750
751struct ChartCacheKde {
752    column: String,
753    bandwidth_factor: f64,
754    row_limit: Option<usize>,
755    data: chart_data::KdeData,
756}
757
758struct ChartCacheHeatmap {
759    x_column: String,
760    y_column: String,
761    bins: usize,
762    row_limit: Option<usize>,
763    data: chart_data::HeatmapData,
764}
765
766pub struct App {
767    pub data_table_state: Option<DataTableState>,
768    path: Option<PathBuf>,
769    original_file_format: Option<ExportFormat>, // Track original file format for default export
770    original_file_delimiter: Option<u8>, // Track original file delimiter for CSV export default
771    events: Sender<AppEvent>,
772    focus: u32,
773    debug: DebugState,
774    info_modal: InfoModal,
775    parquet_metadata_cache: Option<ParquetMetadataCache>,
776    query_input: TextInput, // Query input widget with history support
777    sql_input: TextInput,   // SQL tab input with its own history (id "sql")
778    fuzzy_input: TextInput, // Fuzzy tab input with its own history (id "fuzzy")
779    pub input_mode: InputMode,
780    input_type: Option<InputType>,
781    query_tab: QueryTab,
782    query_focus: QueryFocus,
783    pub sort_filter_modal: SortFilterModal,
784    pub pivot_melt_modal: PivotMeltModal,
785    pub template_modal: TemplateModal,
786    pub analysis_modal: AnalysisModal,
787    pub chart_modal: ChartModal,
788    pub chart_export_modal: ChartExportModal,
789    pub export_modal: ExportModal,
790    chart_cache: ChartCache,
791    error_modal: ErrorModal,
792    success_modal: SuccessModal,
793    confirmation_modal: ConfirmationModal,
794    pending_export: Option<(PathBuf, ExportFormat, ExportOptions)>, // Store export request while waiting for confirmation
795    /// Collected DataFrame between DoExportCollect and DoExportWrite (two-phase export progress).
796    export_df: Option<DataFrame>,
797    pending_chart_export: Option<(PathBuf, ChartExportFormat, String)>,
798    /// Pending remote file download (HTTP/S3/GCS) while waiting for user confirmation. Size is from HEAD when available.
799    #[cfg(any(feature = "http", feature = "cloud"))]
800    pending_download: Option<PendingDownload>,
801    show_help: bool,
802    help_scroll: usize, // Scroll position for help content
803    cache: CacheManager,
804    template_manager: TemplateManager,
805    active_template_id: Option<String>, // ID of currently applied template
806    loading_state: LoadingState,        // Current loading state for progress indication
807    theme: Theme,                       // Color theme for UI rendering
808    sampling_threshold: Option<usize>, // None = no sampling (full data); Some(n) = sample when rows >= n
809    history_limit: usize, // History limit for all text inputs (from config.query.history_limit)
810    table_cell_padding: u16, // Spaces between columns (from config.display.table_cell_padding)
811    column_colors: bool, // When true, colorize table cells by column type (from config.display.column_colors)
812    busy: bool,          // When true, show throbber and ignore keys
813    throbber_frame: u8,  // Spinner frame index (0..3) for control bar
814    drain_keys_on_next_loop: bool, // Main loop drains crossterm key buffer when true
815    analysis_computation: Option<AnalysisComputationState>,
816    app_config: AppConfig,
817    /// Temp file path for HTTP-downloaded data; removed when user opens different data or exits.
818    #[cfg(feature = "http")]
819    http_temp_path: Option<PathBuf>,
820}
821
822impl App {
823    /// Returns true when the main loop should drain the crossterm key buffer after render.
824    pub fn should_drain_keys(&self) -> bool {
825        self.drain_keys_on_next_loop
826    }
827
828    /// Clears the drain-keys request after the main loop has drained the buffer.
829    pub fn clear_drain_keys_request(&mut self) {
830        self.drain_keys_on_next_loop = false;
831    }
832
833    pub fn send_event(&mut self, event: AppEvent) -> Result<()> {
834        self.events.send(event)?;
835        Ok(())
836    }
837
838    /// Set loading state and phase so the progress dialog is visible. Used by run() to show
839    /// loading UI immediately when launching from LazyFrame (e.g. Python) before sending the open event.
840    pub fn set_loading_phase(&mut self, phase: impl Into<String>, progress_percent: u16) {
841        self.busy = true;
842        self.loading_state = LoadingState::Loading {
843            file_path: None,
844            file_size: 0,
845            current_phase: phase.into(),
846            progress_percent,
847        };
848    }
849
850    /// Ensures file path has an extension when user did not provide one; only adds
851    /// compression suffix (e.g. .gz) when compression is selected. If the user
852    /// provided a path with an extension (e.g. foo.feather), that extension is kept.
853    fn ensure_file_extension(
854        path: &Path,
855        format: ExportFormat,
856        compression: Option<CompressionFormat>,
857    ) -> PathBuf {
858        let current_ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
859        let mut new_path = path.to_path_buf();
860
861        if current_ext.is_empty() {
862            // No extension: use default for format (and add compression if selected)
863            let desired_ext = if let Some(comp) = compression {
864                format!("{}.{}", format.extension(), comp.extension())
865            } else {
866                format.extension().to_string()
867            };
868            new_path.set_extension(&desired_ext);
869        } else {
870            // User provided an extension: keep it. Only add compression suffix when compression is selected.
871            let is_compression_only = matches!(
872                current_ext.to_lowercase().as_str(),
873                "gz" | "zst" | "bz2" | "xz"
874            ) && ExportFormat::from_extension(current_ext).is_none();
875
876            if is_compression_only {
877                // Path has only compression ext (e.g. file.gz); stem may have format (file.csv.gz)
878                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
879                let stem_has_format = stem
880                    .split('.')
881                    .next_back()
882                    .and_then(ExportFormat::from_extension)
883                    .is_some();
884                if stem_has_format {
885                    if let Some(comp) = compression {
886                        if let Some(format_ext) = stem
887                            .split('.')
888                            .next_back()
889                            .and_then(ExportFormat::from_extension)
890                            .map(|f| f.extension())
891                        {
892                            new_path =
893                                PathBuf::from(stem.rsplit_once('.').map(|x| x.0).unwrap_or(stem));
894                            new_path.set_extension(format!("{}.{}", format_ext, comp.extension()));
895                        }
896                    }
897                } else if let Some(comp) = compression {
898                    new_path.set_extension(format!("{}.{}", format.extension(), comp.extension()));
899                } else {
900                    new_path.set_extension(format.extension());
901                }
902            } else if let Some(comp) = compression {
903                if format.supports_compression() {
904                    new_path.set_extension(format!("{}.{}", current_ext, comp.extension()));
905                }
906                // else: path stays as-is (e.g. foo.feather stays foo.feather)
907            }
908            // else: path with format extension stays as-is
909        }
910
911        new_path
912    }
913
914    /// Render loading/export progress as a popover: box with frame, gauge (25% width when area is full frame).
915    /// Uses theme colors for border and gauge. For loading, call with centered_rect_loading(area).
916    fn render_loading_gauge(
917        loading_state: &LoadingState,
918        area: Rect,
919        buf: &mut Buffer,
920        theme: &Theme,
921    ) {
922        let (title, label_text, progress_percent) = match loading_state {
923            LoadingState::Loading {
924                current_phase,
925                progress_percent,
926                ..
927            } => ("Loading", current_phase.clone(), progress_percent),
928            LoadingState::Exporting {
929                file_path,
930                current_phase,
931                progress_percent,
932            } => {
933                let label = format!("{}: {}", current_phase, file_path.display());
934                ("Exporting", label, progress_percent)
935            }
936            LoadingState::Idle => return,
937        };
938
939        Clear.render(area, buf);
940
941        let border_color = theme.get("modal_border");
942        let gauge_fill_color = theme.get("primary_chart_series_color");
943
944        let block = Block::default()
945            .borders(Borders::ALL)
946            .border_type(BorderType::Rounded)
947            .title(title)
948            .border_style(Style::default().fg(border_color));
949
950        let inner = block.inner(area);
951        block.render(area, buf);
952
953        let gauge = Gauge::default()
954            .gauge_style(Style::default().fg(gauge_fill_color))
955            .percent(*progress_percent)
956            .label(label_text);
957
958        gauge.render(inner, buf);
959    }
960
961    pub fn new(events: Sender<AppEvent>) -> App {
962        // Create default theme for backward compatibility
963        let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| {
964            // Create a minimal fallback theme
965            Theme {
966                colors: std::collections::HashMap::new(),
967            }
968        });
969
970        Self::new_with_config(events, theme, AppConfig::default())
971    }
972
973    pub fn new_with_theme(events: Sender<AppEvent>, theme: Theme) -> App {
974        Self::new_with_config(events, theme, AppConfig::default())
975    }
976
977    pub fn new_with_config(events: Sender<AppEvent>, theme: Theme, app_config: AppConfig) -> App {
978        let cache = CacheManager::new(APP_NAME).unwrap_or_else(|_| CacheManager {
979            cache_dir: std::env::temp_dir().join(APP_NAME),
980        });
981
982        let config_manager = ConfigManager::new(APP_NAME).unwrap_or_else(|_| ConfigManager {
983            config_dir: std::env::temp_dir().join(APP_NAME).join("config"),
984        });
985
986        let template_manager = TemplateManager::new(&config_manager).unwrap_or_else(|_| {
987            let temp_config = ConfigManager::new("datui").unwrap_or_else(|_| ConfigManager {
988                config_dir: std::env::temp_dir().join("datui").join("config"),
989            });
990            TemplateManager::new(&temp_config).unwrap_or_else(|_| {
991                let last_resort = ConfigManager {
992                    config_dir: std::env::temp_dir().join("datui_config"),
993                };
994                TemplateManager::new(&last_resort)
995                    .unwrap_or_else(|_| TemplateManager::empty(&last_resort))
996            })
997        });
998
999        App {
1000            path: None,
1001            data_table_state: None,
1002            original_file_format: None,
1003            original_file_delimiter: None,
1004            events,
1005            focus: 0,
1006            debug: DebugState::default(),
1007            info_modal: InfoModal::new(),
1008            parquet_metadata_cache: None,
1009            query_input: TextInput::new()
1010                .with_history_limit(app_config.query.history_limit)
1011                .with_theme(&theme)
1012                .with_history("query".to_string()),
1013            sql_input: TextInput::new()
1014                .with_history_limit(app_config.query.history_limit)
1015                .with_theme(&theme)
1016                .with_history("sql".to_string()),
1017            fuzzy_input: TextInput::new()
1018                .with_history_limit(app_config.query.history_limit)
1019                .with_theme(&theme)
1020                .with_history("fuzzy".to_string()),
1021            input_mode: InputMode::Normal,
1022            input_type: None,
1023            query_tab: QueryTab::SqlLike,
1024            query_focus: QueryFocus::Input,
1025            sort_filter_modal: SortFilterModal::new(),
1026            pivot_melt_modal: PivotMeltModal::new(),
1027            template_modal: TemplateModal::new(),
1028            analysis_modal: AnalysisModal::new(),
1029            chart_modal: ChartModal::new(),
1030            chart_export_modal: ChartExportModal::new(),
1031            export_modal: ExportModal::new(),
1032            chart_cache: ChartCache::default(),
1033            error_modal: ErrorModal::new(),
1034            success_modal: SuccessModal::new(),
1035            confirmation_modal: ConfirmationModal::new(),
1036            pending_export: None,
1037            export_df: None,
1038            pending_chart_export: None,
1039            #[cfg(any(feature = "http", feature = "cloud"))]
1040            pending_download: None,
1041            show_help: false,
1042            help_scroll: 0,
1043            cache,
1044            template_manager,
1045            active_template_id: None,
1046            loading_state: LoadingState::Idle,
1047            theme,
1048            sampling_threshold: app_config.performance.sampling_threshold,
1049            history_limit: app_config.query.history_limit,
1050            table_cell_padding: app_config.display.table_cell_padding.min(u16::MAX as usize) as u16,
1051            column_colors: app_config.display.column_colors,
1052            busy: false,
1053            throbber_frame: 0,
1054            drain_keys_on_next_loop: false,
1055            analysis_computation: None,
1056            app_config,
1057            #[cfg(feature = "http")]
1058            http_temp_path: None,
1059        }
1060    }
1061
1062    pub fn enable_debug(&mut self) {
1063        self.debug.enabled = true;
1064    }
1065
1066    /// Get a color from the theme by name
1067    fn color(&self, name: &str) -> Color {
1068        self.theme.get(name)
1069    }
1070
1071    fn load(&mut self, paths: &[PathBuf], options: &OpenOptions) -> Result<()> {
1072        self.parquet_metadata_cache = None;
1073        self.export_df = None;
1074        let path = &paths[0]; // Primary path for format detection and single-path logic
1075                              // Check for compressed CSV files (e.g., file.csv.gz, file.csv.zst, etc.) — only single-file
1076        let compression = options
1077            .compression
1078            .or_else(|| CompressionFormat::from_extension(path));
1079        let is_csv = path
1080            .file_stem()
1081            .and_then(|stem| stem.to_str())
1082            .map(|stem| {
1083                stem.ends_with(".csv")
1084                    || path
1085                        .extension()
1086                        .and_then(|e| e.to_str())
1087                        .map(|e| e.eq_ignore_ascii_case("csv"))
1088                        .unwrap_or(false)
1089            })
1090            .unwrap_or(false);
1091        let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
1092
1093        // For compressed files, decompression phase is already set in DoLoad handler
1094        // Now actually perform decompression and CSV reading (this is the slow part)
1095        if is_compressed_csv {
1096            // Phase: Reading data (decompressing + parsing CSV; user may see "Decompressing" until we return)
1097            if let LoadingState::Loading {
1098                file_path,
1099                file_size,
1100                ..
1101            } = &self.loading_state
1102            {
1103                self.loading_state = LoadingState::Loading {
1104                    file_path: file_path.clone(),
1105                    file_size: *file_size,
1106                    current_phase: "Reading data".to_string(),
1107                    progress_percent: 50,
1108                };
1109            }
1110            let lf = DataTableState::from_csv(path, options)?; // Already passes pages_lookahead/lookback via options
1111
1112            // Phase: Building lazyframe (after decompression, before rendering)
1113            if let LoadingState::Loading {
1114                file_path,
1115                file_size,
1116                ..
1117            } = &self.loading_state
1118            {
1119                self.loading_state = LoadingState::Loading {
1120                    file_path: file_path.clone(),
1121                    file_size: *file_size,
1122                    current_phase: "Building lazyframe".to_string(),
1123                    progress_percent: 60,
1124                };
1125            }
1126
1127            // Phased loading: set "Loading buffer" so UI can show progress; caller (DoDecompress) will send DoLoadBuffer
1128            if let LoadingState::Loading {
1129                file_path,
1130                file_size,
1131                ..
1132            } = &self.loading_state
1133            {
1134                self.loading_state = LoadingState::Loading {
1135                    file_path: file_path.clone(),
1136                    file_size: *file_size,
1137                    current_phase: "Loading buffer".to_string(),
1138                    progress_percent: 70,
1139                };
1140            }
1141
1142            self.data_table_state = Some(lf);
1143            self.path = Some(path.clone());
1144            let original_format =
1145                path.file_stem()
1146                    .and_then(|stem| stem.to_str())
1147                    .and_then(|stem| {
1148                        if stem.ends_with(".csv") {
1149                            Some(ExportFormat::Csv)
1150                        } else {
1151                            None
1152                        }
1153                    });
1154            self.original_file_format = original_format;
1155            self.original_file_delimiter = Some(options.delimiter.unwrap_or(b','));
1156            self.sort_filter_modal = SortFilterModal::new();
1157            self.pivot_melt_modal = PivotMeltModal::new();
1158            return Ok(());
1159        }
1160
1161        // Hive path: when --hive and single path is directory or glob (not a single file), use hive load.
1162        // Multiple paths or single file with --hive use the normal path below.
1163        if paths.len() == 1 && options.hive {
1164            let path_str = path.as_os_str().to_string_lossy();
1165            let is_single_file = path.exists()
1166                && path.is_file()
1167                && !path_str.contains('*')
1168                && !path_str.contains("**");
1169            if !is_single_file {
1170                // Directory or glob: only Parquet supported for hive in this implementation
1171                let use_parquet_hive = path.is_dir()
1172                    || path_str.contains(".parquet")
1173                    || path_str.contains("*.parquet");
1174                if use_parquet_hive {
1175                    if let LoadingState::Loading {
1176                        file_path,
1177                        file_size,
1178                        ..
1179                    } = &self.loading_state
1180                    {
1181                        self.loading_state = LoadingState::Loading {
1182                            file_path: file_path.clone(),
1183                            file_size: *file_size,
1184                            current_phase: "Scanning partitioned dataset".to_string(),
1185                            progress_percent: 60,
1186                        };
1187                    }
1188                    let lf = DataTableState::from_parquet_hive(
1189                        path,
1190                        options.pages_lookahead,
1191                        options.pages_lookback,
1192                        options.max_buffered_rows,
1193                        options.max_buffered_mb,
1194                        options.row_numbers,
1195                        options.row_start_index,
1196                    )?;
1197                    if let LoadingState::Loading {
1198                        file_path,
1199                        file_size,
1200                        ..
1201                    } = &self.loading_state
1202                    {
1203                        self.loading_state = LoadingState::Loading {
1204                            file_path: file_path.clone(),
1205                            file_size: *file_size,
1206                            current_phase: "Rendering data".to_string(),
1207                            progress_percent: 90,
1208                        };
1209                    }
1210                    self.loading_state = LoadingState::Idle;
1211                    self.data_table_state = Some(lf);
1212                    self.path = Some(path.clone());
1213                    self.original_file_format = Some(ExportFormat::Parquet);
1214                    self.original_file_delimiter = None;
1215                    self.sort_filter_modal = SortFilterModal::new();
1216                    self.pivot_melt_modal = PivotMeltModal::new();
1217                    return Ok(());
1218                }
1219                self.loading_state = LoadingState::Idle;
1220                return Err(color_eyre::eyre::eyre!(
1221                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
1222                ));
1223            }
1224        }
1225
1226        // For non-gzipped files, proceed with normal loading
1227        // Phase 2: Building lazyframe
1228        if let LoadingState::Loading {
1229            file_path,
1230            file_size,
1231            ..
1232        } = &self.loading_state
1233        {
1234            self.loading_state = LoadingState::Loading {
1235                file_path: file_path.clone(),
1236                file_size: *file_size,
1237                current_phase: "Building lazyframe".to_string(),
1238                progress_percent: 60,
1239            };
1240        }
1241
1242        // Determine and store original file format (from first path)
1243        let original_format = path.extension().and_then(|e| e.to_str()).and_then(|ext| {
1244            if ext.eq_ignore_ascii_case("parquet") {
1245                Some(ExportFormat::Parquet)
1246            } else if ext.eq_ignore_ascii_case("csv") {
1247                Some(ExportFormat::Csv)
1248            } else if ext.eq_ignore_ascii_case("json") {
1249                Some(ExportFormat::Json)
1250            } else if ext.eq_ignore_ascii_case("jsonl") || ext.eq_ignore_ascii_case("ndjson") {
1251                Some(ExportFormat::Ndjson)
1252            } else if ext.eq_ignore_ascii_case("arrow")
1253                || ext.eq_ignore_ascii_case("ipc")
1254                || ext.eq_ignore_ascii_case("feather")
1255            {
1256                Some(ExportFormat::Ipc)
1257            } else if ext.eq_ignore_ascii_case("avro") {
1258                Some(ExportFormat::Avro)
1259            } else {
1260                None
1261            }
1262        });
1263
1264        let lf = if paths.len() > 1 {
1265            // Multiple files: same format assumed (from first path), concatenated into one LazyFrame
1266            match path.extension() {
1267                Some(ext) if ext.eq_ignore_ascii_case("parquet") => {
1268                    DataTableState::from_parquet_paths(
1269                        paths,
1270                        options.pages_lookahead,
1271                        options.pages_lookback,
1272                        options.max_buffered_rows,
1273                        options.max_buffered_mb,
1274                        options.row_numbers,
1275                        options.row_start_index,
1276                    )?
1277                }
1278                Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1279                    DataTableState::from_csv_paths(paths, options)?
1280                }
1281                Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json_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("jsonl") => {
1291                    DataTableState::from_json_lines_paths(
1292                        paths,
1293                        options.pages_lookahead,
1294                        options.pages_lookback,
1295                        options.max_buffered_rows,
1296                        options.max_buffered_mb,
1297                        options.row_numbers,
1298                        options.row_start_index,
1299                    )?
1300                }
1301                Some(ext) if ext.eq_ignore_ascii_case("ndjson") => {
1302                    DataTableState::from_ndjson_paths(
1303                        paths,
1304                        options.pages_lookahead,
1305                        options.pages_lookback,
1306                        options.max_buffered_rows,
1307                        options.max_buffered_mb,
1308                        options.row_numbers,
1309                        options.row_start_index,
1310                    )?
1311                }
1312                Some(ext)
1313                    if ext.eq_ignore_ascii_case("arrow")
1314                        || ext.eq_ignore_ascii_case("ipc")
1315                        || ext.eq_ignore_ascii_case("feather") =>
1316                {
1317                    DataTableState::from_ipc_paths(
1318                        paths,
1319                        options.pages_lookahead,
1320                        options.pages_lookback,
1321                        options.max_buffered_rows,
1322                        options.max_buffered_mb,
1323                        options.row_numbers,
1324                        options.row_start_index,
1325                    )?
1326                }
1327                Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro_paths(
1328                    paths,
1329                    options.pages_lookahead,
1330                    options.pages_lookback,
1331                    options.max_buffered_rows,
1332                    options.max_buffered_mb,
1333                    options.row_numbers,
1334                    options.row_start_index,
1335                )?,
1336                Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc_paths(
1337                    paths,
1338                    options.pages_lookahead,
1339                    options.pages_lookback,
1340                    options.max_buffered_rows,
1341                    options.max_buffered_mb,
1342                    options.row_numbers,
1343                    options.row_start_index,
1344                )?,
1345                _ => {
1346                    self.loading_state = LoadingState::Idle;
1347                    if !paths.is_empty() && !path.exists() {
1348                        return Err(std::io::Error::new(
1349                            std::io::ErrorKind::NotFound,
1350                            format!("File not found: {}", path.display()),
1351                        )
1352                        .into());
1353                    }
1354                    return Err(color_eyre::eyre::eyre!(
1355                        "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
1356                    ));
1357                }
1358            }
1359        } else {
1360            match path.extension() {
1361                Some(ext) if ext.eq_ignore_ascii_case("parquet") => DataTableState::from_parquet(
1362                    path,
1363                    options.pages_lookahead,
1364                    options.pages_lookback,
1365                    options.max_buffered_rows,
1366                    options.max_buffered_mb,
1367                    options.row_numbers,
1368                    options.row_start_index,
1369                )?,
1370                Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1371                    DataTableState::from_csv(path, options)? // Already passes row_numbers via options
1372                }
1373                Some(ext) if ext.eq_ignore_ascii_case("tsv") => DataTableState::from_delimited(
1374                    path,
1375                    b'\t',
1376                    options.pages_lookahead,
1377                    options.pages_lookback,
1378                    options.max_buffered_rows,
1379                    options.max_buffered_mb,
1380                    options.row_numbers,
1381                    options.row_start_index,
1382                )?,
1383                Some(ext) if ext.eq_ignore_ascii_case("psv") => DataTableState::from_delimited(
1384                    path,
1385                    b'|',
1386                    options.pages_lookahead,
1387                    options.pages_lookback,
1388                    options.max_buffered_rows,
1389                    options.max_buffered_mb,
1390                    options.row_numbers,
1391                    options.row_start_index,
1392                )?,
1393                Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json(
1394                    path,
1395                    options.pages_lookahead,
1396                    options.pages_lookback,
1397                    options.max_buffered_rows,
1398                    options.max_buffered_mb,
1399                    options.row_numbers,
1400                    options.row_start_index,
1401                )?,
1402                Some(ext) if ext.eq_ignore_ascii_case("jsonl") => DataTableState::from_json_lines(
1403                    path,
1404                    options.pages_lookahead,
1405                    options.pages_lookback,
1406                    options.max_buffered_rows,
1407                    options.max_buffered_mb,
1408                    options.row_numbers,
1409                    options.row_start_index,
1410                )?,
1411                Some(ext) if ext.eq_ignore_ascii_case("ndjson") => DataTableState::from_ndjson(
1412                    path,
1413                    options.pages_lookahead,
1414                    options.pages_lookback,
1415                    options.max_buffered_rows,
1416                    options.max_buffered_mb,
1417                    options.row_numbers,
1418                    options.row_start_index,
1419                )?,
1420                Some(ext)
1421                    if ext.eq_ignore_ascii_case("arrow")
1422                        || ext.eq_ignore_ascii_case("ipc")
1423                        || ext.eq_ignore_ascii_case("feather") =>
1424                {
1425                    DataTableState::from_ipc(
1426                        path,
1427                        options.pages_lookahead,
1428                        options.pages_lookback,
1429                        options.max_buffered_rows,
1430                        options.max_buffered_mb,
1431                        options.row_numbers,
1432                        options.row_start_index,
1433                    )?
1434                }
1435                Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro(
1436                    path,
1437                    options.pages_lookahead,
1438                    options.pages_lookback,
1439                    options.max_buffered_rows,
1440                    options.max_buffered_mb,
1441                    options.row_numbers,
1442                    options.row_start_index,
1443                )?,
1444                Some(ext)
1445                    if ext.eq_ignore_ascii_case("xls")
1446                        || ext.eq_ignore_ascii_case("xlsx")
1447                        || ext.eq_ignore_ascii_case("xlsm")
1448                        || ext.eq_ignore_ascii_case("xlsb") =>
1449                {
1450                    DataTableState::from_excel(
1451                        path,
1452                        options.pages_lookahead,
1453                        options.pages_lookback,
1454                        options.max_buffered_rows,
1455                        options.max_buffered_mb,
1456                        options.row_numbers,
1457                        options.row_start_index,
1458                        options.excel_sheet.as_deref(),
1459                    )?
1460                }
1461                Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc(
1462                    path,
1463                    options.pages_lookahead,
1464                    options.pages_lookback,
1465                    options.max_buffered_rows,
1466                    options.max_buffered_mb,
1467                    options.row_numbers,
1468                    options.row_start_index,
1469                )?,
1470                _ => {
1471                    self.loading_state = LoadingState::Idle;
1472                    if paths.len() == 1 && !path.exists() {
1473                        return Err(std::io::Error::new(
1474                            std::io::ErrorKind::NotFound,
1475                            format!("File not found: {}", path.display()),
1476                        )
1477                        .into());
1478                    }
1479                    return Err(color_eyre::eyre::eyre!("Unsupported file type"));
1480                }
1481            }
1482        };
1483
1484        // Phase 3: Rendering data
1485        if let LoadingState::Loading {
1486            file_path,
1487            file_size,
1488            ..
1489        } = &self.loading_state
1490        {
1491            self.loading_state = LoadingState::Loading {
1492                file_path: file_path.clone(),
1493                file_size: *file_size,
1494                current_phase: "Rendering data".to_string(),
1495                progress_percent: 90,
1496            };
1497        }
1498
1499        // Clear loading state after successful load
1500        self.loading_state = LoadingState::Idle;
1501        self.data_table_state = Some(lf);
1502        self.path = Some(path.clone());
1503        self.original_file_format = original_format;
1504        // Store delimiter based on file type
1505        self.original_file_delimiter = match path.extension().and_then(|e| e.to_str()) {
1506            Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1507                // For CSV, use delimiter from options or default to comma
1508                Some(options.delimiter.unwrap_or(b','))
1509            }
1510            Some(ext) if ext.eq_ignore_ascii_case("tsv") => Some(b'\t'),
1511            Some(ext) if ext.eq_ignore_ascii_case("psv") => Some(b'|'),
1512            _ => None, // Not a delimited file
1513        };
1514        self.sort_filter_modal = SortFilterModal::new();
1515        self.pivot_melt_modal = PivotMeltModal::new();
1516        Ok(())
1517    }
1518
1519    #[cfg(feature = "cloud")]
1520    fn build_s3_cloud_options(
1521        cloud: &crate::config::CloudConfig,
1522        options: &OpenOptions,
1523    ) -> CloudOptions {
1524        let mut opts = CloudOptions::default();
1525        let mut configs: Vec<(AmazonS3ConfigKey, String)> = Vec::new();
1526        let e = options
1527            .s3_endpoint_url_override
1528            .as_ref()
1529            .or(cloud.s3_endpoint_url.as_ref());
1530        let k = options
1531            .s3_access_key_id_override
1532            .as_ref()
1533            .or(cloud.s3_access_key_id.as_ref());
1534        let s = options
1535            .s3_secret_access_key_override
1536            .as_ref()
1537            .or(cloud.s3_secret_access_key.as_ref());
1538        let r = options
1539            .s3_region_override
1540            .as_ref()
1541            .or(cloud.s3_region.as_ref());
1542        if let Some(e) = e {
1543            configs.push((AmazonS3ConfigKey::Endpoint, e.clone()));
1544        }
1545        if let Some(k) = k {
1546            configs.push((AmazonS3ConfigKey::AccessKeyId, k.clone()));
1547        }
1548        if let Some(s) = s {
1549            configs.push((AmazonS3ConfigKey::SecretAccessKey, s.clone()));
1550        }
1551        if let Some(r) = r {
1552            configs.push((AmazonS3ConfigKey::Region, r.clone()));
1553        }
1554        if !configs.is_empty() {
1555            opts = opts.with_aws(configs);
1556        }
1557        opts
1558    }
1559
1560    #[cfg(feature = "cloud")]
1561    fn build_s3_object_store(
1562        s3_url: &str,
1563        cloud: &crate::config::CloudConfig,
1564        options: &OpenOptions,
1565    ) -> Result<Arc<dyn object_store::ObjectStore>> {
1566        let (path_part, _ext) = source::url_path_extension(s3_url);
1567        let (bucket, _key) = path_part
1568            .split_once('/')
1569            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1570        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1571            .with_url(s3_url)
1572            .with_bucket_name(bucket);
1573        let e = options
1574            .s3_endpoint_url_override
1575            .as_ref()
1576            .or(cloud.s3_endpoint_url.as_ref());
1577        let k = options
1578            .s3_access_key_id_override
1579            .as_ref()
1580            .or(cloud.s3_access_key_id.as_ref());
1581        let s = options
1582            .s3_secret_access_key_override
1583            .as_ref()
1584            .or(cloud.s3_secret_access_key.as_ref());
1585        let r = options
1586            .s3_region_override
1587            .as_ref()
1588            .or(cloud.s3_region.as_ref());
1589        if let Some(e) = e {
1590            builder = builder.with_endpoint(e);
1591        }
1592        if let Some(k) = k {
1593            builder = builder.with_access_key_id(k);
1594        }
1595        if let Some(s) = s {
1596            builder = builder.with_secret_access_key(s);
1597        }
1598        if let Some(r) = r {
1599            builder = builder.with_region(r);
1600        }
1601        let store = builder
1602            .build()
1603            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1604        Ok(Arc::new(store))
1605    }
1606
1607    #[cfg(feature = "cloud")]
1608    fn build_gcs_object_store(gs_url: &str) -> Result<Arc<dyn object_store::ObjectStore>> {
1609        let (path_part, _ext) = source::url_path_extension(gs_url);
1610        let (bucket, _key) = path_part
1611            .split_once('/')
1612            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1613        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1614            .with_url(gs_url)
1615            .with_bucket_name(bucket)
1616            .build()
1617            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1618        Ok(Arc::new(store))
1619    }
1620
1621    /// Human-readable byte size for download confirmation modal.
1622    fn format_bytes(n: u64) -> String {
1623        const KB: u64 = 1024;
1624        const MB: u64 = KB * 1024;
1625        const GB: u64 = MB * 1024;
1626        const TB: u64 = GB * 1024;
1627        if n >= TB {
1628            format!("{:.2} TB", n as f64 / TB as f64)
1629        } else if n >= GB {
1630            format!("{:.2} GB", n as f64 / GB as f64)
1631        } else if n >= MB {
1632            format!("{:.2} MB", n as f64 / MB as f64)
1633        } else if n >= KB {
1634            format!("{:.2} KB", n as f64 / KB as f64)
1635        } else {
1636            format!("{} bytes", n)
1637        }
1638    }
1639
1640    #[cfg(feature = "http")]
1641    fn fetch_remote_size_http(url: &str) -> Result<Option<u64>> {
1642        let response = ureq::request("HEAD", url)
1643            .timeout(std::time::Duration::from_secs(15))
1644            .call();
1645        match response {
1646            Ok(r) => Ok(r
1647                .header("Content-Length")
1648                .and_then(|s| s.parse::<u64>().ok())),
1649            Err(_) => Ok(None),
1650        }
1651    }
1652
1653    #[cfg(feature = "cloud")]
1654    fn fetch_remote_size_s3(
1655        s3_url: &str,
1656        cloud: &crate::config::CloudConfig,
1657        options: &OpenOptions,
1658    ) -> Result<Option<u64>> {
1659        use object_store::path::Path as OsPath;
1660        use object_store::ObjectStore;
1661
1662        let (path_part, _ext) = source::url_path_extension(s3_url);
1663        let (bucket, key) = path_part
1664            .split_once('/')
1665            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1666        if key.is_empty() {
1667            return Ok(None);
1668        }
1669        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1670            .with_url(s3_url)
1671            .with_bucket_name(bucket);
1672        let e = options
1673            .s3_endpoint_url_override
1674            .as_ref()
1675            .or(cloud.s3_endpoint_url.as_ref());
1676        let k = options
1677            .s3_access_key_id_override
1678            .as_ref()
1679            .or(cloud.s3_access_key_id.as_ref());
1680        let s = options
1681            .s3_secret_access_key_override
1682            .as_ref()
1683            .or(cloud.s3_secret_access_key.as_ref());
1684        let r = options
1685            .s3_region_override
1686            .as_ref()
1687            .or(cloud.s3_region.as_ref());
1688        if let Some(e) = e {
1689            builder = builder.with_endpoint(e);
1690        }
1691        if let Some(k) = k {
1692            builder = builder.with_access_key_id(k);
1693        }
1694        if let Some(s) = s {
1695            builder = builder.with_secret_access_key(s);
1696        }
1697        if let Some(r) = r {
1698            builder = builder.with_region(r);
1699        }
1700        let store = builder
1701            .build()
1702            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1703        let rt = tokio::runtime::Runtime::new()
1704            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1705        let path = OsPath::from(key);
1706        match rt.block_on(store.head(&path)) {
1707            Ok(meta) => Ok(Some(meta.size)),
1708            Err(_) => Ok(None),
1709        }
1710    }
1711
1712    #[cfg(feature = "cloud")]
1713    fn fetch_remote_size_gcs(gs_url: &str, _options: &OpenOptions) -> Result<Option<u64>> {
1714        use object_store::path::Path as OsPath;
1715        use object_store::ObjectStore;
1716
1717        let (path_part, _ext) = source::url_path_extension(gs_url);
1718        let (bucket, key) = path_part
1719            .split_once('/')
1720            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1721        if key.is_empty() {
1722            return Ok(None);
1723        }
1724        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1725            .with_url(gs_url)
1726            .with_bucket_name(bucket)
1727            .build()
1728            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1729        let rt = tokio::runtime::Runtime::new()
1730            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1731        let path = OsPath::from(key);
1732        match rt.block_on(store.head(&path)) {
1733            Ok(meta) => Ok(Some(meta.size)),
1734            Err(_) => Ok(None),
1735        }
1736    }
1737
1738    #[cfg(feature = "http")]
1739    fn download_http_to_temp(
1740        url: &str,
1741        temp_dir: Option<&Path>,
1742        extension: Option<&str>,
1743    ) -> Result<PathBuf> {
1744        let dir = temp_dir
1745            .map(Path::to_path_buf)
1746            .unwrap_or_else(std::env::temp_dir);
1747        let suffix = extension
1748            .map(|e| format!(".{e}"))
1749            .unwrap_or_else(|| ".tmp".to_string());
1750        let mut temp = tempfile::Builder::new()
1751            .suffix(&suffix)
1752            .tempfile_in(&dir)
1753            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1754        let response = ureq::get(url)
1755            .timeout(std::time::Duration::from_secs(300))
1756            .call()
1757            .map_err(|e| {
1758                color_eyre::eyre::eyre!("Download failed. Check the URL and your connection: {}", e)
1759            })?;
1760        let status = response.status();
1761        if status >= 400 {
1762            return Err(color_eyre::eyre::eyre!(
1763                "Server returned {} {}. Check the URL.",
1764                status,
1765                response.status_text()
1766            ));
1767        }
1768        std::io::copy(&mut response.into_reader(), &mut temp)
1769            .map_err(|_| color_eyre::eyre::eyre!("Download failed while saving the file."))?;
1770        let (_file, path) = temp
1771            .keep()
1772            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1773        Ok(path)
1774    }
1775
1776    #[cfg(feature = "cloud")]
1777    fn download_s3_to_temp(
1778        s3_url: &str,
1779        cloud: &crate::config::CloudConfig,
1780        options: &OpenOptions,
1781    ) -> Result<PathBuf> {
1782        use object_store::path::Path as OsPath;
1783        use object_store::ObjectStore;
1784
1785        let (path_part, ext) = source::url_path_extension(s3_url);
1786        let (bucket, key) = path_part
1787            .split_once('/')
1788            .ok_or_else(|| color_eyre::eyre::eyre!("S3 URL must be s3://bucket/key"))?;
1789        if key.is_empty() {
1790            return Err(color_eyre::eyre::eyre!(
1791                "S3 URL must point to an object (e.g. s3://bucket/path/file.csv)"
1792            ));
1793        }
1794
1795        let mut builder = object_store::aws::AmazonS3Builder::from_env()
1796            .with_url(s3_url)
1797            .with_bucket_name(bucket);
1798        let e = options
1799            .s3_endpoint_url_override
1800            .as_ref()
1801            .or(cloud.s3_endpoint_url.as_ref());
1802        let k = options
1803            .s3_access_key_id_override
1804            .as_ref()
1805            .or(cloud.s3_access_key_id.as_ref());
1806        let s = options
1807            .s3_secret_access_key_override
1808            .as_ref()
1809            .or(cloud.s3_secret_access_key.as_ref());
1810        let r = options
1811            .s3_region_override
1812            .as_ref()
1813            .or(cloud.s3_region.as_ref());
1814        if let Some(e) = e {
1815            builder = builder.with_endpoint(e);
1816        }
1817        if let Some(k) = k {
1818            builder = builder.with_access_key_id(k);
1819        }
1820        if let Some(s) = s {
1821            builder = builder.with_secret_access_key(s);
1822        }
1823        if let Some(r) = r {
1824            builder = builder.with_region(r);
1825        }
1826        let store = builder
1827            .build()
1828            .map_err(|e| color_eyre::eyre::eyre!("S3 config failed: {}", e))?;
1829
1830        let rt = tokio::runtime::Runtime::new()
1831            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1832        let path = OsPath::from(key);
1833        let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1834            color_eyre::eyre::eyre!("Could not read from S3. Check credentials and URL: {}", e)
1835        })?;
1836        let bytes = rt
1837            .block_on(get_result.bytes())
1838            .map_err(|e| color_eyre::eyre::eyre!("Could not read S3 object body: {}", e))?;
1839
1840        let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1841        let suffix = ext
1842            .as_ref()
1843            .map(|e| format!(".{e}"))
1844            .unwrap_or_else(|| ".tmp".to_string());
1845        let mut temp = tempfile::Builder::new()
1846            .suffix(&suffix)
1847            .tempfile_in(&dir)
1848            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1849        std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1850            .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1851        let (_file, path_buf) = temp
1852            .keep()
1853            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1854        Ok(path_buf)
1855    }
1856
1857    #[cfg(feature = "cloud")]
1858    fn download_gcs_to_temp(gs_url: &str, options: &OpenOptions) -> Result<PathBuf> {
1859        use object_store::path::Path as OsPath;
1860        use object_store::ObjectStore;
1861
1862        let (path_part, ext) = source::url_path_extension(gs_url);
1863        let (bucket, key) = path_part
1864            .split_once('/')
1865            .ok_or_else(|| color_eyre::eyre::eyre!("GCS URL must be gs://bucket/key"))?;
1866        if key.is_empty() {
1867            return Err(color_eyre::eyre::eyre!(
1868                "GCS URL must point to an object (e.g. gs://bucket/path/file.csv)"
1869            ));
1870        }
1871
1872        let store = object_store::gcp::GoogleCloudStorageBuilder::from_env()
1873            .with_url(gs_url)
1874            .with_bucket_name(bucket)
1875            .build()
1876            .map_err(|e| color_eyre::eyre::eyre!("GCS config failed: {}", e))?;
1877
1878        let rt = tokio::runtime::Runtime::new()
1879            .map_err(|e| color_eyre::eyre::eyre!("Could not start runtime: {}", e))?;
1880        let path = OsPath::from(key);
1881        let get_result = rt.block_on(store.get(&path)).map_err(|e| {
1882            color_eyre::eyre::eyre!("Could not read from GCS. Check credentials and URL: {}", e)
1883        })?;
1884        let bytes = rt
1885            .block_on(get_result.bytes())
1886            .map_err(|e| color_eyre::eyre::eyre!("Could not read GCS object body: {}", e))?;
1887
1888        let dir = options.temp_dir.clone().unwrap_or_else(std::env::temp_dir);
1889        let suffix = ext
1890            .as_ref()
1891            .map(|e| format!(".{e}"))
1892            .unwrap_or_else(|| ".tmp".to_string());
1893        let mut temp = tempfile::Builder::new()
1894            .suffix(&suffix)
1895            .tempfile_in(&dir)
1896            .map_err(|_| color_eyre::eyre::eyre!("Could not create a temporary file."))?;
1897        std::io::copy(&mut std::io::Cursor::new(bytes.as_ref()), &mut temp)
1898            .map_err(|_| color_eyre::eyre::eyre!("Could not write downloaded file."))?;
1899        let (_file, path_buf) = temp
1900            .keep()
1901            .map_err(|_| color_eyre::eyre::eyre!("Could not save the downloaded file."))?;
1902        Ok(path_buf)
1903    }
1904
1905    /// Build LazyFrame from paths for phased loading (non-compressed only). Caller must not use for compressed CSV.
1906    fn build_lazyframe_from_paths(
1907        &mut self,
1908        paths: &[PathBuf],
1909        options: &OpenOptions,
1910    ) -> Result<LazyFrame> {
1911        let path = &paths[0];
1912        match source::input_source(path) {
1913            source::InputSource::Http(_url) => {
1914                #[cfg(feature = "http")]
1915                {
1916                    return Err(color_eyre::eyre::eyre!(
1917                        "HTTP/HTTPS load is handled in the event loop; this path should not be reached."
1918                    ));
1919                }
1920                #[cfg(not(feature = "http"))]
1921                {
1922                    return Err(color_eyre::eyre::eyre!(
1923                        "HTTP/HTTPS URLs are not supported in this build. Rebuild with default features."
1924                    ));
1925                }
1926            }
1927            source::InputSource::S3(url) => {
1928                #[cfg(feature = "cloud")]
1929                {
1930                    let full = format!("s3://{url}");
1931                    let cloud_opts = Self::build_s3_cloud_options(&self.app_config.cloud, options);
1932                    let pl_path = PlPathRef::new(&full).into_owned();
1933                    let is_glob = full.contains('*') || full.ends_with('/');
1934                    let hive_options = if is_glob {
1935                        polars::io::HiveOptions::new_enabled()
1936                    } else {
1937                        polars::io::HiveOptions::default()
1938                    };
1939                    let args = ScanArgsParquet {
1940                        cloud_options: Some(cloud_opts),
1941                        hive_options,
1942                        glob: is_glob,
1943                        ..Default::default()
1944                    };
1945                    let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
1946                        color_eyre::eyre::eyre!(
1947                            "Could not read from S3. Check credentials and URL: {}",
1948                            e
1949                        )
1950                    })?;
1951                    let state = DataTableState::from_lazyframe(lf, options)?;
1952                    return Ok(state.lf);
1953                }
1954                #[cfg(not(feature = "cloud"))]
1955                {
1956                    return Err(color_eyre::eyre::eyre!(
1957                        "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)."
1958                    ));
1959                }
1960            }
1961            source::InputSource::Gcs(url) => {
1962                #[cfg(feature = "cloud")]
1963                {
1964                    let full = format!("gs://{url}");
1965                    let pl_path = PlPathRef::new(&full).into_owned();
1966                    let is_glob = full.contains('*') || full.ends_with('/');
1967                    let hive_options = if is_glob {
1968                        polars::io::HiveOptions::new_enabled()
1969                    } else {
1970                        polars::io::HiveOptions::default()
1971                    };
1972                    let args = ScanArgsParquet {
1973                        cloud_options: Some(CloudOptions::default()),
1974                        hive_options,
1975                        glob: is_glob,
1976                        ..Default::default()
1977                    };
1978                    let lf = LazyFrame::scan_parquet(pl_path, args).map_err(|e| {
1979                        color_eyre::eyre::eyre!(
1980                            "Could not read from GCS. Check credentials and URL: {}",
1981                            e
1982                        )
1983                    })?;
1984                    let state = DataTableState::from_lazyframe(lf, options)?;
1985                    return Ok(state.lf);
1986                }
1987                #[cfg(not(feature = "cloud"))]
1988                {
1989                    return Err(color_eyre::eyre::eyre!(
1990                        "GCS (gs://) is not supported in this build. Rebuild with default features."
1991                    ));
1992                }
1993            }
1994            source::InputSource::Local(_) => {}
1995        }
1996
1997        if paths.len() == 1 && options.hive {
1998            let path_str = path.as_os_str().to_string_lossy();
1999            let is_single_file = path.exists()
2000                && path.is_file()
2001                && !path_str.contains('*')
2002                && !path_str.contains("**");
2003            if !is_single_file {
2004                let use_parquet_hive = path.is_dir()
2005                    || path_str.contains(".parquet")
2006                    || path_str.contains("*.parquet");
2007                if use_parquet_hive {
2008                    // Only build LazyFrame here; schema + partition discovery happen in DoLoadSchema ("Caching schema")
2009                    return DataTableState::scan_parquet_hive(path);
2010                }
2011                return Err(color_eyre::eyre::eyre!(
2012                    "With --hive use a directory or a glob pattern for Parquet (e.g. path/to/dir or path/**/*.parquet)"
2013                ));
2014            }
2015        }
2016
2017        let lf = if paths.len() > 1 {
2018            match path.extension() {
2019                Some(ext) if ext.eq_ignore_ascii_case("parquet") => {
2020                    DataTableState::from_parquet_paths(
2021                        paths,
2022                        options.pages_lookahead,
2023                        options.pages_lookback,
2024                        options.max_buffered_rows,
2025                        options.max_buffered_mb,
2026                        options.row_numbers,
2027                        options.row_start_index,
2028                    )?
2029                }
2030                Some(ext) if ext.eq_ignore_ascii_case("csv") => {
2031                    DataTableState::from_csv_paths(paths, options)?
2032                }
2033                Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json_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("jsonl") => {
2043                    DataTableState::from_json_lines_paths(
2044                        paths,
2045                        options.pages_lookahead,
2046                        options.pages_lookback,
2047                        options.max_buffered_rows,
2048                        options.max_buffered_mb,
2049                        options.row_numbers,
2050                        options.row_start_index,
2051                    )?
2052                }
2053                Some(ext) if ext.eq_ignore_ascii_case("ndjson") => {
2054                    DataTableState::from_ndjson_paths(
2055                        paths,
2056                        options.pages_lookahead,
2057                        options.pages_lookback,
2058                        options.max_buffered_rows,
2059                        options.max_buffered_mb,
2060                        options.row_numbers,
2061                        options.row_start_index,
2062                    )?
2063                }
2064                Some(ext)
2065                    if ext.eq_ignore_ascii_case("arrow")
2066                        || ext.eq_ignore_ascii_case("ipc")
2067                        || ext.eq_ignore_ascii_case("feather") =>
2068                {
2069                    DataTableState::from_ipc_paths(
2070                        paths,
2071                        options.pages_lookahead,
2072                        options.pages_lookback,
2073                        options.max_buffered_rows,
2074                        options.max_buffered_mb,
2075                        options.row_numbers,
2076                        options.row_start_index,
2077                    )?
2078                }
2079                Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro_paths(
2080                    paths,
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("orc") => DataTableState::from_orc_paths(
2089                    paths,
2090                    options.pages_lookahead,
2091                    options.pages_lookback,
2092                    options.max_buffered_rows,
2093                    options.max_buffered_mb,
2094                    options.row_numbers,
2095                    options.row_start_index,
2096                )?,
2097                _ => {
2098                    if !paths.is_empty() && !path.exists() {
2099                        return Err(std::io::Error::new(
2100                            std::io::ErrorKind::NotFound,
2101                            format!("File not found: {}", path.display()),
2102                        )
2103                        .into());
2104                    }
2105                    return Err(color_eyre::eyre::eyre!(
2106                        "Unsupported file type for multiple files (parquet, csv, json, jsonl, ndjson, arrow/ipc/feather, avro, orc only)"
2107                    ));
2108                }
2109            }
2110        } else {
2111            match path.extension() {
2112                Some(ext) if ext.eq_ignore_ascii_case("parquet") => DataTableState::from_parquet(
2113                    path,
2114                    options.pages_lookahead,
2115                    options.pages_lookback,
2116                    options.max_buffered_rows,
2117                    options.max_buffered_mb,
2118                    options.row_numbers,
2119                    options.row_start_index,
2120                )?,
2121                Some(ext) if ext.eq_ignore_ascii_case("csv") => {
2122                    DataTableState::from_csv(path, options)?
2123                }
2124                Some(ext) if ext.eq_ignore_ascii_case("tsv") => DataTableState::from_delimited(
2125                    path,
2126                    b'\t',
2127                    options.pages_lookahead,
2128                    options.pages_lookback,
2129                    options.max_buffered_rows,
2130                    options.max_buffered_mb,
2131                    options.row_numbers,
2132                    options.row_start_index,
2133                )?,
2134                Some(ext) if ext.eq_ignore_ascii_case("psv") => DataTableState::from_delimited(
2135                    path,
2136                    b'|',
2137                    options.pages_lookahead,
2138                    options.pages_lookback,
2139                    options.max_buffered_rows,
2140                    options.max_buffered_mb,
2141                    options.row_numbers,
2142                    options.row_start_index,
2143                )?,
2144                Some(ext) if ext.eq_ignore_ascii_case("json") => DataTableState::from_json(
2145                    path,
2146                    options.pages_lookahead,
2147                    options.pages_lookback,
2148                    options.max_buffered_rows,
2149                    options.max_buffered_mb,
2150                    options.row_numbers,
2151                    options.row_start_index,
2152                )?,
2153                Some(ext) if ext.eq_ignore_ascii_case("jsonl") => DataTableState::from_json_lines(
2154                    path,
2155                    options.pages_lookahead,
2156                    options.pages_lookback,
2157                    options.max_buffered_rows,
2158                    options.max_buffered_mb,
2159                    options.row_numbers,
2160                    options.row_start_index,
2161                )?,
2162                Some(ext) if ext.eq_ignore_ascii_case("ndjson") => DataTableState::from_ndjson(
2163                    path,
2164                    options.pages_lookahead,
2165                    options.pages_lookback,
2166                    options.max_buffered_rows,
2167                    options.max_buffered_mb,
2168                    options.row_numbers,
2169                    options.row_start_index,
2170                )?,
2171                Some(ext)
2172                    if ext.eq_ignore_ascii_case("arrow")
2173                        || ext.eq_ignore_ascii_case("ipc")
2174                        || ext.eq_ignore_ascii_case("feather") =>
2175                {
2176                    DataTableState::from_ipc(
2177                        path,
2178                        options.pages_lookahead,
2179                        options.pages_lookback,
2180                        options.max_buffered_rows,
2181                        options.max_buffered_mb,
2182                        options.row_numbers,
2183                        options.row_start_index,
2184                    )?
2185                }
2186                Some(ext) if ext.eq_ignore_ascii_case("avro") => DataTableState::from_avro(
2187                    path,
2188                    options.pages_lookahead,
2189                    options.pages_lookback,
2190                    options.max_buffered_rows,
2191                    options.max_buffered_mb,
2192                    options.row_numbers,
2193                    options.row_start_index,
2194                )?,
2195                Some(ext)
2196                    if ext.eq_ignore_ascii_case("xls")
2197                        || ext.eq_ignore_ascii_case("xlsx")
2198                        || ext.eq_ignore_ascii_case("xlsm")
2199                        || ext.eq_ignore_ascii_case("xlsb") =>
2200                {
2201                    DataTableState::from_excel(
2202                        path,
2203                        options.pages_lookahead,
2204                        options.pages_lookback,
2205                        options.max_buffered_rows,
2206                        options.max_buffered_mb,
2207                        options.row_numbers,
2208                        options.row_start_index,
2209                        options.excel_sheet.as_deref(),
2210                    )?
2211                }
2212                Some(ext) if ext.eq_ignore_ascii_case("orc") => DataTableState::from_orc(
2213                    path,
2214                    options.pages_lookahead,
2215                    options.pages_lookback,
2216                    options.max_buffered_rows,
2217                    options.max_buffered_mb,
2218                    options.row_numbers,
2219                    options.row_start_index,
2220                )?,
2221                _ => {
2222                    if paths.len() == 1 && !path.exists() {
2223                        return Err(std::io::Error::new(
2224                            std::io::ErrorKind::NotFound,
2225                            format!("File not found: {}", path.display()),
2226                        )
2227                        .into());
2228                    }
2229                    return Err(color_eyre::eyre::eyre!("Unsupported file type"));
2230                }
2231            }
2232        };
2233        Ok(lf.lf)
2234    }
2235
2236    /// Set the appropriate help overlay visible (main, template, or analysis). No-op if already visible.
2237    fn open_help_overlay(&mut self) {
2238        let already = self.show_help
2239            || (self.template_modal.active && self.template_modal.show_help)
2240            || (self.analysis_modal.active && self.analysis_modal.show_help);
2241        if already {
2242            return;
2243        }
2244        if self.analysis_modal.active {
2245            self.analysis_modal.show_help = true;
2246        } else if self.template_modal.active {
2247            self.template_modal.show_help = true;
2248        } else {
2249            self.show_help = true;
2250        }
2251    }
2252
2253    fn key(&mut self, event: &KeyEvent) -> Option<AppEvent> {
2254        self.debug.on_key(event);
2255
2256        // F1 opens help first so no other branch (e.g. Editing) can consume it.
2257        if event.code == KeyCode::F(1) {
2258            self.open_help_overlay();
2259            return None;
2260        }
2261
2262        // Handle modals first - they have highest priority
2263        // Confirmation modal (for overwrite)
2264        if self.confirmation_modal.active {
2265            match event.code {
2266                KeyCode::Left | KeyCode::Char('h') => {
2267                    self.confirmation_modal.focus_yes = true;
2268                }
2269                KeyCode::Right | KeyCode::Char('l') => {
2270                    self.confirmation_modal.focus_yes = false;
2271                }
2272                KeyCode::Tab => {
2273                    // Toggle between Yes and No
2274                    self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
2275                }
2276                KeyCode::Enter => {
2277                    if self.confirmation_modal.focus_yes {
2278                        // User confirmed overwrite: chart export first, then dataframe export
2279                        if let Some((path, format, title)) = self.pending_chart_export.take() {
2280                            self.confirmation_modal.hide();
2281                            return Some(AppEvent::ChartExport(path, format, title));
2282                        }
2283                        if let Some((path, format, options)) = self.pending_export.take() {
2284                            self.confirmation_modal.hide();
2285                            return Some(AppEvent::Export(path, format, options));
2286                        }
2287                        #[cfg(any(feature = "http", feature = "cloud"))]
2288                        if let Some(pending) = self.pending_download.take() {
2289                            self.confirmation_modal.hide();
2290                            if let LoadingState::Loading {
2291                                file_path,
2292                                file_size,
2293                                ..
2294                            } = &self.loading_state
2295                            {
2296                                self.loading_state = LoadingState::Loading {
2297                                    file_path: file_path.clone(),
2298                                    file_size: *file_size,
2299                                    current_phase: "Downloading".to_string(),
2300                                    progress_percent: 20,
2301                                };
2302                            }
2303                            return Some(match pending {
2304                                #[cfg(feature = "http")]
2305                                PendingDownload::Http { url, options, .. } => {
2306                                    AppEvent::DoDownloadHttp(url, options)
2307                                }
2308                                #[cfg(feature = "cloud")]
2309                                PendingDownload::S3 { url, options, .. } => {
2310                                    AppEvent::DoDownloadS3ToTemp(url, options)
2311                                }
2312                                #[cfg(feature = "cloud")]
2313                                PendingDownload::Gcs { url, options, .. } => {
2314                                    AppEvent::DoDownloadGcsToTemp(url, options)
2315                                }
2316                            });
2317                        }
2318                    } else {
2319                        // User cancelled: if chart export overwrite, reopen chart export modal with path pre-filled
2320                        if let Some((path, format, _)) = self.pending_chart_export.take() {
2321                            self.chart_export_modal.reopen_with_path(&path, format);
2322                        }
2323                        self.pending_export = None;
2324                        #[cfg(any(feature = "http", feature = "cloud"))]
2325                        if self.pending_download.take().is_some() {
2326                            self.confirmation_modal.hide();
2327                            return Some(AppEvent::Exit);
2328                        }
2329                        self.confirmation_modal.hide();
2330                    }
2331                }
2332                KeyCode::Esc => {
2333                    // Cancel: if chart export overwrite, reopen chart export modal with path pre-filled
2334                    if let Some((path, format, _)) = self.pending_chart_export.take() {
2335                        self.chart_export_modal.reopen_with_path(&path, format);
2336                    }
2337                    self.pending_export = None;
2338                    #[cfg(any(feature = "http", feature = "cloud"))]
2339                    if self.pending_download.take().is_some() {
2340                        self.confirmation_modal.hide();
2341                        return Some(AppEvent::Exit);
2342                    }
2343                    self.confirmation_modal.hide();
2344                }
2345                _ => {}
2346            }
2347            return None;
2348        }
2349        // Success modal
2350        if self.success_modal.active {
2351            match event.code {
2352                KeyCode::Esc | KeyCode::Enter => {
2353                    self.success_modal.hide();
2354                }
2355                _ => {}
2356            }
2357            return None;
2358        }
2359        // Error modal
2360        if self.error_modal.active {
2361            match event.code {
2362                KeyCode::Esc | KeyCode::Enter => {
2363                    self.error_modal.hide();
2364                }
2365                _ => {}
2366            }
2367            return None;
2368        }
2369
2370        // Main table: left/right scroll columns (before help/mode blocks so column scroll always works in Normal).
2371        // No is_press()/is_release() check: some terminals do not report key kind correctly.
2372        // Exclude template/analysis modals so they can handle Left/Right themselves.
2373        let in_main_table = !(self.input_mode != InputMode::Normal
2374            || self.show_help
2375            || self.template_modal.active
2376            || self.analysis_modal.active);
2377        if in_main_table {
2378            let did_scroll = match event.code {
2379                KeyCode::Right | KeyCode::Char('l') => {
2380                    if let Some(ref mut state) = self.data_table_state {
2381                        state.scroll_right();
2382                        if self.debug.enabled {
2383                            self.debug.last_action = "scroll_right".to_string();
2384                        }
2385                        true
2386                    } else {
2387                        false
2388                    }
2389                }
2390                KeyCode::Left | KeyCode::Char('h') => {
2391                    if let Some(ref mut state) = self.data_table_state {
2392                        state.scroll_left();
2393                        if self.debug.enabled {
2394                            self.debug.last_action = "scroll_left".to_string();
2395                        }
2396                        true
2397                    } else {
2398                        false
2399                    }
2400                }
2401                _ => false,
2402            };
2403            if did_scroll {
2404                return None;
2405            }
2406        }
2407
2408        if self.show_help
2409            || (self.template_modal.active && self.template_modal.show_help)
2410            || (self.analysis_modal.active && self.analysis_modal.show_help)
2411        {
2412            match event.code {
2413                KeyCode::Esc => {
2414                    if self.analysis_modal.active && self.analysis_modal.show_help {
2415                        self.analysis_modal.show_help = false;
2416                    } else if self.template_modal.active && self.template_modal.show_help {
2417                        self.template_modal.show_help = false;
2418                    } else {
2419                        self.show_help = false;
2420                    }
2421                    self.help_scroll = 0;
2422                }
2423                KeyCode::Char('?') => {
2424                    if self.analysis_modal.active && self.analysis_modal.show_help {
2425                        self.analysis_modal.show_help = false;
2426                    } else if self.template_modal.active && self.template_modal.show_help {
2427                        self.template_modal.show_help = false;
2428                    } else {
2429                        self.show_help = false;
2430                    }
2431                    self.help_scroll = 0;
2432                }
2433                KeyCode::Down | KeyCode::Char('j') => {
2434                    self.help_scroll = self.help_scroll.saturating_add(1);
2435                }
2436                KeyCode::Up | KeyCode::Char('k') => {
2437                    self.help_scroll = self.help_scroll.saturating_sub(1);
2438                }
2439                KeyCode::PageDown => {
2440                    self.help_scroll = self.help_scroll.saturating_add(10);
2441                }
2442                KeyCode::PageUp => {
2443                    self.help_scroll = self.help_scroll.saturating_sub(10);
2444                }
2445                KeyCode::Home => {
2446                    self.help_scroll = 0;
2447                }
2448                KeyCode::End => {
2449                    // Will be set based on content height in render
2450                }
2451                _ => {}
2452            }
2453            return None;
2454        }
2455
2456        if event.code == KeyCode::Char('?') {
2457            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
2458            let in_text_input = match self.input_mode {
2459                InputMode::Editing => true,
2460                InputMode::Export => matches!(
2461                    self.export_modal.focus,
2462                    ExportFocus::PathInput | ExportFocus::CsvDelimiter
2463                ),
2464                InputMode::SortFilter => {
2465                    let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2466                    let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2467                    on_body
2468                        && filter_tab
2469                        && self.sort_filter_modal.filter.focus == FilterFocus::Value
2470                }
2471                InputMode::PivotMelt => matches!(
2472                    self.pivot_melt_modal.focus,
2473                    PivotMeltFocus::PivotFilter
2474                        | PivotMeltFocus::MeltFilter
2475                        | PivotMeltFocus::MeltPattern
2476                        | PivotMeltFocus::MeltVarName
2477                        | PivotMeltFocus::MeltValName
2478                ),
2479                InputMode::Info | InputMode::Chart => false,
2480                InputMode::Normal => {
2481                    if self.template_modal.active
2482                        && self.template_modal.mode != TemplateModalMode::List
2483                    {
2484                        matches!(
2485                            self.template_modal.create_focus,
2486                            CreateFocus::Name
2487                                | CreateFocus::Description
2488                                | CreateFocus::ExactPath
2489                                | CreateFocus::RelativePath
2490                                | CreateFocus::PathPattern
2491                                | CreateFocus::FilenamePattern
2492                        )
2493                    } else {
2494                        false
2495                    }
2496                }
2497            };
2498            // Ctrl-? always opens help; bare ? only when not in a text field
2499            if ctrl_help || !in_text_input {
2500                self.open_help_overlay();
2501                return None;
2502            }
2503        }
2504
2505        if self.input_mode == InputMode::SortFilter {
2506            let on_tab_bar = self.sort_filter_modal.focus == SortFilterFocus::TabBar;
2507            let on_body = self.sort_filter_modal.focus == SortFilterFocus::Body;
2508            let on_apply = self.sort_filter_modal.focus == SortFilterFocus::Apply;
2509            let on_cancel = self.sort_filter_modal.focus == SortFilterFocus::Cancel;
2510            let on_clear = self.sort_filter_modal.focus == SortFilterFocus::Clear;
2511            let sort_tab = self.sort_filter_modal.active_tab == SortFilterTab::Sort;
2512            let filter_tab = self.sort_filter_modal.active_tab == SortFilterTab::Filter;
2513
2514            match event.code {
2515                KeyCode::Esc => {
2516                    for col in &mut self.sort_filter_modal.sort.columns {
2517                        col.is_to_be_locked = false;
2518                    }
2519                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2520                    self.sort_filter_modal.close();
2521                    self.input_mode = InputMode::Normal;
2522                }
2523                KeyCode::Tab => self.sort_filter_modal.next_focus(),
2524                KeyCode::BackTab => self.sort_filter_modal.prev_focus(),
2525                KeyCode::Left | KeyCode::Char('h') if on_tab_bar => {
2526                    self.sort_filter_modal.switch_tab();
2527                }
2528                KeyCode::Right | KeyCode::Char('l') if on_tab_bar => {
2529                    self.sort_filter_modal.switch_tab();
2530                }
2531                KeyCode::Enter if event.modifiers.contains(KeyModifiers::CONTROL) && sort_tab => {
2532                    let columns = self.sort_filter_modal.sort.get_sorted_columns();
2533                    let column_order = self.sort_filter_modal.sort.get_column_order();
2534                    let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2535                    let ascending = self.sort_filter_modal.sort.ascending;
2536                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2537                    self.sort_filter_modal.close();
2538                    self.input_mode = InputMode::Normal;
2539                    let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2540                    return Some(AppEvent::Sort(columns, ascending));
2541                }
2542                KeyCode::Enter if on_apply => {
2543                    if sort_tab {
2544                        let columns = self.sort_filter_modal.sort.get_sorted_columns();
2545                        let column_order = self.sort_filter_modal.sort.get_column_order();
2546                        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2547                        let ascending = self.sort_filter_modal.sort.ascending;
2548                        self.sort_filter_modal.sort.has_unapplied_changes = false;
2549                        self.sort_filter_modal.close();
2550                        self.input_mode = InputMode::Normal;
2551                        let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2552                        return Some(AppEvent::Sort(columns, ascending));
2553                    } else {
2554                        let statements = self.sort_filter_modal.filter.statements.clone();
2555                        self.sort_filter_modal.close();
2556                        self.input_mode = InputMode::Normal;
2557                        return Some(AppEvent::Filter(statements));
2558                    }
2559                }
2560                KeyCode::Enter if on_cancel => {
2561                    for col in &mut self.sort_filter_modal.sort.columns {
2562                        col.is_to_be_locked = false;
2563                    }
2564                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2565                    self.sort_filter_modal.close();
2566                    self.input_mode = InputMode::Normal;
2567                }
2568                KeyCode::Enter if on_clear => {
2569                    if sort_tab {
2570                        self.sort_filter_modal.sort.clear_selection();
2571                    } else {
2572                        self.sort_filter_modal.filter.statements.clear();
2573                        self.sort_filter_modal.filter.list_state.select(None);
2574                    }
2575                }
2576                KeyCode::Char(' ')
2577                    if on_body
2578                        && sort_tab
2579                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2580                {
2581                    self.sort_filter_modal.sort.toggle_selection();
2582                }
2583                KeyCode::Char(' ')
2584                    if on_body
2585                        && sort_tab
2586                        && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2587                {
2588                    self.sort_filter_modal.sort.ascending = !self.sort_filter_modal.sort.ascending;
2589                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2590                }
2591                KeyCode::Char(' ') if on_apply && sort_tab => {
2592                    let columns = self.sort_filter_modal.sort.get_sorted_columns();
2593                    let column_order = self.sort_filter_modal.sort.get_column_order();
2594                    let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2595                    let ascending = self.sort_filter_modal.sort.ascending;
2596                    self.sort_filter_modal.sort.has_unapplied_changes = false;
2597                    let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2598                    return Some(AppEvent::Sort(columns, ascending));
2599                }
2600                KeyCode::Enter if on_body && filter_tab => {
2601                    match self.sort_filter_modal.filter.focus {
2602                        FilterFocus::Add => {
2603                            self.sort_filter_modal.filter.add_statement();
2604                        }
2605                        FilterFocus::Statements => {
2606                            let m = &mut self.sort_filter_modal.filter;
2607                            if let Some(idx) = m.list_state.selected() {
2608                                if idx < m.statements.len() {
2609                                    m.statements.remove(idx);
2610                                    if m.statements.is_empty() {
2611                                        m.list_state.select(None);
2612                                        m.focus = FilterFocus::Column;
2613                                    } else {
2614                                        m.list_state
2615                                            .select(Some(m.statements.len().saturating_sub(1)));
2616                                    }
2617                                }
2618                            }
2619                        }
2620                        _ => {}
2621                    }
2622                }
2623                KeyCode::Enter if on_body && sort_tab => match self.sort_filter_modal.sort.focus {
2624                    SortFocus::Filter => {
2625                        self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
2626                    }
2627                    SortFocus::ColumnList => {
2628                        self.sort_filter_modal.sort.toggle_selection();
2629                        let columns = self.sort_filter_modal.sort.get_sorted_columns();
2630                        let column_order = self.sort_filter_modal.sort.get_column_order();
2631                        let locked_count = self.sort_filter_modal.sort.get_locked_columns_count();
2632                        let ascending = self.sort_filter_modal.sort.ascending;
2633                        self.sort_filter_modal.sort.has_unapplied_changes = false;
2634                        let _ = self.send_event(AppEvent::ColumnOrder(column_order, locked_count));
2635                        return Some(AppEvent::Sort(columns, ascending));
2636                    }
2637                    SortFocus::Order => {
2638                        self.sort_filter_modal.sort.ascending =
2639                            !self.sort_filter_modal.sort.ascending;
2640                        self.sort_filter_modal.sort.has_unapplied_changes = true;
2641                    }
2642                    _ => {}
2643                },
2644                KeyCode::Left
2645                | KeyCode::Right
2646                | KeyCode::Char('h')
2647                | KeyCode::Char('l')
2648                | KeyCode::Up
2649                | KeyCode::Down
2650                | KeyCode::Char('j')
2651                | KeyCode::Char('k')
2652                    if on_body
2653                        && sort_tab
2654                        && self.sort_filter_modal.sort.focus == SortFocus::Order =>
2655                {
2656                    let s = &mut self.sort_filter_modal.sort;
2657                    match event.code {
2658                        KeyCode::Left | KeyCode::Char('h') | KeyCode::Up | KeyCode::Char('k') => {
2659                            s.ascending = true;
2660                        }
2661                        KeyCode::Right
2662                        | KeyCode::Char('l')
2663                        | KeyCode::Down
2664                        | KeyCode::Char('j') => {
2665                            s.ascending = false;
2666                        }
2667                        _ => {}
2668                    }
2669                    s.has_unapplied_changes = true;
2670                }
2671                KeyCode::Down
2672                    if on_body
2673                        && filter_tab
2674                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2675                {
2676                    let m = &mut self.sort_filter_modal.filter;
2677                    let i = match m.list_state.selected() {
2678                        Some(i) => {
2679                            if i >= m.statements.len().saturating_sub(1) {
2680                                0
2681                            } else {
2682                                i + 1
2683                            }
2684                        }
2685                        None => 0,
2686                    };
2687                    m.list_state.select(Some(i));
2688                }
2689                KeyCode::Up
2690                    if on_body
2691                        && filter_tab
2692                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2693                {
2694                    let m = &mut self.sort_filter_modal.filter;
2695                    let i = match m.list_state.selected() {
2696                        Some(i) => {
2697                            if i == 0 {
2698                                m.statements.len().saturating_sub(1)
2699                            } else {
2700                                i - 1
2701                            }
2702                        }
2703                        None => 0,
2704                    };
2705                    m.list_state.select(Some(i));
2706                }
2707                KeyCode::Down | KeyCode::Char('j') if on_body && sort_tab => {
2708                    let s = &mut self.sort_filter_modal.sort;
2709                    if s.focus == SortFocus::ColumnList {
2710                        let i = match s.table_state.selected() {
2711                            Some(i) => {
2712                                if i >= s.filtered_columns().len().saturating_sub(1) {
2713                                    0
2714                                } else {
2715                                    i + 1
2716                                }
2717                            }
2718                            None => 0,
2719                        };
2720                        s.table_state.select(Some(i));
2721                    } else {
2722                        let _ = s.next_body_focus();
2723                    }
2724                }
2725                KeyCode::Up | KeyCode::Char('k') if on_body && sort_tab => {
2726                    let s = &mut self.sort_filter_modal.sort;
2727                    if s.focus == SortFocus::ColumnList {
2728                        let i = match s.table_state.selected() {
2729                            Some(i) => {
2730                                if i == 0 {
2731                                    s.filtered_columns().len().saturating_sub(1)
2732                                } else {
2733                                    i - 1
2734                                }
2735                            }
2736                            None => 0,
2737                        };
2738                        s.table_state.select(Some(i));
2739                    } else {
2740                        let _ = s.prev_body_focus();
2741                    }
2742                }
2743                KeyCode::Char(']')
2744                    if on_body
2745                        && sort_tab
2746                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2747                {
2748                    self.sort_filter_modal.sort.move_selection_down();
2749                }
2750                KeyCode::Char('[')
2751                    if on_body
2752                        && sort_tab
2753                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2754                {
2755                    self.sort_filter_modal.sort.move_selection_up();
2756                }
2757                KeyCode::Char('+') | KeyCode::Char('=')
2758                    if on_body
2759                        && sort_tab
2760                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2761                {
2762                    self.sort_filter_modal.sort.move_column_display_up();
2763                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2764                }
2765                KeyCode::Char('-') | KeyCode::Char('_')
2766                    if on_body
2767                        && sort_tab
2768                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2769                {
2770                    self.sort_filter_modal.sort.move_column_display_down();
2771                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2772                }
2773                KeyCode::Char('L')
2774                    if on_body
2775                        && sort_tab
2776                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2777                {
2778                    self.sort_filter_modal.sort.toggle_lock_at_column();
2779                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2780                }
2781                KeyCode::Char('v')
2782                    if on_body
2783                        && sort_tab
2784                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2785                {
2786                    self.sort_filter_modal.sort.toggle_visibility();
2787                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2788                }
2789                KeyCode::Char(c)
2790                    if on_body
2791                        && sort_tab
2792                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList
2793                        && c.is_ascii_digit() =>
2794                {
2795                    if let Some(digit) = c.to_digit(10) {
2796                        self.sort_filter_modal
2797                            .sort
2798                            .jump_selection_to_order(digit as usize);
2799                    }
2800                }
2801                // Handle filter input field in sort tab
2802                // Only handle keys that the text input should process
2803                // Special keys like Tab, Esc, Enter are handled by other patterns above
2804                _ if on_body
2805                    && sort_tab
2806                    && self.sort_filter_modal.sort.focus == SortFocus::Filter
2807                    && !matches!(
2808                        event.code,
2809                        KeyCode::Tab
2810                            | KeyCode::BackTab
2811                            | KeyCode::Esc
2812                            | KeyCode::Enter
2813                            | KeyCode::Up
2814                            | KeyCode::Down
2815                    ) =>
2816                {
2817                    // Pass key events to the filter input
2818                    let _ = self
2819                        .sort_filter_modal
2820                        .sort
2821                        .filter_input
2822                        .handle_key(event, Some(&self.cache));
2823                }
2824                KeyCode::Char(c)
2825                    if on_body
2826                        && filter_tab
2827                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2828                {
2829                    self.sort_filter_modal.filter.new_value.push(c);
2830                }
2831                KeyCode::Backspace
2832                    if on_body
2833                        && filter_tab
2834                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2835                {
2836                    self.sort_filter_modal.filter.new_value.pop();
2837                }
2838                KeyCode::Right | KeyCode::Char('l') if on_body && filter_tab => {
2839                    let m = &mut self.sort_filter_modal.filter;
2840                    match m.focus {
2841                        FilterFocus::Column => {
2842                            m.new_column_idx =
2843                                (m.new_column_idx + 1) % m.available_columns.len().max(1);
2844                        }
2845                        FilterFocus::Operator => {
2846                            m.new_operator_idx =
2847                                (m.new_operator_idx + 1) % FilterOperator::iterator().count();
2848                        }
2849                        FilterFocus::Logical => {
2850                            m.new_logical_idx =
2851                                (m.new_logical_idx + 1) % LogicalOperator::iterator().count();
2852                        }
2853                        _ => {}
2854                    }
2855                }
2856                KeyCode::Left | KeyCode::Char('h') if on_body && filter_tab => {
2857                    let m = &mut self.sort_filter_modal.filter;
2858                    match m.focus {
2859                        FilterFocus::Column => {
2860                            m.new_column_idx = if m.new_column_idx == 0 {
2861                                m.available_columns.len().saturating_sub(1)
2862                            } else {
2863                                m.new_column_idx - 1
2864                            };
2865                        }
2866                        FilterFocus::Operator => {
2867                            m.new_operator_idx = if m.new_operator_idx == 0 {
2868                                FilterOperator::iterator().count() - 1
2869                            } else {
2870                                m.new_operator_idx - 1
2871                            };
2872                        }
2873                        FilterFocus::Logical => {
2874                            m.new_logical_idx = if m.new_logical_idx == 0 {
2875                                LogicalOperator::iterator().count() - 1
2876                            } else {
2877                                m.new_logical_idx - 1
2878                            };
2879                        }
2880                        _ => {}
2881                    }
2882                }
2883                _ => {}
2884            }
2885            return None;
2886        }
2887
2888        if self.input_mode == InputMode::Export {
2889            match event.code {
2890                KeyCode::Esc => {
2891                    self.export_modal.close();
2892                    self.input_mode = InputMode::Normal;
2893                }
2894                KeyCode::Tab => self.export_modal.next_focus(),
2895                KeyCode::BackTab => self.export_modal.prev_focus(),
2896                KeyCode::Up | KeyCode::Char('k') => {
2897                    match self.export_modal.focus {
2898                        ExportFocus::FormatSelector => {
2899                            // Cycle through formats
2900                            let current_idx = ExportFormat::ALL
2901                                .iter()
2902                                .position(|&f| f == self.export_modal.selected_format)
2903                                .unwrap_or(0);
2904                            let prev_idx = if current_idx == 0 {
2905                                ExportFormat::ALL.len() - 1
2906                            } else {
2907                                current_idx - 1
2908                            };
2909                            self.export_modal.selected_format = ExportFormat::ALL[prev_idx];
2910                        }
2911                        ExportFocus::PathInput => {
2912                            // Pass to text input widget (for history navigation)
2913                            self.export_modal.path_input.handle_key(event, None);
2914                        }
2915                        ExportFocus::CsvDelimiter => {
2916                            // Pass to text input widget (for history navigation)
2917                            self.export_modal
2918                                .csv_delimiter_input
2919                                .handle_key(event, None);
2920                        }
2921                        ExportFocus::CsvCompression
2922                        | ExportFocus::JsonCompression
2923                        | ExportFocus::NdjsonCompression => {
2924                            // Left to move to previous compression option
2925                            self.export_modal.cycle_compression_backward();
2926                        }
2927                        _ => {
2928                            self.export_modal.prev_focus();
2929                        }
2930                    }
2931                }
2932                KeyCode::Down | KeyCode::Char('j') => {
2933                    match self.export_modal.focus {
2934                        ExportFocus::FormatSelector => {
2935                            // Cycle through formats
2936                            let current_idx = ExportFormat::ALL
2937                                .iter()
2938                                .position(|&f| f == self.export_modal.selected_format)
2939                                .unwrap_or(0);
2940                            let next_idx = (current_idx + 1) % ExportFormat::ALL.len();
2941                            self.export_modal.selected_format = ExportFormat::ALL[next_idx];
2942                        }
2943                        ExportFocus::PathInput => {
2944                            // Pass to text input widget (for history navigation)
2945                            self.export_modal.path_input.handle_key(event, None);
2946                        }
2947                        ExportFocus::CsvDelimiter => {
2948                            // Pass to text input widget (for history navigation)
2949                            self.export_modal
2950                                .csv_delimiter_input
2951                                .handle_key(event, None);
2952                        }
2953                        ExportFocus::CsvCompression
2954                        | ExportFocus::JsonCompression
2955                        | ExportFocus::NdjsonCompression => {
2956                            // Right to move to next compression option
2957                            self.export_modal.cycle_compression();
2958                        }
2959                        _ => {
2960                            self.export_modal.next_focus();
2961                        }
2962                    }
2963                }
2964                KeyCode::Left | KeyCode::Char('h') => {
2965                    match self.export_modal.focus {
2966                        ExportFocus::PathInput => {
2967                            self.export_modal.path_input.handle_key(event, None);
2968                        }
2969                        ExportFocus::CsvDelimiter => {
2970                            self.export_modal
2971                                .csv_delimiter_input
2972                                .handle_key(event, None);
2973                        }
2974                        ExportFocus::FormatSelector => {
2975                            // Don't change focus in format selector
2976                        }
2977                        ExportFocus::CsvCompression
2978                        | ExportFocus::JsonCompression
2979                        | ExportFocus::NdjsonCompression => {
2980                            // Move to previous compression option
2981                            self.export_modal.cycle_compression_backward();
2982                        }
2983                        _ => self.export_modal.prev_focus(),
2984                    }
2985                }
2986                KeyCode::Right | KeyCode::Char('l') => {
2987                    match self.export_modal.focus {
2988                        ExportFocus::PathInput => {
2989                            self.export_modal.path_input.handle_key(event, None);
2990                        }
2991                        ExportFocus::CsvDelimiter => {
2992                            self.export_modal
2993                                .csv_delimiter_input
2994                                .handle_key(event, None);
2995                        }
2996                        ExportFocus::FormatSelector => {
2997                            // Don't change focus in format selector
2998                        }
2999                        ExportFocus::CsvCompression
3000                        | ExportFocus::JsonCompression
3001                        | ExportFocus::NdjsonCompression => {
3002                            // Move to next compression option
3003                            self.export_modal.cycle_compression();
3004                        }
3005                        _ => self.export_modal.next_focus(),
3006                    }
3007                }
3008                KeyCode::Enter => {
3009                    match self.export_modal.focus {
3010                        ExportFocus::PathInput => {
3011                            // Enter from path input triggers export (same as Export button)
3012                            let path_str = self.export_modal.path_input.value.trim();
3013                            if !path_str.is_empty() {
3014                                let mut path = PathBuf::from(path_str);
3015                                let format = self.export_modal.selected_format;
3016                                // Get compression format for this export format
3017                                let compression = match format {
3018                                    ExportFormat::Csv => self.export_modal.csv_compression,
3019                                    ExportFormat::Json => self.export_modal.json_compression,
3020                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
3021                                    ExportFormat::Parquet
3022                                    | ExportFormat::Ipc
3023                                    | ExportFormat::Avro => None,
3024                                };
3025                                // Ensure file extension is present (including compression extension if needed)
3026                                let path_with_ext =
3027                                    Self::ensure_file_extension(&path, format, compression);
3028                                // Update the path input to show the extension
3029                                if path_with_ext != path {
3030                                    self.export_modal
3031                                        .path_input
3032                                        .set_value(path_with_ext.display().to_string());
3033                                }
3034                                path = path_with_ext;
3035                                let delimiter =
3036                                    self.export_modal
3037                                        .csv_delimiter_input
3038                                        .value
3039                                        .chars()
3040                                        .next()
3041                                        .unwrap_or(',') as u8;
3042                                let options = ExportOptions {
3043                                    csv_delimiter: delimiter,
3044                                    csv_include_header: self.export_modal.csv_include_header,
3045                                    csv_compression: self.export_modal.csv_compression,
3046                                    json_compression: self.export_modal.json_compression,
3047                                    ndjson_compression: self.export_modal.ndjson_compression,
3048                                    parquet_compression: None,
3049                                };
3050                                // Check if file exists and show confirmation
3051                                if path.exists() {
3052                                    let path_display = path.display().to_string();
3053                                    self.pending_export = Some((path, format, options));
3054                                    self.confirmation_modal.show(format!(
3055                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3056                                        path_display
3057                                    ));
3058                                    self.export_modal.close();
3059                                    self.input_mode = InputMode::Normal;
3060                                } else {
3061                                    // Start export with progress
3062                                    self.export_modal.close();
3063                                    self.input_mode = InputMode::Normal;
3064                                    return Some(AppEvent::Export(path, format, options));
3065                                }
3066                            }
3067                        }
3068                        ExportFocus::ExportButton => {
3069                            if !self.export_modal.path_input.value.is_empty() {
3070                                let mut path = PathBuf::from(&self.export_modal.path_input.value);
3071                                let format = self.export_modal.selected_format;
3072                                // Get compression format for this export format
3073                                let compression = match format {
3074                                    ExportFormat::Csv => self.export_modal.csv_compression,
3075                                    ExportFormat::Json => self.export_modal.json_compression,
3076                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
3077                                    ExportFormat::Parquet
3078                                    | ExportFormat::Ipc
3079                                    | ExportFormat::Avro => None,
3080                                };
3081                                // Ensure file extension is present (including compression extension if needed)
3082                                let path_with_ext =
3083                                    Self::ensure_file_extension(&path, format, compression);
3084                                // Update the path input to show the extension
3085                                if path_with_ext != path {
3086                                    self.export_modal
3087                                        .path_input
3088                                        .set_value(path_with_ext.display().to_string());
3089                                }
3090                                path = path_with_ext;
3091                                let delimiter =
3092                                    self.export_modal
3093                                        .csv_delimiter_input
3094                                        .value
3095                                        .chars()
3096                                        .next()
3097                                        .unwrap_or(',') as u8;
3098                                let options = ExportOptions {
3099                                    csv_delimiter: delimiter,
3100                                    csv_include_header: self.export_modal.csv_include_header,
3101                                    csv_compression: self.export_modal.csv_compression,
3102                                    json_compression: self.export_modal.json_compression,
3103                                    ndjson_compression: self.export_modal.ndjson_compression,
3104                                    parquet_compression: None,
3105                                };
3106                                // Check if file exists and show confirmation
3107                                if path.exists() {
3108                                    let path_display = path.display().to_string();
3109                                    self.pending_export = Some((path, format, options));
3110                                    self.confirmation_modal.show(format!(
3111                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3112                                        path_display
3113                                    ));
3114                                    self.export_modal.close();
3115                                    self.input_mode = InputMode::Normal;
3116                                } else {
3117                                    // Start export with progress
3118                                    self.export_modal.close();
3119                                    self.input_mode = InputMode::Normal;
3120                                    return Some(AppEvent::Export(path, format, options));
3121                                }
3122                            }
3123                        }
3124                        ExportFocus::CancelButton => {
3125                            self.export_modal.close();
3126                            self.input_mode = InputMode::Normal;
3127                        }
3128                        ExportFocus::CsvIncludeHeader => {
3129                            self.export_modal.csv_include_header =
3130                                !self.export_modal.csv_include_header;
3131                        }
3132                        ExportFocus::CsvCompression
3133                        | ExportFocus::JsonCompression
3134                        | ExportFocus::NdjsonCompression => {
3135                            // Enter to select current compression option
3136                            // (Already selected via Left/Right navigation)
3137                        }
3138                        _ => {}
3139                    }
3140                }
3141                KeyCode::Char(' ') => {
3142                    // Space to toggle checkboxes, but pass to text inputs if they're focused
3143                    match self.export_modal.focus {
3144                        ExportFocus::PathInput => {
3145                            // Pass spacebar to text input
3146                            self.export_modal.path_input.handle_key(event, None);
3147                        }
3148                        ExportFocus::CsvDelimiter => {
3149                            // Pass spacebar to text input
3150                            self.export_modal
3151                                .csv_delimiter_input
3152                                .handle_key(event, None);
3153                        }
3154                        ExportFocus::CsvIncludeHeader => {
3155                            // Toggle checkbox
3156                            self.export_modal.csv_include_header =
3157                                !self.export_modal.csv_include_header;
3158                        }
3159                        _ => {}
3160                    }
3161                }
3162                KeyCode::Char(_)
3163                | KeyCode::Backspace
3164                | KeyCode::Delete
3165                | KeyCode::Home
3166                | KeyCode::End => {
3167                    match self.export_modal.focus {
3168                        ExportFocus::PathInput => {
3169                            self.export_modal.path_input.handle_key(event, None);
3170                        }
3171                        ExportFocus::CsvDelimiter => {
3172                            self.export_modal
3173                                .csv_delimiter_input
3174                                .handle_key(event, None);
3175                        }
3176                        ExportFocus::FormatSelector => {
3177                            // Don't input text in format selector
3178                        }
3179                        _ => {}
3180                    }
3181                }
3182                _ => {}
3183            }
3184            return None;
3185        }
3186
3187        if self.input_mode == InputMode::PivotMelt {
3188            let pivot_melt_text_focus = matches!(
3189                self.pivot_melt_modal.focus,
3190                PivotMeltFocus::PivotFilter
3191                    | PivotMeltFocus::MeltFilter
3192                    | PivotMeltFocus::MeltPattern
3193                    | PivotMeltFocus::MeltVarName
3194                    | PivotMeltFocus::MeltValName
3195            );
3196            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
3197            if event.code == KeyCode::Char('?') && (ctrl_help || !pivot_melt_text_focus) {
3198                self.show_help = true;
3199                return None;
3200            }
3201            match event.code {
3202                KeyCode::Esc => {
3203                    self.pivot_melt_modal.close();
3204                    self.input_mode = InputMode::Normal;
3205                }
3206                KeyCode::Tab => self.pivot_melt_modal.next_focus(),
3207                KeyCode::BackTab => self.pivot_melt_modal.prev_focus(),
3208                KeyCode::Left => {
3209                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3210                        self.pivot_melt_modal
3211                            .pivot_filter_input
3212                            .handle_key(event, None);
3213                        self.pivot_melt_modal.pivot_index_table.select(None);
3214                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3215                        self.pivot_melt_modal
3216                            .melt_filter_input
3217                            .handle_key(event, None);
3218                        self.pivot_melt_modal.melt_index_table.select(None);
3219                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern
3220                        && self.pivot_melt_modal.melt_pattern_cursor > 0
3221                    {
3222                        self.pivot_melt_modal.melt_pattern_cursor -= 1;
3223                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName
3224                        && self.pivot_melt_modal.melt_variable_cursor > 0
3225                    {
3226                        self.pivot_melt_modal.melt_variable_cursor -= 1;
3227                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName
3228                        && self.pivot_melt_modal.melt_value_cursor > 0
3229                    {
3230                        self.pivot_melt_modal.melt_value_cursor -= 1;
3231                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3232                        self.pivot_melt_modal.switch_tab();
3233                    } else {
3234                        self.pivot_melt_modal.prev_focus();
3235                    }
3236                }
3237                KeyCode::Right => {
3238                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3239                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern {
3240                        let n = self.pivot_melt_modal.melt_pattern.chars().count();
3241                        if self.pivot_melt_modal.melt_pattern_cursor < n {
3242                            self.pivot_melt_modal.melt_pattern_cursor += 1;
3243                        }
3244                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName {
3245                        let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3246                        if self.pivot_melt_modal.melt_variable_cursor < n {
3247                            self.pivot_melt_modal.melt_variable_cursor += 1;
3248                        }
3249                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName {
3250                        let n = self.pivot_melt_modal.melt_value_name.chars().count();
3251                        if self.pivot_melt_modal.melt_value_cursor < n {
3252                            self.pivot_melt_modal.melt_value_cursor += 1;
3253                        }
3254                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3255                        self.pivot_melt_modal.switch_tab();
3256                    } else {
3257                        self.pivot_melt_modal.next_focus();
3258                    }
3259                }
3260                KeyCode::Enter => match self.pivot_melt_modal.focus {
3261                    PivotMeltFocus::Apply => {
3262                        return match self.pivot_melt_modal.active_tab {
3263                            PivotMeltTab::Pivot => {
3264                                if let Some(err) = self.pivot_melt_modal.pivot_validation_error() {
3265                                    self.error_modal.show(err);
3266                                    None
3267                                } else {
3268                                    self.pivot_melt_modal
3269                                        .build_pivot_spec()
3270                                        .map(AppEvent::Pivot)
3271                                }
3272                            }
3273                            PivotMeltTab::Melt => {
3274                                if let Some(err) = self.pivot_melt_modal.melt_validation_error() {
3275                                    self.error_modal.show(err);
3276                                    None
3277                                } else {
3278                                    self.pivot_melt_modal.build_melt_spec().map(AppEvent::Melt)
3279                                }
3280                            }
3281                        };
3282                    }
3283                    PivotMeltFocus::Cancel => {
3284                        self.pivot_melt_modal.close();
3285                        self.input_mode = InputMode::Normal;
3286                    }
3287                    PivotMeltFocus::Clear => {
3288                        self.pivot_melt_modal.reset_form();
3289                    }
3290                    _ => {}
3291                },
3292                KeyCode::Up | KeyCode::Char('k') => match self.pivot_melt_modal.focus {
3293                    PivotMeltFocus::PivotIndexList => {
3294                        self.pivot_melt_modal.pivot_move_index_selection(false);
3295                    }
3296                    PivotMeltFocus::PivotPivotCol => {
3297                        self.pivot_melt_modal.pivot_move_pivot_selection(false);
3298                    }
3299                    PivotMeltFocus::PivotValueCol => {
3300                        self.pivot_melt_modal.pivot_move_value_selection(false);
3301                    }
3302                    PivotMeltFocus::PivotAggregation => {
3303                        self.pivot_melt_modal.pivot_move_aggregation(false);
3304                    }
3305                    PivotMeltFocus::MeltIndexList => {
3306                        self.pivot_melt_modal.melt_move_index_selection(false);
3307                    }
3308                    PivotMeltFocus::MeltStrategy => {
3309                        self.pivot_melt_modal.melt_move_strategy(false);
3310                    }
3311                    PivotMeltFocus::MeltType => {
3312                        self.pivot_melt_modal.melt_move_type_filter(false);
3313                    }
3314                    PivotMeltFocus::MeltExplicitList => {
3315                        self.pivot_melt_modal.melt_move_explicit_selection(false);
3316                    }
3317                    _ => {}
3318                },
3319                KeyCode::Down | KeyCode::Char('j') => match self.pivot_melt_modal.focus {
3320                    PivotMeltFocus::PivotIndexList => {
3321                        self.pivot_melt_modal.pivot_move_index_selection(true);
3322                    }
3323                    PivotMeltFocus::PivotPivotCol => {
3324                        self.pivot_melt_modal.pivot_move_pivot_selection(true);
3325                    }
3326                    PivotMeltFocus::PivotValueCol => {
3327                        self.pivot_melt_modal.pivot_move_value_selection(true);
3328                    }
3329                    PivotMeltFocus::PivotAggregation => {
3330                        self.pivot_melt_modal.pivot_move_aggregation(true);
3331                    }
3332                    PivotMeltFocus::MeltIndexList => {
3333                        self.pivot_melt_modal.melt_move_index_selection(true);
3334                    }
3335                    PivotMeltFocus::MeltStrategy => {
3336                        self.pivot_melt_modal.melt_move_strategy(true);
3337                    }
3338                    PivotMeltFocus::MeltType => {
3339                        self.pivot_melt_modal.melt_move_type_filter(true);
3340                    }
3341                    PivotMeltFocus::MeltExplicitList => {
3342                        self.pivot_melt_modal.melt_move_explicit_selection(true);
3343                    }
3344                    _ => {}
3345                },
3346                KeyCode::Char(' ') => match self.pivot_melt_modal.focus {
3347                    PivotMeltFocus::PivotIndexList => {
3348                        self.pivot_melt_modal.pivot_toggle_index_at_selection();
3349                    }
3350                    PivotMeltFocus::PivotSortToggle => {
3351                        self.pivot_melt_modal.sort_new_columns =
3352                            !self.pivot_melt_modal.sort_new_columns;
3353                    }
3354                    PivotMeltFocus::MeltIndexList => {
3355                        self.pivot_melt_modal.melt_toggle_index_at_selection();
3356                    }
3357                    PivotMeltFocus::MeltExplicitList => {
3358                        self.pivot_melt_modal.melt_toggle_explicit_at_selection();
3359                    }
3360                    _ => {}
3361                },
3362                KeyCode::Home
3363                | KeyCode::End
3364                | KeyCode::Char(_)
3365                | KeyCode::Backspace
3366                | KeyCode::Delete
3367                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter =>
3368                {
3369                    self.pivot_melt_modal
3370                        .pivot_filter_input
3371                        .handle_key(event, None);
3372                    self.pivot_melt_modal.pivot_index_table.select(None);
3373                }
3374                KeyCode::Home
3375                | KeyCode::End
3376                | KeyCode::Char(_)
3377                | KeyCode::Backspace
3378                | KeyCode::Delete
3379                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter =>
3380                {
3381                    self.pivot_melt_modal
3382                        .melt_filter_input
3383                        .handle_key(event, None);
3384                    self.pivot_melt_modal.melt_index_table.select(None);
3385                }
3386                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3387                    self.pivot_melt_modal.melt_pattern_cursor = 0;
3388                }
3389                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3390                    self.pivot_melt_modal.melt_pattern_cursor =
3391                        self.pivot_melt_modal.melt_pattern.chars().count();
3392                }
3393                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3394                    let byte_pos: usize = self
3395                        .pivot_melt_modal
3396                        .melt_pattern
3397                        .chars()
3398                        .take(self.pivot_melt_modal.melt_pattern_cursor)
3399                        .map(|ch| ch.len_utf8())
3400                        .sum();
3401                    self.pivot_melt_modal.melt_pattern.insert(byte_pos, c);
3402                    self.pivot_melt_modal.melt_pattern_cursor += 1;
3403                }
3404                KeyCode::Backspace
3405                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern =>
3406                {
3407                    if self.pivot_melt_modal.melt_pattern_cursor > 0 {
3408                        let prev_byte: usize = self
3409                            .pivot_melt_modal
3410                            .melt_pattern
3411                            .chars()
3412                            .take(self.pivot_melt_modal.melt_pattern_cursor - 1)
3413                            .map(|ch| ch.len_utf8())
3414                            .sum();
3415                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[prev_byte..]
3416                            .chars()
3417                            .next()
3418                        {
3419                            self.pivot_melt_modal
3420                                .melt_pattern
3421                                .drain(prev_byte..prev_byte + ch.len_utf8());
3422                            self.pivot_melt_modal.melt_pattern_cursor -= 1;
3423                        }
3424                    }
3425                }
3426                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3427                    let n = self.pivot_melt_modal.melt_pattern.chars().count();
3428                    if self.pivot_melt_modal.melt_pattern_cursor < n {
3429                        let byte_pos: usize = self
3430                            .pivot_melt_modal
3431                            .melt_pattern
3432                            .chars()
3433                            .take(self.pivot_melt_modal.melt_pattern_cursor)
3434                            .map(|ch| ch.len_utf8())
3435                            .sum();
3436                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[byte_pos..]
3437                            .chars()
3438                            .next()
3439                        {
3440                            self.pivot_melt_modal
3441                                .melt_pattern
3442                                .drain(byte_pos..byte_pos + ch.len_utf8());
3443                        }
3444                    }
3445                }
3446                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3447                    self.pivot_melt_modal.melt_variable_cursor = 0;
3448                }
3449                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3450                    self.pivot_melt_modal.melt_variable_cursor =
3451                        self.pivot_melt_modal.melt_variable_name.chars().count();
3452                }
3453                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3454                    let byte_pos: usize = self
3455                        .pivot_melt_modal
3456                        .melt_variable_name
3457                        .chars()
3458                        .take(self.pivot_melt_modal.melt_variable_cursor)
3459                        .map(|ch| ch.len_utf8())
3460                        .sum();
3461                    self.pivot_melt_modal.melt_variable_name.insert(byte_pos, c);
3462                    self.pivot_melt_modal.melt_variable_cursor += 1;
3463                }
3464                KeyCode::Backspace
3465                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName =>
3466                {
3467                    if self.pivot_melt_modal.melt_variable_cursor > 0 {
3468                        let prev_byte: usize = self
3469                            .pivot_melt_modal
3470                            .melt_variable_name
3471                            .chars()
3472                            .take(self.pivot_melt_modal.melt_variable_cursor - 1)
3473                            .map(|ch| ch.len_utf8())
3474                            .sum();
3475                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[prev_byte..]
3476                            .chars()
3477                            .next()
3478                        {
3479                            self.pivot_melt_modal
3480                                .melt_variable_name
3481                                .drain(prev_byte..prev_byte + ch.len_utf8());
3482                            self.pivot_melt_modal.melt_variable_cursor -= 1;
3483                        }
3484                    }
3485                }
3486                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3487                    let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3488                    if self.pivot_melt_modal.melt_variable_cursor < n {
3489                        let byte_pos: usize = self
3490                            .pivot_melt_modal
3491                            .melt_variable_name
3492                            .chars()
3493                            .take(self.pivot_melt_modal.melt_variable_cursor)
3494                            .map(|ch| ch.len_utf8())
3495                            .sum();
3496                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[byte_pos..]
3497                            .chars()
3498                            .next()
3499                        {
3500                            self.pivot_melt_modal
3501                                .melt_variable_name
3502                                .drain(byte_pos..byte_pos + ch.len_utf8());
3503                        }
3504                    }
3505                }
3506                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3507                    self.pivot_melt_modal.melt_value_cursor = 0;
3508                }
3509                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3510                    self.pivot_melt_modal.melt_value_cursor =
3511                        self.pivot_melt_modal.melt_value_name.chars().count();
3512                }
3513                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3514                    let byte_pos: usize = self
3515                        .pivot_melt_modal
3516                        .melt_value_name
3517                        .chars()
3518                        .take(self.pivot_melt_modal.melt_value_cursor)
3519                        .map(|ch| ch.len_utf8())
3520                        .sum();
3521                    self.pivot_melt_modal.melt_value_name.insert(byte_pos, c);
3522                    self.pivot_melt_modal.melt_value_cursor += 1;
3523                }
3524                KeyCode::Backspace
3525                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName =>
3526                {
3527                    if self.pivot_melt_modal.melt_value_cursor > 0 {
3528                        let prev_byte: usize = self
3529                            .pivot_melt_modal
3530                            .melt_value_name
3531                            .chars()
3532                            .take(self.pivot_melt_modal.melt_value_cursor - 1)
3533                            .map(|ch| ch.len_utf8())
3534                            .sum();
3535                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[prev_byte..]
3536                            .chars()
3537                            .next()
3538                        {
3539                            self.pivot_melt_modal
3540                                .melt_value_name
3541                                .drain(prev_byte..prev_byte + ch.len_utf8());
3542                            self.pivot_melt_modal.melt_value_cursor -= 1;
3543                        }
3544                    }
3545                }
3546                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3547                    let n = self.pivot_melt_modal.melt_value_name.chars().count();
3548                    if self.pivot_melt_modal.melt_value_cursor < n {
3549                        let byte_pos: usize = self
3550                            .pivot_melt_modal
3551                            .melt_value_name
3552                            .chars()
3553                            .take(self.pivot_melt_modal.melt_value_cursor)
3554                            .map(|ch| ch.len_utf8())
3555                            .sum();
3556                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[byte_pos..]
3557                            .chars()
3558                            .next()
3559                        {
3560                            self.pivot_melt_modal
3561                                .melt_value_name
3562                                .drain(byte_pos..byte_pos + ch.len_utf8());
3563                        }
3564                    }
3565                }
3566                _ => {}
3567            }
3568            return None;
3569        }
3570
3571        if self.input_mode == InputMode::Info {
3572            let on_tab_bar = self.info_modal.focus == InfoFocus::TabBar;
3573            let on_body = self.info_modal.focus == InfoFocus::Body;
3574            let schema_tab = self.info_modal.active_tab == InfoTab::Schema;
3575            let total_rows = self
3576                .data_table_state
3577                .as_ref()
3578                .map(|s| s.schema.len())
3579                .unwrap_or(0);
3580            let visible = self.info_modal.schema_visible_height;
3581
3582            match event.code {
3583                KeyCode::Esc | KeyCode::Char('i') if event.is_press() => {
3584                    self.info_modal.close();
3585                    self.input_mode = InputMode::Normal;
3586                }
3587                KeyCode::Tab if event.is_press() => {
3588                    if schema_tab {
3589                        self.info_modal.next_focus();
3590                    }
3591                }
3592                KeyCode::BackTab if event.is_press() => {
3593                    if schema_tab {
3594                        self.info_modal.prev_focus();
3595                    }
3596                }
3597                KeyCode::Left | KeyCode::Char('h') if event.is_press() && on_tab_bar => {
3598                    let has_partitions = self
3599                        .data_table_state
3600                        .as_ref()
3601                        .and_then(|s| s.partition_columns.as_ref())
3602                        .map(|v| !v.is_empty())
3603                        .unwrap_or(false);
3604                    self.info_modal.switch_tab_prev(has_partitions);
3605                }
3606                KeyCode::Right | KeyCode::Char('l') if event.is_press() && on_tab_bar => {
3607                    let has_partitions = self
3608                        .data_table_state
3609                        .as_ref()
3610                        .and_then(|s| s.partition_columns.as_ref())
3611                        .map(|v| !v.is_empty())
3612                        .unwrap_or(false);
3613                    self.info_modal.switch_tab(has_partitions);
3614                }
3615                KeyCode::Down | KeyCode::Char('j') if event.is_press() && on_body && schema_tab => {
3616                    self.info_modal.schema_table_down(total_rows, visible);
3617                }
3618                KeyCode::Up | KeyCode::Char('k') if event.is_press() && on_body && schema_tab => {
3619                    self.info_modal.schema_table_up(total_rows, visible);
3620                }
3621                _ => {}
3622            }
3623            return None;
3624        }
3625
3626        if self.input_mode == InputMode::Chart {
3627            // Chart export modal (sub-dialog within Chart mode)
3628            if self.chart_export_modal.active {
3629                match event.code {
3630                    KeyCode::Esc if event.is_press() => {
3631                        self.chart_export_modal.close();
3632                    }
3633                    KeyCode::Tab if event.is_press() => {
3634                        self.chart_export_modal.next_focus();
3635                    }
3636                    KeyCode::BackTab if event.is_press() => {
3637                        self.chart_export_modal.prev_focus();
3638                    }
3639                    // Only use h/j/k/l and arrows for format selector; when path input focused, pass all keys to path input
3640                    KeyCode::Left | KeyCode::Char('h')
3641                        if event.is_press()
3642                            && self.chart_export_modal.focus
3643                                == ChartExportFocus::FormatSelector =>
3644                    {
3645                        let idx = ChartExportFormat::ALL
3646                            .iter()
3647                            .position(|&f| f == self.chart_export_modal.selected_format)
3648                            .unwrap_or(0);
3649                        let prev = if idx == 0 {
3650                            ChartExportFormat::ALL.len() - 1
3651                        } else {
3652                            idx - 1
3653                        };
3654                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[prev];
3655                    }
3656                    KeyCode::Right | KeyCode::Char('l')
3657                        if event.is_press()
3658                            && self.chart_export_modal.focus
3659                                == ChartExportFocus::FormatSelector =>
3660                    {
3661                        let idx = ChartExportFormat::ALL
3662                            .iter()
3663                            .position(|&f| f == self.chart_export_modal.selected_format)
3664                            .unwrap_or(0);
3665                        let next = (idx + 1) % ChartExportFormat::ALL.len();
3666                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[next];
3667                    }
3668                    KeyCode::Enter if event.is_press() => match self.chart_export_modal.focus {
3669                        ChartExportFocus::PathInput | ChartExportFocus::ExportButton => {
3670                            let path_str = self.chart_export_modal.path_input.value.trim();
3671                            if !path_str.is_empty() {
3672                                let title =
3673                                    self.chart_export_modal.title_input.value.trim().to_string();
3674                                let mut path = PathBuf::from(path_str);
3675                                let format = self.chart_export_modal.selected_format;
3676                                // Only add default extension when user did not provide one
3677                                if path.extension().is_none() {
3678                                    path.set_extension(format.extension());
3679                                }
3680                                let path_display = path.display().to_string();
3681                                if path.exists() {
3682                                    self.pending_chart_export = Some((path, format, title));
3683                                    self.chart_export_modal.close();
3684                                    self.confirmation_modal.show(format!(
3685                                            "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3686                                            path_display
3687                                        ));
3688                                } else {
3689                                    self.chart_export_modal.close();
3690                                    return Some(AppEvent::ChartExport(path, format, title));
3691                                }
3692                            }
3693                        }
3694                        ChartExportFocus::CancelButton => {
3695                            self.chart_export_modal.close();
3696                        }
3697                        _ => {}
3698                    },
3699                    _ => {
3700                        if event.is_press() {
3701                            if self.chart_export_modal.focus == ChartExportFocus::TitleInput {
3702                                let _ = self.chart_export_modal.title_input.handle_key(event, None);
3703                            } else if self.chart_export_modal.focus == ChartExportFocus::PathInput {
3704                                let _ = self.chart_export_modal.path_input.handle_key(event, None);
3705                            }
3706                        }
3707                    }
3708                }
3709                return None;
3710            }
3711
3712            match event.code {
3713                KeyCode::Char('e')
3714                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3715                {
3716                    // Open chart export modal when there is something visible to export
3717                    if self.data_table_state.is_some() && self.chart_modal.can_export() {
3718                        self.chart_export_modal
3719                            .open(&self.theme, self.history_limit);
3720                    }
3721                }
3722                // q/Q do nothing in chart view (no exit)
3723                KeyCode::Char('?') if event.is_press() => {
3724                    self.show_help = true;
3725                }
3726                KeyCode::Esc if event.is_press() => {
3727                    self.chart_modal.close();
3728                    self.chart_cache.clear();
3729                    self.input_mode = InputMode::Normal;
3730                }
3731                KeyCode::Tab if event.is_press() => {
3732                    self.chart_modal.next_focus();
3733                }
3734                KeyCode::BackTab if event.is_press() => {
3735                    self.chart_modal.prev_focus();
3736                }
3737                KeyCode::Enter | KeyCode::Char(' ') if event.is_press() => {
3738                    match self.chart_modal.focus {
3739                        ChartFocus::YStartsAtZero => self.chart_modal.toggle_y_starts_at_zero(),
3740                        ChartFocus::LogScale => self.chart_modal.toggle_log_scale(),
3741                        ChartFocus::ShowLegend => self.chart_modal.toggle_show_legend(),
3742                        ChartFocus::XList => self.chart_modal.x_list_toggle(),
3743                        ChartFocus::YList => self.chart_modal.y_list_toggle(),
3744                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3745                        ChartFocus::HistList => self.chart_modal.hist_list_toggle(),
3746                        ChartFocus::BoxList => self.chart_modal.box_list_toggle(),
3747                        ChartFocus::KdeList => self.chart_modal.kde_list_toggle(),
3748                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_toggle(),
3749                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_toggle(),
3750                        _ => {}
3751                    }
3752                }
3753                KeyCode::Char('+') | KeyCode::Char('=')
3754                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3755                {
3756                    match self.chart_modal.focus {
3757                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3758                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3759                        ChartFocus::KdeBandwidth => self
3760                            .chart_modal
3761                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3762                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3763                        _ => {}
3764                    }
3765                }
3766                KeyCode::Char('-')
3767                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3768                {
3769                    match self.chart_modal.focus {
3770                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3771                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3772                        ChartFocus::KdeBandwidth => self
3773                            .chart_modal
3774                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3775                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3776                        _ => {}
3777                    }
3778                }
3779                KeyCode::Left | KeyCode::Char('h')
3780                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3781                {
3782                    match self.chart_modal.focus {
3783                        ChartFocus::TabBar => self.chart_modal.prev_chart_kind(),
3784                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3785                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3786                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3787                        ChartFocus::KdeBandwidth => self
3788                            .chart_modal
3789                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3790                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3791                        _ => {}
3792                    }
3793                }
3794                KeyCode::Right | KeyCode::Char('l')
3795                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3796                {
3797                    match self.chart_modal.focus {
3798                        ChartFocus::TabBar => self.chart_modal.next_chart_kind(),
3799                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3800                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3801                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3802                        ChartFocus::KdeBandwidth => self
3803                            .chart_modal
3804                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3805                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3806                        _ => {}
3807                    }
3808                }
3809                KeyCode::PageUp
3810                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3811                {
3812                    if self.chart_modal.focus == ChartFocus::LimitRows {
3813                        self.chart_modal.adjust_row_limit_page(1);
3814                    }
3815                }
3816                KeyCode::PageDown
3817                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3818                {
3819                    if self.chart_modal.focus == ChartFocus::LimitRows {
3820                        self.chart_modal.adjust_row_limit_page(-1);
3821                    }
3822                }
3823                KeyCode::Up | KeyCode::Char('k')
3824                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3825                {
3826                    match self.chart_modal.focus {
3827                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3828                        ChartFocus::XList => self.chart_modal.x_list_up(),
3829                        ChartFocus::YList => self.chart_modal.y_list_up(),
3830                        ChartFocus::HistList => self.chart_modal.hist_list_up(),
3831                        ChartFocus::BoxList => self.chart_modal.box_list_up(),
3832                        ChartFocus::KdeList => self.chart_modal.kde_list_up(),
3833                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_up(),
3834                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_up(),
3835                        _ => {}
3836                    }
3837                }
3838                KeyCode::Down | KeyCode::Char('j')
3839                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3840                {
3841                    match self.chart_modal.focus {
3842                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3843                        ChartFocus::XList => self.chart_modal.x_list_down(),
3844                        ChartFocus::YList => self.chart_modal.y_list_down(),
3845                        ChartFocus::HistList => self.chart_modal.hist_list_down(),
3846                        ChartFocus::BoxList => self.chart_modal.box_list_down(),
3847                        ChartFocus::KdeList => self.chart_modal.kde_list_down(),
3848                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_down(),
3849                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_down(),
3850                        _ => {}
3851                    }
3852                }
3853                _ => {
3854                    // Pass key to text inputs when focused (including h/j/k/l for typing)
3855                    if event.is_press() {
3856                        if self.chart_modal.focus == ChartFocus::XInput {
3857                            let _ = self.chart_modal.x_input.handle_key(event, None);
3858                        } else if self.chart_modal.focus == ChartFocus::YInput {
3859                            let _ = self.chart_modal.y_input.handle_key(event, None);
3860                        } else if self.chart_modal.focus == ChartFocus::HistInput {
3861                            let _ = self.chart_modal.hist_input.handle_key(event, None);
3862                        } else if self.chart_modal.focus == ChartFocus::BoxInput {
3863                            let _ = self.chart_modal.box_input.handle_key(event, None);
3864                        } else if self.chart_modal.focus == ChartFocus::KdeInput {
3865                            let _ = self.chart_modal.kde_input.handle_key(event, None);
3866                        } else if self.chart_modal.focus == ChartFocus::HeatmapXInput {
3867                            let _ = self.chart_modal.heatmap_x_input.handle_key(event, None);
3868                        } else if self.chart_modal.focus == ChartFocus::HeatmapYInput {
3869                            let _ = self.chart_modal.heatmap_y_input.handle_key(event, None);
3870                        }
3871                    }
3872                }
3873            }
3874            return None;
3875        }
3876
3877        if self.analysis_modal.active {
3878            match event.code {
3879                KeyCode::Esc => {
3880                    if self.analysis_modal.show_help {
3881                        self.analysis_modal.show_help = false;
3882                    } else if self.analysis_modal.view != analysis_modal::AnalysisView::Main {
3883                        // Close detail view
3884                        self.analysis_modal.close_detail();
3885                    } else {
3886                        self.analysis_modal.close();
3887                    }
3888                }
3889                KeyCode::Char('?') => {
3890                    self.analysis_modal.show_help = !self.analysis_modal.show_help;
3891                }
3892                KeyCode::Char('r') => {
3893                    if self.sampling_threshold.is_some() {
3894                        self.analysis_modal.recalculate();
3895                        // Invalidate current tool's cache so it recalculates with new seed
3896                        match self.analysis_modal.selected_tool {
3897                            Some(analysis_modal::AnalysisTool::Describe) => {
3898                                self.analysis_modal.describe_results = None;
3899                            }
3900                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3901                                self.analysis_modal.distribution_results = None;
3902                            }
3903                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3904                                self.analysis_modal.correlation_results = None;
3905                            }
3906                            None => {}
3907                        }
3908                    }
3909                }
3910                KeyCode::Tab => {
3911                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3912                        // Switch focus between main area and sidebar
3913                        self.analysis_modal.switch_focus();
3914                    } else if self.analysis_modal.view
3915                        == analysis_modal::AnalysisView::DistributionDetail
3916                    {
3917                        // In distribution detail view, only the distribution selector is focusable
3918                        // Tab does nothing - focus stays on the distribution selector
3919                    } else {
3920                        // In other detail views, Tab cycles through sections
3921                        self.analysis_modal.next_detail_section();
3922                    }
3923                }
3924                KeyCode::Enter => {
3925                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3926                        if self.analysis_modal.focus == analysis_modal::AnalysisFocus::Sidebar {
3927                            // Select tool from sidebar
3928                            self.analysis_modal.select_tool();
3929                            // Trigger computation for the selected tool when that tool has no cached results
3930                            match self.analysis_modal.selected_tool {
3931                                Some(analysis_modal::AnalysisTool::Describe)
3932                                    if self.analysis_modal.describe_results.is_none() =>
3933                                {
3934                                    self.analysis_modal.computing = Some(AnalysisProgress {
3935                                        phase: "Describing data".to_string(),
3936                                        current: 0,
3937                                        total: 1,
3938                                    });
3939                                    self.analysis_computation = Some(AnalysisComputationState {
3940                                        df: None,
3941                                        schema: None,
3942                                        partial_stats: Vec::new(),
3943                                        current: 0,
3944                                        total: 0,
3945                                        total_rows: 0,
3946                                        sample_seed: self.analysis_modal.random_seed,
3947                                        sample_size: None,
3948                                    });
3949                                    self.busy = true;
3950                                    return Some(AppEvent::AnalysisChunk);
3951                                }
3952                                Some(analysis_modal::AnalysisTool::DistributionAnalysis)
3953                                    if self.analysis_modal.distribution_results.is_none() =>
3954                                {
3955                                    self.analysis_modal.computing = Some(AnalysisProgress {
3956                                        phase: "Distribution".to_string(),
3957                                        current: 0,
3958                                        total: 1,
3959                                    });
3960                                    self.busy = true;
3961                                    return Some(AppEvent::AnalysisDistributionCompute);
3962                                }
3963                                Some(analysis_modal::AnalysisTool::CorrelationMatrix)
3964                                    if self.analysis_modal.correlation_results.is_none() =>
3965                                {
3966                                    self.analysis_modal.computing = Some(AnalysisProgress {
3967                                        phase: "Correlation".to_string(),
3968                                        current: 0,
3969                                        total: 1,
3970                                    });
3971                                    self.busy = true;
3972                                    return Some(AppEvent::AnalysisCorrelationCompute);
3973                                }
3974                                _ => {}
3975                            }
3976                        } else {
3977                            // Enter in main area opens detail view if applicable
3978                            match self.analysis_modal.selected_tool {
3979                                Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3980                                    self.analysis_modal.open_distribution_detail();
3981                                }
3982                                Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3983                                    self.analysis_modal.open_correlation_detail();
3984                                }
3985                                _ => {}
3986                            }
3987                        }
3988                    }
3989                }
3990                KeyCode::Down | KeyCode::Char('j') => {
3991                    match self.analysis_modal.view {
3992                        analysis_modal::AnalysisView::Main => {
3993                            match self.analysis_modal.focus {
3994                                analysis_modal::AnalysisFocus::Sidebar => {
3995                                    // Navigate sidebar tool list
3996                                    self.analysis_modal.next_tool();
3997                                }
3998                                analysis_modal::AnalysisFocus::Main => {
3999                                    // Navigate in main area based on selected tool
4000                                    match self.analysis_modal.selected_tool {
4001                                        Some(analysis_modal::AnalysisTool::Describe) => {
4002                                            if let Some(state) = &self.data_table_state {
4003                                                let max_rows = state.schema.len();
4004                                                self.analysis_modal.next_row(max_rows);
4005                                            }
4006                                        }
4007                                        Some(
4008                                            analysis_modal::AnalysisTool::DistributionAnalysis,
4009                                        ) => {
4010                                            if let Some(results) =
4011                                                self.analysis_modal.current_results()
4012                                            {
4013                                                let max_rows = results.distribution_analyses.len();
4014                                                self.analysis_modal.next_row(max_rows);
4015                                            }
4016                                        }
4017                                        Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4018                                            if let Some(results) =
4019                                                self.analysis_modal.current_results()
4020                                            {
4021                                                if let Some(corr) = &results.correlation_matrix {
4022                                                    let max_rows = corr.columns.len();
4023                                                    // Calculate visible columns (same logic as horizontal moves)
4024                                                    let row_header_width = 20u16;
4025                                                    let cell_width = 12u16;
4026                                                    let column_spacing = 1u16;
4027                                                    let estimated_width = 80u16;
4028                                                    let available_width = estimated_width
4029                                                        .saturating_sub(row_header_width);
4030                                                    let mut calculated_visible = 0usize;
4031                                                    let mut used = 0u16;
4032                                                    let max_cols = corr.columns.len();
4033                                                    loop {
4034                                                        let needed = if calculated_visible == 0 {
4035                                                            cell_width
4036                                                        } else {
4037                                                            column_spacing + cell_width
4038                                                        };
4039                                                        if used + needed <= available_width
4040                                                            && calculated_visible < max_cols
4041                                                        {
4042                                                            used += needed;
4043                                                            calculated_visible += 1;
4044                                                        } else {
4045                                                            break;
4046                                                        }
4047                                                    }
4048                                                    let visible_cols =
4049                                                        calculated_visible.max(1).min(max_cols);
4050                                                    self.analysis_modal.move_correlation_cell(
4051                                                        (1, 0),
4052                                                        max_rows,
4053                                                        max_rows,
4054                                                        visible_cols,
4055                                                    );
4056                                                }
4057                                            }
4058                                        }
4059                                        None => {}
4060                                    }
4061                                }
4062                                _ => {}
4063                            }
4064                        }
4065                        analysis_modal::AnalysisView::DistributionDetail => {
4066                            if self.analysis_modal.focus
4067                                == analysis_modal::AnalysisFocus::DistributionSelector
4068                            {
4069                                self.analysis_modal.next_distribution();
4070                            }
4071                        }
4072                        _ => {}
4073                    }
4074                }
4075                KeyCode::Char('s') => {
4076                    // Toggle histogram scale (linear/log) in distribution detail view
4077                    if self.analysis_modal.view == analysis_modal::AnalysisView::DistributionDetail
4078                    {
4079                        self.analysis_modal.histogram_scale =
4080                            match self.analysis_modal.histogram_scale {
4081                                analysis_modal::HistogramScale::Linear => {
4082                                    analysis_modal::HistogramScale::Log
4083                                }
4084                                analysis_modal::HistogramScale::Log => {
4085                                    analysis_modal::HistogramScale::Linear
4086                                }
4087                            };
4088                    }
4089                }
4090                KeyCode::Up | KeyCode::Char('k') => {
4091                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4092                        self.analysis_modal.previous_row();
4093                    } else if self.analysis_modal.view
4094                        == analysis_modal::AnalysisView::DistributionDetail
4095                        && self.analysis_modal.focus
4096                            == analysis_modal::AnalysisFocus::DistributionSelector
4097                    {
4098                        self.analysis_modal.previous_distribution();
4099                    }
4100                }
4101                KeyCode::Left | KeyCode::Char('h')
4102                    if !event.modifiers.contains(KeyModifiers::CONTROL) =>
4103                {
4104                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4105                        match self.analysis_modal.focus {
4106                            analysis_modal::AnalysisFocus::Sidebar => {
4107                                // Sidebar navigation handled by Up/Down
4108                            }
4109                            analysis_modal::AnalysisFocus::DistributionSelector => {
4110                                // Distribution selector navigation handled by Up/Down
4111                            }
4112                            analysis_modal::AnalysisFocus::Main => {
4113                                match self.analysis_modal.selected_tool {
4114                                    Some(analysis_modal::AnalysisTool::Describe) => {
4115                                        self.analysis_modal.scroll_left();
4116                                    }
4117                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4118                                        self.analysis_modal.scroll_left();
4119                                    }
4120                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4121                                        if let Some(results) = self.analysis_modal.current_results()
4122                                        {
4123                                            if let Some(corr) = &results.correlation_matrix {
4124                                                let max_cols = corr.columns.len();
4125                                                // Calculate visible columns using same logic as render function
4126                                                // This matches the render_correlation_matrix calculation
4127                                                let row_header_width = 20u16;
4128                                                let cell_width = 12u16;
4129                                                let column_spacing = 1u16;
4130                                                // Use a conservative estimate for available width
4131                                                // In practice, main_area.width would be available, but we don't have access here
4132                                                // Using a reasonable default that works for most terminals
4133                                                let estimated_width = 80u16; // Conservative estimate (most terminals are 80+ wide)
4134                                                let available_width = estimated_width
4135                                                    .saturating_sub(row_header_width);
4136                                                // Match render logic: first column has no spacing, subsequent ones do
4137                                                let mut calculated_visible = 0usize;
4138                                                let mut used = 0u16;
4139                                                loop {
4140                                                    let needed = if calculated_visible == 0 {
4141                                                        cell_width
4142                                                    } else {
4143                                                        column_spacing + cell_width
4144                                                    };
4145                                                    if used + needed <= available_width
4146                                                        && calculated_visible < max_cols
4147                                                    {
4148                                                        used += needed;
4149                                                        calculated_visible += 1;
4150                                                    } else {
4151                                                        break;
4152                                                    }
4153                                                }
4154                                                let visible_cols =
4155                                                    calculated_visible.max(1).min(max_cols);
4156                                                self.analysis_modal.move_correlation_cell(
4157                                                    (0, -1),
4158                                                    max_cols,
4159                                                    max_cols,
4160                                                    visible_cols,
4161                                                );
4162                                            }
4163                                        }
4164                                    }
4165                                    None => {}
4166                                }
4167                            }
4168                        }
4169                    }
4170                }
4171                KeyCode::Right | KeyCode::Char('l') => {
4172                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4173                        match self.analysis_modal.focus {
4174                            analysis_modal::AnalysisFocus::Sidebar => {
4175                                // Sidebar navigation handled by Up/Down
4176                            }
4177                            analysis_modal::AnalysisFocus::DistributionSelector => {
4178                                // Distribution selector navigation handled by Up/Down
4179                            }
4180                            analysis_modal::AnalysisFocus::Main => {
4181                                match self.analysis_modal.selected_tool {
4182                                    Some(analysis_modal::AnalysisTool::Describe) => {
4183                                        // Number of statistics: count, null_count, mean, std, min, 25%, 50%, 75%, max, skewness, kurtosis, distribution
4184                                        let max_stats = 12;
4185                                        // Estimate visible stats based on terminal width (rough estimate)
4186                                        let visible_stats = 8; // Will be calculated more accurately in widget
4187                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4188                                    }
4189                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4190                                        // Number of statistics: Distribution, P-value, Shapiro-Wilk, SW p-value, CV, Outliers, Skewness, Kurtosis
4191                                        let max_stats = 8;
4192                                        // Estimate visible stats based on terminal width (rough estimate)
4193                                        let visible_stats = 6; // Will be calculated more accurately in widget
4194                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4195                                    }
4196                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4197                                        if let Some(results) = self.analysis_modal.current_results()
4198                                        {
4199                                            if let Some(corr) = &results.correlation_matrix {
4200                                                let max_cols = corr.columns.len();
4201                                                // Calculate visible columns using same logic as render function
4202                                                let row_header_width = 20u16;
4203                                                let cell_width = 12u16;
4204                                                let column_spacing = 1u16;
4205                                                let estimated_width = 80u16; // Conservative estimate
4206                                                let available_width = estimated_width
4207                                                    .saturating_sub(row_header_width);
4208                                                let mut calculated_visible = 0usize;
4209                                                let mut used = 0u16;
4210                                                loop {
4211                                                    let needed = if calculated_visible == 0 {
4212                                                        cell_width
4213                                                    } else {
4214                                                        column_spacing + cell_width
4215                                                    };
4216                                                    if used + needed <= available_width
4217                                                        && calculated_visible < max_cols
4218                                                    {
4219                                                        used += needed;
4220                                                        calculated_visible += 1;
4221                                                    } else {
4222                                                        break;
4223                                                    }
4224                                                }
4225                                                let visible_cols =
4226                                                    calculated_visible.max(1).min(max_cols);
4227                                                self.analysis_modal.move_correlation_cell(
4228                                                    (0, 1),
4229                                                    max_cols,
4230                                                    max_cols,
4231                                                    visible_cols,
4232                                                );
4233                                            }
4234                                        }
4235                                    }
4236                                    None => {}
4237                                }
4238                            }
4239                        }
4240                    }
4241                }
4242                KeyCode::PageDown => {
4243                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4244                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4245                    {
4246                        match self.analysis_modal.selected_tool {
4247                            Some(analysis_modal::AnalysisTool::Describe) => {
4248                                if let Some(state) = &self.data_table_state {
4249                                    let max_rows = state.schema.len();
4250                                    let page_size = 10;
4251                                    self.analysis_modal.page_down(max_rows, page_size);
4252                                }
4253                            }
4254                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4255                                if let Some(results) = self.analysis_modal.current_results() {
4256                                    let max_rows = results.distribution_analyses.len();
4257                                    let page_size = 10;
4258                                    self.analysis_modal.page_down(max_rows, page_size);
4259                                }
4260                            }
4261                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4262                                if let Some(results) = self.analysis_modal.current_results() {
4263                                    if let Some(corr) = &results.correlation_matrix {
4264                                        let max_rows = corr.columns.len();
4265                                        let page_size = 10;
4266                                        self.analysis_modal.page_down(max_rows, page_size);
4267                                    }
4268                                }
4269                            }
4270                            None => {}
4271                        }
4272                    }
4273                }
4274                KeyCode::PageUp => {
4275                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4276                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4277                    {
4278                        let page_size = 10;
4279                        self.analysis_modal.page_up(page_size);
4280                    }
4281                }
4282                KeyCode::Home => {
4283                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4284                        match self.analysis_modal.focus {
4285                            analysis_modal::AnalysisFocus::Sidebar => {
4286                                self.analysis_modal.sidebar_state.select(Some(0));
4287                            }
4288                            analysis_modal::AnalysisFocus::DistributionSelector => {
4289                                self.analysis_modal
4290                                    .distribution_selector_state
4291                                    .select(Some(0));
4292                            }
4293                            analysis_modal::AnalysisFocus::Main => {
4294                                match self.analysis_modal.selected_tool {
4295                                    Some(analysis_modal::AnalysisTool::Describe) => {
4296                                        self.analysis_modal.table_state.select(Some(0));
4297                                    }
4298                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4299                                        self.analysis_modal
4300                                            .distribution_table_state
4301                                            .select(Some(0));
4302                                    }
4303                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4304                                        self.analysis_modal.correlation_table_state.select(Some(0));
4305                                        self.analysis_modal.selected_correlation = Some((0, 0));
4306                                    }
4307                                    None => {}
4308                                }
4309                            }
4310                        }
4311                    }
4312                }
4313                KeyCode::End => {
4314                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4315                        match self.analysis_modal.focus {
4316                            analysis_modal::AnalysisFocus::Sidebar => {
4317                                self.analysis_modal.sidebar_state.select(Some(2));
4318                                // Last tool
4319                            }
4320                            analysis_modal::AnalysisFocus::DistributionSelector => {
4321                                self.analysis_modal
4322                                    .distribution_selector_state
4323                                    .select(Some(13)); // Last distribution (Weibull, index 13 of 14 total)
4324                            }
4325                            analysis_modal::AnalysisFocus::Main => {
4326                                match self.analysis_modal.selected_tool {
4327                                    Some(analysis_modal::AnalysisTool::Describe) => {
4328                                        if let Some(state) = &self.data_table_state {
4329                                            let max_rows = state.schema.len();
4330                                            if max_rows > 0 {
4331                                                self.analysis_modal
4332                                                    .table_state
4333                                                    .select(Some(max_rows - 1));
4334                                            }
4335                                        }
4336                                    }
4337                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4338                                        if let Some(results) = self.analysis_modal.current_results()
4339                                        {
4340                                            let max_rows = results.distribution_analyses.len();
4341                                            if max_rows > 0 {
4342                                                self.analysis_modal
4343                                                    .distribution_table_state
4344                                                    .select(Some(max_rows - 1));
4345                                            }
4346                                        }
4347                                    }
4348                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4349                                        if let Some(results) = self.analysis_modal.current_results()
4350                                        {
4351                                            if let Some(corr) = &results.correlation_matrix {
4352                                                let max_rows = corr.columns.len();
4353                                                if max_rows > 0 {
4354                                                    self.analysis_modal
4355                                                        .correlation_table_state
4356                                                        .select(Some(max_rows - 1));
4357                                                    self.analysis_modal.selected_correlation =
4358                                                        Some((max_rows - 1, max_rows - 1));
4359                                                }
4360                                            }
4361                                        }
4362                                    }
4363                                    None => {}
4364                                }
4365                            }
4366                        }
4367                    }
4368                }
4369                _ => {}
4370            }
4371            return None;
4372        }
4373
4374        if self.template_modal.active {
4375            match event.code {
4376                KeyCode::Esc => {
4377                    if self.template_modal.show_score_details {
4378                        // Close score details popup
4379                        self.template_modal.show_score_details = false;
4380                    } else if self.template_modal.delete_confirm {
4381                        // Cancel delete confirmation
4382                        self.template_modal.delete_confirm = false;
4383                    } else if self.template_modal.mode == TemplateModalMode::Create
4384                        || self.template_modal.mode == TemplateModalMode::Edit
4385                    {
4386                        // In create/edit mode, Esc goes back to list mode
4387                        self.template_modal.exit_create_mode();
4388                    } else {
4389                        // In list mode, Esc closes modal
4390                        if self.template_modal.show_help {
4391                            self.template_modal.show_help = false;
4392                        } else {
4393                            self.template_modal.active = false;
4394                            self.template_modal.show_help = false;
4395                            self.template_modal.delete_confirm = false;
4396                        }
4397                    }
4398                }
4399                KeyCode::BackTab if self.template_modal.delete_confirm => {
4400                    // Toggle between Cancel and Delete buttons (reverse)
4401                    self.template_modal.delete_confirm_focus =
4402                        !self.template_modal.delete_confirm_focus;
4403                }
4404                KeyCode::Tab if !self.template_modal.delete_confirm => {
4405                    self.template_modal.next_focus();
4406                }
4407                KeyCode::BackTab => {
4408                    self.template_modal.prev_focus();
4409                }
4410                KeyCode::Char('s') if self.template_modal.mode == TemplateModalMode::List => {
4411                    // Switch to create mode from list mode
4412                    self.template_modal
4413                        .enter_create_mode(self.history_limit, &self.theme);
4414                    // Auto-populate fields
4415                    if let Some(ref path) = self.path {
4416                        // Auto-populate name
4417                        self.template_modal.create_name_input.value =
4418                            self.template_manager.generate_next_template_name();
4419                        self.template_modal.create_name_input.cursor =
4420                            self.template_modal.create_name_input.value.chars().count();
4421
4422                        // Auto-populate exact_path (absolute) - canonicalize to ensure absolute path
4423                        let absolute_path = if path.is_absolute() {
4424                            path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4425                        } else {
4426                            // If relative, make it absolute from current dir
4427                            if let Ok(cwd) = std::env::current_dir() {
4428                                let abs = cwd.join(path);
4429                                abs.canonicalize().unwrap_or(abs)
4430                            } else {
4431                                path.to_path_buf()
4432                            }
4433                        };
4434                        self.template_modal.create_exact_path_input.value =
4435                            absolute_path.to_string_lossy().to_string();
4436                        self.template_modal.create_exact_path_input.cursor = self
4437                            .template_modal
4438                            .create_exact_path_input
4439                            .value
4440                            .chars()
4441                            .count();
4442
4443                        // Auto-populate relative_path from current working directory
4444                        if let Ok(cwd) = std::env::current_dir() {
4445                            let abs_path = if path.is_absolute() {
4446                                path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4447                            } else {
4448                                let abs = cwd.join(path);
4449                                abs.canonicalize().unwrap_or(abs)
4450                            };
4451                            if let Ok(canonical_cwd) = cwd.canonicalize() {
4452                                if let Ok(rel_path) = abs_path.strip_prefix(&canonical_cwd) {
4453                                    // Ensure relative path starts with ./ or just the path
4454                                    let rel_str = rel_path.to_string_lossy().to_string();
4455                                    self.template_modal.create_relative_path_input.value =
4456                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4457                                    self.template_modal.create_relative_path_input.cursor = self
4458                                        .template_modal
4459                                        .create_relative_path_input
4460                                        .value
4461                                        .chars()
4462                                        .count();
4463                                } else {
4464                                    // Path is not under CWD, leave empty or use full path
4465                                    self.template_modal.create_relative_path_input.clear();
4466                                }
4467                            } else {
4468                                // Fallback: try without canonicalization
4469                                if let Ok(rel_path) = abs_path.strip_prefix(&cwd) {
4470                                    let rel_str = rel_path.to_string_lossy().to_string();
4471                                    self.template_modal.create_relative_path_input.value =
4472                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4473                                    self.template_modal.create_relative_path_input.cursor = self
4474                                        .template_modal
4475                                        .create_relative_path_input
4476                                        .value
4477                                        .chars()
4478                                        .count();
4479                                } else {
4480                                    self.template_modal.create_relative_path_input.clear();
4481                                }
4482                            }
4483                        } else {
4484                            self.template_modal.create_relative_path_input.clear();
4485                        }
4486
4487                        // Suggest path pattern
4488                        if let Some(parent) = path.parent() {
4489                            if let Some(parent_str) = parent.to_str() {
4490                                if path.file_name().is_some() {
4491                                    if let Some(ext) = path.extension() {
4492                                        self.template_modal.create_path_pattern_input.value =
4493                                            format!("{}/*.{}", parent_str, ext.to_string_lossy());
4494                                        self.template_modal.create_path_pattern_input.cursor = self
4495                                            .template_modal
4496                                            .create_path_pattern_input
4497                                            .value
4498                                            .chars()
4499                                            .count();
4500                                    }
4501                                }
4502                            }
4503                        }
4504
4505                        // Suggest filename pattern
4506                        if let Some(filename) = path.file_name() {
4507                            if let Some(filename_str) = filename.to_str() {
4508                                // Try to create a pattern by replacing numbers/dates with *
4509                                let mut pattern = filename_str.to_string();
4510                                // Simple heuristic: replace sequences of digits with *
4511                                use regex::Regex;
4512                                if let Ok(re) = Regex::new(r"\d+") {
4513                                    pattern = re.replace_all(&pattern, "*").to_string();
4514                                }
4515                                self.template_modal.create_filename_pattern_input.value = pattern;
4516                                self.template_modal.create_filename_pattern_input.cursor = self
4517                                    .template_modal
4518                                    .create_filename_pattern_input
4519                                    .value
4520                                    .chars()
4521                                    .count();
4522                            }
4523                        }
4524                    }
4525
4526                    // Suggest schema match
4527                    if let Some(ref state) = self.data_table_state {
4528                        if !state.schema.is_empty() {
4529                            self.template_modal.create_schema_match_enabled = false;
4530                            // Not auto-enabled, just suggested
4531                        }
4532                    }
4533                }
4534                KeyCode::Char('e') if self.template_modal.mode == TemplateModalMode::List => {
4535                    // Edit selected template
4536                    if let Some(idx) = self.template_modal.table_state.selected() {
4537                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4538                            let template_clone = template.clone();
4539                            self.template_modal.enter_edit_mode(
4540                                &template_clone,
4541                                self.history_limit,
4542                                &self.theme,
4543                            );
4544                        }
4545                    }
4546                }
4547                KeyCode::Char('d')
4548                    if self.template_modal.mode == TemplateModalMode::List
4549                        && !self.template_modal.delete_confirm =>
4550                {
4551                    // Show delete confirmation
4552                    if let Some(_idx) = self.template_modal.table_state.selected() {
4553                        self.template_modal.delete_confirm = true;
4554                        self.template_modal.delete_confirm_focus = false; // Cancel is default
4555                    }
4556                }
4557                KeyCode::Char('?')
4558                    if self.template_modal.mode == TemplateModalMode::List
4559                        && !self.template_modal.delete_confirm =>
4560                {
4561                    // Show score details popup
4562                    self.template_modal.show_score_details = true;
4563                }
4564                KeyCode::Char('D') if self.template_modal.delete_confirm => {
4565                    // Delete with capital D
4566                    if let Some(idx) = self.template_modal.table_state.selected() {
4567                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4568                            if self.template_manager.delete_template(&template.id).is_err() {
4569                                // Delete failed; list will be unchanged
4570                            } else {
4571                                // Reload templates
4572                                if let Some(ref state) = self.data_table_state {
4573                                    if let Some(ref path) = self.path {
4574                                        self.template_modal.templates = self
4575                                            .template_manager
4576                                            .find_relevant_templates(path, &state.schema);
4577                                        if !self.template_modal.templates.is_empty() {
4578                                            let new_idx = idx.min(
4579                                                self.template_modal
4580                                                    .templates
4581                                                    .len()
4582                                                    .saturating_sub(1),
4583                                            );
4584                                            self.template_modal.table_state.select(Some(new_idx));
4585                                        } else {
4586                                            self.template_modal.table_state.select(None);
4587                                        }
4588                                    }
4589                                }
4590                            }
4591                            self.template_modal.delete_confirm = false;
4592                        }
4593                    }
4594                }
4595                KeyCode::Tab if self.template_modal.delete_confirm => {
4596                    // Toggle between Cancel and Delete buttons
4597                    self.template_modal.delete_confirm_focus =
4598                        !self.template_modal.delete_confirm_focus;
4599                }
4600                KeyCode::Enter if self.template_modal.delete_confirm => {
4601                    // Enter cancels by default (Cancel is selected)
4602                    if self.template_modal.delete_confirm_focus {
4603                        // Delete button is selected
4604                        if let Some(idx) = self.template_modal.table_state.selected() {
4605                            if let Some((template, _)) = self.template_modal.templates.get(idx) {
4606                                if self.template_manager.delete_template(&template.id).is_err() {
4607                                    // Delete failed; list will be unchanged
4608                                } else {
4609                                    // Reload templates
4610                                    if let Some(ref state) = self.data_table_state {
4611                                        if let Some(ref path) = self.path {
4612                                            self.template_modal.templates = self
4613                                                .template_manager
4614                                                .find_relevant_templates(path, &state.schema);
4615                                            if !self.template_modal.templates.is_empty() {
4616                                                let new_idx = idx.min(
4617                                                    self.template_modal
4618                                                        .templates
4619                                                        .len()
4620                                                        .saturating_sub(1),
4621                                                );
4622                                                self.template_modal
4623                                                    .table_state
4624                                                    .select(Some(new_idx));
4625                                            } else {
4626                                                self.template_modal.table_state.select(None);
4627                                            }
4628                                        }
4629                                    }
4630                                }
4631                                self.template_modal.delete_confirm = false;
4632                            }
4633                        }
4634                    } else {
4635                        // Cancel button is selected (default)
4636                        self.template_modal.delete_confirm = false;
4637                    }
4638                }
4639                KeyCode::Enter => {
4640                    match self.template_modal.mode {
4641                        TemplateModalMode::List => {
4642                            match self.template_modal.focus {
4643                                TemplateFocus::TemplateList => {
4644                                    // Apply selected template
4645                                    let template_idx = self.template_modal.table_state.selected();
4646                                    if let Some(idx) = template_idx {
4647                                        if let Some((template, _)) =
4648                                            self.template_modal.templates.get(idx)
4649                                        {
4650                                            let template_clone = template.clone();
4651                                            if let Err(e) = self.apply_template(&template_clone) {
4652                                                // Show error modal instead of just printing
4653                                                self.error_modal.show(format!(
4654                                                    "Error applying template: {}",
4655                                                    e
4656                                                ));
4657                                                // Keep template modal open so user can see what failed
4658                                            } else {
4659                                                // Only close template modal on success
4660                                                self.template_modal.active = false;
4661                                            }
4662                                        }
4663                                    }
4664                                }
4665                                TemplateFocus::CreateButton => {
4666                                    // Same as 's' key - enter create mode
4667                                    // (handled by 's' key handler above)
4668                                }
4669                                _ => {}
4670                            }
4671                        }
4672                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4673                            // If in description field, Enter adds a newline instead of moving to next field
4674                            if self.template_modal.create_focus == CreateFocus::Description {
4675                                let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
4676                                self.template_modal
4677                                    .create_description_input
4678                                    .handle_key(&event, None);
4679                                // Auto-scroll to keep cursor visible
4680                                let area_height = 10; // Estimate, will be adjusted in rendering
4681                                self.template_modal
4682                                    .create_description_input
4683                                    .ensure_cursor_visible(area_height, 80);
4684                                return None;
4685                            }
4686                            match self.template_modal.create_focus {
4687                                CreateFocus::SaveButton => {
4688                                    // Validate name
4689                                    self.template_modal.name_error = None;
4690                                    if self
4691                                        .template_modal
4692                                        .create_name_input
4693                                        .value
4694                                        .trim()
4695                                        .is_empty()
4696                                    {
4697                                        self.template_modal.name_error =
4698                                            Some("(required)".to_string());
4699                                        self.template_modal.create_focus = CreateFocus::Name;
4700                                        return None;
4701                                    }
4702
4703                                    // Check for duplicate name (only if creating new, not editing)
4704                                    if self.template_modal.editing_template_id.is_none()
4705                                        && self.template_manager.template_exists(
4706                                            self.template_modal.create_name_input.value.trim(),
4707                                        )
4708                                    {
4709                                        self.template_modal.name_error =
4710                                            Some("(name already exists)".to_string());
4711                                        self.template_modal.create_focus = CreateFocus::Name;
4712                                        return None;
4713                                    }
4714
4715                                    // Create template from current state
4716                                    let match_criteria = template::MatchCriteria {
4717                                        exact_path: if !self
4718                                            .template_modal
4719                                            .create_exact_path_input
4720                                            .value
4721                                            .trim()
4722                                            .is_empty()
4723                                        {
4724                                            Some(std::path::PathBuf::from(
4725                                                self.template_modal
4726                                                    .create_exact_path_input
4727                                                    .value
4728                                                    .trim(),
4729                                            ))
4730                                        } else {
4731                                            None
4732                                        },
4733                                        relative_path: if !self
4734                                            .template_modal
4735                                            .create_relative_path_input
4736                                            .value
4737                                            .trim()
4738                                            .is_empty()
4739                                        {
4740                                            Some(
4741                                                self.template_modal
4742                                                    .create_relative_path_input
4743                                                    .value
4744                                                    .trim()
4745                                                    .to_string(),
4746                                            )
4747                                        } else {
4748                                            None
4749                                        },
4750                                        path_pattern: if !self
4751                                            .template_modal
4752                                            .create_path_pattern_input
4753                                            .value
4754                                            .is_empty()
4755                                        {
4756                                            Some(
4757                                                self.template_modal
4758                                                    .create_path_pattern_input
4759                                                    .value
4760                                                    .clone(),
4761                                            )
4762                                        } else {
4763                                            None
4764                                        },
4765                                        filename_pattern: if !self
4766                                            .template_modal
4767                                            .create_filename_pattern_input
4768                                            .value
4769                                            .is_empty()
4770                                        {
4771                                            Some(
4772                                                self.template_modal
4773                                                    .create_filename_pattern_input
4774                                                    .value
4775                                                    .clone(),
4776                                            )
4777                                        } else {
4778                                            None
4779                                        },
4780                                        schema_columns: if self
4781                                            .template_modal
4782                                            .create_schema_match_enabled
4783                                        {
4784                                            self.data_table_state.as_ref().map(|state| {
4785                                                state
4786                                                    .schema
4787                                                    .iter_names()
4788                                                    .map(|s| s.to_string())
4789                                                    .collect()
4790                                            })
4791                                        } else {
4792                                            None
4793                                        },
4794                                        schema_types: None, // Can be enhanced later
4795                                    };
4796
4797                                    let description = if !self
4798                                        .template_modal
4799                                        .create_description_input
4800                                        .value
4801                                        .is_empty()
4802                                    {
4803                                        Some(
4804                                            self.template_modal
4805                                                .create_description_input
4806                                                .value
4807                                                .clone(),
4808                                        )
4809                                    } else {
4810                                        None
4811                                    };
4812
4813                                    if let Some(ref editing_id) =
4814                                        self.template_modal.editing_template_id
4815                                    {
4816                                        // Update existing template
4817                                        if let Some(mut template) = self
4818                                            .template_manager
4819                                            .get_template_by_id(editing_id)
4820                                            .cloned()
4821                                        {
4822                                            template.name = self
4823                                                .template_modal
4824                                                .create_name_input
4825                                                .value
4826                                                .trim()
4827                                                .to_string();
4828                                            template.description = description;
4829                                            template.match_criteria = match_criteria;
4830                                            // Update settings from current state
4831                                            if let Some(state) = &self.data_table_state {
4832                                                let (query, sql_query, fuzzy_query) =
4833                                                    active_query_settings(
4834                                                        state.get_active_query(),
4835                                                        state.get_active_sql_query(),
4836                                                        state.get_active_fuzzy_query(),
4837                                                    );
4838                                                template.settings = template::TemplateSettings {
4839                                                    query,
4840                                                    sql_query,
4841                                                    fuzzy_query,
4842                                                    filters: state.get_filters().to_vec(),
4843                                                    sort_columns: state.get_sort_columns().to_vec(),
4844                                                    sort_ascending: state.get_sort_ascending(),
4845                                                    column_order: state.get_column_order().to_vec(),
4846                                                    locked_columns_count: state
4847                                                        .locked_columns_count(),
4848                                                    pivot: state.last_pivot_spec().cloned(),
4849                                                    melt: state.last_melt_spec().cloned(),
4850                                                };
4851                                            }
4852
4853                                            match self.template_manager.update_template(&template) {
4854                                                Ok(_) => {
4855                                                    // Reload templates and go back to list mode
4856                                                    if let Some(ref state) = self.data_table_state {
4857                                                        if let Some(ref path) = self.path {
4858                                                            self.template_modal.templates = self
4859                                                                .template_manager
4860                                                                .find_relevant_templates(
4861                                                                    path,
4862                                                                    &state.schema,
4863                                                                );
4864                                                            self.template_modal.table_state.select(
4865                                                                if self
4866                                                                    .template_modal
4867                                                                    .templates
4868                                                                    .is_empty()
4869                                                                {
4870                                                                    None
4871                                                                } else {
4872                                                                    Some(0)
4873                                                                },
4874                                                            );
4875                                                        }
4876                                                    }
4877                                                    self.template_modal.exit_create_mode();
4878                                                }
4879                                                Err(_) => {
4880                                                    // Update failed; stay in edit mode
4881                                                }
4882                                            }
4883                                        }
4884                                    } else {
4885                                        // Create new template
4886                                        match self.create_template_from_current_state(
4887                                            self.template_modal
4888                                                .create_name_input
4889                                                .value
4890                                                .trim()
4891                                                .to_string(),
4892                                            description,
4893                                            match_criteria,
4894                                        ) {
4895                                            Ok(_) => {
4896                                                // Reload templates and go back to list mode
4897                                                if let Some(ref state) = self.data_table_state {
4898                                                    if let Some(ref path) = self.path {
4899                                                        self.template_modal.templates = self
4900                                                            .template_manager
4901                                                            .find_relevant_templates(
4902                                                                path,
4903                                                                &state.schema,
4904                                                            );
4905                                                        self.template_modal.table_state.select(
4906                                                            if self
4907                                                                .template_modal
4908                                                                .templates
4909                                                                .is_empty()
4910                                                            {
4911                                                                None
4912                                                            } else {
4913                                                                Some(0)
4914                                                            },
4915                                                        );
4916                                                    }
4917                                                }
4918                                                self.template_modal.exit_create_mode();
4919                                            }
4920                                            Err(_) => {
4921                                                // Create failed; stay in create mode
4922                                            }
4923                                        }
4924                                    }
4925                                }
4926                                CreateFocus::CancelButton => {
4927                                    self.template_modal.exit_create_mode();
4928                                }
4929                                _ => {
4930                                    // Move to next field
4931                                    self.template_modal.next_focus();
4932                                }
4933                            }
4934                        }
4935                    }
4936                }
4937                KeyCode::Up => {
4938                    match self.template_modal.mode {
4939                        TemplateModalMode::List => {
4940                            if self.template_modal.focus == TemplateFocus::TemplateList {
4941                                let i = match self.template_modal.table_state.selected() {
4942                                    Some(i) => {
4943                                        if i == 0 {
4944                                            self.template_modal.templates.len().saturating_sub(1)
4945                                        } else {
4946                                            i - 1
4947                                        }
4948                                    }
4949                                    None => 0,
4950                                };
4951                                self.template_modal.table_state.select(Some(i));
4952                            }
4953                        }
4954                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4955                            // If in description field, move cursor up one line
4956                            if self.template_modal.create_focus == CreateFocus::Description {
4957                                let event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
4958                                self.template_modal
4959                                    .create_description_input
4960                                    .handle_key(&event, None);
4961                                // Auto-scroll to keep cursor visible
4962                                let area_height = 10; // Estimate, will be adjusted in rendering
4963                                self.template_modal
4964                                    .create_description_input
4965                                    .ensure_cursor_visible(area_height, 80);
4966                            } else {
4967                                // Move to previous field (works for all fields)
4968                                self.template_modal.prev_focus();
4969                            }
4970                        }
4971                    }
4972                }
4973                KeyCode::Down => {
4974                    match self.template_modal.mode {
4975                        TemplateModalMode::List => {
4976                            if self.template_modal.focus == TemplateFocus::TemplateList {
4977                                let i = match self.template_modal.table_state.selected() {
4978                                    Some(i) => {
4979                                        if i >= self
4980                                            .template_modal
4981                                            .templates
4982                                            .len()
4983                                            .saturating_sub(1)
4984                                        {
4985                                            0
4986                                        } else {
4987                                            i + 1
4988                                        }
4989                                    }
4990                                    None => 0,
4991                                };
4992                                self.template_modal.table_state.select(Some(i));
4993                            }
4994                        }
4995                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4996                            // If in description field, move cursor down one line
4997                            if self.template_modal.create_focus == CreateFocus::Description {
4998                                let event = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
4999                                self.template_modal
5000                                    .create_description_input
5001                                    .handle_key(&event, None);
5002                                // Auto-scroll to keep cursor visible
5003                                let area_height = 10; // Estimate, will be adjusted in rendering
5004                                self.template_modal
5005                                    .create_description_input
5006                                    .ensure_cursor_visible(area_height, 80);
5007                            } else {
5008                                // Move to next field (works for all fields)
5009                                self.template_modal.next_focus();
5010                            }
5011                        }
5012                    }
5013                }
5014                KeyCode::Char(c)
5015                    if self.template_modal.mode == TemplateModalMode::Create
5016                        || self.template_modal.mode == TemplateModalMode::Edit =>
5017                {
5018                    match self.template_modal.create_focus {
5019                        CreateFocus::Name => {
5020                            // Clear error when user starts typing
5021                            self.template_modal.name_error = None;
5022                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5023                            self.template_modal
5024                                .create_name_input
5025                                .handle_key(&event, None);
5026                        }
5027                        CreateFocus::Description => {
5028                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5029                            self.template_modal
5030                                .create_description_input
5031                                .handle_key(&event, None);
5032                            // Auto-scroll to keep cursor visible
5033                            let area_height = 10; // Estimate, will be adjusted in rendering
5034                            self.template_modal
5035                                .create_description_input
5036                                .ensure_cursor_visible(area_height, 80);
5037                        }
5038                        CreateFocus::ExactPath => {
5039                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5040                            self.template_modal
5041                                .create_exact_path_input
5042                                .handle_key(&event, None);
5043                        }
5044                        CreateFocus::RelativePath => {
5045                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5046                            self.template_modal
5047                                .create_relative_path_input
5048                                .handle_key(&event, None);
5049                        }
5050                        CreateFocus::PathPattern => {
5051                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5052                            self.template_modal
5053                                .create_path_pattern_input
5054                                .handle_key(&event, None);
5055                        }
5056                        CreateFocus::FilenamePattern => {
5057                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5058                            self.template_modal
5059                                .create_filename_pattern_input
5060                                .handle_key(&event, None);
5061                        }
5062                        CreateFocus::SchemaMatch => {
5063                            // Space toggles
5064                            if c == ' ' {
5065                                self.template_modal.create_schema_match_enabled =
5066                                    !self.template_modal.create_schema_match_enabled;
5067                            }
5068                        }
5069                        _ => {}
5070                    }
5071                }
5072                KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End
5073                    if self.template_modal.mode == TemplateModalMode::Create
5074                        || self.template_modal.mode == TemplateModalMode::Edit =>
5075                {
5076                    match self.template_modal.create_focus {
5077                        CreateFocus::Name => {
5078                            self.template_modal
5079                                .create_name_input
5080                                .handle_key(event, None);
5081                        }
5082                        CreateFocus::Description => {
5083                            self.template_modal
5084                                .create_description_input
5085                                .handle_key(event, None);
5086                            // Auto-scroll to keep cursor visible
5087                            let area_height = 10;
5088                            self.template_modal
5089                                .create_description_input
5090                                .ensure_cursor_visible(area_height, 80);
5091                        }
5092                        CreateFocus::ExactPath => {
5093                            self.template_modal
5094                                .create_exact_path_input
5095                                .handle_key(event, None);
5096                        }
5097                        CreateFocus::RelativePath => {
5098                            self.template_modal
5099                                .create_relative_path_input
5100                                .handle_key(event, None);
5101                        }
5102                        CreateFocus::PathPattern => {
5103                            self.template_modal
5104                                .create_path_pattern_input
5105                                .handle_key(event, None);
5106                        }
5107                        CreateFocus::FilenamePattern => {
5108                            self.template_modal
5109                                .create_filename_pattern_input
5110                                .handle_key(event, None);
5111                        }
5112                        _ => {}
5113                    }
5114                }
5115                KeyCode::PageUp | KeyCode::PageDown
5116                    if self.template_modal.mode == TemplateModalMode::Create
5117                        || self.template_modal.mode == TemplateModalMode::Edit =>
5118                {
5119                    // PageUp/PageDown for description field - move cursor up/down by 5 lines
5120                    // This is handled manually since MultiLineTextInput doesn't have built-in PageUp/PageDown
5121                    if self.template_modal.create_focus == CreateFocus::Description {
5122                        let lines: Vec<&str> = self
5123                            .template_modal
5124                            .create_description_input
5125                            .value
5126                            .lines()
5127                            .collect();
5128                        let current_line = self.template_modal.create_description_input.cursor_line;
5129                        let current_col = self.template_modal.create_description_input.cursor_col;
5130
5131                        let target_line = if event.code == KeyCode::PageUp {
5132                            current_line.saturating_sub(5)
5133                        } else {
5134                            (current_line + 5).min(lines.len().saturating_sub(1))
5135                        };
5136
5137                        if target_line < lines.len() {
5138                            let target_line_str = lines.get(target_line).unwrap_or(&"");
5139                            let new_col = current_col.min(target_line_str.chars().count());
5140                            self.template_modal.create_description_input.cursor = self
5141                                .template_modal
5142                                .create_description_input
5143                                .line_col_to_cursor(target_line, new_col);
5144                            self.template_modal
5145                                .create_description_input
5146                                .update_line_col_from_cursor();
5147                            // Auto-scroll
5148                            let area_height = 10;
5149                            self.template_modal
5150                                .create_description_input
5151                                .ensure_cursor_visible(area_height, 80);
5152                        }
5153                    }
5154                }
5155                KeyCode::Backspace
5156                | KeyCode::Delete
5157                | KeyCode::Left
5158                | KeyCode::Right
5159                | KeyCode::Home
5160                | KeyCode::End
5161                    if self.template_modal.mode == TemplateModalMode::Create
5162                        || self.template_modal.mode == TemplateModalMode::Edit =>
5163                {
5164                    match self.template_modal.create_focus {
5165                        CreateFocus::Name => {
5166                            self.template_modal
5167                                .create_name_input
5168                                .handle_key(event, None);
5169                        }
5170                        CreateFocus::Description => {
5171                            self.template_modal
5172                                .create_description_input
5173                                .handle_key(event, None);
5174                            // Auto-scroll to keep cursor visible
5175                            let area_height = 10;
5176                            self.template_modal
5177                                .create_description_input
5178                                .ensure_cursor_visible(area_height, 80);
5179                        }
5180                        CreateFocus::ExactPath => {
5181                            self.template_modal
5182                                .create_exact_path_input
5183                                .handle_key(event, None);
5184                        }
5185                        CreateFocus::RelativePath => {
5186                            self.template_modal
5187                                .create_relative_path_input
5188                                .handle_key(event, None);
5189                        }
5190                        CreateFocus::PathPattern => {
5191                            self.template_modal
5192                                .create_path_pattern_input
5193                                .handle_key(event, None);
5194                        }
5195                        CreateFocus::FilenamePattern => {
5196                            self.template_modal
5197                                .create_filename_pattern_input
5198                                .handle_key(event, None);
5199                        }
5200                        _ => {}
5201                    }
5202                }
5203                _ => {}
5204            }
5205            return None;
5206        }
5207
5208        if self.input_mode == InputMode::Editing {
5209            if self.input_type == Some(InputType::Search) {
5210                const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5211                const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5212
5213                if self.query_focus == QueryFocus::TabBar && event.is_press() {
5214                    if event.code == KeyCode::BackTab
5215                        || (event.code == KeyCode::Tab
5216                            && !event.modifiers.contains(KeyModifiers::SHIFT))
5217                    {
5218                        self.query_focus = QueryFocus::Input;
5219                        if let Some(state) = &self.data_table_state {
5220                            if self.query_tab == QueryTab::SqlLike {
5221                                self.query_input.value = state.get_active_query().to_string();
5222                                self.query_input.cursor = self.query_input.value.chars().count();
5223                                self.sql_input.set_focused(false);
5224                                self.fuzzy_input.set_focused(false);
5225                                self.query_input.set_focused(true);
5226                            } else if self.query_tab == QueryTab::Fuzzy {
5227                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5228                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5229                                self.query_input.set_focused(false);
5230                                self.sql_input.set_focused(false);
5231                                self.fuzzy_input.set_focused(true);
5232                            } else if self.query_tab == QueryTab::Sql {
5233                                self.sql_input.value = state.get_active_sql_query().to_string();
5234                                self.sql_input.cursor = self.sql_input.value.chars().count();
5235                                self.query_input.set_focused(false);
5236                                self.fuzzy_input.set_focused(false);
5237                                self.sql_input.set_focused(true);
5238                            }
5239                        }
5240                        return None;
5241                    }
5242                    if RIGHT_KEYS.contains(&event.code) {
5243                        self.query_tab = self.query_tab.next();
5244                        if let Some(state) = &self.data_table_state {
5245                            if self.query_tab == QueryTab::SqlLike {
5246                                self.query_input.value = state.get_active_query().to_string();
5247                                self.query_input.cursor = self.query_input.value.chars().count();
5248                            } else if self.query_tab == QueryTab::Fuzzy {
5249                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5250                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5251                            } else if self.query_tab == QueryTab::Sql {
5252                                self.sql_input.value = state.get_active_sql_query().to_string();
5253                                self.sql_input.cursor = self.sql_input.value.chars().count();
5254                            }
5255                        }
5256                        self.query_input.set_focused(false);
5257                        self.sql_input.set_focused(false);
5258                        self.fuzzy_input.set_focused(false);
5259                        return None;
5260                    }
5261                    if LEFT_KEYS.contains(&event.code) {
5262                        self.query_tab = self.query_tab.prev();
5263                        if let Some(state) = &self.data_table_state {
5264                            if self.query_tab == QueryTab::SqlLike {
5265                                self.query_input.value = state.get_active_query().to_string();
5266                                self.query_input.cursor = self.query_input.value.chars().count();
5267                            } else if self.query_tab == QueryTab::Fuzzy {
5268                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5269                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5270                            } else if self.query_tab == QueryTab::Sql {
5271                                self.sql_input.value = state.get_active_sql_query().to_string();
5272                                self.sql_input.cursor = self.sql_input.value.chars().count();
5273                            }
5274                        }
5275                        self.query_input.set_focused(false);
5276                        self.sql_input.set_focused(false);
5277                        self.fuzzy_input.set_focused(false);
5278                        return None;
5279                    }
5280                    if event.code == KeyCode::Esc {
5281                        self.query_input.clear();
5282                        self.sql_input.clear();
5283                        self.fuzzy_input.clear();
5284                        self.query_input.set_focused(false);
5285                        self.sql_input.set_focused(false);
5286                        self.fuzzy_input.set_focused(false);
5287                        self.input_mode = InputMode::Normal;
5288                        self.input_type = None;
5289                        if let Some(state) = &mut self.data_table_state {
5290                            state.error = None;
5291                            state.suppress_error_display = false;
5292                        }
5293                        return None;
5294                    }
5295                    return None;
5296                }
5297
5298                if event.is_press()
5299                    && event.code == KeyCode::Tab
5300                    && !event.modifiers.contains(KeyModifiers::SHIFT)
5301                {
5302                    self.query_focus = QueryFocus::TabBar;
5303                    self.query_input.set_focused(false);
5304                    self.sql_input.set_focused(false);
5305                    self.fuzzy_input.set_focused(false);
5306                    return None;
5307                }
5308
5309                if self.query_focus != QueryFocus::Input {
5310                    return None;
5311                }
5312
5313                if self.query_tab == QueryTab::Sql {
5314                    self.query_input.set_focused(false);
5315                    self.fuzzy_input.set_focused(false);
5316                    self.sql_input.set_focused(true);
5317                    let result = self.sql_input.handle_key(event, Some(&self.cache));
5318                    match result {
5319                        TextInputEvent::Submit => {
5320                            let _ = self.sql_input.save_to_history(&self.cache);
5321                            let sql = self.sql_input.value.clone();
5322                            self.sql_input.set_focused(false);
5323                            return Some(AppEvent::SqlSearch(sql));
5324                        }
5325                        TextInputEvent::Cancel => {
5326                            self.sql_input.clear();
5327                            self.sql_input.set_focused(false);
5328                            self.input_mode = InputMode::Normal;
5329                            self.input_type = None;
5330                            if let Some(state) = &mut self.data_table_state {
5331                                state.error = None;
5332                                state.suppress_error_display = false;
5333                            }
5334                        }
5335                        TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5336                    }
5337                    return None;
5338                }
5339
5340                if self.query_tab == QueryTab::Fuzzy {
5341                    self.query_input.set_focused(false);
5342                    self.sql_input.set_focused(false);
5343                    self.fuzzy_input.set_focused(true);
5344                    let result = self.fuzzy_input.handle_key(event, Some(&self.cache));
5345                    match result {
5346                        TextInputEvent::Submit => {
5347                            let _ = self.fuzzy_input.save_to_history(&self.cache);
5348                            let query = self.fuzzy_input.value.clone();
5349                            self.fuzzy_input.set_focused(false);
5350                            return Some(AppEvent::FuzzySearch(query));
5351                        }
5352                        TextInputEvent::Cancel => {
5353                            self.fuzzy_input.clear();
5354                            self.fuzzy_input.set_focused(false);
5355                            self.input_mode = InputMode::Normal;
5356                            self.input_type = None;
5357                            if let Some(state) = &mut self.data_table_state {
5358                                state.error = None;
5359                                state.suppress_error_display = false;
5360                            }
5361                        }
5362                        TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5363                    }
5364                    return None;
5365                }
5366
5367                if self.query_tab != QueryTab::SqlLike {
5368                    return None;
5369                }
5370
5371                self.sql_input.set_focused(false);
5372                self.fuzzy_input.set_focused(false);
5373                self.query_input.set_focused(true);
5374                let result = self.query_input.handle_key(event, Some(&self.cache));
5375
5376                match result {
5377                    TextInputEvent::Submit => {
5378                        // Save to history and execute query
5379                        let _ = self.query_input.save_to_history(&self.cache);
5380                        let query = self.query_input.value.clone();
5381                        self.query_input.set_focused(false);
5382                        return Some(AppEvent::Search(query));
5383                    }
5384                    TextInputEvent::Cancel => {
5385                        // Clear and exit input mode
5386                        self.query_input.clear();
5387                        self.query_input.set_focused(false);
5388                        self.input_mode = InputMode::Normal;
5389                        if let Some(state) = &mut self.data_table_state {
5390                            // Clear error and re-enable error display in main view
5391                            state.error = None;
5392                            state.suppress_error_display = false;
5393                        }
5394                    }
5395                    TextInputEvent::HistoryChanged => {
5396                        // History navigation occurred, nothing special needed
5397                    }
5398                    TextInputEvent::None => {
5399                        // Regular input, nothing special needed
5400                    }
5401                }
5402                return None;
5403            }
5404
5405            // Line number input (GoToLine): ":" then type line number, Enter to jump, Esc to cancel
5406            if self.input_type == Some(InputType::GoToLine) {
5407                self.query_input.set_focused(true);
5408                let result = self.query_input.handle_key(event, None);
5409                match result {
5410                    TextInputEvent::Submit => {
5411                        let value = self.query_input.value.trim().to_string();
5412                        self.query_input.clear();
5413                        self.query_input.set_focused(false);
5414                        self.input_mode = InputMode::Normal;
5415                        self.input_type = None;
5416                        if let Some(state) = &mut self.data_table_state {
5417                            if let Ok(display_line) = value.parse::<usize>() {
5418                                let row_index =
5419                                    display_line.saturating_sub(state.row_start_index());
5420                                let would_collect = state.scroll_would_trigger_collect(
5421                                    row_index as i64 - state.start_row as i64,
5422                                );
5423                                if would_collect {
5424                                    self.busy = true;
5425                                    return Some(AppEvent::GoToLine(row_index));
5426                                }
5427                                state.scroll_to_row_centered(row_index);
5428                            }
5429                        }
5430                    }
5431                    TextInputEvent::Cancel => {
5432                        self.query_input.clear();
5433                        self.query_input.set_focused(false);
5434                        self.input_mode = InputMode::Normal;
5435                        self.input_type = None;
5436                    }
5437                    TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5438                }
5439                return None;
5440            }
5441
5442            // For other input types (Filter, etc.), keep old behavior for now
5443            // TODO: Migrate these in later phases
5444            return None;
5445        }
5446
5447        const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5448
5449        const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5450
5451        const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
5452
5453        const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
5454
5455        match event.code {
5456            KeyCode::Char('q') | KeyCode::Char('Q') => Some(AppEvent::Exit),
5457            KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => {
5458                Some(AppEvent::Exit)
5459            }
5460            KeyCode::Char('R') => Some(AppEvent::Reset),
5461            KeyCode::Char('N') => {
5462                if let Some(ref mut state) = self.data_table_state {
5463                    state.toggle_row_numbers();
5464                }
5465                None
5466            }
5467            KeyCode::Esc => {
5468                // First check if we're in drill-down mode
5469                if let Some(ref mut state) = self.data_table_state {
5470                    if state.is_drilled_down() {
5471                        let _ = state.drill_up();
5472                        return None;
5473                    }
5474                }
5475                // Escape no longer exits - use 'q' or Ctrl-C to exit
5476                // (Info modal handles Esc in its own block)
5477                None
5478            }
5479            code if RIGHT_KEYS.contains(&code) => {
5480                if let Some(ref mut state) = self.data_table_state {
5481                    state.scroll_right();
5482                    if self.debug.enabled {
5483                        self.debug.last_action = "scroll_right".to_string();
5484                    }
5485                }
5486                None
5487            }
5488            code if LEFT_KEYS.contains(&code) => {
5489                if let Some(ref mut state) = self.data_table_state {
5490                    state.scroll_left();
5491                    if self.debug.enabled {
5492                        self.debug.last_action = "scroll_left".to_string();
5493                    }
5494                }
5495                None
5496            }
5497            code if event.is_press() && DOWN_KEYS.contains(&code) => {
5498                let would_collect = self
5499                    .data_table_state
5500                    .as_ref()
5501                    .map(|s| s.scroll_would_trigger_collect(1))
5502                    .unwrap_or(false);
5503                if would_collect {
5504                    self.busy = true;
5505                    Some(AppEvent::DoScrollNext)
5506                } else {
5507                    if let Some(ref mut s) = self.data_table_state {
5508                        s.select_next();
5509                    }
5510                    None
5511                }
5512            }
5513            code if event.is_press() && UP_KEYS.contains(&code) => {
5514                let would_collect = self
5515                    .data_table_state
5516                    .as_ref()
5517                    .map(|s| s.scroll_would_trigger_collect(-1))
5518                    .unwrap_or(false);
5519                if would_collect {
5520                    self.busy = true;
5521                    Some(AppEvent::DoScrollPrev)
5522                } else {
5523                    if let Some(ref mut s) = self.data_table_state {
5524                        s.select_previous();
5525                    }
5526                    None
5527                }
5528            }
5529            KeyCode::PageDown if event.is_press() => {
5530                let would_collect = self
5531                    .data_table_state
5532                    .as_ref()
5533                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5534                    .unwrap_or(false);
5535                if would_collect {
5536                    self.busy = true;
5537                    Some(AppEvent::DoScrollDown)
5538                } else {
5539                    if let Some(ref mut s) = self.data_table_state {
5540                        s.page_down();
5541                    }
5542                    None
5543                }
5544            }
5545            KeyCode::Home if event.is_press() => {
5546                if let Some(ref mut state) = self.data_table_state {
5547                    if state.start_row > 0 {
5548                        state.scroll_to(0);
5549                    }
5550                    state.table_state.select(Some(0));
5551                }
5552                None
5553            }
5554            KeyCode::End if event.is_press() => {
5555                if self.data_table_state.is_some() {
5556                    self.busy = true;
5557                    Some(AppEvent::DoScrollEnd)
5558                } else {
5559                    None
5560                }
5561            }
5562            KeyCode::Char('G') if event.is_press() => {
5563                if self.data_table_state.is_some() {
5564                    self.busy = true;
5565                    Some(AppEvent::DoScrollEnd)
5566                } else {
5567                    None
5568                }
5569            }
5570            KeyCode::Char('f')
5571                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5572            {
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::Char('b')
5589                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5590            {
5591                let would_collect = self
5592                    .data_table_state
5593                    .as_ref()
5594                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5595                    .unwrap_or(false);
5596                if would_collect {
5597                    self.busy = true;
5598                    Some(AppEvent::DoScrollUp)
5599                } else {
5600                    if let Some(ref mut s) = self.data_table_state {
5601                        s.page_up();
5602                    }
5603                    None
5604                }
5605            }
5606            KeyCode::Char('d')
5607                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5608            {
5609                let half = self
5610                    .data_table_state
5611                    .as_ref()
5612                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5613                    .unwrap_or(1);
5614                let would_collect = self
5615                    .data_table_state
5616                    .as_ref()
5617                    .map(|s| s.scroll_would_trigger_collect(half))
5618                    .unwrap_or(false);
5619                if would_collect {
5620                    self.busy = true;
5621                    Some(AppEvent::DoScrollHalfDown)
5622                } else {
5623                    if let Some(ref mut s) = self.data_table_state {
5624                        s.half_page_down();
5625                    }
5626                    None
5627                }
5628            }
5629            KeyCode::Char('u')
5630                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5631            {
5632                let half = self
5633                    .data_table_state
5634                    .as_ref()
5635                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5636                    .unwrap_or(1);
5637                let would_collect = self
5638                    .data_table_state
5639                    .as_ref()
5640                    .map(|s| s.scroll_would_trigger_collect(-half))
5641                    .unwrap_or(false);
5642                if would_collect {
5643                    self.busy = true;
5644                    Some(AppEvent::DoScrollHalfUp)
5645                } else {
5646                    if let Some(ref mut s) = self.data_table_state {
5647                        s.half_page_up();
5648                    }
5649                    None
5650                }
5651            }
5652            KeyCode::PageUp if event.is_press() => {
5653                let would_collect = self
5654                    .data_table_state
5655                    .as_ref()
5656                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5657                    .unwrap_or(false);
5658                if would_collect {
5659                    self.busy = true;
5660                    Some(AppEvent::DoScrollUp)
5661                } else {
5662                    if let Some(ref mut s) = self.data_table_state {
5663                        s.page_up();
5664                    }
5665                    None
5666                }
5667            }
5668            KeyCode::Enter if event.is_press() => {
5669                // Only drill down if not in a modal and viewing grouped data
5670                if self.input_mode == InputMode::Normal {
5671                    if let Some(ref mut state) = self.data_table_state {
5672                        if state.is_grouped() && !state.is_drilled_down() {
5673                            if let Some(selected) = state.table_state.selected() {
5674                                let group_index = state.start_row + selected;
5675                                let _ = state.drill_down_into_group(group_index);
5676                            }
5677                        }
5678                    }
5679                }
5680                None
5681            }
5682            KeyCode::Tab if event.is_press() => {
5683                self.focus = (self.focus + 1) % 2;
5684                None
5685            }
5686            KeyCode::BackTab if event.is_press() => {
5687                self.focus = (self.focus + 1) % 2;
5688                None
5689            }
5690            KeyCode::Char('i') if event.is_press() => {
5691                if self.data_table_state.is_some() {
5692                    self.info_modal.open();
5693                    self.input_mode = InputMode::Info;
5694                    // Defer Parquet metadata load so UI can show throbber; avoid blocking in render
5695                    if self.path.is_some()
5696                        && self.original_file_format == Some(ExportFormat::Parquet)
5697                        && self.parquet_metadata_cache.is_none()
5698                    {
5699                        self.busy = true;
5700                        return Some(AppEvent::DoLoadParquetMetadata);
5701                    }
5702                }
5703                None
5704            }
5705            KeyCode::Char('/') => {
5706                self.input_mode = InputMode::Editing;
5707                self.input_type = Some(InputType::Search);
5708                self.query_tab = QueryTab::SqlLike;
5709                self.query_focus = QueryFocus::Input;
5710                if let Some(state) = &mut self.data_table_state {
5711                    self.query_input.value = state.active_query.clone();
5712                    self.query_input.cursor = self.query_input.value.chars().count();
5713                    self.sql_input.value = state.get_active_sql_query().to_string();
5714                    self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5715                    self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5716                    self.sql_input.cursor = self.sql_input.value.chars().count();
5717                    state.suppress_error_display = true;
5718                } else {
5719                    self.query_input.clear();
5720                    self.sql_input.clear();
5721                    self.fuzzy_input.clear();
5722                }
5723                self.sql_input.set_focused(false);
5724                self.fuzzy_input.set_focused(false);
5725                self.query_input.set_focused(true);
5726                None
5727            }
5728            KeyCode::Char(':') if event.is_press() => {
5729                if self.data_table_state.is_some() {
5730                    self.input_mode = InputMode::Editing;
5731                    self.input_type = Some(InputType::GoToLine);
5732                    self.query_input.value.clear();
5733                    self.query_input.cursor = 0;
5734                    self.query_input.set_focused(true);
5735                }
5736                None
5737            }
5738            KeyCode::Char('T') => {
5739                // Apply most relevant template immediately (no modal)
5740                if let Some(ref state) = self.data_table_state {
5741                    if let Some(ref path) = self.path {
5742                        if let Some(template) =
5743                            self.template_manager.get_most_relevant(path, &state.schema)
5744                        {
5745                            // Apply template settings
5746                            if let Err(e) = self.apply_template(&template) {
5747                                // Show error modal instead of just printing
5748                                self.error_modal
5749                                    .show(format!("Error applying template: {}", e));
5750                            }
5751                        }
5752                    }
5753                }
5754                None
5755            }
5756            KeyCode::Char('t') => {
5757                // Open template modal
5758                if let Some(ref state) = self.data_table_state {
5759                    if let Some(ref path) = self.path {
5760                        // Load relevant templates
5761                        self.template_modal.templates = self
5762                            .template_manager
5763                            .find_relevant_templates(path, &state.schema);
5764                        self.template_modal.table_state.select(
5765                            if self.template_modal.templates.is_empty() {
5766                                None
5767                            } else {
5768                                Some(0)
5769                            },
5770                        );
5771                        self.template_modal.active = true;
5772                        self.template_modal.mode = TemplateModalMode::List;
5773                        self.template_modal.focus = TemplateFocus::TemplateList;
5774                    }
5775                }
5776                None
5777            }
5778            KeyCode::Char('s') => {
5779                if let Some(state) = &self.data_table_state {
5780                    let headers: Vec<String> =
5781                        state.schema.iter_names().map(|s| s.to_string()).collect();
5782                    let locked_count = state.locked_columns_count();
5783
5784                    // Populate sort tab
5785                    let mut existing_columns: std::collections::HashMap<String, SortColumn> = self
5786                        .sort_filter_modal
5787                        .sort
5788                        .columns
5789                        .iter()
5790                        .map(|c| (c.name.clone(), c.clone()))
5791                        .collect();
5792                    self.sort_filter_modal.sort.columns = headers
5793                        .iter()
5794                        .enumerate()
5795                        .map(|(i, h)| {
5796                            if let Some(mut col) = existing_columns.remove(h) {
5797                                col.display_order = i;
5798                                col.is_locked = i < locked_count;
5799                                col.is_to_be_locked = false;
5800                                col
5801                            } else {
5802                                SortColumn {
5803                                    name: h.clone(),
5804                                    sort_order: None,
5805                                    display_order: i,
5806                                    is_locked: i < locked_count,
5807                                    is_to_be_locked: false,
5808                                    is_visible: true,
5809                                }
5810                            }
5811                        })
5812                        .collect();
5813                    self.sort_filter_modal.sort.filter_input.clear();
5814                    self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
5815
5816                    // Populate filter tab
5817                    self.sort_filter_modal.filter.available_columns = state.headers();
5818                    if !self.sort_filter_modal.filter.available_columns.is_empty() {
5819                        self.sort_filter_modal.filter.new_column_idx =
5820                            self.sort_filter_modal.filter.new_column_idx.min(
5821                                self.sort_filter_modal
5822                                    .filter
5823                                    .available_columns
5824                                    .len()
5825                                    .saturating_sub(1),
5826                            );
5827                    } else {
5828                        self.sort_filter_modal.filter.new_column_idx = 0;
5829                    }
5830
5831                    self.sort_filter_modal.open(self.history_limit, &self.theme);
5832                    self.input_mode = InputMode::SortFilter;
5833                }
5834                None
5835            }
5836            KeyCode::Char('r') => {
5837                if let Some(state) = &mut self.data_table_state {
5838                    state.reverse();
5839                }
5840                None
5841            }
5842            KeyCode::Char('a') => {
5843                // Open analysis modal; no computation until user selects a tool from the sidebar (Enter)
5844                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5845                    self.analysis_modal.open();
5846                }
5847                None
5848            }
5849            KeyCode::Char('c') => {
5850                if let Some(state) = &self.data_table_state {
5851                    if self.input_mode == InputMode::Normal {
5852                        let numeric_columns: Vec<String> = state
5853                            .schema
5854                            .iter()
5855                            .filter(|(_, dtype)| dtype.is_numeric())
5856                            .map(|(name, _)| name.to_string())
5857                            .collect();
5858                        let datetime_columns: Vec<String> = state
5859                            .schema
5860                            .iter()
5861                            .filter(|(_, dtype)| {
5862                                matches!(
5863                                    dtype,
5864                                    DataType::Datetime(_, _) | DataType::Date | DataType::Time
5865                                )
5866                            })
5867                            .map(|(name, _)| name.to_string())
5868                            .collect();
5869                        self.chart_modal.open(
5870                            &numeric_columns,
5871                            &datetime_columns,
5872                            self.app_config.chart.row_limit,
5873                        );
5874                        self.chart_modal.x_input =
5875                            std::mem::take(&mut self.chart_modal.x_input).with_theme(&self.theme);
5876                        self.chart_modal.y_input =
5877                            std::mem::take(&mut self.chart_modal.y_input).with_theme(&self.theme);
5878                        self.chart_modal.hist_input =
5879                            std::mem::take(&mut self.chart_modal.hist_input)
5880                                .with_theme(&self.theme);
5881                        self.chart_modal.box_input =
5882                            std::mem::take(&mut self.chart_modal.box_input).with_theme(&self.theme);
5883                        self.chart_modal.kde_input =
5884                            std::mem::take(&mut self.chart_modal.kde_input).with_theme(&self.theme);
5885                        self.chart_modal.heatmap_x_input =
5886                            std::mem::take(&mut self.chart_modal.heatmap_x_input)
5887                                .with_theme(&self.theme);
5888                        self.chart_modal.heatmap_y_input =
5889                            std::mem::take(&mut self.chart_modal.heatmap_y_input)
5890                                .with_theme(&self.theme);
5891                        self.chart_cache.clear();
5892                        self.input_mode = InputMode::Chart;
5893                    }
5894                }
5895                None
5896            }
5897            KeyCode::Char('p') => {
5898                if let Some(state) = &self.data_table_state {
5899                    if self.input_mode == InputMode::Normal {
5900                        self.pivot_melt_modal.available_columns =
5901                            state.schema.iter_names().map(|s| s.to_string()).collect();
5902                        self.pivot_melt_modal.column_dtypes = state
5903                            .schema
5904                            .iter()
5905                            .map(|(n, d)| (n.to_string(), d.clone()))
5906                            .collect();
5907                        self.pivot_melt_modal.open(self.history_limit, &self.theme);
5908                        self.input_mode = InputMode::PivotMelt;
5909                    }
5910                }
5911                None
5912            }
5913            KeyCode::Char('e') => {
5914                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5915                    // Load config to get delimiter preference
5916                    let config_delimiter = AppConfig::load(APP_NAME)
5917                        .ok()
5918                        .and_then(|config| config.file_loading.delimiter);
5919                    self.export_modal.open(
5920                        self.original_file_format,
5921                        self.history_limit,
5922                        &self.theme,
5923                        self.original_file_delimiter,
5924                        config_delimiter,
5925                    );
5926                    self.input_mode = InputMode::Export;
5927                }
5928                None
5929            }
5930            _ => None,
5931        }
5932    }
5933
5934    pub fn event(&mut self, event: &AppEvent) -> Option<AppEvent> {
5935        self.debug.num_events += 1;
5936        match event {
5937            AppEvent::Key(key) => {
5938                let is_column_scroll = matches!(
5939                    key.code,
5940                    KeyCode::Left | KeyCode::Right | KeyCode::Char('h') | KeyCode::Char('l')
5941                );
5942                let is_help_key = key.code == KeyCode::F(1);
5943                // When busy (e.g. loading), still process column scroll, F1, and confirmation modal keys.
5944                if self.busy && !is_column_scroll && !is_help_key && !self.confirmation_modal.active
5945                {
5946                    return None;
5947                }
5948                self.key(key)
5949            }
5950            AppEvent::Open(paths, options) => {
5951                if paths.is_empty() {
5952                    return Some(AppEvent::Crash("No paths provided".to_string()));
5953                }
5954                #[cfg(feature = "http")]
5955                if let Some(ref p) = self.http_temp_path.take() {
5956                    let _ = std::fs::remove_file(p);
5957                }
5958                self.busy = true;
5959                let first = &paths[0];
5960                let file_size = match source::input_source(first) {
5961                    source::InputSource::Local(_) => {
5962                        std::fs::metadata(first).map(|m| m.len()).unwrap_or(0)
5963                    }
5964                    source::InputSource::S3(_)
5965                    | source::InputSource::Gcs(_)
5966                    | source::InputSource::Http(_) => 0,
5967                };
5968                let path_str = first.as_os_str().to_string_lossy();
5969                let _is_partitioned_path = paths.len() == 1
5970                    && options.hive
5971                    && (first.is_dir() || path_str.contains('*') || path_str.contains("**"));
5972                let phase = "Scanning input";
5973
5974                self.loading_state = LoadingState::Loading {
5975                    file_path: Some(first.clone()),
5976                    file_size,
5977                    current_phase: phase.to_string(),
5978                    progress_percent: 10,
5979                };
5980
5981                Some(AppEvent::DoLoadScanPaths(paths.clone(), options.clone()))
5982            }
5983            AppEvent::OpenLazyFrame(lf, options) => {
5984                self.busy = true;
5985                self.loading_state = LoadingState::Loading {
5986                    file_path: None,
5987                    file_size: 0,
5988                    current_phase: "Scanning input".to_string(),
5989                    progress_percent: 10,
5990                };
5991                Some(AppEvent::DoLoadSchema(lf.clone(), None, options.clone()))
5992            }
5993            AppEvent::DoLoadScanPaths(paths, options) => {
5994                let first = &paths[0];
5995                let src = source::input_source(first);
5996                if paths.len() > 1 {
5997                    match &src {
5998                        source::InputSource::S3(_) => {
5999                            return Some(AppEvent::Crash(
6000                                "Only one S3 URL at a time. Open a single s3:// path.".to_string(),
6001                            ));
6002                        }
6003                        source::InputSource::Gcs(_) => {
6004                            return Some(AppEvent::Crash(
6005                                "Only one GCS URL at a time. Open a single gs:// path.".to_string(),
6006                            ));
6007                        }
6008                        source::InputSource::Http(_) => {
6009                            return Some(AppEvent::Crash(
6010                                "Only one HTTP/HTTPS URL at a time. Open a single URL.".to_string(),
6011                            ));
6012                        }
6013                        source::InputSource::Local(_) => {}
6014                    }
6015                }
6016                let compression = options
6017                    .compression
6018                    .or_else(|| CompressionFormat::from_extension(first));
6019                let is_csv = first
6020                    .file_stem()
6021                    .and_then(|stem| stem.to_str())
6022                    .map(|stem| {
6023                        stem.ends_with(".csv")
6024                            || first
6025                                .extension()
6026                                .and_then(|e| e.to_str())
6027                                .map(|e| e.eq_ignore_ascii_case("csv"))
6028                                .unwrap_or(false)
6029                    })
6030                    .unwrap_or(false);
6031                let is_compressed_csv = matches!(src, source::InputSource::Local(_))
6032                    && paths.len() == 1
6033                    && compression.is_some()
6034                    && is_csv;
6035                if is_compressed_csv {
6036                    if let LoadingState::Loading {
6037                        file_path,
6038                        file_size,
6039                        ..
6040                    } = &self.loading_state
6041                    {
6042                        self.loading_state = LoadingState::Loading {
6043                            file_path: file_path.clone(),
6044                            file_size: *file_size,
6045                            current_phase: "Decompressing".to_string(),
6046                            progress_percent: 30,
6047                        };
6048                    }
6049                    Some(AppEvent::DoLoad(paths.clone(), options.clone()))
6050                } else {
6051                    #[cfg(feature = "http")]
6052                    if let source::InputSource::Http(ref url) = src {
6053                        let size = Self::fetch_remote_size_http(url).unwrap_or(None);
6054                        let size_str = size
6055                            .map(Self::format_bytes)
6056                            .unwrap_or_else(|| "unknown".to_string());
6057                        let dest_dir = options
6058                            .temp_dir
6059                            .as_deref()
6060                            .map(|p| p.display().to_string())
6061                            .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6062                        let message = format!(
6063                            "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6064                            url, size_str, dest_dir
6065                        );
6066                        self.pending_download = Some(PendingDownload::Http {
6067                            url: url.clone(),
6068                            size,
6069                            options: options.clone(),
6070                        });
6071                        self.confirmation_modal.show(message);
6072                        return None;
6073                    }
6074                    #[cfg(feature = "cloud")]
6075                    if let source::InputSource::S3(ref url) = src {
6076                        let full = format!("s3://{url}");
6077                        let (_, ext) = source::url_path_extension(&full);
6078                        let is_glob = full.contains('*') || full.ends_with('/');
6079                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6080                            let size =
6081                                Self::fetch_remote_size_s3(&full, &self.app_config.cloud, options)
6082                                    .unwrap_or(None);
6083                            let size_str = size
6084                                .map(Self::format_bytes)
6085                                .unwrap_or_else(|| "unknown".to_string());
6086                            let dest_dir = options
6087                                .temp_dir
6088                                .as_deref()
6089                                .map(|p| p.display().to_string())
6090                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6091                            let message = format!(
6092                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6093                                full, size_str, dest_dir
6094                            );
6095                            self.pending_download = Some(PendingDownload::S3 {
6096                                url: full,
6097                                size,
6098                                options: options.clone(),
6099                            });
6100                            self.confirmation_modal.show(message);
6101                            return None;
6102                        }
6103                    }
6104                    #[cfg(feature = "cloud")]
6105                    if let source::InputSource::Gcs(ref url) = src {
6106                        let full = format!("gs://{url}");
6107                        let (_, ext) = source::url_path_extension(&full);
6108                        let is_glob = full.contains('*') || full.ends_with('/');
6109                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6110                            let size = Self::fetch_remote_size_gcs(&full, options).unwrap_or(None);
6111                            let size_str = size
6112                                .map(Self::format_bytes)
6113                                .unwrap_or_else(|| "unknown".to_string());
6114                            let dest_dir = options
6115                                .temp_dir
6116                                .as_deref()
6117                                .map(|p| p.display().to_string())
6118                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6119                            let message = format!(
6120                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6121                                full, size_str, dest_dir
6122                            );
6123                            self.pending_download = Some(PendingDownload::Gcs {
6124                                url: full,
6125                                size,
6126                                options: options.clone(),
6127                            });
6128                            self.confirmation_modal.show(message);
6129                            return None;
6130                        }
6131                    }
6132                    let first = paths[0].clone();
6133                    #[allow(clippy::needless_borrow)]
6134                    match self.build_lazyframe_from_paths(&paths, options) {
6135                        Ok(lf) => {
6136                            if let LoadingState::Loading {
6137                                file_path,
6138                                file_size,
6139                                ..
6140                            } = &self.loading_state
6141                            {
6142                                self.loading_state = LoadingState::Loading {
6143                                    file_path: file_path.clone(),
6144                                    file_size: *file_size,
6145                                    current_phase: "Caching schema".to_string(),
6146                                    progress_percent: 40,
6147                                };
6148                            }
6149                            Some(AppEvent::DoLoadSchema(
6150                                Box::new(lf),
6151                                Some(first),
6152                                options.clone(),
6153                            ))
6154                        }
6155                        Err(e) => {
6156                            self.loading_state = LoadingState::Idle;
6157                            self.busy = false;
6158                            self.drain_keys_on_next_loop = true;
6159                            let msg = crate::error_display::user_message_from_report(
6160                                &e,
6161                                paths.first().map(|p| p.as_path()),
6162                            );
6163                            Some(AppEvent::Crash(msg))
6164                        }
6165                    }
6166                }
6167            }
6168            #[cfg(feature = "http")]
6169            AppEvent::DoDownloadHttp(url, options) => {
6170                let (_, ext) = source::url_path_extension(url.as_str());
6171                match Self::download_http_to_temp(
6172                    url.as_str(),
6173                    options.temp_dir.as_deref(),
6174                    ext.as_deref(),
6175                ) {
6176                    Ok(temp_path) => {
6177                        self.http_temp_path = Some(temp_path.clone());
6178                        if let LoadingState::Loading {
6179                            file_path,
6180                            file_size,
6181                            ..
6182                        } = &self.loading_state
6183                        {
6184                            self.loading_state = LoadingState::Loading {
6185                                file_path: file_path.clone(),
6186                                file_size: *file_size,
6187                                current_phase: "Scanning".to_string(),
6188                                progress_percent: 30,
6189                            };
6190                        }
6191                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6192                    }
6193                    Err(e) => {
6194                        self.loading_state = LoadingState::Idle;
6195                        self.busy = false;
6196                        self.drain_keys_on_next_loop = true;
6197                        let msg = crate::error_display::user_message_from_report(&e, None);
6198                        Some(AppEvent::Crash(msg))
6199                    }
6200                }
6201            }
6202            #[cfg(feature = "cloud")]
6203            AppEvent::DoDownloadS3ToTemp(s3_url, options) => {
6204                match Self::download_s3_to_temp(s3_url, &self.app_config.cloud, options) {
6205                    Ok(temp_path) => {
6206                        self.http_temp_path = Some(temp_path.clone());
6207                        if let LoadingState::Loading {
6208                            file_path,
6209                            file_size,
6210                            ..
6211                        } = &self.loading_state
6212                        {
6213                            self.loading_state = LoadingState::Loading {
6214                                file_path: file_path.clone(),
6215                                file_size: *file_size,
6216                                current_phase: "Scanning".to_string(),
6217                                progress_percent: 30,
6218                            };
6219                        }
6220                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6221                    }
6222                    Err(e) => {
6223                        self.loading_state = LoadingState::Idle;
6224                        self.busy = false;
6225                        self.drain_keys_on_next_loop = true;
6226                        let msg = crate::error_display::user_message_from_report(&e, None);
6227                        Some(AppEvent::Crash(msg))
6228                    }
6229                }
6230            }
6231            #[cfg(feature = "cloud")]
6232            AppEvent::DoDownloadGcsToTemp(gs_url, options) => {
6233                match Self::download_gcs_to_temp(gs_url, options) {
6234                    Ok(temp_path) => {
6235                        self.http_temp_path = Some(temp_path.clone());
6236                        if let LoadingState::Loading {
6237                            file_path,
6238                            file_size,
6239                            ..
6240                        } = &self.loading_state
6241                        {
6242                            self.loading_state = LoadingState::Loading {
6243                                file_path: file_path.clone(),
6244                                file_size: *file_size,
6245                                current_phase: "Scanning".to_string(),
6246                                progress_percent: 30,
6247                            };
6248                        }
6249                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6250                    }
6251                    Err(e) => {
6252                        self.loading_state = LoadingState::Idle;
6253                        self.busy = false;
6254                        self.drain_keys_on_next_loop = true;
6255                        let msg = crate::error_display::user_message_from_report(&e, None);
6256                        Some(AppEvent::Crash(msg))
6257                    }
6258                }
6259            }
6260            #[cfg(any(feature = "http", feature = "cloud"))]
6261            AppEvent::DoLoadFromHttpTemp(temp_path, options) => {
6262                self.http_temp_path = Some(temp_path.clone());
6263                let display_path = match &self.loading_state {
6264                    LoadingState::Loading { file_path, .. } => file_path.clone(),
6265                    _ => None,
6266                };
6267                if let LoadingState::Loading {
6268                    file_path,
6269                    file_size,
6270                    ..
6271                } = &self.loading_state
6272                {
6273                    self.loading_state = LoadingState::Loading {
6274                        file_path: file_path.clone(),
6275                        file_size: *file_size,
6276                        current_phase: "Scanning".to_string(),
6277                        progress_percent: 30,
6278                    };
6279                }
6280                #[allow(clippy::cloned_ref_to_slice_refs)]
6281                match self.build_lazyframe_from_paths(&[temp_path.clone()], options) {
6282                    Ok(lf) => {
6283                        if let LoadingState::Loading {
6284                            file_path,
6285                            file_size,
6286                            ..
6287                        } = &self.loading_state
6288                        {
6289                            self.loading_state = LoadingState::Loading {
6290                                file_path: file_path.clone(),
6291                                file_size: *file_size,
6292                                current_phase: "Caching schema".to_string(),
6293                                progress_percent: 40,
6294                            };
6295                        }
6296                        Some(AppEvent::DoLoadSchema(
6297                            Box::new(lf),
6298                            display_path,
6299                            options.clone(),
6300                        ))
6301                    }
6302                    Err(e) => {
6303                        self.loading_state = LoadingState::Idle;
6304                        self.busy = false;
6305                        self.drain_keys_on_next_loop = true;
6306                        let msg = crate::error_display::user_message_from_report(
6307                            &e,
6308                            Some(temp_path.as_path()),
6309                        );
6310                        Some(AppEvent::Crash(msg))
6311                    }
6312                }
6313            }
6314            AppEvent::DoLoadSchema(lf, path, options) => {
6315                // Set "Caching schema" and return so the UI draws this phase before we block in DoLoadSchemaBlocking
6316                if let LoadingState::Loading {
6317                    file_path,
6318                    file_size,
6319                    ..
6320                } = &self.loading_state
6321                {
6322                    self.loading_state = LoadingState::Loading {
6323                        file_path: file_path.clone(),
6324                        file_size: *file_size,
6325                        current_phase: "Caching schema".to_string(),
6326                        progress_percent: 40,
6327                    };
6328                }
6329                Some(AppEvent::DoLoadSchemaBlocking(
6330                    lf.clone(),
6331                    path.clone(),
6332                    options.clone(),
6333                ))
6334            }
6335            AppEvent::DoLoadSchemaBlocking(lf, path, options) => {
6336                self.debug.schema_load = None;
6337                // Fast path for hive directory: infer schema from one parquet file instead of collect_schema() over all files.
6338                if options.single_spine_schema
6339                    && path.as_ref().is_some_and(|p| p.is_dir() && options.hive)
6340                {
6341                    let p = path.as_ref().expect("path set by caller");
6342                    if let Ok((merged_schema, partition_columns)) =
6343                        DataTableState::schema_from_one_hive_parquet(p)
6344                    {
6345                        if let Ok(lf_owned) =
6346                            DataTableState::scan_parquet_hive_with_schema(p, merged_schema.clone())
6347                        {
6348                            match DataTableState::from_schema_and_lazyframe(
6349                                merged_schema,
6350                                lf_owned,
6351                                options,
6352                                Some(partition_columns),
6353                            ) {
6354                                Ok(state) => {
6355                                    self.debug.schema_load = Some("one-file (local)".to_string());
6356                                    self.parquet_metadata_cache = None;
6357                                    self.export_df = None;
6358                                    self.data_table_state = Some(state);
6359                                    self.path = path.clone();
6360                                    if let Some(ref path_p) = path {
6361                                        self.original_file_format = path_p
6362                                            .extension()
6363                                            .and_then(|e| e.to_str())
6364                                            .and_then(|ext| {
6365                                                if ext.eq_ignore_ascii_case("parquet") {
6366                                                    Some(ExportFormat::Parquet)
6367                                                } else if ext.eq_ignore_ascii_case("csv") {
6368                                                    Some(ExportFormat::Csv)
6369                                                } else if ext.eq_ignore_ascii_case("json") {
6370                                                    Some(ExportFormat::Json)
6371                                                } else if ext.eq_ignore_ascii_case("jsonl")
6372                                                    || ext.eq_ignore_ascii_case("ndjson")
6373                                                {
6374                                                    Some(ExportFormat::Ndjson)
6375                                                } else if ext.eq_ignore_ascii_case("arrow")
6376                                                    || ext.eq_ignore_ascii_case("ipc")
6377                                                    || ext.eq_ignore_ascii_case("feather")
6378                                                {
6379                                                    Some(ExportFormat::Ipc)
6380                                                } else if ext.eq_ignore_ascii_case("avro") {
6381                                                    Some(ExportFormat::Avro)
6382                                                } else {
6383                                                    None
6384                                                }
6385                                            });
6386                                        self.original_file_delimiter =
6387                                            Some(options.delimiter.unwrap_or(b','));
6388                                    } else {
6389                                        self.original_file_format = None;
6390                                        self.original_file_delimiter = None;
6391                                    }
6392                                    self.sort_filter_modal = SortFilterModal::new();
6393                                    self.pivot_melt_modal = PivotMeltModal::new();
6394                                    if let LoadingState::Loading {
6395                                        file_path,
6396                                        file_size,
6397                                        ..
6398                                    } = &self.loading_state
6399                                    {
6400                                        self.loading_state = LoadingState::Loading {
6401                                            file_path: file_path.clone(),
6402                                            file_size: *file_size,
6403                                            current_phase: "Loading buffer".to_string(),
6404                                            progress_percent: 70,
6405                                        };
6406                                    }
6407                                    return Some(AppEvent::DoLoadBuffer);
6408                                }
6409                                Err(e) => {
6410                                    self.loading_state = LoadingState::Idle;
6411                                    self.busy = false;
6412                                    self.drain_keys_on_next_loop = true;
6413                                    let msg =
6414                                        crate::error_display::user_message_from_report(&e, None);
6415                                    return Some(AppEvent::Crash(msg));
6416                                }
6417                            }
6418                        }
6419                    }
6420                }
6421
6422                #[cfg(feature = "cloud")]
6423                {
6424                    // Use fast path for directory/glob cloud URLs (same as build_lazyframe_from_paths).
6425                    // Don't require --hive: path shape already implies hive scan.
6426                    if options.single_spine_schema
6427                        && path.as_ref().is_some_and(|p| {
6428                            let s = p.as_os_str().to_string_lossy();
6429                            let is_cloud = s.starts_with("s3://") || s.starts_with("gs://");
6430                            let looks_like_hive = s.ends_with('/') || s.contains('*');
6431                            is_cloud && (options.hive || looks_like_hive)
6432                        })
6433                    {
6434                        self.debug.schema_load = Some("trying one-file (cloud)".to_string());
6435                        let src = source::input_source(path.as_ref().expect("path set by caller"));
6436                        let try_cloud = match &src {
6437                            source::InputSource::S3(url) => {
6438                                let full = format!("s3://{url}");
6439                                let (path_part, _) = source::url_path_extension(&full);
6440                                let key = path_part
6441                                    .split_once('/')
6442                                    .map(|(_, k)| k.trim_end_matches('/'))
6443                                    .unwrap_or("");
6444                                let cloud_opts =
6445                                    Self::build_s3_cloud_options(&self.app_config.cloud, options);
6446                                Self::build_s3_object_store(&full, &self.app_config.cloud, options)
6447                                    .ok()
6448                                    .and_then(|store| {
6449                                        let rt = tokio::runtime::Runtime::new().ok()?;
6450                                        let (merged_schema, partition_columns) = rt
6451                                            .block_on(cloud_hive::schema_from_one_cloud_hive(
6452                                                store, key,
6453                                            ))
6454                                            .ok()?;
6455                                        let pl_path = PlPathRef::new(&full).into_owned();
6456                                        let args = ScanArgsParquet {
6457                                            schema: Some(merged_schema.clone()),
6458                                            cloud_options: Some(cloud_opts),
6459                                            hive_options: polars::io::HiveOptions::new_enabled(),
6460                                            glob: true,
6461                                            ..Default::default()
6462                                        };
6463                                        let mut lf_owned =
6464                                            LazyFrame::scan_parquet(pl_path, args).ok()?;
6465                                        if !partition_columns.is_empty() {
6466                                            let exprs: Vec<_> = partition_columns
6467                                                .iter()
6468                                                .map(|s| col(s.as_str()))
6469                                                .chain(
6470                                                    merged_schema
6471                                                        .iter_names()
6472                                                        .map(|s| s.to_string())
6473                                                        .filter(|c| !partition_columns.contains(c))
6474                                                        .map(|s| col(s.as_str())),
6475                                                )
6476                                                .collect();
6477                                            lf_owned = lf_owned.select(exprs);
6478                                        }
6479                                        DataTableState::from_schema_and_lazyframe(
6480                                            merged_schema,
6481                                            lf_owned,
6482                                            options,
6483                                            Some(partition_columns),
6484                                        )
6485                                        .ok()
6486                                    })
6487                            }
6488                            source::InputSource::Gcs(url) => {
6489                                let full = format!("gs://{url}");
6490                                let (path_part, _) = source::url_path_extension(&full);
6491                                let key = path_part
6492                                    .split_once('/')
6493                                    .map(|(_, k)| k.trim_end_matches('/'))
6494                                    .unwrap_or("");
6495                                Self::build_gcs_object_store(&full).ok().and_then(|store| {
6496                                    let rt = tokio::runtime::Runtime::new().ok()?;
6497                                    let (merged_schema, partition_columns) = rt
6498                                        .block_on(cloud_hive::schema_from_one_cloud_hive(
6499                                            store, key,
6500                                        ))
6501                                        .ok()?;
6502                                    let pl_path = PlPathRef::new(&full).into_owned();
6503                                    let args = ScanArgsParquet {
6504                                        schema: Some(merged_schema.clone()),
6505                                        cloud_options: Some(CloudOptions::default()),
6506                                        hive_options: polars::io::HiveOptions::new_enabled(),
6507                                        glob: true,
6508                                        ..Default::default()
6509                                    };
6510                                    let mut lf_owned =
6511                                        LazyFrame::scan_parquet(pl_path, args).ok()?;
6512                                    if !partition_columns.is_empty() {
6513                                        let exprs: Vec<_> = partition_columns
6514                                            .iter()
6515                                            .map(|s| col(s.as_str()))
6516                                            .chain(
6517                                                merged_schema
6518                                                    .iter_names()
6519                                                    .map(|s| s.to_string())
6520                                                    .filter(|c| !partition_columns.contains(c))
6521                                                    .map(|s| col(s.as_str())),
6522                                            )
6523                                            .collect();
6524                                        lf_owned = lf_owned.select(exprs);
6525                                    }
6526                                    DataTableState::from_schema_and_lazyframe(
6527                                        merged_schema,
6528                                        lf_owned,
6529                                        options,
6530                                        Some(partition_columns),
6531                                    )
6532                                    .ok()
6533                                })
6534                            }
6535                            _ => None,
6536                        };
6537                        if let Some(state) = try_cloud {
6538                            self.debug.schema_load = Some("one-file (cloud)".to_string());
6539                            self.parquet_metadata_cache = None;
6540                            self.export_df = None;
6541                            self.data_table_state = Some(state);
6542                            self.path = path.clone();
6543                            if let Some(ref path_p) = path {
6544                                self.original_file_format =
6545                                    path_p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6546                                        if ext.eq_ignore_ascii_case("parquet") {
6547                                            Some(ExportFormat::Parquet)
6548                                        } else if ext.eq_ignore_ascii_case("csv") {
6549                                            Some(ExportFormat::Csv)
6550                                        } else if ext.eq_ignore_ascii_case("json") {
6551                                            Some(ExportFormat::Json)
6552                                        } else if ext.eq_ignore_ascii_case("jsonl")
6553                                            || ext.eq_ignore_ascii_case("ndjson")
6554                                        {
6555                                            Some(ExportFormat::Ndjson)
6556                                        } else if ext.eq_ignore_ascii_case("arrow")
6557                                            || ext.eq_ignore_ascii_case("ipc")
6558                                            || ext.eq_ignore_ascii_case("feather")
6559                                        {
6560                                            Some(ExportFormat::Ipc)
6561                                        } else if ext.eq_ignore_ascii_case("avro") {
6562                                            Some(ExportFormat::Avro)
6563                                        } else {
6564                                            None
6565                                        }
6566                                    });
6567                                self.original_file_delimiter =
6568                                    Some(options.delimiter.unwrap_or(b','));
6569                            } else {
6570                                self.original_file_format = None;
6571                                self.original_file_delimiter = None;
6572                            }
6573                            self.sort_filter_modal = SortFilterModal::new();
6574                            self.pivot_melt_modal = PivotMeltModal::new();
6575                            if let LoadingState::Loading {
6576                                file_path,
6577                                file_size,
6578                                ..
6579                            } = &self.loading_state
6580                            {
6581                                self.loading_state = LoadingState::Loading {
6582                                    file_path: file_path.clone(),
6583                                    file_size: *file_size,
6584                                    current_phase: "Loading buffer".to_string(),
6585                                    progress_percent: 70,
6586                                };
6587                            }
6588                            return Some(AppEvent::DoLoadBuffer);
6589                        } else {
6590                            self.debug.schema_load = Some("fallback (cloud)".to_string());
6591                        }
6592                    }
6593                }
6594
6595                if self.debug.schema_load.is_none() {
6596                    self.debug.schema_load = Some("full scan".to_string());
6597                }
6598                let mut lf_owned = (**lf).clone();
6599                match lf_owned.collect_schema() {
6600                    Ok(schema) => {
6601                        let partition_columns = if path.as_ref().is_some_and(|p| {
6602                            options.hive
6603                                && (p.is_dir() || p.as_os_str().to_string_lossy().contains('*'))
6604                        }) {
6605                            let discovered = DataTableState::discover_hive_partition_columns(
6606                                path.as_ref().expect("path set by caller"),
6607                            );
6608                            discovered
6609                                .into_iter()
6610                                .filter(|c| schema.contains(c.as_str()))
6611                                .collect::<Vec<_>>()
6612                        } else {
6613                            Vec::new()
6614                        };
6615                        if !partition_columns.is_empty() {
6616                            let exprs: Vec<_> = partition_columns
6617                                .iter()
6618                                .map(|s| col(s.as_str()))
6619                                .chain(
6620                                    schema
6621                                        .iter_names()
6622                                        .map(|s| s.to_string())
6623                                        .filter(|c| !partition_columns.contains(c))
6624                                        .map(|s| col(s.as_str())),
6625                                )
6626                                .collect();
6627                            lf_owned = lf_owned.select(exprs);
6628                        }
6629                        let part_cols_opt = if partition_columns.is_empty() {
6630                            None
6631                        } else {
6632                            Some(partition_columns)
6633                        };
6634                        match DataTableState::from_schema_and_lazyframe(
6635                            schema,
6636                            lf_owned,
6637                            options,
6638                            part_cols_opt,
6639                        ) {
6640                            Ok(state) => {
6641                                self.parquet_metadata_cache = None;
6642                                self.export_df = None;
6643                                self.data_table_state = Some(state);
6644                                self.path = path.clone();
6645                                if let Some(ref p) = path {
6646                                    self.original_file_format =
6647                                        p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6648                                            if ext.eq_ignore_ascii_case("parquet") {
6649                                                Some(ExportFormat::Parquet)
6650                                            } else if ext.eq_ignore_ascii_case("csv") {
6651                                                Some(ExportFormat::Csv)
6652                                            } else if ext.eq_ignore_ascii_case("json") {
6653                                                Some(ExportFormat::Json)
6654                                            } else if ext.eq_ignore_ascii_case("jsonl")
6655                                                || ext.eq_ignore_ascii_case("ndjson")
6656                                            {
6657                                                Some(ExportFormat::Ndjson)
6658                                            } else if ext.eq_ignore_ascii_case("arrow")
6659                                                || ext.eq_ignore_ascii_case("ipc")
6660                                                || ext.eq_ignore_ascii_case("feather")
6661                                            {
6662                                                Some(ExportFormat::Ipc)
6663                                            } else if ext.eq_ignore_ascii_case("avro") {
6664                                                Some(ExportFormat::Avro)
6665                                            } else {
6666                                                None
6667                                            }
6668                                        });
6669                                    self.original_file_delimiter =
6670                                        Some(options.delimiter.unwrap_or(b','));
6671                                } else {
6672                                    self.original_file_format = None;
6673                                    self.original_file_delimiter = None;
6674                                }
6675                                self.sort_filter_modal = SortFilterModal::new();
6676                                self.pivot_melt_modal = PivotMeltModal::new();
6677                                if let LoadingState::Loading {
6678                                    file_path,
6679                                    file_size,
6680                                    ..
6681                                } = &self.loading_state
6682                                {
6683                                    self.loading_state = LoadingState::Loading {
6684                                        file_path: file_path.clone(),
6685                                        file_size: *file_size,
6686                                        current_phase: "Loading buffer".to_string(),
6687                                        progress_percent: 70,
6688                                    };
6689                                }
6690                                Some(AppEvent::DoLoadBuffer)
6691                            }
6692                            Err(e) => {
6693                                self.loading_state = LoadingState::Idle;
6694                                self.busy = false;
6695                                self.drain_keys_on_next_loop = true;
6696                                let msg = crate::error_display::user_message_from_report(&e, None);
6697                                Some(AppEvent::Crash(msg))
6698                            }
6699                        }
6700                    }
6701                    Err(e) => {
6702                        self.loading_state = LoadingState::Idle;
6703                        self.busy = false;
6704                        self.drain_keys_on_next_loop = true;
6705                        let report = color_eyre::eyre::Report::from(e);
6706                        let msg = crate::error_display::user_message_from_report(&report, None);
6707                        Some(AppEvent::Crash(msg))
6708                    }
6709                }
6710            }
6711            AppEvent::DoLoadBuffer => {
6712                if let Some(state) = &mut self.data_table_state {
6713                    state.collect();
6714                    if let Some(e) = state.error.take() {
6715                        self.loading_state = LoadingState::Idle;
6716                        self.busy = false;
6717                        self.drain_keys_on_next_loop = true;
6718                        let msg = crate::error_display::user_message_from_polars(&e);
6719                        return Some(AppEvent::Crash(msg));
6720                    }
6721                }
6722                self.loading_state = LoadingState::Idle;
6723                self.busy = false;
6724                self.drain_keys_on_next_loop = true;
6725                Some(AppEvent::Collect)
6726            }
6727            AppEvent::DoLoad(paths, options) => {
6728                let first = &paths[0];
6729                // Check if file is compressed (only single-file compressed CSV supported for now)
6730                let compression = options
6731                    .compression
6732                    .or_else(|| CompressionFormat::from_extension(first));
6733                let is_csv = first
6734                    .file_stem()
6735                    .and_then(|stem| stem.to_str())
6736                    .map(|stem| {
6737                        stem.ends_with(".csv")
6738                            || first
6739                                .extension()
6740                                .and_then(|e| e.to_str())
6741                                .map(|e| e.eq_ignore_ascii_case("csv"))
6742                                .unwrap_or(false)
6743                    })
6744                    .unwrap_or(false);
6745                let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
6746
6747                if is_compressed_csv {
6748                    // Set "Decompressing" phase and return event to trigger render
6749                    if let LoadingState::Loading {
6750                        file_path,
6751                        file_size,
6752                        ..
6753                    } = &self.loading_state
6754                    {
6755                        self.loading_state = LoadingState::Loading {
6756                            file_path: file_path.clone(),
6757                            file_size: *file_size,
6758                            current_phase: "Decompressing".to_string(),
6759                            progress_percent: 30,
6760                        };
6761                    }
6762                    // Return DoDecompress to allow UI to render "Decompressing" before blocking
6763                    Some(AppEvent::DoDecompress(paths.clone(), options.clone()))
6764                } else {
6765                    // For non-compressed files, proceed with normal loading
6766                    match self.load(paths, options) {
6767                        Ok(_) => {
6768                            self.busy = false;
6769                            self.drain_keys_on_next_loop = true;
6770                            Some(AppEvent::Collect)
6771                        }
6772                        Err(e) => {
6773                            self.loading_state = LoadingState::Idle;
6774                            self.busy = false;
6775                            self.drain_keys_on_next_loop = true;
6776                            let msg = crate::error_display::user_message_from_report(
6777                                &e,
6778                                paths.first().map(|p| p.as_path()),
6779                            );
6780                            Some(AppEvent::Crash(msg))
6781                        }
6782                    }
6783                }
6784            }
6785            AppEvent::DoDecompress(paths, options) => {
6786                // Actually perform decompression now (after UI has rendered "Decompressing")
6787                match self.load(paths, options) {
6788                    Ok(_) => Some(AppEvent::DoLoadBuffer),
6789                    Err(e) => {
6790                        self.loading_state = LoadingState::Idle;
6791                        self.busy = false;
6792                        self.drain_keys_on_next_loop = true;
6793                        let msg = crate::error_display::user_message_from_report(
6794                            &e,
6795                            paths.first().map(|p| p.as_path()),
6796                        );
6797                        Some(AppEvent::Crash(msg))
6798                    }
6799                }
6800            }
6801            AppEvent::Resize(_cols, rows) => {
6802                self.busy = true;
6803                if let Some(state) = &mut self.data_table_state {
6804                    state.visible_rows = *rows as usize;
6805                    state.collect();
6806                }
6807                self.busy = false;
6808                self.drain_keys_on_next_loop = true;
6809                None
6810            }
6811            AppEvent::Collect => {
6812                self.busy = true;
6813                if let Some(ref mut state) = self.data_table_state {
6814                    state.collect();
6815                }
6816                self.busy = false;
6817                self.drain_keys_on_next_loop = true;
6818                None
6819            }
6820            AppEvent::DoScrollDown => {
6821                if let Some(state) = &mut self.data_table_state {
6822                    state.page_down();
6823                }
6824                self.busy = false;
6825                self.drain_keys_on_next_loop = true;
6826                None
6827            }
6828            AppEvent::DoScrollUp => {
6829                if let Some(state) = &mut self.data_table_state {
6830                    state.page_up();
6831                }
6832                self.busy = false;
6833                self.drain_keys_on_next_loop = true;
6834                None
6835            }
6836            AppEvent::DoScrollNext => {
6837                if let Some(state) = &mut self.data_table_state {
6838                    state.select_next();
6839                }
6840                self.busy = false;
6841                self.drain_keys_on_next_loop = true;
6842                None
6843            }
6844            AppEvent::DoScrollPrev => {
6845                if let Some(state) = &mut self.data_table_state {
6846                    state.select_previous();
6847                }
6848                self.busy = false;
6849                self.drain_keys_on_next_loop = true;
6850                None
6851            }
6852            AppEvent::DoScrollEnd => {
6853                if let Some(state) = &mut self.data_table_state {
6854                    state.scroll_to_end();
6855                }
6856                self.busy = false;
6857                self.drain_keys_on_next_loop = true;
6858                None
6859            }
6860            AppEvent::DoScrollHalfDown => {
6861                if let Some(state) = &mut self.data_table_state {
6862                    state.half_page_down();
6863                }
6864                self.busy = false;
6865                self.drain_keys_on_next_loop = true;
6866                None
6867            }
6868            AppEvent::DoScrollHalfUp => {
6869                if let Some(state) = &mut self.data_table_state {
6870                    state.half_page_up();
6871                }
6872                self.busy = false;
6873                self.drain_keys_on_next_loop = true;
6874                None
6875            }
6876            AppEvent::GoToLine(n) => {
6877                if let Some(state) = &mut self.data_table_state {
6878                    state.scroll_to_row_centered(*n);
6879                }
6880                self.busy = false;
6881                self.drain_keys_on_next_loop = true;
6882                None
6883            }
6884            AppEvent::AnalysisChunk => {
6885                let lf = match &self.data_table_state {
6886                    Some(state) => state.lf.clone(),
6887                    None => {
6888                        self.analysis_computation = None;
6889                        self.analysis_modal.computing = None;
6890                        self.busy = false;
6891                        return None;
6892                    }
6893                };
6894                let comp = self.analysis_computation.take()?;
6895                if comp.df.is_none() {
6896                    // First chunk: get row count then run describe (lazy aggregation, no full collect)
6897                    // Reuse cached row count from control bar when valid to avoid extra full scan.
6898                    let total_rows = match self
6899                        .data_table_state
6900                        .as_ref()
6901                        .and_then(|s| s.num_rows_if_valid())
6902                    {
6903                        Some(n) => n,
6904                        None => match crate::statistics::collect_lazy(
6905                            lf.clone().select([len()]),
6906                            self.app_config.performance.polars_streaming,
6907                        ) {
6908                            Ok(count_df) => {
6909                                if let Some(col) = count_df.get(0) {
6910                                    match col.first() {
6911                                        Some(AnyValue::UInt32(n)) => *n as usize,
6912                                        _ => 0,
6913                                    }
6914                                } else {
6915                                    0
6916                                }
6917                            }
6918                            Err(_e) => {
6919                                self.analysis_modal.computing = None;
6920                                self.busy = false;
6921                                self.drain_keys_on_next_loop = true;
6922                                return None;
6923                            }
6924                        },
6925                    };
6926                    match crate::statistics::compute_describe_from_lazy(
6927                        &lf,
6928                        total_rows,
6929                        self.sampling_threshold,
6930                        comp.sample_seed,
6931                        self.app_config.performance.polars_streaming,
6932                    ) {
6933                        Ok(results) => {
6934                            self.analysis_modal.describe_results = Some(results);
6935                            self.analysis_modal.computing = None;
6936                            self.busy = false;
6937                            self.drain_keys_on_next_loop = true;
6938                            None
6939                        }
6940                        Err(_e) => {
6941                            self.analysis_modal.computing = None;
6942                            self.busy = false;
6943                            self.drain_keys_on_next_loop = true;
6944                            None
6945                        }
6946                    }
6947                } else {
6948                    None
6949                }
6950            }
6951            AppEvent::AnalysisDistributionCompute => {
6952                if let Some(state) = &self.data_table_state {
6953                    let options = crate::statistics::ComputeOptions {
6954                        include_distribution_info: true,
6955                        include_distribution_analyses: true,
6956                        include_correlation_matrix: false,
6957                        include_skewness_kurtosis_outliers: true,
6958                        polars_streaming: self.app_config.performance.polars_streaming,
6959                    };
6960                    if let Ok(results) = crate::statistics::compute_statistics_with_options(
6961                        &state.lf,
6962                        self.sampling_threshold,
6963                        self.analysis_modal.random_seed,
6964                        options,
6965                    ) {
6966                        self.analysis_modal.distribution_results = Some(results);
6967                    }
6968                }
6969                self.analysis_modal.computing = None;
6970                self.busy = false;
6971                self.drain_keys_on_next_loop = true;
6972                None
6973            }
6974            AppEvent::AnalysisCorrelationCompute => {
6975                if let Some(state) = &self.data_table_state {
6976                    if let Ok(df) =
6977                        crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
6978                    {
6979                        if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
6980                            self.analysis_modal.correlation_results =
6981                                Some(crate::statistics::AnalysisResults {
6982                                    column_statistics: vec![],
6983                                    total_rows: df.height(),
6984                                    sample_size: None,
6985                                    sample_seed: self.analysis_modal.random_seed,
6986                                    correlation_matrix: Some(matrix),
6987                                    distribution_analyses: vec![],
6988                                });
6989                        }
6990                    }
6991                }
6992                self.analysis_modal.computing = None;
6993                self.busy = false;
6994                self.drain_keys_on_next_loop = true;
6995                None
6996            }
6997            AppEvent::Search(query) => {
6998                let query_succeeded = if let Some(state) = &mut self.data_table_state {
6999                    state.query(query.clone());
7000                    state.error.is_none()
7001                } else {
7002                    false
7003                };
7004
7005                // Only close input mode if query succeeded (no error after execution)
7006                if query_succeeded {
7007                    // History was already saved in TextInputEvent::Submit handler
7008                    self.input_mode = InputMode::Normal;
7009                    self.query_input.set_focused(false);
7010                    // Re-enable error display in main view when closing query input
7011                    if let Some(state) = &mut self.data_table_state {
7012                        state.suppress_error_display = false;
7013                    }
7014                }
7015                // If there's an error, keep input mode open so user can fix the query
7016                // suppress_error_display remains true to keep main view clean
7017                None
7018            }
7019            AppEvent::SqlSearch(sql) => {
7020                let sql_succeeded = if let Some(state) = &mut self.data_table_state {
7021                    state.sql_query(sql.clone());
7022                    state.error.is_none()
7023                } else {
7024                    false
7025                };
7026                if sql_succeeded {
7027                    self.input_mode = InputMode::Normal;
7028                    self.sql_input.set_focused(false);
7029                    if let Some(state) = &mut self.data_table_state {
7030                        state.suppress_error_display = false;
7031                    }
7032                    Some(AppEvent::Collect)
7033                } else {
7034                    None
7035                }
7036            }
7037            AppEvent::FuzzySearch(query) => {
7038                let fuzzy_succeeded = if let Some(state) = &mut self.data_table_state {
7039                    state.fuzzy_search(query.clone());
7040                    state.error.is_none()
7041                } else {
7042                    false
7043                };
7044                if fuzzy_succeeded {
7045                    self.input_mode = InputMode::Normal;
7046                    self.fuzzy_input.set_focused(false);
7047                    if let Some(state) = &mut self.data_table_state {
7048                        state.suppress_error_display = false;
7049                    }
7050                    Some(AppEvent::Collect)
7051                } else {
7052                    None
7053                }
7054            }
7055            AppEvent::Filter(statements) => {
7056                if let Some(state) = &mut self.data_table_state {
7057                    state.filter(statements.clone());
7058                }
7059                None
7060            }
7061            AppEvent::Sort(columns, ascending) => {
7062                if let Some(state) = &mut self.data_table_state {
7063                    state.sort(columns.clone(), *ascending);
7064                }
7065                None
7066            }
7067            AppEvent::Reset => {
7068                if let Some(state) = &mut self.data_table_state {
7069                    state.reset();
7070                }
7071                // Clear active template when resetting
7072                self.active_template_id = None;
7073                None
7074            }
7075            AppEvent::ColumnOrder(order, locked_count) => {
7076                if let Some(state) = &mut self.data_table_state {
7077                    state.set_column_order(order.clone());
7078                    state.set_locked_columns(*locked_count);
7079                }
7080                None
7081            }
7082            AppEvent::Pivot(spec) => {
7083                self.busy = true;
7084                if let Some(state) = &mut self.data_table_state {
7085                    match state.pivot(spec) {
7086                        Ok(()) => {
7087                            self.pivot_melt_modal.close();
7088                            self.input_mode = InputMode::Normal;
7089                            Some(AppEvent::Collect)
7090                        }
7091                        Err(e) => {
7092                            self.busy = false;
7093                            self.error_modal
7094                                .show(crate::error_display::user_message_from_report(&e, None));
7095                            None
7096                        }
7097                    }
7098                } else {
7099                    self.busy = false;
7100                    None
7101                }
7102            }
7103            AppEvent::Melt(spec) => {
7104                self.busy = true;
7105                if let Some(state) = &mut self.data_table_state {
7106                    match state.melt(spec) {
7107                        Ok(()) => {
7108                            self.pivot_melt_modal.close();
7109                            self.input_mode = InputMode::Normal;
7110                            Some(AppEvent::Collect)
7111                        }
7112                        Err(e) => {
7113                            self.busy = false;
7114                            self.error_modal
7115                                .show(crate::error_display::user_message_from_report(&e, None));
7116                            None
7117                        }
7118                    }
7119                } else {
7120                    self.busy = false;
7121                    None
7122                }
7123            }
7124            AppEvent::ChartExport(path, format, title) => {
7125                self.busy = true;
7126                self.loading_state = LoadingState::Exporting {
7127                    file_path: path.clone(),
7128                    current_phase: "Exporting chart".to_string(),
7129                    progress_percent: 0,
7130                };
7131                Some(AppEvent::DoChartExport(
7132                    path.clone(),
7133                    *format,
7134                    title.clone(),
7135                ))
7136            }
7137            AppEvent::DoChartExport(path, format, title) => {
7138                let result = self.do_chart_export(path, *format, title);
7139                self.loading_state = LoadingState::Idle;
7140                self.busy = false;
7141                self.drain_keys_on_next_loop = true;
7142                match result {
7143                    Ok(()) => {
7144                        self.success_modal.show(format!(
7145                            "Chart exported successfully to\n{}",
7146                            path.display()
7147                        ));
7148                        self.chart_export_modal.close();
7149                    }
7150                    Err(e) => {
7151                        self.error_modal
7152                            .show(crate::error_display::user_message_from_report(
7153                                &e,
7154                                Some(path),
7155                            ));
7156                        self.chart_export_modal.reopen_with_path(path, *format);
7157                    }
7158                }
7159                None
7160            }
7161            AppEvent::Export(path, format, options) => {
7162                if let Some(_state) = &self.data_table_state {
7163                    self.busy = true;
7164                    // Show progress immediately
7165                    self.loading_state = LoadingState::Exporting {
7166                        file_path: path.clone(),
7167                        current_phase: "Preparing export".to_string(),
7168                        progress_percent: 0,
7169                    };
7170                    // Return DoExport to allow UI to render progress before blocking
7171                    Some(AppEvent::DoExport(path.clone(), *format, options.clone()))
7172                } else {
7173                    None
7174                }
7175            }
7176            AppEvent::DoExport(path, format, options) => {
7177                if let Some(_state) = &self.data_table_state {
7178                    // Phase 1: show "Collecting data" so UI can redraw before blocking collect
7179                    self.loading_state = LoadingState::Exporting {
7180                        file_path: path.clone(),
7181                        current_phase: "Collecting data".to_string(),
7182                        progress_percent: 10,
7183                    };
7184                    Some(AppEvent::DoExportCollect(
7185                        path.clone(),
7186                        *format,
7187                        options.clone(),
7188                    ))
7189                } else {
7190                    self.busy = false;
7191                    None
7192                }
7193            }
7194            AppEvent::DoExportCollect(path, format, options) => {
7195                if let Some(state) = &self.data_table_state {
7196                    match crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7197                    {
7198                        Ok(df) => {
7199                            self.export_df = Some(df);
7200                            let has_compression = match format {
7201                                ExportFormat::Csv => options.csv_compression.is_some(),
7202                                ExportFormat::Json => options.json_compression.is_some(),
7203                                ExportFormat::Ndjson => options.ndjson_compression.is_some(),
7204                                ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
7205                                    false
7206                                }
7207                            };
7208                            let phase = if has_compression {
7209                                "Writing and compressing file"
7210                            } else {
7211                                "Writing file"
7212                            };
7213                            self.loading_state = LoadingState::Exporting {
7214                                file_path: path.clone(),
7215                                current_phase: phase.to_string(),
7216                                progress_percent: 50,
7217                            };
7218                            Some(AppEvent::DoExportWrite(
7219                                path.clone(),
7220                                *format,
7221                                options.clone(),
7222                            ))
7223                        }
7224                        Err(e) => {
7225                            self.loading_state = LoadingState::Idle;
7226                            self.busy = false;
7227                            self.drain_keys_on_next_loop = true;
7228                            self.error_modal.show(format!(
7229                                "Export failed: {}",
7230                                crate::error_display::user_message_from_polars(&e)
7231                            ));
7232                            None
7233                        }
7234                    }
7235                } else {
7236                    self.busy = false;
7237                    None
7238                }
7239            }
7240            AppEvent::DoExportWrite(path, format, options) => {
7241                let result = self
7242                    .export_df
7243                    .take()
7244                    .map(|mut df| Self::export_data_from_df(&mut df, path, *format, options));
7245                self.loading_state = LoadingState::Idle;
7246                self.busy = false;
7247                self.drain_keys_on_next_loop = true;
7248                match result {
7249                    Some(Ok(())) => {
7250                        self.success_modal
7251                            .show(format!("Data exported successfully to\n{}", path.display()));
7252                    }
7253                    Some(Err(e)) => {
7254                        let error_msg = Self::format_export_error(&e, path);
7255                        self.error_modal.show(error_msg);
7256                    }
7257                    None => {}
7258                }
7259                None
7260            }
7261            AppEvent::DoLoadParquetMetadata => {
7262                let path = self.path.clone();
7263                if let Some(p) = &path {
7264                    if let Some(meta) = read_parquet_metadata(p) {
7265                        self.parquet_metadata_cache = Some(meta);
7266                    }
7267                }
7268                self.busy = false;
7269                self.drain_keys_on_next_loop = true;
7270                None
7271            }
7272            _ => None,
7273        }
7274    }
7275
7276    /// Perform chart export to file. Exports what is currently visible (effective x + y).
7277    /// Title is optional; blank or whitespace means no chart title on export.
7278    fn do_chart_export(
7279        &self,
7280        path: &Path,
7281        format: ChartExportFormat,
7282        title: &str,
7283    ) -> color_eyre::Result<()> {
7284        let state = self
7285            .data_table_state
7286            .as_ref()
7287            .ok_or_else(|| color_eyre::eyre::eyre!("No data loaded"))?;
7288        let chart_title = title.trim();
7289        let chart_title = if chart_title.is_empty() {
7290            None
7291        } else {
7292            Some(chart_title.to_string())
7293        };
7294
7295        match self.chart_modal.chart_kind {
7296            ChartKind::XY => {
7297                let x_column = self
7298                    .chart_modal
7299                    .effective_x_column()
7300                    .ok_or_else(|| color_eyre::eyre::eyre!("No X axis column selected"))?;
7301                let y_columns = self.chart_modal.effective_y_columns();
7302                if y_columns.is_empty() {
7303                    return Err(color_eyre::eyre::eyre!("No Y axis columns selected"));
7304                }
7305
7306                let row_limit_opt = self.chart_modal.row_limit;
7307                let row_limit = self.chart_modal.effective_row_limit();
7308                let cache_matches = self.chart_cache.xy.as_ref().is_some_and(|c| {
7309                    c.x_column == *x_column
7310                        && c.y_columns == y_columns
7311                        && c.row_limit == row_limit_opt
7312                });
7313
7314                let (series_vec, x_axis_kind_export, from_cache) = if cache_matches {
7315                    if let Some(cache) = self.chart_cache.xy.as_ref() {
7316                        let pts = if self.chart_modal.log_scale {
7317                            cache.series_log.as_ref().cloned().unwrap_or_else(|| {
7318                                cache
7319                                    .series
7320                                    .iter()
7321                                    .map(|s| {
7322                                        s.iter().map(|&(x, y)| (x, y.max(0.0).ln_1p())).collect()
7323                                    })
7324                                    .collect()
7325                            })
7326                        } else {
7327                            cache.series.clone()
7328                        };
7329                        (pts, cache.x_axis_kind, true)
7330                    } else {
7331                        let r = chart_data::prepare_chart_data(
7332                            &state.lf,
7333                            &state.schema,
7334                            x_column,
7335                            &y_columns,
7336                            row_limit,
7337                        )?;
7338                        (r.series, r.x_axis_kind, false)
7339                    }
7340                } else {
7341                    let r = chart_data::prepare_chart_data(
7342                        &state.lf,
7343                        &state.schema,
7344                        x_column,
7345                        &y_columns,
7346                        row_limit,
7347                    )?;
7348                    (r.series, r.x_axis_kind, false)
7349                };
7350
7351                let log_scale = self.chart_modal.log_scale;
7352                let series: Vec<ChartExportSeries> = series_vec
7353                    .iter()
7354                    .zip(y_columns.iter())
7355                    .filter(|(points, _)| !points.is_empty())
7356                    .map(|(points, name)| {
7357                        let pts = if log_scale && !from_cache {
7358                            points
7359                                .iter()
7360                                .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
7361                                .collect()
7362                        } else {
7363                            points.clone()
7364                        };
7365                        ChartExportSeries {
7366                            name: name.clone(),
7367                            points: pts,
7368                        }
7369                    })
7370                    .collect();
7371
7372                if series.is_empty() {
7373                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7374                }
7375
7376                let mut all_x_min = f64::INFINITY;
7377                let mut all_x_max = f64::NEG_INFINITY;
7378                let mut all_y_min = f64::INFINITY;
7379                let mut all_y_max = f64::NEG_INFINITY;
7380                for s in &series {
7381                    for &(x, y) in &s.points {
7382                        all_x_min = all_x_min.min(x);
7383                        all_x_max = all_x_max.max(x);
7384                        all_y_min = all_y_min.min(y);
7385                        all_y_max = all_y_max.max(y);
7386                    }
7387                }
7388
7389                let chart_type = self.chart_modal.chart_type;
7390                let y_starts_at_zero = self.chart_modal.y_starts_at_zero;
7391                let y_min_bounds = if chart_type == ChartType::Bar {
7392                    0.0_f64.min(all_y_min)
7393                } else if y_starts_at_zero {
7394                    0.0
7395                } else {
7396                    all_y_min
7397                };
7398                let y_max_bounds = if all_y_max > y_min_bounds {
7399                    all_y_max
7400                } else {
7401                    y_min_bounds + 1.0
7402                };
7403                let x_min_bounds = if all_x_max > all_x_min {
7404                    all_x_min
7405                } else {
7406                    all_x_min - 0.5
7407                };
7408                let x_max_bounds = if all_x_max > all_x_min {
7409                    all_x_max
7410                } else {
7411                    all_x_min + 0.5
7412                };
7413
7414                let x_label = x_column.to_string();
7415                let y_label = y_columns.join(", ");
7416                let bounds = ChartExportBounds {
7417                    x_min: x_min_bounds,
7418                    x_max: x_max_bounds,
7419                    y_min: y_min_bounds,
7420                    y_max: y_max_bounds,
7421                    x_label: x_label.clone(),
7422                    y_label: y_label.clone(),
7423                    x_axis_kind: x_axis_kind_export,
7424                    log_scale: self.chart_modal.log_scale,
7425                    chart_title,
7426                };
7427
7428                match format {
7429                    ChartExportFormat::Png => write_chart_png(path, &series, chart_type, &bounds),
7430                    ChartExportFormat::Eps => write_chart_eps(path, &series, chart_type, &bounds),
7431                }
7432            }
7433            ChartKind::Histogram => {
7434                let column = self
7435                    .chart_modal
7436                    .effective_hist_column()
7437                    .ok_or_else(|| color_eyre::eyre::eyre!("No histogram column selected"))?;
7438                let row_limit = self.chart_modal.effective_row_limit();
7439                let data = if let Some(c) = self.chart_cache.histogram.as_ref().filter(|c| {
7440                    c.column == column
7441                        && c.bins == self.chart_modal.hist_bins
7442                        && c.row_limit == self.chart_modal.row_limit
7443                }) {
7444                    c.data.clone()
7445                } else {
7446                    chart_data::prepare_histogram_data(
7447                        &state.lf,
7448                        &column,
7449                        self.chart_modal.hist_bins,
7450                        row_limit,
7451                    )?
7452                };
7453                if data.bins.is_empty() {
7454                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7455                }
7456                let points: Vec<(f64, f64)> =
7457                    data.bins.iter().map(|b| (b.center, b.count)).collect();
7458                let series = vec![ChartExportSeries {
7459                    name: column.clone(),
7460                    points,
7461                }];
7462                let x_max = if data.x_max > data.x_min {
7463                    data.x_max
7464                } else {
7465                    data.x_min + 1.0
7466                };
7467                let y_max = if data.max_count > 0.0 {
7468                    data.max_count
7469                } else {
7470                    1.0
7471                };
7472                let bounds = ChartExportBounds {
7473                    x_min: data.x_min,
7474                    x_max,
7475                    y_min: 0.0,
7476                    y_max,
7477                    x_label: column.clone(),
7478                    y_label: "Count".to_string(),
7479                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7480                    log_scale: false,
7481                    chart_title,
7482                };
7483                match format {
7484                    ChartExportFormat::Png => {
7485                        write_chart_png(path, &series, ChartType::Bar, &bounds)
7486                    }
7487                    ChartExportFormat::Eps => {
7488                        write_chart_eps(path, &series, ChartType::Bar, &bounds)
7489                    }
7490                }
7491            }
7492            ChartKind::BoxPlot => {
7493                let column = self
7494                    .chart_modal
7495                    .effective_box_column()
7496                    .ok_or_else(|| color_eyre::eyre::eyre!("No box plot column selected"))?;
7497                let row_limit = self.chart_modal.effective_row_limit();
7498                let data = if let Some(c) = self
7499                    .chart_cache
7500                    .box_plot
7501                    .as_ref()
7502                    .filter(|c| c.column == column && c.row_limit == self.chart_modal.row_limit)
7503                {
7504                    c.data.clone()
7505                } else {
7506                    chart_data::prepare_box_plot_data(
7507                        &state.lf,
7508                        std::slice::from_ref(&column),
7509                        row_limit,
7510                    )?
7511                };
7512                if data.stats.is_empty() {
7513                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7514                }
7515                let bounds = BoxPlotExportBounds {
7516                    y_min: data.y_min,
7517                    y_max: data.y_max,
7518                    x_labels: vec![column.clone()],
7519                    x_label: "Columns".to_string(),
7520                    y_label: "Value".to_string(),
7521                    chart_title,
7522                };
7523                match format {
7524                    ChartExportFormat::Png => write_box_plot_png(path, &data, &bounds),
7525                    ChartExportFormat::Eps => write_box_plot_eps(path, &data, &bounds),
7526                }
7527            }
7528            ChartKind::Kde => {
7529                let column = self
7530                    .chart_modal
7531                    .effective_kde_column()
7532                    .ok_or_else(|| color_eyre::eyre::eyre!("No KDE column selected"))?;
7533                let row_limit = self.chart_modal.effective_row_limit();
7534                let data = if let Some(c) = self.chart_cache.kde.as_ref().filter(|c| {
7535                    c.column == column
7536                        && c.bandwidth_factor == self.chart_modal.kde_bandwidth_factor
7537                        && c.row_limit == self.chart_modal.row_limit
7538                }) {
7539                    c.data.clone()
7540                } else {
7541                    chart_data::prepare_kde_data(
7542                        &state.lf,
7543                        std::slice::from_ref(&column),
7544                        self.chart_modal.kde_bandwidth_factor,
7545                        row_limit,
7546                    )?
7547                };
7548                if data.series.is_empty() {
7549                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7550                }
7551                let series: Vec<ChartExportSeries> = data
7552                    .series
7553                    .iter()
7554                    .map(|s| ChartExportSeries {
7555                        name: s.name.clone(),
7556                        points: s.points.clone(),
7557                    })
7558                    .collect();
7559                let bounds = ChartExportBounds {
7560                    x_min: data.x_min,
7561                    x_max: data.x_max,
7562                    y_min: 0.0,
7563                    y_max: data.y_max,
7564                    x_label: column.clone(),
7565                    y_label: "Density".to_string(),
7566                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7567                    log_scale: false,
7568                    chart_title,
7569                };
7570                match format {
7571                    ChartExportFormat::Png => {
7572                        write_chart_png(path, &series, ChartType::Line, &bounds)
7573                    }
7574                    ChartExportFormat::Eps => {
7575                        write_chart_eps(path, &series, ChartType::Line, &bounds)
7576                    }
7577                }
7578            }
7579            ChartKind::Heatmap => {
7580                let x_column = self
7581                    .chart_modal
7582                    .effective_heatmap_x_column()
7583                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap X column selected"))?;
7584                let y_column = self
7585                    .chart_modal
7586                    .effective_heatmap_y_column()
7587                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap Y column selected"))?;
7588                let row_limit = self.chart_modal.effective_row_limit();
7589                let data = if let Some(c) = self.chart_cache.heatmap.as_ref().filter(|c| {
7590                    c.x_column == *x_column
7591                        && c.y_column == *y_column
7592                        && c.bins == self.chart_modal.heatmap_bins
7593                        && c.row_limit == self.chart_modal.row_limit
7594                }) {
7595                    c.data.clone()
7596                } else {
7597                    chart_data::prepare_heatmap_data(
7598                        &state.lf,
7599                        &x_column,
7600                        &y_column,
7601                        self.chart_modal.heatmap_bins,
7602                        row_limit,
7603                    )?
7604                };
7605                if data.counts.is_empty() || data.max_count <= 0.0 {
7606                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7607                }
7608                let bounds = ChartExportBounds {
7609                    x_min: data.x_min,
7610                    x_max: data.x_max,
7611                    y_min: data.y_min,
7612                    y_max: data.y_max,
7613                    x_label: x_column.clone(),
7614                    y_label: y_column.clone(),
7615                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7616                    log_scale: false,
7617                    chart_title,
7618                };
7619                match format {
7620                    ChartExportFormat::Png => write_heatmap_png(path, &data, &bounds),
7621                    ChartExportFormat::Eps => write_heatmap_eps(path, &data, &bounds),
7622                }
7623            }
7624        }
7625    }
7626
7627    fn apply_template(&mut self, template: &Template) -> Result<()> {
7628        // Save state before applying template so we can restore on failure
7629        let saved_state = self
7630            .data_table_state
7631            .as_ref()
7632            .map(|state| TemplateApplicationState {
7633                lf: state.lf.clone(),
7634                schema: state.schema.clone(),
7635                active_query: state.active_query.clone(),
7636                active_sql_query: state.get_active_sql_query().to_string(),
7637                active_fuzzy_query: state.get_active_fuzzy_query().to_string(),
7638                filters: state.get_filters().to_vec(),
7639                sort_columns: state.get_sort_columns().to_vec(),
7640                sort_ascending: state.get_sort_ascending(),
7641                column_order: state.get_column_order().to_vec(),
7642                locked_columns_count: state.locked_columns_count(),
7643            });
7644        let saved_active_template_id = self.active_template_id.clone();
7645
7646        if let Some(state) = &mut self.data_table_state {
7647            state.error = None;
7648
7649            // At most one of SQL or DSL query is stored per template; then fuzzy. Apply in that order.
7650            let sql_trimmed = template.settings.sql_query.as_deref().unwrap_or("").trim();
7651            let query_opt = template.settings.query.as_deref().filter(|s| !s.is_empty());
7652            let fuzzy_trimmed = template
7653                .settings
7654                .fuzzy_query
7655                .as_deref()
7656                .unwrap_or("")
7657                .trim();
7658
7659            if !sql_trimmed.is_empty() {
7660                state.sql_query(template.settings.sql_query.clone().unwrap_or_default());
7661            } else if let Some(q) = query_opt {
7662                state.query(q.to_string());
7663            }
7664            if let Some(error) = state.error.clone() {
7665                if let Some(saved) = saved_state {
7666                    self.restore_state(saved);
7667                }
7668                self.active_template_id = saved_active_template_id;
7669                return Err(color_eyre::eyre::eyre!(
7670                    "{}",
7671                    crate::error_display::user_message_from_polars(&error)
7672                ));
7673            }
7674
7675            if !fuzzy_trimmed.is_empty() {
7676                state.fuzzy_search(template.settings.fuzzy_query.clone().unwrap_or_default());
7677                if let Some(error) = state.error.clone() {
7678                    if let Some(saved) = saved_state {
7679                        self.restore_state(saved);
7680                    }
7681                    self.active_template_id = saved_active_template_id;
7682                    return Err(color_eyre::eyre::eyre!(
7683                        "{}",
7684                        crate::error_display::user_message_from_polars(&error)
7685                    ));
7686                }
7687            }
7688
7689            // Apply filters
7690            if !template.settings.filters.is_empty() {
7691                state.filter(template.settings.filters.clone());
7692                // Check for errors after filter
7693                let error_opt = state.error.clone();
7694                if let Some(error) = error_opt {
7695                    // End the if let block to drop the borrow
7696                    if let Some(saved) = saved_state {
7697                        self.restore_state(saved);
7698                    }
7699                    self.active_template_id = saved_active_template_id;
7700                    return Err(color_eyre::eyre::eyre!("{}", error));
7701                }
7702            }
7703
7704            // Apply sort
7705            if !template.settings.sort_columns.is_empty() {
7706                state.sort(
7707                    template.settings.sort_columns.clone(),
7708                    template.settings.sort_ascending,
7709                );
7710                // Check for errors after sort
7711                let error_opt = state.error.clone();
7712                if let Some(error) = error_opt {
7713                    // End the if let block to drop the borrow
7714                    if let Some(saved) = saved_state {
7715                        self.restore_state(saved);
7716                    }
7717                    self.active_template_id = saved_active_template_id;
7718                    return Err(color_eyre::eyre::eyre!("{}", error));
7719                }
7720            }
7721
7722            // Apply pivot or melt (reshape) if present. Order: query → filters → sort → reshape → column_order.
7723            if let Some(ref spec) = template.settings.pivot {
7724                if let Err(e) = state.pivot(spec) {
7725                    if let Some(saved) = saved_state {
7726                        self.restore_state(saved);
7727                    }
7728                    self.active_template_id = saved_active_template_id;
7729                    return Err(color_eyre::eyre::eyre!(
7730                        "{}",
7731                        crate::error_display::user_message_from_report(&e, None)
7732                    ));
7733                }
7734            } else if let Some(ref spec) = template.settings.melt {
7735                if let Err(e) = state.melt(spec) {
7736                    if let Some(saved) = saved_state {
7737                        self.restore_state(saved);
7738                    }
7739                    self.active_template_id = saved_active_template_id;
7740                    return Err(color_eyre::eyre::eyre!(
7741                        "{}",
7742                        crate::error_display::user_message_from_report(&e, None)
7743                    ));
7744                }
7745            }
7746
7747            // Apply column order and locks
7748            if !template.settings.column_order.is_empty() {
7749                state.set_column_order(template.settings.column_order.clone());
7750                // Check for errors after set_column_order
7751                let error_opt = state.error.clone();
7752                if let Some(error) = error_opt {
7753                    // End the if let block to drop the borrow
7754                    if let Some(saved) = saved_state {
7755                        self.restore_state(saved);
7756                    }
7757                    self.active_template_id = saved_active_template_id;
7758                    return Err(color_eyre::eyre::eyre!("{}", error));
7759                }
7760                state.set_locked_columns(template.settings.locked_columns_count);
7761                // Check for errors after set_locked_columns
7762                let error_opt = state.error.clone();
7763                if let Some(error) = error_opt {
7764                    // End the if let block to drop the borrow
7765                    if let Some(saved) = saved_state {
7766                        self.restore_state(saved);
7767                    }
7768                    self.active_template_id = saved_active_template_id;
7769                    return Err(color_eyre::eyre::eyre!("{}", error));
7770                }
7771            }
7772        }
7773
7774        // Update template usage statistics
7775        // Note: We need to clone and update the template, then save it
7776        // For now, we'll update the template manager's internal state
7777        // A more complete implementation would reload templates after saving
7778        if let Some(path) = &self.path {
7779            let mut updated_template = template.clone();
7780            updated_template.last_used = Some(std::time::SystemTime::now());
7781            updated_template.usage_count += 1;
7782            updated_template.last_matched_file = Some(path.clone());
7783
7784            // Save updated template
7785            let _ = self.template_manager.save_template(&updated_template);
7786        }
7787
7788        // Track active template
7789        self.active_template_id = Some(template.id.clone());
7790
7791        Ok(())
7792    }
7793
7794    /// Format export error messages to be more user-friendly using type-based handling.
7795    fn format_export_error(error: &color_eyre::eyre::Report, path: &Path) -> String {
7796        use std::io;
7797
7798        for cause in error.chain() {
7799            if let Some(io_err) = cause.downcast_ref::<io::Error>() {
7800                let msg = crate::error_display::user_message_from_io(io_err, None);
7801                return format!("Cannot write to {}: {}", path.display(), msg);
7802            }
7803            if let Some(pe) = cause.downcast_ref::<polars::prelude::PolarsError>() {
7804                let msg = crate::error_display::user_message_from_polars(pe);
7805                return format!("Export failed: {}", msg);
7806            }
7807        }
7808        let error_str = error.to_string();
7809        let first_line = error_str.lines().next().unwrap_or("Unknown error").trim();
7810        format!("Export failed: {}", first_line)
7811    }
7812
7813    /// Write an already-collected DataFrame to file. Used by two-phase export (DoExportWrite).
7814    fn export_data_from_df(
7815        df: &mut DataFrame,
7816        path: &Path,
7817        format: ExportFormat,
7818        options: &ExportOptions,
7819    ) -> Result<()> {
7820        use polars::prelude::*;
7821        use std::fs::File;
7822        use std::io::{BufWriter, Write};
7823
7824        match format {
7825            ExportFormat::Csv => {
7826                use polars::prelude::CsvWriter;
7827                if let Some(compression) = options.csv_compression {
7828                    // Write to compressed file
7829                    let file = File::create(path)?;
7830                    let writer: Box<dyn Write> = match compression {
7831                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7832                            file,
7833                            flate2::Compression::default(),
7834                        )),
7835                        CompressionFormat::Zstd => {
7836                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7837                        }
7838                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7839                            file,
7840                            bzip2::Compression::default(),
7841                        )),
7842                        CompressionFormat::Xz => {
7843                            Box::new(xz2::write::XzEncoder::new(
7844                                file, 6, // compression level
7845                            ))
7846                        }
7847                    };
7848                    CsvWriter::new(writer)
7849                        .with_separator(options.csv_delimiter)
7850                        .include_header(options.csv_include_header)
7851                        .finish(df)?;
7852                } else {
7853                    // Write uncompressed
7854                    let file = File::create(path)?;
7855                    CsvWriter::new(file)
7856                        .with_separator(options.csv_delimiter)
7857                        .include_header(options.csv_include_header)
7858                        .finish(df)?;
7859                }
7860            }
7861            ExportFormat::Parquet => {
7862                use polars::prelude::ParquetWriter;
7863                let file = File::create(path)?;
7864                let mut writer = BufWriter::new(file);
7865                ParquetWriter::new(&mut writer).finish(df)?;
7866            }
7867            ExportFormat::Json => {
7868                use polars::prelude::JsonWriter;
7869                if let Some(compression) = options.json_compression {
7870                    // Write to compressed file
7871                    let file = File::create(path)?;
7872                    let writer: Box<dyn Write> = match compression {
7873                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7874                            file,
7875                            flate2::Compression::default(),
7876                        )),
7877                        CompressionFormat::Zstd => {
7878                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7879                        }
7880                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7881                            file,
7882                            bzip2::Compression::default(),
7883                        )),
7884                        CompressionFormat::Xz => {
7885                            Box::new(xz2::write::XzEncoder::new(
7886                                file, 6, // compression level
7887                            ))
7888                        }
7889                    };
7890                    JsonWriter::new(writer)
7891                        .with_json_format(JsonFormat::Json)
7892                        .finish(df)?;
7893                } else {
7894                    // Write uncompressed
7895                    let file = File::create(path)?;
7896                    JsonWriter::new(file)
7897                        .with_json_format(JsonFormat::Json)
7898                        .finish(df)?;
7899                }
7900            }
7901            ExportFormat::Ndjson => {
7902                use polars::prelude::{JsonFormat, JsonWriter};
7903                if let Some(compression) = options.ndjson_compression {
7904                    // Write to compressed file
7905                    let file = File::create(path)?;
7906                    let writer: Box<dyn Write> = match compression {
7907                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7908                            file,
7909                            flate2::Compression::default(),
7910                        )),
7911                        CompressionFormat::Zstd => {
7912                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7913                        }
7914                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7915                            file,
7916                            bzip2::Compression::default(),
7917                        )),
7918                        CompressionFormat::Xz => {
7919                            Box::new(xz2::write::XzEncoder::new(
7920                                file, 6, // compression level
7921                            ))
7922                        }
7923                    };
7924                    JsonWriter::new(writer)
7925                        .with_json_format(JsonFormat::JsonLines)
7926                        .finish(df)?;
7927                } else {
7928                    // Write uncompressed
7929                    let file = File::create(path)?;
7930                    JsonWriter::new(file)
7931                        .with_json_format(JsonFormat::JsonLines)
7932                        .finish(df)?;
7933                }
7934            }
7935            ExportFormat::Ipc => {
7936                use polars::prelude::IpcWriter;
7937                let file = File::create(path)?;
7938                let mut writer = BufWriter::new(file);
7939                IpcWriter::new(&mut writer).finish(df)?;
7940            }
7941            ExportFormat::Avro => {
7942                use polars::io::avro::AvroWriter;
7943                let file = File::create(path)?;
7944                let mut writer = BufWriter::new(file);
7945                AvroWriter::new(&mut writer).finish(df)?;
7946            }
7947        }
7948
7949        Ok(())
7950    }
7951
7952    #[allow(dead_code)] // Used only when not using two-phase export; kept for tests/single-shot use
7953    fn export_data(
7954        state: &DataTableState,
7955        path: &Path,
7956        format: ExportFormat,
7957        options: &ExportOptions,
7958    ) -> Result<()> {
7959        let mut df = crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)?;
7960        Self::export_data_from_df(&mut df, path, format, options)
7961    }
7962
7963    fn restore_state(&mut self, saved: TemplateApplicationState) {
7964        if let Some(state) = &mut self.data_table_state {
7965            // Clone saved lf and schema so we can restore them after applying methods
7966            let saved_lf = saved.lf.clone();
7967            let saved_schema = saved.schema.clone();
7968
7969            // Restore lf and schema directly (these are public fields)
7970            // This preserves the exact LazyFrame state from before template application
7971            state.lf = saved.lf;
7972            state.schema = saved.schema;
7973            state.active_query = saved.active_query;
7974            state.active_sql_query = saved.active_sql_query;
7975            state.active_fuzzy_query = saved.active_fuzzy_query;
7976            // Clear error
7977            state.error = None;
7978            // Restore private fields using public methods
7979            // Note: These methods will modify lf by applying transformations, but since
7980            // we've already restored lf to the saved state, we need to restore it again after
7981            state.filter(saved.filters.clone());
7982            if state.error.is_none() {
7983                state.sort(saved.sort_columns.clone(), saved.sort_ascending);
7984            }
7985            if state.error.is_none() {
7986                state.set_column_order(saved.column_order.clone());
7987            }
7988            if state.error.is_none() {
7989                state.set_locked_columns(saved.locked_columns_count);
7990            }
7991            // Restore the exact saved lf and schema (in case filter/sort modified them)
7992            state.lf = saved_lf;
7993            state.schema = saved_schema;
7994            state.collect();
7995        }
7996    }
7997
7998    pub fn create_template_from_current_state(
7999        &mut self,
8000        name: String,
8001        description: Option<String>,
8002        match_criteria: template::MatchCriteria,
8003    ) -> Result<template::Template> {
8004        let settings = if let Some(state) = &self.data_table_state {
8005            let (query, sql_query, fuzzy_query) = active_query_settings(
8006                state.get_active_query(),
8007                state.get_active_sql_query(),
8008                state.get_active_fuzzy_query(),
8009            );
8010            template::TemplateSettings {
8011                query,
8012                sql_query,
8013                fuzzy_query,
8014                filters: state.get_filters().to_vec(),
8015                sort_columns: state.get_sort_columns().to_vec(),
8016                sort_ascending: state.get_sort_ascending(),
8017                column_order: state.get_column_order().to_vec(),
8018                locked_columns_count: state.locked_columns_count(),
8019                pivot: state.last_pivot_spec().cloned(),
8020                melt: state.last_melt_spec().cloned(),
8021            }
8022        } else {
8023            template::TemplateSettings {
8024                query: None,
8025                sql_query: None,
8026                fuzzy_query: None,
8027                filters: Vec::new(),
8028                sort_columns: Vec::new(),
8029                sort_ascending: true,
8030                column_order: Vec::new(),
8031                locked_columns_count: 0,
8032                pivot: None,
8033                melt: None,
8034            }
8035        };
8036
8037        self.template_manager
8038            .create_template(name, description, match_criteria, settings)
8039    }
8040
8041    fn get_help_info(&self) -> (String, String) {
8042        let (title, content) = match self.input_mode {
8043            InputMode::Normal => ("Main View Help", help_strings::main_view()),
8044            InputMode::Editing => match self.input_type {
8045                Some(InputType::Search) => ("Query Help", help_strings::query()),
8046                _ => ("Editing Help", help_strings::editing()),
8047            },
8048            InputMode::SortFilter => ("Sort & Filter Help", help_strings::sort_filter()),
8049            InputMode::PivotMelt => ("Pivot / Melt Help", help_strings::pivot_melt()),
8050            InputMode::Export => ("Export Help", help_strings::export()),
8051            InputMode::Info => ("Info Panel Help", help_strings::info_panel()),
8052            InputMode::Chart => ("Chart Help", help_strings::chart()),
8053        };
8054        (title.to_string(), content.to_string())
8055    }
8056}
8057
8058impl Widget for &mut App {
8059    fn render(self, area: Rect, buf: &mut Buffer) {
8060        self.debug.num_frames += 1;
8061        if self.debug.enabled {
8062            self.debug.show_help_at_render = self.show_help;
8063        }
8064
8065        // Clear entire area first so no ghost text from any widget (loading gauge label,
8066        // modals, controls, etc.) can persist when layout or visibility changes (e.g. after pivot).
8067        Clear.render(area, buf);
8068
8069        // Set background color for the entire application area
8070        let background_color = self.color("background");
8071        Block::default()
8072            .style(Style::default().bg(background_color))
8073            .render(area, buf);
8074
8075        let mut constraints = vec![Constraint::Fill(1)];
8076
8077        // Adjust layout if sorting to show panel on the right
8078        let mut has_error = false;
8079        let mut err_msg = String::new();
8080        if let Some(state) = &self.data_table_state {
8081            if let Some(e) = &state.error {
8082                has_error = true;
8083                err_msg = crate::error_display::user_message_from_polars(e);
8084            }
8085        }
8086
8087        if self.input_mode == InputMode::Editing {
8088            let height = if self.input_type == Some(InputType::Search) {
8089                if has_error {
8090                    9
8091                } else {
8092                    5
8093                }
8094            } else if has_error {
8095                6
8096            } else {
8097                3
8098            };
8099            constraints.insert(1, Constraint::Length(height));
8100        }
8101        constraints.push(Constraint::Length(1)); // Controls
8102        if self.debug.enabled {
8103            constraints.push(Constraint::Length(1));
8104        }
8105        let layout = Layout::default()
8106            .direction(Direction::Vertical)
8107            .constraints(constraints)
8108            .split(area);
8109
8110        let main_area = layout[0];
8111        // Clear entire main content so no ghost text from modals or previous layout persists (e.g. after pivot).
8112        Clear.render(main_area, buf);
8113        let mut data_area = main_area;
8114        let mut sort_area = Rect::default();
8115
8116        if self.sort_filter_modal.active {
8117            let chunks = Layout::default()
8118                .direction(Direction::Horizontal)
8119                .constraints([Constraint::Min(0), Constraint::Length(50)])
8120                .split(main_area);
8121            data_area = chunks[0];
8122            sort_area = chunks[1];
8123        }
8124        if self.template_modal.active {
8125            let chunks = Layout::default()
8126                .direction(Direction::Horizontal)
8127                .constraints([Constraint::Min(0), Constraint::Length(80)]) // Wider for 30 char descriptions
8128                .split(main_area);
8129            data_area = chunks[0];
8130            sort_area = chunks[1]; // Reuse sort_area for template modal
8131        }
8132        if self.pivot_melt_modal.active {
8133            let chunks = Layout::default()
8134                .direction(Direction::Horizontal)
8135                .constraints([Constraint::Min(0), Constraint::Length(50)])
8136                .split(main_area);
8137            data_area = chunks[0];
8138            sort_area = chunks[1];
8139        }
8140        if self.info_modal.active {
8141            let chunks = Layout::default()
8142                .direction(Direction::Horizontal)
8143                .constraints([Constraint::Min(0), Constraint::Max(72)])
8144                .split(main_area);
8145            data_area = chunks[0];
8146            sort_area = chunks[1];
8147        }
8148
8149        // Extract colors and table config before mutable borrow to avoid borrow checker issues
8150        let primary_color = self.color("keybind_hints");
8151        let _controls_bg_color = self.color("controls_bg");
8152        let table_header_color = self.color("table_header");
8153        let row_numbers_color = self.color("row_numbers");
8154        let column_separator_color = self.color("column_separator");
8155        let table_header_bg_color = self.color("table_header_bg");
8156        let modal_border_color = self.color("modal_border");
8157        let info_active_color = self.color("modal_border_active");
8158        let info_primary_color = self.color("text_primary");
8159        let table_cell_padding = self.table_cell_padding;
8160        let alternate_row_bg = self.theme.get_optional("alternate_row_color");
8161        let column_colors = self.column_colors;
8162        let (str_col, int_col, float_col, bool_col, temporal_col) = if column_colors {
8163            (
8164                self.theme.get("str_col"),
8165                self.theme.get("int_col"),
8166                self.theme.get("float_col"),
8167                self.theme.get("bool_col"),
8168                self.theme.get("temporal_col"),
8169            )
8170        } else {
8171            (
8172                Color::Reset,
8173                Color::Reset,
8174                Color::Reset,
8175                Color::Reset,
8176                Color::Reset,
8177            )
8178        };
8179
8180        // Parquet metadata is loaded via DoLoadParquetMetadata when info panel is opened (not in render)
8181
8182        match &mut self.data_table_state {
8183            Some(state) => {
8184                // Render breadcrumb if drilled down
8185                let mut table_area = data_area;
8186                if state.is_drilled_down() {
8187                    if let Some(ref key_values) = state.drilled_down_group_key {
8188                        let breadcrumb_layout = Layout::default()
8189                            .direction(Direction::Vertical)
8190                            .constraints([Constraint::Length(3), Constraint::Fill(1)])
8191                            .split(data_area);
8192
8193                        // Render breadcrumb with better styling
8194                        let empty_vec = Vec::new();
8195                        let key_columns = state
8196                            .drilled_down_group_key_columns
8197                            .as_ref()
8198                            .unwrap_or(&empty_vec);
8199                        let breadcrumb_parts: Vec<String> = key_columns
8200                            .iter()
8201                            .zip(key_values.iter())
8202                            .map(|(col, val)| format!("{}={}", col, val))
8203                            .collect();
8204                        let breadcrumb_text = format!(
8205                            "← Group: {} (Press Esc to go back)",
8206                            breadcrumb_parts.join(" | ")
8207                        );
8208
8209                        Block::default()
8210                            .borders(Borders::ALL)
8211                            .border_type(BorderType::Rounded)
8212                            .border_style(Style::default().fg(primary_color))
8213                            .title("Breadcrumb")
8214                            .render(breadcrumb_layout[0], buf);
8215
8216                        let inner = Block::default().inner(breadcrumb_layout[0]);
8217                        Paragraph::new(breadcrumb_text)
8218                            .style(
8219                                Style::default()
8220                                    .fg(primary_color)
8221                                    .add_modifier(Modifier::BOLD),
8222                            )
8223                            .wrap(ratatui::widgets::Wrap { trim: true })
8224                            .render(inner, buf);
8225
8226                        table_area = breadcrumb_layout[1];
8227                    }
8228                }
8229
8230                Clear.render(table_area, buf);
8231                let mut dt = DataTable::new()
8232                    .with_colors(
8233                        table_header_bg_color,
8234                        table_header_color,
8235                        row_numbers_color,
8236                        column_separator_color,
8237                    )
8238                    .with_cell_padding(table_cell_padding)
8239                    .with_alternate_row_bg(alternate_row_bg);
8240                if column_colors {
8241                    dt = dt.with_column_type_colors(
8242                        str_col,
8243                        int_col,
8244                        float_col,
8245                        bool_col,
8246                        temporal_col,
8247                    );
8248                }
8249                dt.render(table_area, buf, state);
8250                if self.info_modal.active {
8251                    let ctx = InfoContext {
8252                        path: self.path.as_deref(),
8253                        format: self.original_file_format,
8254                        parquet_metadata: self.parquet_metadata_cache.as_ref(),
8255                    };
8256                    let mut info_widget = DataTableInfo::new(
8257                        state,
8258                        ctx,
8259                        &mut self.info_modal,
8260                        modal_border_color,
8261                        info_active_color,
8262                        info_primary_color,
8263                    );
8264                    info_widget.render(sort_area, buf);
8265                }
8266            }
8267            None => {
8268                Paragraph::new("No data loaded").render(layout[0], buf);
8269            }
8270        }
8271
8272        let mut controls_area = layout[1];
8273        let debug_area_index = layout.len() - 1;
8274
8275        if self.input_mode == InputMode::Editing {
8276            let input_area = layout[1];
8277            controls_area = layout[layout.len() - 1];
8278
8279            let title = match self.input_type {
8280                Some(InputType::Search) => "Query",
8281                Some(InputType::Filter) => "Filter",
8282                Some(InputType::GoToLine) => "Go to line",
8283                None => "Input",
8284            };
8285
8286            let mut border_style = Style::default();
8287            if has_error {
8288                border_style = Style::default().fg(self.color("error"));
8289            }
8290
8291            if self.debug.enabled {
8292                controls_area = layout[layout.len() - 2];
8293            }
8294
8295            let block = Block::default()
8296                .borders(Borders::ALL)
8297                .border_type(BorderType::Rounded)
8298                .title(title)
8299                .border_style(border_style);
8300            let inner_area = block.inner(input_area);
8301            block.render(input_area, buf);
8302
8303            if self.input_type == Some(InputType::Search) {
8304                let border_c = self.color("modal_border");
8305                let active_c = self.color("modal_border_active");
8306                let tab_bar_focused = self.query_focus == QueryFocus::TabBar;
8307
8308                let chunks = Layout::default()
8309                    .direction(Direction::Vertical)
8310                    .constraints([Constraint::Length(2), Constraint::Min(1)])
8311                    .split(inner_area);
8312
8313                let tab_line_chunks = Layout::default()
8314                    .direction(Direction::Vertical)
8315                    .constraints([Constraint::Length(1), Constraint::Length(1)])
8316                    .split(chunks[0]);
8317                let tab_row_chunks = Layout::default()
8318                    .direction(Direction::Horizontal)
8319                    .constraints([Constraint::Min(0), Constraint::Max(40)])
8320                    .split(tab_line_chunks[0]);
8321                let tab_titles = vec!["SQL-Like", "Fuzzy", "SQL"];
8322                let tabs = Tabs::new(tab_titles)
8323                    .style(Style::default().fg(border_c))
8324                    .highlight_style(
8325                        Style::default()
8326                            .fg(active_c)
8327                            .add_modifier(Modifier::REVERSED),
8328                    )
8329                    .select(self.query_tab.index());
8330                tabs.render(tab_row_chunks[0], buf);
8331                let desc_text = match self.query_tab {
8332                    QueryTab::SqlLike => "select [cols] [by ...] [where ...]",
8333                    QueryTab::Fuzzy => "Search text to find matching rows",
8334                    QueryTab::Sql => {
8335                        #[cfg(feature = "sql")]
8336                        {
8337                            "Table: df"
8338                        }
8339                        #[cfg(not(feature = "sql"))]
8340                        {
8341                            ""
8342                        }
8343                    }
8344                };
8345                if !desc_text.is_empty() {
8346                    Paragraph::new(desc_text)
8347                        .style(Style::default().fg(self.color("text_secondary")))
8348                        .alignment(Alignment::Right)
8349                        .render(tab_row_chunks[1], buf);
8350                }
8351                let line_style = if tab_bar_focused {
8352                    Style::default().fg(active_c)
8353                } else {
8354                    Style::default().fg(border_c)
8355                };
8356                Block::default()
8357                    .borders(Borders::BOTTOM)
8358                    .border_type(BorderType::Rounded)
8359                    .border_style(line_style)
8360                    .render(tab_line_chunks[1], buf);
8361
8362                match self.query_tab {
8363                    QueryTab::SqlLike => {
8364                        if has_error {
8365                            let body_chunks = Layout::default()
8366                                .direction(Direction::Vertical)
8367                                .constraints([Constraint::Length(1), Constraint::Min(1)])
8368                                .split(chunks[1]);
8369                            self.query_input
8370                                .set_focused(self.query_focus == QueryFocus::Input);
8371                            (&self.query_input).render(body_chunks[0], buf);
8372                            Paragraph::new(err_msg)
8373                                .style(Style::default().fg(self.color("error")))
8374                                .wrap(ratatui::widgets::Wrap { trim: true })
8375                                .render(body_chunks[1], buf);
8376                        } else {
8377                            self.query_input
8378                                .set_focused(self.query_focus == QueryFocus::Input);
8379                            (&self.query_input).render(chunks[1], buf);
8380                        }
8381                    }
8382                    QueryTab::Fuzzy => {
8383                        self.query_input.set_focused(false);
8384                        self.sql_input.set_focused(false);
8385                        self.fuzzy_input
8386                            .set_focused(self.query_focus == QueryFocus::Input);
8387                        (&self.fuzzy_input).render(chunks[1], buf);
8388                    }
8389                    QueryTab::Sql => {
8390                        self.query_input.set_focused(false);
8391                        #[cfg(feature = "sql")]
8392                        {
8393                            if has_error {
8394                                let body_chunks = Layout::default()
8395                                    .direction(Direction::Vertical)
8396                                    .constraints([Constraint::Length(1), Constraint::Min(1)])
8397                                    .split(chunks[1]);
8398                                self.sql_input
8399                                    .set_focused(self.query_focus == QueryFocus::Input);
8400                                (&self.sql_input).render(body_chunks[0], buf);
8401                                Paragraph::new(err_msg)
8402                                    .style(Style::default().fg(self.color("error")))
8403                                    .wrap(ratatui::widgets::Wrap { trim: true })
8404                                    .render(body_chunks[1], buf);
8405                            } else {
8406                                self.sql_input
8407                                    .set_focused(self.query_focus == QueryFocus::Input);
8408                                (&self.sql_input).render(chunks[1], buf);
8409                            }
8410                        }
8411                        #[cfg(not(feature = "sql"))]
8412                        {
8413                            self.sql_input.set_focused(false);
8414                            Paragraph::new(
8415                                "SQL support not compiled in (build with --features sql)",
8416                            )
8417                            .style(Style::default().fg(self.color("text_secondary")))
8418                            .render(chunks[1], buf);
8419                        }
8420                    }
8421                }
8422            } else if has_error {
8423                let chunks = Layout::default()
8424                    .direction(Direction::Vertical)
8425                    .constraints([
8426                        Constraint::Length(1),
8427                        Constraint::Length(1),
8428                        Constraint::Min(1),
8429                    ])
8430                    .split(inner_area);
8431
8432                (&self.query_input).render(chunks[0], buf);
8433                Paragraph::new(err_msg)
8434                    .style(Style::default().fg(self.color("error")))
8435                    .wrap(ratatui::widgets::Wrap { trim: true })
8436                    .render(chunks[2], buf);
8437            } else {
8438                (&self.query_input).render(inner_area, buf);
8439            }
8440        }
8441
8442        if self.sort_filter_modal.active {
8443            Clear.render(sort_area, buf);
8444            let block = Block::default()
8445                .borders(Borders::ALL)
8446                .border_type(BorderType::Rounded)
8447                .title("Sort & Filter");
8448            let inner_area = block.inner(sort_area);
8449            block.render(sort_area, buf);
8450
8451            let chunks = Layout::default()
8452                .direction(Direction::Vertical)
8453                .constraints([
8454                    Constraint::Length(2), // Tab bar + line
8455                    Constraint::Min(0),    // Body
8456                    Constraint::Length(3), // Footer
8457                ])
8458                .split(inner_area);
8459
8460            // Tab bar + line
8461            let tab_line_chunks = Layout::default()
8462                .direction(Direction::Vertical)
8463                .constraints([Constraint::Length(1), Constraint::Length(1)])
8464                .split(chunks[0]);
8465            let tab_selected = match self.sort_filter_modal.active_tab {
8466                SortFilterTab::Sort => 0,
8467                SortFilterTab::Filter => 1,
8468            };
8469            let border_c = self.color("modal_border");
8470            let active_c = self.color("modal_border_active");
8471            let tabs = Tabs::new(vec!["Sort", "Filter"])
8472                .style(Style::default().fg(border_c))
8473                .highlight_style(
8474                    Style::default()
8475                        .fg(active_c)
8476                        .add_modifier(Modifier::REVERSED),
8477                )
8478                .select(tab_selected);
8479            tabs.render(tab_line_chunks[0], buf);
8480            let line_style = if self.sort_filter_modal.focus == SortFilterFocus::TabBar {
8481                Style::default().fg(active_c)
8482            } else {
8483                Style::default().fg(border_c)
8484            };
8485            Block::default()
8486                .borders(Borders::BOTTOM)
8487                .border_type(BorderType::Rounded)
8488                .border_style(line_style)
8489                .render(tab_line_chunks[1], buf);
8490
8491            if self.sort_filter_modal.active_tab == SortFilterTab::Filter {
8492                let fchunks = Layout::default()
8493                    .direction(Direction::Vertical)
8494                    .constraints([
8495                        Constraint::Length(3),
8496                        Constraint::Length(3),
8497                        Constraint::Min(0),
8498                    ])
8499                    .split(chunks[1]);
8500
8501                let row_layout = Layout::default()
8502                    .direction(Direction::Horizontal)
8503                    .constraints([
8504                        Constraint::Percentage(30),
8505                        Constraint::Percentage(20),
8506                        Constraint::Percentage(30),
8507                        Constraint::Percentage(20),
8508                    ])
8509                    .split(fchunks[0]);
8510
8511                let col_name = if self.sort_filter_modal.filter.available_columns.is_empty() {
8512                    ""
8513                } else {
8514                    &self.sort_filter_modal.filter.available_columns
8515                        [self.sort_filter_modal.filter.new_column_idx]
8516                };
8517                let col_style = if self.sort_filter_modal.filter.focus == FilterFocus::Column {
8518                    Style::default().fg(active_c)
8519                } else {
8520                    Style::default().fg(border_c)
8521                };
8522                Paragraph::new(col_name)
8523                    .block(
8524                        Block::default()
8525                            .borders(Borders::ALL)
8526                            .border_type(BorderType::Rounded)
8527                            .title("Col")
8528                            .border_style(col_style),
8529                    )
8530                    .render(row_layout[0], buf);
8531
8532                let op_name = FilterOperator::iterator()
8533                    .nth(self.sort_filter_modal.filter.new_operator_idx)
8534                    .unwrap_or(FilterOperator::Eq)
8535                    .as_str();
8536                let op_style = if self.sort_filter_modal.filter.focus == FilterFocus::Operator {
8537                    Style::default().fg(active_c)
8538                } else {
8539                    Style::default().fg(border_c)
8540                };
8541                Paragraph::new(op_name)
8542                    .block(
8543                        Block::default()
8544                            .borders(Borders::ALL)
8545                            .border_type(BorderType::Rounded)
8546                            .title("Op")
8547                            .border_style(op_style),
8548                    )
8549                    .render(row_layout[1], buf);
8550
8551                let val_style = if self.sort_filter_modal.filter.focus == FilterFocus::Value {
8552                    Style::default().fg(active_c)
8553                } else {
8554                    Style::default().fg(border_c)
8555                };
8556                Paragraph::new(self.sort_filter_modal.filter.new_value.as_str())
8557                    .block(
8558                        Block::default()
8559                            .borders(Borders::ALL)
8560                            .border_type(BorderType::Rounded)
8561                            .title("Val")
8562                            .border_style(val_style),
8563                    )
8564                    .render(row_layout[2], buf);
8565
8566                let log_name = LogicalOperator::iterator()
8567                    .nth(self.sort_filter_modal.filter.new_logical_idx)
8568                    .unwrap_or(LogicalOperator::And)
8569                    .as_str();
8570                let log_style = if self.sort_filter_modal.filter.focus == FilterFocus::Logical {
8571                    Style::default().fg(active_c)
8572                } else {
8573                    Style::default().fg(border_c)
8574                };
8575                Paragraph::new(log_name)
8576                    .block(
8577                        Block::default()
8578                            .borders(Borders::ALL)
8579                            .border_type(BorderType::Rounded)
8580                            .title("Logic")
8581                            .border_style(log_style),
8582                    )
8583                    .render(row_layout[3], buf);
8584
8585                let add_style = if self.sort_filter_modal.filter.focus == FilterFocus::Add {
8586                    Style::default().fg(active_c)
8587                } else {
8588                    Style::default().fg(border_c)
8589                };
8590                Paragraph::new("Add Filter")
8591                    .block(
8592                        Block::default()
8593                            .borders(Borders::ALL)
8594                            .border_type(BorderType::Rounded)
8595                            .border_style(add_style),
8596                    )
8597                    .centered()
8598                    .render(fchunks[1], buf);
8599
8600                let items: Vec<ListItem> = self
8601                    .sort_filter_modal
8602                    .filter
8603                    .statements
8604                    .iter()
8605                    .enumerate()
8606                    .map(|(i, s)| {
8607                        let prefix = if i > 0 {
8608                            format!("{} ", s.logical_op.as_str())
8609                        } else {
8610                            "".to_string()
8611                        };
8612                        ListItem::new(format!(
8613                            "{}{}{}{}",
8614                            prefix,
8615                            s.column,
8616                            s.operator.as_str(),
8617                            s.value
8618                        ))
8619                    })
8620                    .collect();
8621                let list_style = if self.sort_filter_modal.filter.focus == FilterFocus::Statements {
8622                    Style::default().fg(active_c)
8623                } else {
8624                    Style::default().fg(border_c)
8625                };
8626                let list = List::new(items)
8627                    .block(
8628                        Block::default()
8629                            .borders(Borders::ALL)
8630                            .border_type(BorderType::Rounded)
8631                            .title("Current Filters")
8632                            .border_style(list_style),
8633                    )
8634                    .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
8635                StatefulWidget::render(
8636                    list,
8637                    fchunks[2],
8638                    buf,
8639                    &mut self.sort_filter_modal.filter.list_state,
8640                );
8641            } else {
8642                // Sort tab body
8643                let schunks = Layout::default()
8644                    .direction(Direction::Vertical)
8645                    .constraints([
8646                        Constraint::Length(3),
8647                        Constraint::Min(0),
8648                        Constraint::Length(2),
8649                        Constraint::Length(3),
8650                    ])
8651                    .split(chunks[1]);
8652
8653                let filter_block_title = "Filter Columns";
8654                let mut filter_block_border_style = Style::default().fg(border_c);
8655                if self.sort_filter_modal.sort.focus == SortFocus::Filter {
8656                    filter_block_border_style = filter_block_border_style.fg(active_c);
8657                }
8658                let filter_block = Block::default()
8659                    .borders(Borders::ALL)
8660                    .border_type(BorderType::Rounded)
8661                    .title(filter_block_title)
8662                    .border_style(filter_block_border_style);
8663                let filter_inner_area = filter_block.inner(schunks[0]);
8664                filter_block.render(schunks[0], buf);
8665
8666                // Render filter input using TextInput widget
8667                let is_focused = self.sort_filter_modal.sort.focus == SortFocus::Filter;
8668                self.sort_filter_modal
8669                    .sort
8670                    .filter_input
8671                    .set_focused(is_focused);
8672                (&self.sort_filter_modal.sort.filter_input).render(filter_inner_area, buf);
8673
8674                let filtered = self.sort_filter_modal.sort.filtered_columns();
8675                let rows: Vec<Row> = filtered
8676                    .iter()
8677                    .map(|(_, col)| {
8678                        let lock_cell = if col.is_locked {
8679                            "●" // Full circle for locked
8680                        } else if col.is_to_be_locked {
8681                            "◐" // Half circle to indicate pending lock
8682                        } else {
8683                            " "
8684                        };
8685                        let lock_style = if col.is_locked {
8686                            Style::default()
8687                        } else if col.is_to_be_locked {
8688                            Style::default().fg(self.color("dimmed")) // Dimmed style for pending lock
8689                        } else {
8690                            Style::default()
8691                        };
8692                        let order_cell = if col.is_visible && col.display_order < 9999 {
8693                            format!("{:2}", col.display_order + 1)
8694                        } else {
8695                            "  ".to_string()
8696                        };
8697                        let sort_cell = if let Some(order) = col.sort_order {
8698                            format!("{:2}", order)
8699                        } else {
8700                            "  ".to_string()
8701                        };
8702                        let name_cell = Cell::from(col.name.clone());
8703
8704                        // Apply dimmed style to hidden columns
8705                        let row_style = if col.is_visible {
8706                            Style::default()
8707                        } else {
8708                            Style::default().fg(self.color("dimmed"))
8709                        };
8710
8711                        Row::new(vec![
8712                            Cell::from(lock_cell).style(lock_style),
8713                            Cell::from(order_cell).style(row_style),
8714                            Cell::from(sort_cell).style(row_style),
8715                            name_cell.style(row_style),
8716                        ])
8717                    })
8718                    .collect();
8719
8720                let header = Row::new(vec![
8721                    Cell::from("🔒").style(Style::default()),
8722                    Cell::from("Order").style(Style::default()),
8723                    Cell::from("Sort").style(Style::default()),
8724                    Cell::from("Name").style(Style::default()),
8725                ])
8726                .style(Style::default().add_modifier(Modifier::UNDERLINED));
8727
8728                let table_border_style =
8729                    if self.sort_filter_modal.sort.focus == SortFocus::ColumnList {
8730                        Style::default().fg(active_c)
8731                    } else {
8732                        Style::default().fg(border_c)
8733                    };
8734                let table = Table::new(
8735                    rows,
8736                    [
8737                        Constraint::Length(2),
8738                        Constraint::Length(6),
8739                        Constraint::Length(6),
8740                        Constraint::Min(0),
8741                    ],
8742                )
8743                .header(header)
8744                .block(
8745                    Block::default()
8746                        .borders(Borders::ALL)
8747                        .border_type(BorderType::Rounded)
8748                        .title("Columns")
8749                        .border_style(table_border_style),
8750                )
8751                .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
8752
8753                StatefulWidget::render(
8754                    table,
8755                    schunks[1],
8756                    buf,
8757                    &mut self.sort_filter_modal.sort.table_state,
8758                );
8759
8760                // Keybind Hints
8761                use ratatui::text::{Line, Span};
8762                let mut hint_line1 = Line::default();
8763                hint_line1.spans.push(Span::raw("Sort:    "));
8764                hint_line1.spans.push(Span::styled(
8765                    "Space",
8766                    Style::default()
8767                        .fg(self.color("keybind_hints"))
8768                        .add_modifier(Modifier::BOLD),
8769                ));
8770                hint_line1.spans.push(Span::raw(" Toggle "));
8771                hint_line1.spans.push(Span::styled(
8772                    "[]",
8773                    Style::default()
8774                        .fg(self.color("keybind_hints"))
8775                        .add_modifier(Modifier::BOLD),
8776                ));
8777                hint_line1.spans.push(Span::raw(" Reorder "));
8778                hint_line1.spans.push(Span::styled(
8779                    "1-9",
8780                    Style::default()
8781                        .fg(self.color("keybind_hints"))
8782                        .add_modifier(Modifier::BOLD),
8783                ));
8784                hint_line1.spans.push(Span::raw(" Jump"));
8785
8786                let mut hint_line2 = Line::default();
8787                hint_line2.spans.push(Span::raw("Display: "));
8788                hint_line2.spans.push(Span::styled(
8789                    "L",
8790                    Style::default()
8791                        .fg(self.color("keybind_hints"))
8792                        .add_modifier(Modifier::BOLD),
8793                ));
8794                hint_line2.spans.push(Span::raw(" Lock "));
8795                hint_line2.spans.push(Span::styled(
8796                    "+-",
8797                    Style::default()
8798                        .fg(self.color("keybind_hints"))
8799                        .add_modifier(Modifier::BOLD),
8800                ));
8801                hint_line2.spans.push(Span::raw(" Reorder"));
8802
8803                Paragraph::new(vec![hint_line1, hint_line2]).render(schunks[2], buf);
8804
8805                let order_border_style = if self.sort_filter_modal.sort.focus == SortFocus::Order {
8806                    Style::default().fg(active_c)
8807                } else {
8808                    Style::default().fg(border_c)
8809                };
8810
8811                let order_block = Block::default()
8812                    .borders(Borders::ALL)
8813                    .border_type(BorderType::Rounded)
8814                    .title("Order")
8815                    .border_style(order_border_style);
8816                let order_inner = order_block.inner(schunks[3]);
8817                order_block.render(schunks[3], buf);
8818
8819                let order_layout = Layout::default()
8820                    .direction(Direction::Horizontal)
8821                    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
8822                    .split(order_inner);
8823
8824                // Ascending option
8825                let ascending_indicator = if self.sort_filter_modal.sort.ascending {
8826                    "●"
8827                } else {
8828                    "○"
8829                };
8830                let ascending_text = format!("{} Ascending", ascending_indicator);
8831                let ascending_style = if self.sort_filter_modal.sort.ascending {
8832                    Style::default().add_modifier(Modifier::BOLD)
8833                } else {
8834                    Style::default()
8835                };
8836                Paragraph::new(ascending_text)
8837                    .style(ascending_style)
8838                    .centered()
8839                    .render(order_layout[0], buf);
8840
8841                // Descending option
8842                let descending_indicator = if !self.sort_filter_modal.sort.ascending {
8843                    "●"
8844                } else {
8845                    "○"
8846                };
8847                let descending_text = format!("{} Descending", descending_indicator);
8848                let descending_style = if !self.sort_filter_modal.sort.ascending {
8849                    Style::default().add_modifier(Modifier::BOLD)
8850                } else {
8851                    Style::default()
8852                };
8853                Paragraph::new(descending_text)
8854                    .style(descending_style)
8855                    .centered()
8856                    .render(order_layout[1], buf);
8857            }
8858
8859            // Shared footer
8860            let footer_chunks = Layout::default()
8861                .direction(Direction::Horizontal)
8862                .constraints([
8863                    Constraint::Percentage(33),
8864                    Constraint::Percentage(33),
8865                    Constraint::Percentage(34),
8866                ])
8867                .split(chunks[2]);
8868
8869            let mut apply_text_style = Style::default();
8870            let mut apply_border_style = Style::default();
8871            if self.sort_filter_modal.focus == SortFilterFocus::Apply {
8872                apply_text_style = apply_text_style.fg(active_c);
8873                apply_border_style = apply_border_style.fg(active_c);
8874            } else {
8875                apply_text_style = apply_text_style.fg(border_c);
8876                apply_border_style = apply_border_style.fg(border_c);
8877            }
8878            if self.sort_filter_modal.active_tab == SortFilterTab::Sort
8879                && self.sort_filter_modal.sort.has_unapplied_changes
8880            {
8881                apply_text_style = apply_text_style.add_modifier(Modifier::BOLD);
8882            }
8883
8884            Paragraph::new("Apply")
8885                .style(apply_text_style)
8886                .block(
8887                    Block::default()
8888                        .borders(Borders::ALL)
8889                        .border_type(BorderType::Rounded)
8890                        .border_style(apply_border_style),
8891                )
8892                .centered()
8893                .render(footer_chunks[0], buf);
8894
8895            let cancel_style = if self.sort_filter_modal.focus == SortFilterFocus::Cancel {
8896                Style::default().fg(active_c)
8897            } else {
8898                Style::default().fg(border_c)
8899            };
8900            Paragraph::new("Cancel")
8901                .block(
8902                    Block::default()
8903                        .borders(Borders::ALL)
8904                        .border_type(BorderType::Rounded)
8905                        .border_style(cancel_style),
8906                )
8907                .centered()
8908                .render(footer_chunks[1], buf);
8909
8910            let clear_style = if self.sort_filter_modal.focus == SortFilterFocus::Clear {
8911                Style::default().fg(active_c)
8912            } else {
8913                Style::default().fg(border_c)
8914            };
8915            Paragraph::new("Clear")
8916                .block(
8917                    Block::default()
8918                        .borders(Borders::ALL)
8919                        .border_type(BorderType::Rounded)
8920                        .border_style(clear_style),
8921                )
8922                .centered()
8923                .render(footer_chunks[2], buf);
8924        }
8925
8926        if self.template_modal.active {
8927            Clear.render(sort_area, buf);
8928            let modal_title = match self.template_modal.mode {
8929                TemplateModalMode::List => "Templates",
8930                TemplateModalMode::Create => "Create Template",
8931                TemplateModalMode::Edit => "Edit Template",
8932            };
8933            let block = Block::default()
8934                .borders(Borders::ALL)
8935                .border_type(BorderType::Rounded)
8936                .title(modal_title);
8937            let inner_area = block.inner(sort_area);
8938            block.render(sort_area, buf);
8939
8940            match self.template_modal.mode {
8941                TemplateModalMode::List => {
8942                    // List Mode: Show templates as a table with relevance scores
8943                    let chunks = Layout::default()
8944                        .direction(Direction::Vertical)
8945                        .constraints([
8946                            Constraint::Min(0),    // Template table
8947                            Constraint::Length(1), // Hints
8948                        ])
8949                        .split(inner_area);
8950
8951                    // Template Table
8952                    // Find max score for normalization
8953                    let max_score = self
8954                        .template_modal
8955                        .templates
8956                        .iter()
8957                        .map(|(_, score)| *score)
8958                        .fold(0.0, f64::max);
8959
8960                    // Calculate column widths
8961                    // Score column: 2 chars, Active column: 1 char, Name column: 20 chars, Description: remaining
8962                    let score_col_width = 2;
8963                    let active_col_width = 1;
8964                    let name_col_width = 20;
8965
8966                    let rows: Vec<Row> = self
8967                        .template_modal
8968                        .templates
8969                        .iter()
8970                        .map(|(template, score)| {
8971                            // Check if this template is active
8972                            let is_active = self
8973                                .active_template_id
8974                                .as_ref()
8975                                .map(|id| id == &template.id)
8976                                .unwrap_or(false);
8977
8978                            // Visual score indicator (circle with fill) - color foreground only
8979                            let score_ratio = if max_score > 0.0 {
8980                                score / max_score
8981                            } else {
8982                                0.0
8983                            };
8984                            let (circle_char, circle_color) = if score_ratio >= 0.8 {
8985                                // High scores: green, filled circles
8986                                if score_ratio >= 0.95 {
8987                                    ('●', self.color("success"))
8988                                } else if score_ratio >= 0.9 {
8989                                    ('◉', self.color("success"))
8990                                } else {
8991                                    ('◐', self.color("success"))
8992                                }
8993                            } else if score_ratio >= 0.4 {
8994                                // Medium scores: yellow
8995                                if score_ratio >= 0.7 {
8996                                    ('◐', self.color("warning"))
8997                                } else if score_ratio >= 0.55 {
8998                                    ('◑', self.color("warning"))
8999                                } else {
9000                                    ('○', self.color("warning"))
9001                                }
9002                            } else {
9003                                // Low scores: uncolored
9004                                if score_ratio >= 0.2 {
9005                                    ('○', self.color("text_primary"))
9006                                } else {
9007                                    ('○', self.color("dimmed"))
9008                                }
9009                            };
9010
9011                            // Score cell with colored circle (foreground only)
9012                            let score_cell = Cell::from(circle_char.to_string())
9013                                .style(Style::default().fg(circle_color));
9014
9015                            // Active indicator cell (checkmark)
9016                            let active_cell = if is_active {
9017                                Cell::from("✓")
9018                            } else {
9019                                Cell::from(" ")
9020                            };
9021
9022                            // Name cell
9023                            let name_cell = Cell::from(template.name.clone());
9024
9025                            // Description cell - get first line and truncate if needed
9026                            // Note: actual truncation will be handled by the table widget based on available space
9027                            let desc = template.description.as_deref().unwrap_or("");
9028                            let first_line = desc.lines().next().unwrap_or("");
9029                            let desc_display = first_line.to_string();
9030                            let desc_cell = Cell::from(desc_display);
9031
9032                            // Create row with cells (no highlighting)
9033                            Row::new(vec![score_cell, active_cell, name_cell, desc_cell])
9034                        })
9035                        .collect();
9036
9037                    // Header row
9038                    let header = Row::new(vec![
9039                        Cell::from("●").style(Style::default()),
9040                        Cell::from(" ").style(Style::default()), // Active column header (empty)
9041                        Cell::from("Name").style(Style::default()),
9042                        Cell::from("Description").style(Style::default()),
9043                    ])
9044                    .style(Style::default().add_modifier(Modifier::UNDERLINED));
9045
9046                    let table_border_style =
9047                        if self.template_modal.focus == TemplateFocus::TemplateList {
9048                            Style::default().fg(self.color("modal_border_active"))
9049                        } else {
9050                            Style::default()
9051                        };
9052
9053                    let table = Table::new(
9054                        rows,
9055                        [
9056                            Constraint::Length(score_col_width),
9057                            Constraint::Length(active_col_width),
9058                            Constraint::Length(name_col_width),
9059                            Constraint::Min(0), // Description takes remaining space
9060                        ],
9061                    )
9062                    .header(header)
9063                    .block(
9064                        Block::default()
9065                            .borders(Borders::ALL)
9066                            .border_type(BorderType::Rounded)
9067                            .title("Templates")
9068                            .border_style(table_border_style),
9069                    )
9070                    .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
9071
9072                    StatefulWidget::render(
9073                        table,
9074                        chunks[0],
9075                        buf,
9076                        &mut self.template_modal.table_state,
9077                    );
9078
9079                    // Keybind Hints - Single line
9080                    use ratatui::text::{Line, Span};
9081                    let mut hint_line = Line::default();
9082                    hint_line.spans.push(Span::styled(
9083                        "Enter",
9084                        Style::default()
9085                            .fg(self.color("keybind_hints"))
9086                            .add_modifier(Modifier::BOLD),
9087                    ));
9088                    hint_line.spans.push(Span::raw(" Apply "));
9089                    hint_line.spans.push(Span::styled(
9090                        "s",
9091                        Style::default()
9092                            .fg(self.color("keybind_hints"))
9093                            .add_modifier(Modifier::BOLD),
9094                    ));
9095                    hint_line.spans.push(Span::raw(" Create "));
9096                    hint_line.spans.push(Span::styled(
9097                        "e",
9098                        Style::default()
9099                            .fg(self.color("keybind_hints"))
9100                            .add_modifier(Modifier::BOLD),
9101                    ));
9102                    hint_line.spans.push(Span::raw(" Edit "));
9103                    hint_line.spans.push(Span::styled(
9104                        "d",
9105                        Style::default()
9106                            .fg(self.color("keybind_hints"))
9107                            .add_modifier(Modifier::BOLD),
9108                    ));
9109                    hint_line.spans.push(Span::raw(" Delete "));
9110                    hint_line.spans.push(Span::styled(
9111                        "Esc",
9112                        Style::default()
9113                            .fg(self.color("keybind_hints"))
9114                            .add_modifier(Modifier::BOLD),
9115                    ));
9116                    hint_line.spans.push(Span::raw(" Close"));
9117
9118                    Paragraph::new(vec![hint_line]).render(chunks[1], buf);
9119                }
9120                TemplateModalMode::Create | TemplateModalMode::Edit => {
9121                    // Create/Edit Mode: Multi-step dialog
9122                    let chunks = Layout::default()
9123                        .direction(Direction::Vertical)
9124                        .constraints([
9125                            Constraint::Length(3), // Name
9126                            Constraint::Length(6), // Description (taller for multi-line)
9127                            Constraint::Length(3), // Exact Path
9128                            Constraint::Length(3), // Relative Path
9129                            Constraint::Length(3), // Path Pattern
9130                            Constraint::Length(3), // Filename Pattern
9131                            Constraint::Length(3), // Schema Match
9132                            Constraint::Length(3), // Buttons
9133                        ])
9134                        .split(inner_area);
9135
9136                    // Name input
9137                    let name_style = if self.template_modal.create_focus == CreateFocus::Name {
9138                        Style::default().fg(self.color("modal_border_active"))
9139                    } else {
9140                        Style::default()
9141                    };
9142                    let name_title = if let Some(error) = &self.template_modal.name_error {
9143                        format!("Name {}", error)
9144                    } else {
9145                        "Name".to_string()
9146                    };
9147                    let name_block = Block::default()
9148                        .borders(Borders::ALL)
9149                        .border_type(BorderType::Rounded)
9150                        .title(name_title)
9151                        .title_style(if self.template_modal.name_error.is_some() {
9152                            Style::default().fg(self.color("error"))
9153                        } else {
9154                            Style::default().add_modifier(Modifier::BOLD)
9155                        })
9156                        .border_style(name_style);
9157                    let name_inner = name_block.inner(chunks[0]);
9158                    name_block.render(chunks[0], buf);
9159                    // Render name input using TextInput widget
9160                    let is_focused = self.template_modal.create_focus == CreateFocus::Name;
9161                    self.template_modal
9162                        .create_name_input
9163                        .set_focused(is_focused);
9164                    (&self.template_modal.create_name_input).render(name_inner, buf);
9165
9166                    // Description input (scrollable, multi-line)
9167                    let desc_style = if self.template_modal.create_focus == CreateFocus::Description
9168                    {
9169                        Style::default().fg(self.color("modal_border_active"))
9170                    } else {
9171                        Style::default()
9172                    };
9173                    let desc_block = Block::default()
9174                        .borders(Borders::ALL)
9175                        .border_type(BorderType::Rounded)
9176                        .title("Description")
9177                        .border_style(desc_style);
9178                    let desc_inner = desc_block.inner(chunks[1]);
9179                    desc_block.render(chunks[1], buf);
9180
9181                    // Render description input using MultiLineTextInput widget
9182                    let is_focused = self.template_modal.create_focus == CreateFocus::Description;
9183                    self.template_modal
9184                        .create_description_input
9185                        .set_focused(is_focused);
9186                    // Auto-scroll to keep cursor visible
9187                    self.template_modal
9188                        .create_description_input
9189                        .ensure_cursor_visible(desc_inner.height, desc_inner.width);
9190                    (&self.template_modal.create_description_input).render(desc_inner, buf);
9191
9192                    // Exact Path
9193                    let exact_path_style =
9194                        if self.template_modal.create_focus == CreateFocus::ExactPath {
9195                            Style::default().fg(self.color("modal_border_active"))
9196                        } else {
9197                            Style::default()
9198                        };
9199                    let exact_path_block = Block::default()
9200                        .borders(Borders::ALL)
9201                        .border_type(BorderType::Rounded)
9202                        .title("Exact Path")
9203                        .border_style(exact_path_style);
9204                    let exact_path_inner = exact_path_block.inner(chunks[2]);
9205                    exact_path_block.render(chunks[2], buf);
9206                    // Render exact path input using TextInput widget
9207                    let is_focused = self.template_modal.create_focus == CreateFocus::ExactPath;
9208                    self.template_modal
9209                        .create_exact_path_input
9210                        .set_focused(is_focused);
9211                    (&self.template_modal.create_exact_path_input).render(exact_path_inner, buf);
9212
9213                    // Relative Path
9214                    let relative_path_style =
9215                        if self.template_modal.create_focus == CreateFocus::RelativePath {
9216                            Style::default().fg(self.color("modal_border_active"))
9217                        } else {
9218                            Style::default()
9219                        };
9220                    let relative_path_block = Block::default()
9221                        .borders(Borders::ALL)
9222                        .border_type(BorderType::Rounded)
9223                        .title("Relative Path")
9224                        .border_style(relative_path_style);
9225                    let relative_path_inner = relative_path_block.inner(chunks[3]);
9226                    relative_path_block.render(chunks[3], buf);
9227                    // Render relative path input using TextInput widget
9228                    let is_focused = self.template_modal.create_focus == CreateFocus::RelativePath;
9229                    self.template_modal
9230                        .create_relative_path_input
9231                        .set_focused(is_focused);
9232                    (&self.template_modal.create_relative_path_input)
9233                        .render(relative_path_inner, buf);
9234
9235                    // Path Pattern
9236                    let path_pattern_style =
9237                        if self.template_modal.create_focus == CreateFocus::PathPattern {
9238                            Style::default().fg(self.color("modal_border_active"))
9239                        } else {
9240                            Style::default()
9241                        };
9242                    let path_pattern_block = Block::default()
9243                        .borders(Borders::ALL)
9244                        .border_type(BorderType::Rounded)
9245                        .title("Path Pattern")
9246                        .border_style(path_pattern_style);
9247                    let path_pattern_inner = path_pattern_block.inner(chunks[4]);
9248                    path_pattern_block.render(chunks[4], buf);
9249                    // Render path pattern input using TextInput widget
9250                    let is_focused = self.template_modal.create_focus == CreateFocus::PathPattern;
9251                    self.template_modal
9252                        .create_path_pattern_input
9253                        .set_focused(is_focused);
9254                    (&self.template_modal.create_path_pattern_input)
9255                        .render(path_pattern_inner, buf);
9256
9257                    // Filename Pattern
9258                    let filename_pattern_style =
9259                        if self.template_modal.create_focus == CreateFocus::FilenamePattern {
9260                            Style::default().fg(self.color("modal_border_active"))
9261                        } else {
9262                            Style::default()
9263                        };
9264                    let filename_pattern_block = Block::default()
9265                        .borders(Borders::ALL)
9266                        .border_type(BorderType::Rounded)
9267                        .title("Filename Pattern")
9268                        .border_style(filename_pattern_style);
9269                    let filename_pattern_inner = filename_pattern_block.inner(chunks[5]);
9270                    filename_pattern_block.render(chunks[5], buf);
9271                    // Render filename pattern input using TextInput widget
9272                    let is_focused =
9273                        self.template_modal.create_focus == CreateFocus::FilenamePattern;
9274                    self.template_modal
9275                        .create_filename_pattern_input
9276                        .set_focused(is_focused);
9277                    (&self.template_modal.create_filename_pattern_input)
9278                        .render(filename_pattern_inner, buf);
9279
9280                    // Schema Match
9281                    let schema_style =
9282                        if self.template_modal.create_focus == CreateFocus::SchemaMatch {
9283                            Style::default().fg(self.color("modal_border_active"))
9284                        } else {
9285                            Style::default()
9286                        };
9287                    let schema_text = if self.template_modal.create_schema_match_enabled {
9288                        "Enabled"
9289                    } else {
9290                        "Disabled"
9291                    };
9292                    Paragraph::new(schema_text)
9293                        .block(
9294                            Block::default()
9295                                .borders(Borders::ALL)
9296                                .border_type(BorderType::Rounded)
9297                                .title("Schema Match")
9298                                .border_style(schema_style),
9299                        )
9300                        .render(chunks[6], buf);
9301
9302                    // Buttons
9303                    let btn_layout = Layout::default()
9304                        .direction(Direction::Horizontal)
9305                        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
9306                        .split(chunks[7]);
9307
9308                    let save_style = if self.template_modal.create_focus == CreateFocus::SaveButton
9309                    {
9310                        Style::default().fg(self.color("modal_border_active"))
9311                    } else {
9312                        Style::default()
9313                    };
9314                    Paragraph::new("Save")
9315                        .block(
9316                            Block::default()
9317                                .borders(Borders::ALL)
9318                                .border_type(BorderType::Rounded)
9319                                .border_style(save_style),
9320                        )
9321                        .centered()
9322                        .render(btn_layout[0], buf);
9323
9324                    let cancel_create_style =
9325                        if self.template_modal.create_focus == CreateFocus::CancelButton {
9326                            Style::default().fg(self.color("modal_border_active"))
9327                        } else {
9328                            Style::default()
9329                        };
9330                    Paragraph::new("Cancel")
9331                        .block(
9332                            Block::default()
9333                                .borders(Borders::ALL)
9334                                .border_type(BorderType::Rounded)
9335                                .border_style(cancel_create_style),
9336                        )
9337                        .centered()
9338                        .render(btn_layout[1], buf);
9339                }
9340            }
9341
9342            // Delete Confirmation Dialog
9343            if self.template_modal.delete_confirm {
9344                if let Some(template) = self.template_modal.selected_template() {
9345                    let confirm_area = centered_rect(sort_area, 50, 20);
9346                    Clear.render(confirm_area, buf);
9347                    let block = Block::default()
9348                        .borders(Borders::ALL)
9349                        .border_type(BorderType::Rounded)
9350                        .title("Delete Template");
9351                    let inner_area = block.inner(confirm_area);
9352                    block.render(confirm_area, buf);
9353
9354                    let chunks = Layout::default()
9355                        .direction(Direction::Vertical)
9356                        .constraints([
9357                            Constraint::Min(0),    // Message
9358                            Constraint::Length(3), // Buttons
9359                        ])
9360                        .split(inner_area);
9361
9362                    let message = format!(
9363                        "Are you sure you want to delete the template \"{}\"?\n\nThis action cannot be undone.",
9364                        template.name
9365                    );
9366                    Paragraph::new(message)
9367                        .wrap(ratatui::widgets::Wrap { trim: false })
9368                        .render(chunks[0], buf);
9369
9370                    let btn_layout = Layout::default()
9371                        .direction(Direction::Horizontal)
9372                        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
9373                        .split(chunks[1]);
9374
9375                    // Delete button - highlight "D" in blue
9376                    use ratatui::text::{Line, Span};
9377                    let mut delete_line = Line::default();
9378                    delete_line.spans.push(Span::styled(
9379                        "D",
9380                        Style::default()
9381                            .fg(self.color("keybind_hints"))
9382                            .add_modifier(Modifier::BOLD),
9383                    ));
9384                    delete_line.spans.push(Span::raw("elete"));
9385
9386                    let delete_style = if self.template_modal.delete_confirm_focus {
9387                        Style::default().fg(self.color("modal_border_active"))
9388                    } else {
9389                        Style::default()
9390                    };
9391                    Paragraph::new(vec![delete_line])
9392                        .block(
9393                            Block::default()
9394                                .borders(Borders::ALL)
9395                                .border_type(BorderType::Rounded)
9396                                .border_style(delete_style),
9397                        )
9398                        .centered()
9399                        .render(btn_layout[0], buf);
9400
9401                    // Cancel button (default selected)
9402                    let cancel_style = if !self.template_modal.delete_confirm_focus {
9403                        Style::default().fg(self.color("modal_border_active"))
9404                    } else {
9405                        Style::default()
9406                    };
9407                    Paragraph::new("Cancel")
9408                        .block(
9409                            Block::default()
9410                                .borders(Borders::ALL)
9411                                .border_type(BorderType::Rounded)
9412                                .border_style(cancel_style),
9413                        )
9414                        .centered()
9415                        .render(btn_layout[1], buf);
9416                }
9417            }
9418
9419            // Score Details Dialog
9420            if self.template_modal.show_score_details {
9421                if let Some((template, score)) = self
9422                    .template_modal
9423                    .table_state
9424                    .selected()
9425                    .and_then(|idx| self.template_modal.templates.get(idx))
9426                {
9427                    if let Some(ref state) = self.data_table_state {
9428                        if let Some(ref path) = self.path {
9429                            let details_area = centered_rect(sort_area, 60, 50);
9430                            Clear.render(details_area, buf);
9431                            let block = Block::default()
9432                                .borders(Borders::ALL)
9433                                .border_type(BorderType::Rounded)
9434                                .title(format!("Score Details: {}", template.name));
9435                            let inner_area = block.inner(details_area);
9436                            block.render(details_area, buf);
9437
9438                            // Calculate score components
9439                            let exact_path_match = template
9440                                .match_criteria
9441                                .exact_path
9442                                .as_ref()
9443                                .map(|exact| exact == path)
9444                                .unwrap_or(false);
9445
9446                            let relative_path_match = if let Some(relative_path) =
9447                                &template.match_criteria.relative_path
9448                            {
9449                                if let Ok(cwd) = std::env::current_dir() {
9450                                    if let Ok(rel_path) = path.strip_prefix(&cwd) {
9451                                        rel_path.to_string_lossy() == *relative_path
9452                                    } else {
9453                                        false
9454                                    }
9455                                } else {
9456                                    false
9457                                }
9458                            } else {
9459                                false
9460                            };
9461
9462                            let exact_schema_match = if let Some(required_cols) =
9463                                &template.match_criteria.schema_columns
9464                            {
9465                                let file_cols: std::collections::HashSet<&str> =
9466                                    state.schema.iter_names().map(|s| s.as_str()).collect();
9467                                let required_cols_set: std::collections::HashSet<&str> =
9468                                    required_cols.iter().map(|s| s.as_str()).collect();
9469                                required_cols_set.is_subset(&file_cols)
9470                                    && file_cols.len() == required_cols_set.len()
9471                            } else {
9472                                false
9473                            };
9474
9475                            // Build score details text
9476                            let mut details = format!("Total Score: {:.1}\n\n", score);
9477
9478                            if exact_path_match && exact_schema_match {
9479                                details.push_str("Exact Path + Exact Schema: 2000.0\n");
9480                            } else if exact_path_match {
9481                                details.push_str("Exact Path: 1000.0\n");
9482                            } else if relative_path_match && exact_schema_match {
9483                                details.push_str("Relative Path + Exact Schema: 1950.0\n");
9484                            } else if relative_path_match {
9485                                details.push_str("Relative Path: 950.0\n");
9486                            } else if exact_schema_match {
9487                                details.push_str("Exact Schema: 900.0\n");
9488                            } else {
9489                                // For non-exact matches, show component breakdown
9490                                if let Some(pattern) = &template.match_criteria.path_pattern {
9491                                    if path
9492                                        .to_str()
9493                                        .map(|p| p.contains(pattern.trim_end_matches("/*")))
9494                                        .unwrap_or(false)
9495                                    {
9496                                        details.push_str("Path Pattern Match: 50.0+\n");
9497                                    }
9498                                }
9499                                if let Some(pattern) = &template.match_criteria.filename_pattern {
9500                                    if path
9501                                        .file_name()
9502                                        .and_then(|f| f.to_str())
9503                                        .map(|f| {
9504                                            f.contains(pattern.trim_end_matches("*"))
9505                                                || pattern == "*"
9506                                        })
9507                                        .unwrap_or(false)
9508                                    {
9509                                        details.push_str("Filename Pattern Match: 30.0+\n");
9510                                    }
9511                                }
9512                                if let Some(required_cols) = &template.match_criteria.schema_columns
9513                                {
9514                                    let file_cols: std::collections::HashSet<&str> =
9515                                        state.schema.iter_names().map(|s| s.as_str()).collect();
9516                                    let matching_count = required_cols
9517                                        .iter()
9518                                        .filter(|col| file_cols.contains(col.as_str()))
9519                                        .count();
9520                                    if matching_count > 0 {
9521                                        details.push_str(&format!(
9522                                            "Partial Schema Match: {:.1} ({} columns)\n",
9523                                            matching_count as f64 * 2.0,
9524                                            matching_count
9525                                        ));
9526                                    }
9527                                }
9528                            }
9529
9530                            if template.usage_count > 0 {
9531                                details.push_str(&format!(
9532                                    "Usage Count: {:.1}\n",
9533                                    (template.usage_count.min(10) as f64) * 1.0
9534                                ));
9535                            }
9536                            if let Some(last_used) = template.last_used {
9537                                if let Ok(duration) =
9538                                    std::time::SystemTime::now().duration_since(last_used)
9539                                {
9540                                    let days_since = duration.as_secs() / 86400;
9541                                    if days_since <= 7 {
9542                                        details.push_str("Recent Usage: 5.0\n");
9543                                    } else if days_since <= 30 {
9544                                        details.push_str("Recent Usage: 2.0\n");
9545                                    }
9546                                }
9547                            }
9548                            if let Ok(duration) =
9549                                std::time::SystemTime::now().duration_since(template.created)
9550                            {
9551                                let months_old = (duration.as_secs() / (30 * 86400)) as f64;
9552                                if months_old > 0.0 {
9553                                    details.push_str(&format!(
9554                                        "Age Penalty: -{:.1}\n",
9555                                        months_old * 1.0
9556                                    ));
9557                                }
9558                            }
9559
9560                            Paragraph::new(details)
9561                                .wrap(ratatui::widgets::Wrap { trim: false })
9562                                .render(inner_area, buf);
9563                        }
9564                    }
9565                }
9566            }
9567        }
9568
9569        if self.pivot_melt_modal.active {
9570            let border = self.color("modal_border");
9571            let active = self.color("modal_border_active");
9572            let text_primary = self.color("text_primary");
9573            let text_inverse = self.color("text_inverse");
9574            pivot_melt::render_shell(
9575                sort_area,
9576                buf,
9577                &mut self.pivot_melt_modal,
9578                border,
9579                active,
9580                text_primary,
9581                text_inverse,
9582            );
9583        }
9584
9585        if self.export_modal.active {
9586            let border = self.color("modal_border");
9587            let active = self.color("modal_border_active");
9588            let text_primary = self.color("text_primary");
9589            let text_inverse = self.color("text_inverse");
9590            // Center the modal
9591            let modal_width = (area.width * 3 / 4).min(80);
9592            let modal_height = 20;
9593            let modal_x = (area.width.saturating_sub(modal_width)) / 2;
9594            let modal_y = (area.height.saturating_sub(modal_height)) / 2;
9595            let modal_area = Rect {
9596                x: modal_x,
9597                y: modal_y,
9598                width: modal_width,
9599                height: modal_height,
9600            };
9601            export::render_export_modal(
9602                modal_area,
9603                buf,
9604                &mut self.export_modal,
9605                border,
9606                active,
9607                text_primary,
9608                text_inverse,
9609            );
9610        }
9611
9612        // Render analysis modal (full screen in main area, leaving toolbar visible)
9613        if self.analysis_modal.active {
9614            // Use main_area so toolbar remains visible at bottom
9615            let analysis_area = main_area;
9616
9617            // Progress overlay when chunked describe is running
9618            if let Some(ref progress) = self.analysis_modal.computing {
9619                let border = self.color("modal_border");
9620                let text_primary = self.color("text_primary");
9621                let label = self.color("label");
9622                let percent = if progress.total > 0 {
9623                    (progress.current as u16).saturating_mul(100) / progress.total as u16
9624                } else {
9625                    0
9626                };
9627                Clear.render(analysis_area, buf);
9628                let block = Block::default()
9629                    .borders(Borders::ALL)
9630                    .border_type(BorderType::Rounded)
9631                    .border_style(Style::default().fg(border))
9632                    .title(" Analysis ");
9633                let inner = block.inner(analysis_area);
9634                block.render(analysis_area, buf);
9635                let text = format!(
9636                    "{}: {} / {}",
9637                    progress.phase, progress.current, progress.total
9638                );
9639                Paragraph::new(text)
9640                    .style(Style::default().fg(text_primary))
9641                    .render(
9642                        Rect {
9643                            x: inner.x,
9644                            y: inner.y,
9645                            width: inner.width,
9646                            height: 1,
9647                        },
9648                        buf,
9649                    );
9650                Gauge::default()
9651                    .gauge_style(Style::default().fg(label))
9652                    .ratio(percent as f64 / 100.0)
9653                    .render(
9654                        Rect {
9655                            x: inner.x,
9656                            y: inner.y + 1,
9657                            width: inner.width,
9658                            height: 1,
9659                        },
9660                        buf,
9661                    );
9662            } else if let Some(state) = &self.data_table_state {
9663                // Per-tool sync fallback: when the selected tool has no cached results or seed changed, compute for that tool only.
9664                let seed = self.analysis_modal.random_seed;
9665                let needs_describe = self.analysis_modal.selected_tool
9666                    == Some(analysis_modal::AnalysisTool::Describe)
9667                    && (self.analysis_modal.describe_results.is_none()
9668                        || self
9669                            .analysis_modal
9670                            .describe_results
9671                            .as_ref()
9672                            .is_some_and(|r| r.sample_seed != seed));
9673                let needs_distribution = self.analysis_modal.selected_tool
9674                    == Some(analysis_modal::AnalysisTool::DistributionAnalysis)
9675                    && (self.analysis_modal.distribution_results.is_none()
9676                        || self
9677                            .analysis_modal
9678                            .distribution_results
9679                            .as_ref()
9680                            .is_some_and(|r| r.sample_seed != seed));
9681                let needs_correlation = self.analysis_modal.selected_tool
9682                    == Some(analysis_modal::AnalysisTool::CorrelationMatrix)
9683                    && (self.analysis_modal.correlation_results.is_none()
9684                        || self
9685                            .analysis_modal
9686                            .correlation_results
9687                            .as_ref()
9688                            .is_some_and(|r| r.sample_seed != seed));
9689
9690                if needs_describe {
9691                    self.busy = true;
9692                    let lf = state.lf.clone();
9693                    let options = crate::statistics::ComputeOptions {
9694                        include_distribution_info: false,
9695                        include_distribution_analyses: false,
9696                        include_correlation_matrix: false,
9697                        include_skewness_kurtosis_outliers: false,
9698                        polars_streaming: self.app_config.performance.polars_streaming,
9699                    };
9700                    match crate::statistics::compute_statistics_with_options(
9701                        &lf,
9702                        self.sampling_threshold,
9703                        seed,
9704                        options,
9705                    ) {
9706                        Ok(results) => {
9707                            self.analysis_modal.describe_results = Some(results);
9708                        }
9709                        Err(e) => {
9710                            Clear.render(analysis_area, buf);
9711                            let error_msg = format!(
9712                                "Error computing statistics: {}",
9713                                crate::error_display::user_message_from_report(&e, None)
9714                            );
9715                            Paragraph::new(error_msg)
9716                                .centered()
9717                                .style(Style::default().fg(self.color("error")))
9718                                .render(analysis_area, buf);
9719                        }
9720                    }
9721                    self.busy = false;
9722                    self.drain_keys_on_next_loop = true;
9723                } else if needs_distribution {
9724                    self.busy = true;
9725                    let lf = state.lf.clone();
9726                    let options = crate::statistics::ComputeOptions {
9727                        include_distribution_info: true,
9728                        include_distribution_analyses: true,
9729                        include_correlation_matrix: false,
9730                        include_skewness_kurtosis_outliers: true,
9731                        polars_streaming: self.app_config.performance.polars_streaming,
9732                    };
9733                    match crate::statistics::compute_statistics_with_options(
9734                        &lf,
9735                        self.sampling_threshold,
9736                        seed,
9737                        options,
9738                    ) {
9739                        Ok(results) => {
9740                            self.analysis_modal.distribution_results = Some(results);
9741                        }
9742                        Err(e) => {
9743                            Clear.render(analysis_area, buf);
9744                            let error_msg = format!(
9745                                "Error computing distribution: {}",
9746                                crate::error_display::user_message_from_report(&e, None)
9747                            );
9748                            Paragraph::new(error_msg)
9749                                .centered()
9750                                .style(Style::default().fg(self.color("error")))
9751                                .render(analysis_area, buf);
9752                        }
9753                    }
9754                    self.busy = false;
9755                    self.drain_keys_on_next_loop = true;
9756                } else if needs_correlation {
9757                    self.busy = true;
9758                    if let Ok(df) =
9759                        crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
9760                    {
9761                        if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
9762                            self.analysis_modal.correlation_results =
9763                                Some(crate::statistics::AnalysisResults {
9764                                    column_statistics: vec![],
9765                                    total_rows: df.height(),
9766                                    sample_size: None,
9767                                    sample_seed: seed,
9768                                    correlation_matrix: Some(matrix),
9769                                    distribution_analyses: vec![],
9770                                });
9771                        }
9772                    }
9773                    self.busy = false;
9774                    self.drain_keys_on_next_loop = true;
9775                }
9776
9777                // Always render the analysis widget when we have data (with or without results: widget shows
9778                // "Select an analysis tool", "Computing...", or the selected tool content).
9779                let context = state.get_analysis_context();
9780                Clear.render(analysis_area, buf);
9781                let column_offset = match self.analysis_modal.selected_tool {
9782                    Some(analysis_modal::AnalysisTool::Describe) => {
9783                        self.analysis_modal.describe_column_offset
9784                    }
9785                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
9786                        self.analysis_modal.distribution_column_offset
9787                    }
9788                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
9789                        self.analysis_modal.correlation_column_offset
9790                    }
9791                    None => 0,
9792                };
9793
9794                // Clone current tool's results so we can pass a ref without holding a borrow on the modal (widget also needs &mut modal for table state).
9795                let results_for_widget = self.analysis_modal.current_results().cloned();
9796                let config = widgets::analysis::AnalysisWidgetConfig {
9797                    state,
9798                    results: results_for_widget.as_ref(),
9799                    context: &context,
9800                    view: self.analysis_modal.view,
9801                    selected_tool: self.analysis_modal.selected_tool,
9802                    column_offset,
9803                    selected_correlation: self.analysis_modal.selected_correlation,
9804                    focus: self.analysis_modal.focus,
9805                    selected_theoretical_distribution: self
9806                        .analysis_modal
9807                        .selected_theoretical_distribution,
9808                    histogram_scale: self.analysis_modal.histogram_scale,
9809                    theme: &self.theme,
9810                    table_cell_padding: self.table_cell_padding,
9811                };
9812                let widget = widgets::analysis::AnalysisWidget::new(
9813                    config,
9814                    &mut self.analysis_modal.table_state,
9815                    &mut self.analysis_modal.distribution_table_state,
9816                    &mut self.analysis_modal.correlation_table_state,
9817                    &mut self.analysis_modal.sidebar_state,
9818                    &mut self.analysis_modal.distribution_selector_state,
9819                );
9820                widget.render(analysis_area, buf);
9821            } else {
9822                // No data available
9823                Clear.render(analysis_area, buf);
9824                Paragraph::new("No data available for analysis")
9825                    .centered()
9826                    .style(Style::default().fg(self.color("warning")))
9827                    .render(analysis_area, buf);
9828            }
9829            // Don't return - continue to render toolbar and other UI elements
9830        }
9831
9832        // Render chart view (full screen in main area)
9833        if self.input_mode == InputMode::Chart {
9834            let chart_area = main_area;
9835            Clear.render(chart_area, buf);
9836            let mut xy_series: Option<&Vec<Vec<(f64, f64)>>> = None;
9837            let mut x_axis_kind = chart_data::XAxisTemporalKind::Numeric;
9838            let mut x_bounds: Option<(f64, f64)> = None;
9839            let mut hist_data: Option<&chart_data::HistogramData> = None;
9840            let mut box_data: Option<&chart_data::BoxPlotData> = None;
9841            let mut kde_data: Option<&chart_data::KdeData> = None;
9842            let mut heatmap_data: Option<&chart_data::HeatmapData> = None;
9843
9844            let row_limit_opt = self.chart_modal.row_limit;
9845            let row_limit = self.chart_modal.effective_row_limit();
9846            match self.chart_modal.chart_kind {
9847                ChartKind::XY => {
9848                    if let Some(x_column) = self.chart_modal.effective_x_column() {
9849                        let x_key = x_column.to_string();
9850                        let y_columns = self.chart_modal.effective_y_columns();
9851                        if !y_columns.is_empty() {
9852                            let use_cache = self.chart_cache.xy.as_ref().filter(|c| {
9853                                c.x_column == x_key
9854                                    && c.y_columns == y_columns
9855                                    && c.row_limit == row_limit_opt
9856                            });
9857                            if use_cache.is_none() {
9858                                if let Some(state) = self.data_table_state.as_ref() {
9859                                    if let Ok(result) = chart_data::prepare_chart_data(
9860                                        &state.lf,
9861                                        &state.schema,
9862                                        x_column,
9863                                        &y_columns,
9864                                        row_limit,
9865                                    ) {
9866                                        self.chart_cache.xy = Some(ChartCacheXY {
9867                                            x_column: x_key.clone(),
9868                                            y_columns: y_columns.clone(),
9869                                            row_limit: row_limit_opt,
9870                                            series: result.series,
9871                                            series_log: None,
9872                                            x_axis_kind: result.x_axis_kind,
9873                                        });
9874                                    }
9875                                }
9876                            }
9877                            if self.chart_modal.log_scale {
9878                                if let Some(cache) = self.chart_cache.xy.as_mut() {
9879                                    if cache.x_column == x_key
9880                                        && cache.y_columns == y_columns
9881                                        && cache.row_limit == row_limit_opt
9882                                        && cache.series_log.is_none()
9883                                        && cache.series.iter().any(|s| !s.is_empty())
9884                                    {
9885                                        cache.series_log = Some(
9886                                            cache
9887                                                .series
9888                                                .iter()
9889                                                .map(|pts| {
9890                                                    pts.iter()
9891                                                        .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
9892                                                        .collect()
9893                                                })
9894                                                .collect(),
9895                                        );
9896                                    }
9897                                }
9898                            }
9899                            if let Some(cache) = self.chart_cache.xy.as_ref() {
9900                                if cache.x_column == x_key
9901                                    && cache.y_columns == y_columns
9902                                    && cache.row_limit == row_limit_opt
9903                                {
9904                                    x_axis_kind = cache.x_axis_kind;
9905                                    if self.chart_modal.log_scale {
9906                                        if let Some(ref log) = cache.series_log {
9907                                            if log.iter().any(|v| !v.is_empty()) {
9908                                                xy_series = Some(log);
9909                                            }
9910                                        }
9911                                    } else if cache.series.iter().any(|s| !s.is_empty()) {
9912                                        xy_series = Some(&cache.series);
9913                                    }
9914                                }
9915                            }
9916                        } else {
9917                            // Only X selected: cache x range for axis bounds
9918                            let use_cache =
9919                                self.chart_cache.x_range.as_ref().filter(|c| {
9920                                    c.x_column == x_key && c.row_limit == row_limit_opt
9921                                });
9922                            if use_cache.is_none() {
9923                                if let Some(state) = self.data_table_state.as_ref() {
9924                                    if let Ok(result) = chart_data::prepare_chart_x_range(
9925                                        &state.lf,
9926                                        &state.schema,
9927                                        x_column,
9928                                        row_limit,
9929                                    ) {
9930                                        self.chart_cache.x_range = Some(ChartCacheXRange {
9931                                            x_column: x_key.clone(),
9932                                            row_limit: row_limit_opt,
9933                                            x_min: result.x_min,
9934                                            x_max: result.x_max,
9935                                            x_axis_kind: result.x_axis_kind,
9936                                        });
9937                                    }
9938                                }
9939                            }
9940                            if let Some(cache) = self.chart_cache.x_range.as_ref() {
9941                                if cache.x_column == x_key && cache.row_limit == row_limit_opt {
9942                                    x_axis_kind = cache.x_axis_kind;
9943                                    x_bounds = Some((cache.x_min, cache.x_max));
9944                                }
9945                            } else if let Some(state) = self.data_table_state.as_ref() {
9946                                x_axis_kind = chart_data::x_axis_temporal_kind_for_column(
9947                                    &state.schema,
9948                                    x_column,
9949                                );
9950                            }
9951                        }
9952                    }
9953                }
9954                ChartKind::Histogram => {
9955                    if let (Some(state), Some(column)) = (
9956                        self.data_table_state.as_ref(),
9957                        self.chart_modal.effective_hist_column(),
9958                    ) {
9959                        let bins = self.chart_modal.hist_bins;
9960                        let use_cache = self.chart_cache.histogram.as_ref().filter(|c| {
9961                            c.column == column && c.bins == bins && c.row_limit == row_limit_opt
9962                        });
9963                        if use_cache.is_none() {
9964                            if let Ok(data) = chart_data::prepare_histogram_data(
9965                                &state.lf, &column, bins, row_limit,
9966                            ) {
9967                                self.chart_cache.histogram = Some(ChartCacheHistogram {
9968                                    column: column.clone(),
9969                                    bins,
9970                                    row_limit: row_limit_opt,
9971                                    data,
9972                                });
9973                            }
9974                        }
9975                        hist_data = self
9976                            .chart_cache
9977                            .histogram
9978                            .as_ref()
9979                            .filter(|c| {
9980                                c.column == column && c.bins == bins && c.row_limit == row_limit_opt
9981                            })
9982                            .map(|c| &c.data);
9983                    }
9984                }
9985                ChartKind::BoxPlot => {
9986                    if let (Some(state), Some(column)) = (
9987                        self.data_table_state.as_ref(),
9988                        self.chart_modal.effective_box_column(),
9989                    ) {
9990                        let use_cache = self
9991                            .chart_cache
9992                            .box_plot
9993                            .as_ref()
9994                            .filter(|c| c.column == column && c.row_limit == row_limit_opt);
9995                        if use_cache.is_none() {
9996                            if let Ok(data) = chart_data::prepare_box_plot_data(
9997                                &state.lf,
9998                                std::slice::from_ref(&column),
9999                                row_limit,
10000                            ) {
10001                                self.chart_cache.box_plot = Some(ChartCacheBoxPlot {
10002                                    column: column.clone(),
10003                                    row_limit: row_limit_opt,
10004                                    data,
10005                                });
10006                            }
10007                        }
10008                        box_data = self
10009                            .chart_cache
10010                            .box_plot
10011                            .as_ref()
10012                            .filter(|c| c.column == column && c.row_limit == row_limit_opt)
10013                            .map(|c| &c.data);
10014                    }
10015                }
10016                ChartKind::Kde => {
10017                    if let (Some(state), Some(column)) = (
10018                        self.data_table_state.as_ref(),
10019                        self.chart_modal.effective_kde_column(),
10020                    ) {
10021                        let bandwidth = self.chart_modal.kde_bandwidth_factor;
10022                        let use_cache = self.chart_cache.kde.as_ref().filter(|c| {
10023                            c.column == column
10024                                && c.bandwidth_factor == bandwidth
10025                                && c.row_limit == row_limit_opt
10026                        });
10027                        if use_cache.is_none() {
10028                            if let Ok(data) = chart_data::prepare_kde_data(
10029                                &state.lf,
10030                                std::slice::from_ref(&column),
10031                                bandwidth,
10032                                row_limit,
10033                            ) {
10034                                self.chart_cache.kde = Some(ChartCacheKde {
10035                                    column: column.clone(),
10036                                    bandwidth_factor: bandwidth,
10037                                    row_limit: row_limit_opt,
10038                                    data,
10039                                });
10040                            }
10041                        }
10042                        kde_data = self
10043                            .chart_cache
10044                            .kde
10045                            .as_ref()
10046                            .filter(|c| {
10047                                c.column == column
10048                                    && c.bandwidth_factor == bandwidth
10049                                    && c.row_limit == row_limit_opt
10050                            })
10051                            .map(|c| &c.data);
10052                    }
10053                }
10054                ChartKind::Heatmap => {
10055                    if let (Some(state), Some(x_column), Some(y_column)) = (
10056                        self.data_table_state.as_ref(),
10057                        self.chart_modal.effective_heatmap_x_column(),
10058                        self.chart_modal.effective_heatmap_y_column(),
10059                    ) {
10060                        let bins = self.chart_modal.heatmap_bins;
10061                        let use_cache = self.chart_cache.heatmap.as_ref().filter(|c| {
10062                            c.x_column == x_column
10063                                && c.y_column == y_column
10064                                && c.bins == bins
10065                                && c.row_limit == row_limit_opt
10066                        });
10067                        if use_cache.is_none() {
10068                            if let Ok(data) = chart_data::prepare_heatmap_data(
10069                                &state.lf, &x_column, &y_column, bins, row_limit,
10070                            ) {
10071                                self.chart_cache.heatmap = Some(ChartCacheHeatmap {
10072                                    x_column: x_column.clone(),
10073                                    y_column: y_column.clone(),
10074                                    bins,
10075                                    row_limit: row_limit_opt,
10076                                    data,
10077                                });
10078                            }
10079                        }
10080                        heatmap_data = self
10081                            .chart_cache
10082                            .heatmap
10083                            .as_ref()
10084                            .filter(|c| {
10085                                c.x_column == x_column
10086                                    && c.y_column == y_column
10087                                    && c.bins == bins
10088                                    && c.row_limit == row_limit_opt
10089                            })
10090                            .map(|c| &c.data);
10091                    }
10092                }
10093            }
10094
10095            let render_data = match self.chart_modal.chart_kind {
10096                ChartKind::XY => widgets::chart::ChartRenderData::XY {
10097                    series: xy_series,
10098                    x_axis_kind,
10099                    x_bounds,
10100                },
10101                ChartKind::Histogram => {
10102                    widgets::chart::ChartRenderData::Histogram { data: hist_data }
10103                }
10104                ChartKind::BoxPlot => widgets::chart::ChartRenderData::BoxPlot { data: box_data },
10105                ChartKind::Kde => widgets::chart::ChartRenderData::Kde { data: kde_data },
10106                ChartKind::Heatmap => {
10107                    widgets::chart::ChartRenderData::Heatmap { data: heatmap_data }
10108                }
10109            };
10110
10111            widgets::chart::render_chart_view(
10112                chart_area,
10113                buf,
10114                &mut self.chart_modal,
10115                &self.theme,
10116                render_data,
10117            );
10118
10119            if self.chart_export_modal.active {
10120                let border = self.color("modal_border");
10121                let active = self.color("modal_border_active");
10122                // 4 rows (format, title, path, buttons) of 3 lines each + 2 for outer border = 14
10123                const CHART_EXPORT_MODAL_HEIGHT: u16 = 14;
10124                let modal_width = (chart_area.width * 3 / 4).clamp(40, 54);
10125                let modal_height = CHART_EXPORT_MODAL_HEIGHT
10126                    .min(chart_area.height)
10127                    .max(CHART_EXPORT_MODAL_HEIGHT);
10128                let modal_x = chart_area.x + chart_area.width.saturating_sub(modal_width) / 2;
10129                let modal_y = chart_area.y + chart_area.height.saturating_sub(modal_height) / 2;
10130                let modal_area = Rect {
10131                    x: modal_x,
10132                    y: modal_y,
10133                    width: modal_width,
10134                    height: modal_height,
10135                };
10136                widgets::chart_export_modal::render_chart_export_modal(
10137                    modal_area,
10138                    buf,
10139                    &mut self.chart_export_modal,
10140                    border,
10141                    active,
10142                );
10143            }
10144        }
10145
10146        // Render loading progress popover (min 25 chars wide, max 25% of area; throbber spins via busy in controls)
10147        if matches!(self.loading_state, LoadingState::Loading { .. }) {
10148            let popover_rect = centered_rect_loading(area);
10149            App::render_loading_gauge(&self.loading_state, popover_rect, buf, &self.theme);
10150        }
10151        // Render export progress bar (overlay when exporting)
10152        if matches!(self.loading_state, LoadingState::Exporting { .. }) {
10153            App::render_loading_gauge(&self.loading_state, area, buf, &self.theme);
10154        }
10155
10156        // Render confirmation modal (highest priority)
10157        if self.confirmation_modal.active {
10158            let popup_area = centered_rect_with_min(area, 64, 26, 50, 12);
10159            Clear.render(popup_area, buf);
10160
10161            // Set background color for the modal
10162            let bg_color = self.color("background");
10163            Block::default()
10164                .style(Style::default().bg(bg_color))
10165                .render(popup_area, buf);
10166
10167            let block = Block::default()
10168                .borders(Borders::ALL)
10169                .border_type(BorderType::Rounded)
10170                .title("Confirm")
10171                .border_style(Style::default().fg(self.color("modal_border_active")))
10172                .style(Style::default().bg(bg_color));
10173            let inner_area = block.inner(popup_area);
10174            block.render(popup_area, buf);
10175
10176            // Split inner area into message and buttons
10177            let chunks = Layout::default()
10178                .direction(Direction::Vertical)
10179                .constraints([
10180                    Constraint::Min(6),    // Message (minimum 6 lines for file path + question)
10181                    Constraint::Length(3), // Buttons
10182                ])
10183                .split(inner_area);
10184
10185            // Render confirmation message (wrapped)
10186            Paragraph::new(self.confirmation_modal.message.as_str())
10187                .style(Style::default().fg(self.color("text_primary")).bg(bg_color))
10188                .wrap(ratatui::widgets::Wrap { trim: true })
10189                .render(chunks[0], buf);
10190
10191            // Render Yes/No buttons
10192            let button_chunks = Layout::default()
10193                .direction(Direction::Horizontal)
10194                .constraints([
10195                    Constraint::Fill(1),
10196                    Constraint::Length(12), // Yes button
10197                    Constraint::Length(2),  // Spacing
10198                    Constraint::Length(12), // No button
10199                    Constraint::Fill(1),
10200                ])
10201                .split(chunks[1]);
10202
10203            let yes_style = if self.confirmation_modal.focus_yes {
10204                Style::default().fg(self.color("modal_border_active"))
10205            } else {
10206                Style::default()
10207            };
10208            let no_style = if !self.confirmation_modal.focus_yes {
10209                Style::default().fg(self.color("modal_border_active"))
10210            } else {
10211                Style::default()
10212            };
10213
10214            Paragraph::new("Yes")
10215                .centered()
10216                .block(
10217                    Block::default()
10218                        .borders(Borders::ALL)
10219                        .border_type(BorderType::Rounded)
10220                        .border_style(yes_style),
10221                )
10222                .render(button_chunks[1], buf);
10223
10224            Paragraph::new("No")
10225                .centered()
10226                .block(
10227                    Block::default()
10228                        .borders(Borders::ALL)
10229                        .border_type(BorderType::Rounded)
10230                        .border_style(no_style),
10231                )
10232                .render(button_chunks[3], buf);
10233        }
10234
10235        // Render success modal
10236        if self.success_modal.active {
10237            let popup_area = centered_rect(area, 70, 40);
10238            Clear.render(popup_area, buf);
10239            let block = Block::default()
10240                .borders(Borders::ALL)
10241                .border_type(BorderType::Rounded)
10242                .title("Success");
10243            let inner_area = block.inner(popup_area);
10244            block.render(popup_area, buf);
10245
10246            // Split inner area into message and button
10247            let chunks = Layout::default()
10248                .direction(Direction::Vertical)
10249                .constraints([
10250                    Constraint::Min(0),    // Message (takes available space)
10251                    Constraint::Length(3), // OK button
10252                ])
10253                .split(inner_area);
10254
10255            // Render success message (wrapped)
10256            Paragraph::new(self.success_modal.message.as_str())
10257                .style(Style::default().fg(self.color("text_primary")))
10258                .wrap(ratatui::widgets::Wrap { trim: true })
10259                .render(chunks[0], buf);
10260
10261            // Render OK button
10262            let ok_style = Style::default().fg(self.color("modal_border_active"));
10263            Paragraph::new("OK")
10264                .centered()
10265                .block(
10266                    Block::default()
10267                        .borders(Borders::ALL)
10268                        .border_type(BorderType::Rounded)
10269                        .border_style(ok_style),
10270                )
10271                .render(chunks[1], buf);
10272        }
10273
10274        // Render error modal
10275        if self.error_modal.active {
10276            let popup_area = centered_rect(area, 70, 40);
10277            Clear.render(popup_area, buf);
10278            let block = Block::default()
10279                .borders(Borders::ALL)
10280                .border_type(BorderType::Rounded)
10281                .title("Error")
10282                .border_style(Style::default().fg(self.color("modal_border_error")));
10283            let inner_area = block.inner(popup_area);
10284            block.render(popup_area, buf);
10285
10286            // Split inner area into message and button
10287            let chunks = Layout::default()
10288                .direction(Direction::Vertical)
10289                .constraints([
10290                    Constraint::Min(0),    // Message (takes available space)
10291                    Constraint::Length(3), // OK button
10292                ])
10293                .split(inner_area);
10294
10295            // Render error message (wrapped)
10296            Paragraph::new(self.error_modal.message.as_str())
10297                .style(Style::default().fg(self.color("error")))
10298                .wrap(ratatui::widgets::Wrap { trim: true })
10299                .render(chunks[0], buf);
10300
10301            // Render OK button
10302            let ok_style = Style::default().fg(self.color("modal_border_active"));
10303            Paragraph::new("OK")
10304                .centered()
10305                .block(
10306                    Block::default()
10307                        .borders(Borders::ALL)
10308                        .border_type(BorderType::Rounded)
10309                        .border_style(ok_style),
10310                )
10311                .render(chunks[1], buf);
10312        }
10313
10314        if self.show_help
10315            || (self.template_modal.active && self.template_modal.show_help)
10316            || (self.analysis_modal.active && self.analysis_modal.show_help)
10317        {
10318            let popup_area = centered_rect(area, 80, 80);
10319            Clear.render(popup_area, buf);
10320            let (title, text): (String, String) = if self.analysis_modal.active
10321                && self.analysis_modal.show_help
10322            {
10323                match self.analysis_modal.view {
10324                    analysis_modal::AnalysisView::DistributionDetail => (
10325                        "Distribution Detail Help".to_string(),
10326                        help_strings::analysis_distribution_detail().to_string(),
10327                    ),
10328                    analysis_modal::AnalysisView::CorrelationDetail => (
10329                        "Correlation Detail Help".to_string(),
10330                        help_strings::analysis_correlation_detail().to_string(),
10331                    ),
10332                    analysis_modal::AnalysisView::Main => match self.analysis_modal.selected_tool {
10333                        Some(analysis_modal::AnalysisTool::DistributionAnalysis) => (
10334                            "Distribution Analysis Help".to_string(),
10335                            help_strings::analysis_distribution().to_string(),
10336                        ),
10337                        Some(analysis_modal::AnalysisTool::Describe) => (
10338                            "Describe Tool Help".to_string(),
10339                            help_strings::analysis_describe().to_string(),
10340                        ),
10341                        Some(analysis_modal::AnalysisTool::CorrelationMatrix) => (
10342                            "Correlation Matrix Help".to_string(),
10343                            help_strings::analysis_correlation_matrix().to_string(),
10344                        ),
10345                        None => (
10346                            "Analysis Help".to_string(),
10347                            "Select an analysis tool from the sidebar.".to_string(),
10348                        ),
10349                    },
10350                }
10351            } else if self.template_modal.active {
10352                (
10353                    "Template Help".to_string(),
10354                    help_strings::template().to_string(),
10355                )
10356            } else {
10357                let (t, txt) = self.get_help_info();
10358                (t.to_string(), txt.to_string())
10359            };
10360
10361            // Create layout with scrollbar
10362            let help_layout = Layout::default()
10363                .direction(Direction::Horizontal)
10364                .constraints([Constraint::Fill(1), Constraint::Length(1)])
10365                .split(popup_area);
10366
10367            let text_area = help_layout[0];
10368            let scrollbar_area = help_layout[1];
10369
10370            // Render text with scroll offset
10371            let block = Block::default()
10372                .title(title)
10373                .borders(Borders::ALL)
10374                .border_type(BorderType::Rounded);
10375            let inner_area = block.inner(text_area);
10376            block.render(text_area, buf);
10377
10378            // Split text into source lines
10379            let text_lines: Vec<&str> = text.as_str().lines().collect();
10380            let available_width = inner_area.width as usize;
10381            let available_height = inner_area.height as usize;
10382
10383            // Calculate wrapped lines for each source line
10384            let mut wrapped_lines = Vec::new();
10385            for line in &text_lines {
10386                if line.len() <= available_width {
10387                    wrapped_lines.push(*line);
10388                } else {
10389                    // Split long lines into wrapped segments (at char boundaries so UTF-8 is safe)
10390                    let mut remaining = *line;
10391                    while !remaining.is_empty() {
10392                        let mut take = remaining.len().min(available_width);
10393                        while take > 0 && !remaining.is_char_boundary(take) {
10394                            take -= 1;
10395                        }
10396                        // If take is 0 (e.g. first char is multi-byte and width is 1), advance by one char
10397                        let take_len = if take == 0 {
10398                            remaining.chars().next().map_or(0, |c| c.len_utf8())
10399                        } else {
10400                            take
10401                        };
10402                        let (chunk, rest) = remaining.split_at(take_len);
10403                        wrapped_lines.push(chunk);
10404                        remaining = rest;
10405                    }
10406                }
10407            }
10408
10409            let total_wrapped_lines = wrapped_lines.len();
10410
10411            // Clamp scroll position
10412            let max_scroll = total_wrapped_lines.saturating_sub(available_height).max(0);
10413            // Use analysis modal's help scroll if in analysis help, otherwise use main help scroll
10414            let current_scroll = if self.analysis_modal.active && self.analysis_modal.show_help {
10415                // For now, use main help_scroll - could add separate scroll for analysis if needed
10416                self.help_scroll
10417            } else {
10418                self.help_scroll
10419            };
10420            let clamped_scroll = current_scroll.min(max_scroll);
10421            if self.analysis_modal.active && self.analysis_modal.show_help {
10422                // Could store in analysis_modal if needed, but for now use main help_scroll
10423                self.help_scroll = clamped_scroll;
10424            } else {
10425                self.help_scroll = clamped_scroll;
10426            }
10427
10428            // Get visible lines (use clamped scroll)
10429            let scroll_pos = self.help_scroll;
10430            let visible_lines: Vec<&str> = wrapped_lines
10431                .iter()
10432                .skip(scroll_pos)
10433                .take(available_height)
10434                .copied()
10435                .collect();
10436
10437            let visible_text = visible_lines.join("\n");
10438            Paragraph::new(visible_text)
10439                .wrap(ratatui::widgets::Wrap { trim: false })
10440                .render(inner_area, buf);
10441
10442            // Render scrollbar if content is scrollable
10443            if total_wrapped_lines > available_height {
10444                let scrollbar_height = scrollbar_area.height;
10445                let scroll_pos = self.help_scroll;
10446                let scrollbar_pos = if max_scroll > 0 {
10447                    ((scroll_pos as f64 / max_scroll as f64)
10448                        * (scrollbar_height.saturating_sub(1) as f64)) as u16
10449                } else {
10450                    0
10451                };
10452
10453                // Calculate thumb size (proportion of visible content)
10454                let thumb_size = ((available_height as f64 / total_wrapped_lines as f64)
10455                    * scrollbar_height as f64)
10456                    .max(1.0) as u16;
10457                let thumb_size = thumb_size.min(scrollbar_height);
10458
10459                // Draw scrollbar track
10460                for y in 0..scrollbar_height {
10461                    let is_thumb = y >= scrollbar_pos && y < scrollbar_pos + thumb_size;
10462                    let style = if is_thumb {
10463                        Style::default().bg(self.color("text_primary"))
10464                    } else {
10465                        Style::default().bg(self.color("surface"))
10466                    };
10467                    buf.set_string(scrollbar_area.x, scrollbar_area.y + y, "█", style);
10468                }
10469            }
10470        }
10471
10472        // Get row count from state if available
10473        let row_count = self.data_table_state.as_ref().map(|s| s.num_rows);
10474        // Check if query is active
10475        let query_active = self
10476            .data_table_state
10477            .as_ref()
10478            .map(|s| !s.active_query.trim().is_empty())
10479            .unwrap_or(false);
10480        // Dim controls when any modal is active (except analysis/chart modals use their own controls)
10481        let is_modal_active = self.show_help
10482            || self.input_mode == InputMode::Editing
10483            || self.input_mode == InputMode::SortFilter
10484            || self.input_mode == InputMode::PivotMelt
10485            || self.input_mode == InputMode::Info
10486            || self.sort_filter_modal.active;
10487
10488        // Build controls - use analysis-specific controls if analysis modal is active
10489        let use_unicode_throbber = std::env::var("LANG")
10490            .map(|l| l.to_uppercase().contains("UTF-8"))
10491            .unwrap_or(false);
10492        let mut controls = Controls::with_row_count(row_count.unwrap_or(0))
10493            .with_colors(
10494                self.color("controls_bg"),
10495                self.color("keybind_hints"),
10496                self.color("keybind_labels"),
10497                self.color("throbber"),
10498            )
10499            .with_unicode_throbber(use_unicode_throbber);
10500
10501        if self.analysis_modal.active {
10502            // Build analysis-specific controls based on view
10503            let mut analysis_controls = vec![
10504                ("Esc", "Back"),
10505                ("↑↓", "Navigate"),
10506                ("←→", "Scroll Columns"),
10507                ("Tab", "Sidebar"),
10508                ("Enter", "Select"),
10509            ];
10510
10511            // Show r Resample only when sampling is enabled and current tool's data was sampled
10512            if self.sampling_threshold.is_some() {
10513                if let Some(results) = self.analysis_modal.current_results() {
10514                    if results.sample_size.is_some() {
10515                        analysis_controls.push(("r", "Resample"));
10516                    }
10517                }
10518            }
10519
10520            controls = controls.with_custom_controls(analysis_controls);
10521        } else if self.input_mode == InputMode::Chart {
10522            let chart_controls = vec![("Esc", "Back"), ("e", "Export")];
10523            controls = controls.with_custom_controls(chart_controls);
10524        } else {
10525            controls = controls
10526                .with_dimmed(is_modal_active)
10527                .with_query_active(query_active);
10528        }
10529
10530        if self.busy {
10531            self.throbber_frame = self.throbber_frame.wrapping_add(1);
10532        }
10533        controls = controls.with_busy(self.busy, self.throbber_frame);
10534        controls.render(controls_area, buf);
10535        if self.debug.enabled && layout.len() > debug_area_index {
10536            self.debug.render(layout[debug_area_index], buf);
10537        }
10538    }
10539}
10540
10541fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
10542    let popup_layout = Layout::default()
10543        .direction(Direction::Vertical)
10544        .constraints([
10545            Constraint::Percentage((100 - percent_y) / 2),
10546            Constraint::Percentage(percent_y),
10547            Constraint::Percentage((100 - percent_y) / 2),
10548        ])
10549        .split(r);
10550
10551    Layout::default()
10552        .direction(Direction::Horizontal)
10553        .constraints([
10554            Constraint::Percentage((100 - percent_x) / 2),
10555            Constraint::Percentage(percent_x),
10556            Constraint::Percentage((100 - percent_x) / 2),
10557        ])
10558        .split(popup_layout[1])[1]
10559}
10560
10561/// Like `centered_rect` but enforces minimum width and height so the dialog
10562/// stays usable on very small terminals.
10563fn centered_rect_with_min(
10564    r: Rect,
10565    percent_x: u16,
10566    percent_y: u16,
10567    min_width: u16,
10568    min_height: u16,
10569) -> Rect {
10570    let inner = centered_rect(r, percent_x, percent_y);
10571    let width = inner.width.max(min_width).min(r.width);
10572    let height = inner.height.max(min_height).min(r.height);
10573    let x = r.x + r.width.saturating_sub(width) / 2;
10574    let y = r.y + r.height.saturating_sub(height) / 2;
10575    Rect::new(x, y, width, height)
10576}
10577
10578/// Rect for the loading progress popover: at least 25 characters wide, at most 25% of area width.
10579/// Height is at least 5 lines, at most 20% of area height.
10580fn centered_rect_loading(r: Rect) -> Rect {
10581    const MIN_WIDTH: u16 = 25;
10582    const MAX_WIDTH_PERCENT: u16 = 25;
10583    const MIN_HEIGHT: u16 = 5;
10584    const MAX_HEIGHT_PERCENT: u16 = 20;
10585
10586    let width = (r.width * MAX_WIDTH_PERCENT / 100)
10587        .max(MIN_WIDTH)
10588        .min(r.width);
10589    let height = (r.height * MAX_HEIGHT_PERCENT / 100)
10590        .max(MIN_HEIGHT)
10591        .min(r.height);
10592
10593    let x = r.x + r.width.saturating_sub(width) / 2;
10594    let y = r.y + r.height.saturating_sub(height) / 2;
10595    Rect::new(x, y, width, height)
10596}
10597
10598/// Run the TUI with either file paths or an existing LazyFrame. Single event loop used by CLI and Python binding.
10599pub fn run(input: RunInput, config: Option<AppConfig>, debug: bool) -> Result<()> {
10600    use std::io::Write;
10601    use std::sync::{mpsc, Mutex, Once};
10602
10603    let config = match config {
10604        Some(c) => c,
10605        None => AppConfig::load(APP_NAME)?,
10606    };
10607
10608    let theme = Theme::from_config(&config.theme)
10609        .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
10610
10611    // Install color_eyre at most once per process (e.g. first datui.view() in Python).
10612    // Subsequent run() calls skip install and reuse the result; no error-message detection.
10613    static COLOR_EYRE_INIT: Once = Once::new();
10614    static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
10615    COLOR_EYRE_INIT.call_once(|| {
10616        *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
10617    });
10618    if let Some(Err(e)) = INSTALL_RESULT
10619        .lock()
10620        .unwrap_or_else(|e| e.into_inner())
10621        .as_ref()
10622    {
10623        return Err(color_eyre::eyre::eyre!(e.to_string()));
10624    }
10625    // Require at least one path so event handlers can safely use paths[0].
10626    if let RunInput::Paths(ref paths, _) = input {
10627        if paths.is_empty() {
10628            return Err(color_eyre::eyre::eyre!("At least one path is required"));
10629        }
10630        for path in paths {
10631            let s = path.to_string_lossy();
10632            let is_remote = s.starts_with("s3://")
10633                || s.starts_with("gs://")
10634                || s.starts_with("http://")
10635                || s.starts_with("https://");
10636            let is_glob = s.contains('*');
10637            if !is_remote && !is_glob && !path.exists() {
10638                return Err(std::io::Error::new(
10639                    std::io::ErrorKind::NotFound,
10640                    format!("File not found: {}", path.display()),
10641                )
10642                .into());
10643            }
10644        }
10645    }
10646    let mut terminal = ratatui::try_init().map_err(|e| {
10647        color_eyre::eyre::eyre!(
10648            "datui requires an interactive terminal (TTY). No terminal detected: {}. \
10649             Run from a terminal or ensure stdout is connected to a TTY.",
10650            e
10651        )
10652    })?;
10653    let (tx, rx) = mpsc::channel::<AppEvent>();
10654    let mut app = App::new_with_config(tx.clone(), theme, config.clone());
10655    if debug {
10656        app.enable_debug();
10657    }
10658
10659    terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10660
10661    match input {
10662        RunInput::Paths(paths, opts) => {
10663            tx.send(AppEvent::Open(paths, opts))?;
10664        }
10665        RunInput::LazyFrame(lf, opts) => {
10666            // Show loading dialog immediately so it is visible when launch is from Python/LazyFrame
10667            // (before sending the event and before any blocking work in the event handler).
10668            app.set_loading_phase("Scanning input", 10);
10669            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10670            let _ = std::io::stdout().flush();
10671            // Brief pause so the terminal can display the frame when run from Python (e.g. maturin).
10672            std::thread::sleep(std::time::Duration::from_millis(150));
10673            tx.send(AppEvent::OpenLazyFrame(lf, opts))?;
10674        }
10675    }
10676
10677    // Process load events and draw so the loading progress dialog updates (e.g. "Caching schema")
10678    // before any blocking work. Keeps processing until no event is received (timeout).
10679    loop {
10680        let event = match rx.recv_timeout(std::time::Duration::from_millis(50)) {
10681            Ok(ev) => ev,
10682            Err(mpsc::RecvTimeoutError::Timeout) => break,
10683            Err(mpsc::RecvTimeoutError::Disconnected) => break,
10684        };
10685        match event {
10686            AppEvent::Exit => break,
10687            AppEvent::Crash(msg) => {
10688                ratatui::restore();
10689                return Err(color_eyre::eyre::eyre!(msg));
10690            }
10691            ev => {
10692                if let Some(next) = app.event(&ev) {
10693                    let _ = tx.send(next);
10694                }
10695                terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10696                let _ = std::io::stdout().flush();
10697                // After processing DoLoadSchema we've drawn "Caching schema"; next event is DoLoadSchemaBlocking (blocking).
10698                // Leave it for the main loop so we don't block here.
10699                if matches!(ev, AppEvent::DoLoadSchema(..)) {
10700                    break;
10701                }
10702            }
10703        }
10704    }
10705
10706    loop {
10707        if crossterm::event::poll(std::time::Duration::from_millis(
10708            config.performance.event_poll_interval_ms,
10709        ))? {
10710            match crossterm::event::read()? {
10711                crossterm::event::Event::Key(key) => {
10712                    if key.is_press() {
10713                        tx.send(AppEvent::Key(key))?
10714                    }
10715                }
10716                crossterm::event::Event::Resize(cols, rows) => {
10717                    tx.send(AppEvent::Resize(cols, rows))?
10718                }
10719                _ => {}
10720            }
10721        }
10722
10723        let updated = match rx.recv_timeout(std::time::Duration::from_millis(0)) {
10724            Ok(event) => {
10725                match event {
10726                    AppEvent::Exit => break,
10727                    AppEvent::Crash(msg) => {
10728                        ratatui::restore();
10729                        return Err(color_eyre::eyre::eyre!(msg));
10730                    }
10731                    event => {
10732                        if let Some(next) = app.event(&event) {
10733                            tx.send(next)?;
10734                        }
10735                    }
10736                }
10737                true
10738            }
10739            Err(mpsc::RecvTimeoutError::Timeout) => false,
10740            Err(mpsc::RecvTimeoutError::Disconnected) => break,
10741        };
10742
10743        if updated {
10744            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10745            if app.should_drain_keys() {
10746                while crossterm::event::poll(std::time::Duration::from_millis(0))? {
10747                    let _ = crossterm::event::read();
10748                }
10749                app.clear_drain_keys_request();
10750            }
10751        }
10752    }
10753
10754    ratatui::restore();
10755    Ok(())
10756}