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::Down
2645                    if on_body
2646                        && filter_tab
2647                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2648                {
2649                    let m = &mut self.sort_filter_modal.filter;
2650                    let i = match m.list_state.selected() {
2651                        Some(i) => {
2652                            if i >= m.statements.len().saturating_sub(1) {
2653                                0
2654                            } else {
2655                                i + 1
2656                            }
2657                        }
2658                        None => 0,
2659                    };
2660                    m.list_state.select(Some(i));
2661                }
2662                KeyCode::Up
2663                    if on_body
2664                        && filter_tab
2665                        && self.sort_filter_modal.filter.focus == FilterFocus::Statements =>
2666                {
2667                    let m = &mut self.sort_filter_modal.filter;
2668                    let i = match m.list_state.selected() {
2669                        Some(i) => {
2670                            if i == 0 {
2671                                m.statements.len().saturating_sub(1)
2672                            } else {
2673                                i - 1
2674                            }
2675                        }
2676                        None => 0,
2677                    };
2678                    m.list_state.select(Some(i));
2679                }
2680                KeyCode::Down | KeyCode::Char('j') if on_body && sort_tab => {
2681                    let s = &mut self.sort_filter_modal.sort;
2682                    if s.focus == SortFocus::ColumnList {
2683                        let i = match s.table_state.selected() {
2684                            Some(i) => {
2685                                if i >= s.filtered_columns().len().saturating_sub(1) {
2686                                    0
2687                                } else {
2688                                    i + 1
2689                                }
2690                            }
2691                            None => 0,
2692                        };
2693                        s.table_state.select(Some(i));
2694                    } else {
2695                        let _ = s.next_body_focus();
2696                    }
2697                }
2698                KeyCode::Up | KeyCode::Char('k') if on_body && sort_tab => {
2699                    let s = &mut self.sort_filter_modal.sort;
2700                    if s.focus == SortFocus::ColumnList {
2701                        let i = match s.table_state.selected() {
2702                            Some(i) => {
2703                                if i == 0 {
2704                                    s.filtered_columns().len().saturating_sub(1)
2705                                } else {
2706                                    i - 1
2707                                }
2708                            }
2709                            None => 0,
2710                        };
2711                        s.table_state.select(Some(i));
2712                    } else {
2713                        let _ = s.prev_body_focus();
2714                    }
2715                }
2716                KeyCode::Char(']')
2717                    if on_body
2718                        && sort_tab
2719                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2720                {
2721                    self.sort_filter_modal.sort.move_selection_down();
2722                }
2723                KeyCode::Char('[')
2724                    if on_body
2725                        && sort_tab
2726                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2727                {
2728                    self.sort_filter_modal.sort.move_selection_up();
2729                }
2730                KeyCode::Char('+') | KeyCode::Char('=')
2731                    if on_body
2732                        && sort_tab
2733                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2734                {
2735                    self.sort_filter_modal.sort.move_column_display_up();
2736                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2737                }
2738                KeyCode::Char('-') | KeyCode::Char('_')
2739                    if on_body
2740                        && sort_tab
2741                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2742                {
2743                    self.sort_filter_modal.sort.move_column_display_down();
2744                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2745                }
2746                KeyCode::Char('L')
2747                    if on_body
2748                        && sort_tab
2749                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2750                {
2751                    self.sort_filter_modal.sort.toggle_lock_at_column();
2752                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2753                }
2754                KeyCode::Char('v')
2755                    if on_body
2756                        && sort_tab
2757                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList =>
2758                {
2759                    self.sort_filter_modal.sort.toggle_visibility();
2760                    self.sort_filter_modal.sort.has_unapplied_changes = true;
2761                }
2762                KeyCode::Char(c)
2763                    if on_body
2764                        && sort_tab
2765                        && self.sort_filter_modal.sort.focus == SortFocus::ColumnList
2766                        && c.is_ascii_digit() =>
2767                {
2768                    if let Some(digit) = c.to_digit(10) {
2769                        self.sort_filter_modal
2770                            .sort
2771                            .jump_selection_to_order(digit as usize);
2772                    }
2773                }
2774                // Handle filter input field in sort tab
2775                // Only handle keys that the text input should process
2776                // Special keys like Tab, Esc, Enter are handled by other patterns above
2777                _ if on_body
2778                    && sort_tab
2779                    && self.sort_filter_modal.sort.focus == SortFocus::Filter
2780                    && !matches!(
2781                        event.code,
2782                        KeyCode::Tab
2783                            | KeyCode::BackTab
2784                            | KeyCode::Esc
2785                            | KeyCode::Enter
2786                            | KeyCode::Up
2787                            | KeyCode::Down
2788                    ) =>
2789                {
2790                    // Pass key events to the filter input
2791                    let _ = self
2792                        .sort_filter_modal
2793                        .sort
2794                        .filter_input
2795                        .handle_key(event, Some(&self.cache));
2796                }
2797                KeyCode::Char(c)
2798                    if on_body
2799                        && filter_tab
2800                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2801                {
2802                    self.sort_filter_modal.filter.new_value.push(c);
2803                }
2804                KeyCode::Backspace
2805                    if on_body
2806                        && filter_tab
2807                        && self.sort_filter_modal.filter.focus == FilterFocus::Value =>
2808                {
2809                    self.sort_filter_modal.filter.new_value.pop();
2810                }
2811                KeyCode::Right | KeyCode::Char('l') if on_body && filter_tab => {
2812                    let m = &mut self.sort_filter_modal.filter;
2813                    match m.focus {
2814                        FilterFocus::Column => {
2815                            m.new_column_idx =
2816                                (m.new_column_idx + 1) % m.available_columns.len().max(1);
2817                        }
2818                        FilterFocus::Operator => {
2819                            m.new_operator_idx =
2820                                (m.new_operator_idx + 1) % FilterOperator::iterator().count();
2821                        }
2822                        FilterFocus::Logical => {
2823                            m.new_logical_idx =
2824                                (m.new_logical_idx + 1) % LogicalOperator::iterator().count();
2825                        }
2826                        _ => {}
2827                    }
2828                }
2829                KeyCode::Left | KeyCode::Char('h') if on_body && filter_tab => {
2830                    let m = &mut self.sort_filter_modal.filter;
2831                    match m.focus {
2832                        FilterFocus::Column => {
2833                            m.new_column_idx = if m.new_column_idx == 0 {
2834                                m.available_columns.len().saturating_sub(1)
2835                            } else {
2836                                m.new_column_idx - 1
2837                            };
2838                        }
2839                        FilterFocus::Operator => {
2840                            m.new_operator_idx = if m.new_operator_idx == 0 {
2841                                FilterOperator::iterator().count() - 1
2842                            } else {
2843                                m.new_operator_idx - 1
2844                            };
2845                        }
2846                        FilterFocus::Logical => {
2847                            m.new_logical_idx = if m.new_logical_idx == 0 {
2848                                LogicalOperator::iterator().count() - 1
2849                            } else {
2850                                m.new_logical_idx - 1
2851                            };
2852                        }
2853                        _ => {}
2854                    }
2855                }
2856                _ => {}
2857            }
2858            return None;
2859        }
2860
2861        if self.input_mode == InputMode::Export {
2862            match event.code {
2863                KeyCode::Esc => {
2864                    self.export_modal.close();
2865                    self.input_mode = InputMode::Normal;
2866                }
2867                KeyCode::Tab => self.export_modal.next_focus(),
2868                KeyCode::BackTab => self.export_modal.prev_focus(),
2869                KeyCode::Up | KeyCode::Char('k') => {
2870                    match self.export_modal.focus {
2871                        ExportFocus::FormatSelector => {
2872                            // Cycle through formats
2873                            let current_idx = ExportFormat::ALL
2874                                .iter()
2875                                .position(|&f| f == self.export_modal.selected_format)
2876                                .unwrap_or(0);
2877                            let prev_idx = if current_idx == 0 {
2878                                ExportFormat::ALL.len() - 1
2879                            } else {
2880                                current_idx - 1
2881                            };
2882                            self.export_modal.selected_format = ExportFormat::ALL[prev_idx];
2883                        }
2884                        ExportFocus::PathInput => {
2885                            // Pass to text input widget (for history navigation)
2886                            self.export_modal.path_input.handle_key(event, None);
2887                        }
2888                        ExportFocus::CsvDelimiter => {
2889                            // Pass to text input widget (for history navigation)
2890                            self.export_modal
2891                                .csv_delimiter_input
2892                                .handle_key(event, None);
2893                        }
2894                        ExportFocus::CsvCompression
2895                        | ExportFocus::JsonCompression
2896                        | ExportFocus::NdjsonCompression => {
2897                            // Left to move to previous compression option
2898                            self.export_modal.cycle_compression_backward();
2899                        }
2900                        _ => {
2901                            self.export_modal.prev_focus();
2902                        }
2903                    }
2904                }
2905                KeyCode::Down | KeyCode::Char('j') => {
2906                    match self.export_modal.focus {
2907                        ExportFocus::FormatSelector => {
2908                            // Cycle through formats
2909                            let current_idx = ExportFormat::ALL
2910                                .iter()
2911                                .position(|&f| f == self.export_modal.selected_format)
2912                                .unwrap_or(0);
2913                            let next_idx = (current_idx + 1) % ExportFormat::ALL.len();
2914                            self.export_modal.selected_format = ExportFormat::ALL[next_idx];
2915                        }
2916                        ExportFocus::PathInput => {
2917                            // Pass to text input widget (for history navigation)
2918                            self.export_modal.path_input.handle_key(event, None);
2919                        }
2920                        ExportFocus::CsvDelimiter => {
2921                            // Pass to text input widget (for history navigation)
2922                            self.export_modal
2923                                .csv_delimiter_input
2924                                .handle_key(event, None);
2925                        }
2926                        ExportFocus::CsvCompression
2927                        | ExportFocus::JsonCompression
2928                        | ExportFocus::NdjsonCompression => {
2929                            // Right to move to next compression option
2930                            self.export_modal.cycle_compression();
2931                        }
2932                        _ => {
2933                            self.export_modal.next_focus();
2934                        }
2935                    }
2936                }
2937                KeyCode::Left | KeyCode::Char('h') => {
2938                    match self.export_modal.focus {
2939                        ExportFocus::PathInput => {
2940                            self.export_modal.path_input.handle_key(event, None);
2941                        }
2942                        ExportFocus::CsvDelimiter => {
2943                            self.export_modal
2944                                .csv_delimiter_input
2945                                .handle_key(event, None);
2946                        }
2947                        ExportFocus::FormatSelector => {
2948                            // Don't change focus in format selector
2949                        }
2950                        ExportFocus::CsvCompression
2951                        | ExportFocus::JsonCompression
2952                        | ExportFocus::NdjsonCompression => {
2953                            // Move to previous compression option
2954                            self.export_modal.cycle_compression_backward();
2955                        }
2956                        _ => self.export_modal.prev_focus(),
2957                    }
2958                }
2959                KeyCode::Right | KeyCode::Char('l') => {
2960                    match self.export_modal.focus {
2961                        ExportFocus::PathInput => {
2962                            self.export_modal.path_input.handle_key(event, None);
2963                        }
2964                        ExportFocus::CsvDelimiter => {
2965                            self.export_modal
2966                                .csv_delimiter_input
2967                                .handle_key(event, None);
2968                        }
2969                        ExportFocus::FormatSelector => {
2970                            // Don't change focus in format selector
2971                        }
2972                        ExportFocus::CsvCompression
2973                        | ExportFocus::JsonCompression
2974                        | ExportFocus::NdjsonCompression => {
2975                            // Move to next compression option
2976                            self.export_modal.cycle_compression();
2977                        }
2978                        _ => self.export_modal.next_focus(),
2979                    }
2980                }
2981                KeyCode::Enter => {
2982                    match self.export_modal.focus {
2983                        ExportFocus::PathInput => {
2984                            // Enter from path input triggers export (same as Export button)
2985                            let path_str = self.export_modal.path_input.value.trim();
2986                            if !path_str.is_empty() {
2987                                let mut path = PathBuf::from(path_str);
2988                                let format = self.export_modal.selected_format;
2989                                // Get compression format for this export format
2990                                let compression = match format {
2991                                    ExportFormat::Csv => self.export_modal.csv_compression,
2992                                    ExportFormat::Json => self.export_modal.json_compression,
2993                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
2994                                    ExportFormat::Parquet
2995                                    | ExportFormat::Ipc
2996                                    | ExportFormat::Avro => None,
2997                                };
2998                                // Ensure file extension is present (including compression extension if needed)
2999                                let path_with_ext =
3000                                    Self::ensure_file_extension(&path, format, compression);
3001                                // Update the path input to show the extension
3002                                if path_with_ext != path {
3003                                    self.export_modal
3004                                        .path_input
3005                                        .set_value(path_with_ext.display().to_string());
3006                                }
3007                                path = path_with_ext;
3008                                let delimiter =
3009                                    self.export_modal
3010                                        .csv_delimiter_input
3011                                        .value
3012                                        .chars()
3013                                        .next()
3014                                        .unwrap_or(',') as u8;
3015                                let options = ExportOptions {
3016                                    csv_delimiter: delimiter,
3017                                    csv_include_header: self.export_modal.csv_include_header,
3018                                    csv_compression: self.export_modal.csv_compression,
3019                                    json_compression: self.export_modal.json_compression,
3020                                    ndjson_compression: self.export_modal.ndjson_compression,
3021                                    parquet_compression: None,
3022                                };
3023                                // Check if file exists and show confirmation
3024                                if path.exists() {
3025                                    let path_display = path.display().to_string();
3026                                    self.pending_export = Some((path, format, options));
3027                                    self.confirmation_modal.show(format!(
3028                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3029                                        path_display
3030                                    ));
3031                                    self.export_modal.close();
3032                                    self.input_mode = InputMode::Normal;
3033                                } else {
3034                                    // Start export with progress
3035                                    self.export_modal.close();
3036                                    self.input_mode = InputMode::Normal;
3037                                    return Some(AppEvent::Export(path, format, options));
3038                                }
3039                            }
3040                        }
3041                        ExportFocus::ExportButton => {
3042                            if !self.export_modal.path_input.value.is_empty() {
3043                                let mut path = PathBuf::from(&self.export_modal.path_input.value);
3044                                let format = self.export_modal.selected_format;
3045                                // Get compression format for this export format
3046                                let compression = match format {
3047                                    ExportFormat::Csv => self.export_modal.csv_compression,
3048                                    ExportFormat::Json => self.export_modal.json_compression,
3049                                    ExportFormat::Ndjson => self.export_modal.ndjson_compression,
3050                                    ExportFormat::Parquet
3051                                    | ExportFormat::Ipc
3052                                    | ExportFormat::Avro => None,
3053                                };
3054                                // Ensure file extension is present (including compression extension if needed)
3055                                let path_with_ext =
3056                                    Self::ensure_file_extension(&path, format, compression);
3057                                // Update the path input to show the extension
3058                                if path_with_ext != path {
3059                                    self.export_modal
3060                                        .path_input
3061                                        .set_value(path_with_ext.display().to_string());
3062                                }
3063                                path = path_with_ext;
3064                                let delimiter =
3065                                    self.export_modal
3066                                        .csv_delimiter_input
3067                                        .value
3068                                        .chars()
3069                                        .next()
3070                                        .unwrap_or(',') as u8;
3071                                let options = ExportOptions {
3072                                    csv_delimiter: delimiter,
3073                                    csv_include_header: self.export_modal.csv_include_header,
3074                                    csv_compression: self.export_modal.csv_compression,
3075                                    json_compression: self.export_modal.json_compression,
3076                                    ndjson_compression: self.export_modal.ndjson_compression,
3077                                    parquet_compression: None,
3078                                };
3079                                // Check if file exists and show confirmation
3080                                if path.exists() {
3081                                    let path_display = path.display().to_string();
3082                                    self.pending_export = Some((path, format, options));
3083                                    self.confirmation_modal.show(format!(
3084                                        "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3085                                        path_display
3086                                    ));
3087                                    self.export_modal.close();
3088                                    self.input_mode = InputMode::Normal;
3089                                } else {
3090                                    // Start export with progress
3091                                    self.export_modal.close();
3092                                    self.input_mode = InputMode::Normal;
3093                                    return Some(AppEvent::Export(path, format, options));
3094                                }
3095                            }
3096                        }
3097                        ExportFocus::CancelButton => {
3098                            self.export_modal.close();
3099                            self.input_mode = InputMode::Normal;
3100                        }
3101                        ExportFocus::CsvIncludeHeader => {
3102                            self.export_modal.csv_include_header =
3103                                !self.export_modal.csv_include_header;
3104                        }
3105                        ExportFocus::CsvCompression
3106                        | ExportFocus::JsonCompression
3107                        | ExportFocus::NdjsonCompression => {
3108                            // Enter to select current compression option
3109                            // (Already selected via Left/Right navigation)
3110                        }
3111                        _ => {}
3112                    }
3113                }
3114                KeyCode::Char(' ') => {
3115                    // Space to toggle checkboxes, but pass to text inputs if they're focused
3116                    match self.export_modal.focus {
3117                        ExportFocus::PathInput => {
3118                            // Pass spacebar to text input
3119                            self.export_modal.path_input.handle_key(event, None);
3120                        }
3121                        ExportFocus::CsvDelimiter => {
3122                            // Pass spacebar to text input
3123                            self.export_modal
3124                                .csv_delimiter_input
3125                                .handle_key(event, None);
3126                        }
3127                        ExportFocus::CsvIncludeHeader => {
3128                            // Toggle checkbox
3129                            self.export_modal.csv_include_header =
3130                                !self.export_modal.csv_include_header;
3131                        }
3132                        _ => {}
3133                    }
3134                }
3135                KeyCode::Char(_)
3136                | KeyCode::Backspace
3137                | KeyCode::Delete
3138                | KeyCode::Home
3139                | KeyCode::End => {
3140                    match self.export_modal.focus {
3141                        ExportFocus::PathInput => {
3142                            self.export_modal.path_input.handle_key(event, None);
3143                        }
3144                        ExportFocus::CsvDelimiter => {
3145                            self.export_modal
3146                                .csv_delimiter_input
3147                                .handle_key(event, None);
3148                        }
3149                        ExportFocus::FormatSelector => {
3150                            // Don't input text in format selector
3151                        }
3152                        _ => {}
3153                    }
3154                }
3155                _ => {}
3156            }
3157            return None;
3158        }
3159
3160        if self.input_mode == InputMode::PivotMelt {
3161            let pivot_melt_text_focus = matches!(
3162                self.pivot_melt_modal.focus,
3163                PivotMeltFocus::PivotFilter
3164                    | PivotMeltFocus::MeltFilter
3165                    | PivotMeltFocus::MeltPattern
3166                    | PivotMeltFocus::MeltVarName
3167                    | PivotMeltFocus::MeltValName
3168            );
3169            let ctrl_help = event.modifiers.contains(KeyModifiers::CONTROL);
3170            if event.code == KeyCode::Char('?') && (ctrl_help || !pivot_melt_text_focus) {
3171                self.show_help = true;
3172                return None;
3173            }
3174            match event.code {
3175                KeyCode::Esc => {
3176                    self.pivot_melt_modal.close();
3177                    self.input_mode = InputMode::Normal;
3178                }
3179                KeyCode::Tab => self.pivot_melt_modal.next_focus(),
3180                KeyCode::BackTab => self.pivot_melt_modal.prev_focus(),
3181                KeyCode::Left => {
3182                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3183                        self.pivot_melt_modal
3184                            .pivot_filter_input
3185                            .handle_key(event, None);
3186                        self.pivot_melt_modal.pivot_index_table.select(None);
3187                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter {
3188                        self.pivot_melt_modal
3189                            .melt_filter_input
3190                            .handle_key(event, None);
3191                        self.pivot_melt_modal.melt_index_table.select(None);
3192                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern
3193                        && self.pivot_melt_modal.melt_pattern_cursor > 0
3194                    {
3195                        self.pivot_melt_modal.melt_pattern_cursor -= 1;
3196                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName
3197                        && self.pivot_melt_modal.melt_variable_cursor > 0
3198                    {
3199                        self.pivot_melt_modal.melt_variable_cursor -= 1;
3200                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName
3201                        && self.pivot_melt_modal.melt_value_cursor > 0
3202                    {
3203                        self.pivot_melt_modal.melt_value_cursor -= 1;
3204                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3205                        self.pivot_melt_modal.switch_tab();
3206                    } else {
3207                        self.pivot_melt_modal.prev_focus();
3208                    }
3209                }
3210                KeyCode::Right => {
3211                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter {
3212                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern {
3213                        let n = self.pivot_melt_modal.melt_pattern.chars().count();
3214                        if self.pivot_melt_modal.melt_pattern_cursor < n {
3215                            self.pivot_melt_modal.melt_pattern_cursor += 1;
3216                        }
3217                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName {
3218                        let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3219                        if self.pivot_melt_modal.melt_variable_cursor < n {
3220                            self.pivot_melt_modal.melt_variable_cursor += 1;
3221                        }
3222                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName {
3223                        let n = self.pivot_melt_modal.melt_value_name.chars().count();
3224                        if self.pivot_melt_modal.melt_value_cursor < n {
3225                            self.pivot_melt_modal.melt_value_cursor += 1;
3226                        }
3227                    } else if self.pivot_melt_modal.focus == PivotMeltFocus::TabBar {
3228                        self.pivot_melt_modal.switch_tab();
3229                    } else {
3230                        self.pivot_melt_modal.next_focus();
3231                    }
3232                }
3233                KeyCode::Enter => match self.pivot_melt_modal.focus {
3234                    PivotMeltFocus::Apply => {
3235                        return match self.pivot_melt_modal.active_tab {
3236                            PivotMeltTab::Pivot => {
3237                                if let Some(err) = self.pivot_melt_modal.pivot_validation_error() {
3238                                    self.error_modal.show(err);
3239                                    None
3240                                } else {
3241                                    self.pivot_melt_modal
3242                                        .build_pivot_spec()
3243                                        .map(AppEvent::Pivot)
3244                                }
3245                            }
3246                            PivotMeltTab::Melt => {
3247                                if let Some(err) = self.pivot_melt_modal.melt_validation_error() {
3248                                    self.error_modal.show(err);
3249                                    None
3250                                } else {
3251                                    self.pivot_melt_modal.build_melt_spec().map(AppEvent::Melt)
3252                                }
3253                            }
3254                        };
3255                    }
3256                    PivotMeltFocus::Cancel => {
3257                        self.pivot_melt_modal.close();
3258                        self.input_mode = InputMode::Normal;
3259                    }
3260                    PivotMeltFocus::Clear => {
3261                        self.pivot_melt_modal.reset_form();
3262                    }
3263                    _ => {}
3264                },
3265                KeyCode::Up | KeyCode::Char('k') => match self.pivot_melt_modal.focus {
3266                    PivotMeltFocus::PivotIndexList => {
3267                        self.pivot_melt_modal.pivot_move_index_selection(false);
3268                    }
3269                    PivotMeltFocus::PivotPivotCol => {
3270                        self.pivot_melt_modal.pivot_move_pivot_selection(false);
3271                    }
3272                    PivotMeltFocus::PivotValueCol => {
3273                        self.pivot_melt_modal.pivot_move_value_selection(false);
3274                    }
3275                    PivotMeltFocus::PivotAggregation => {
3276                        self.pivot_melt_modal.pivot_move_aggregation(false);
3277                    }
3278                    PivotMeltFocus::MeltIndexList => {
3279                        self.pivot_melt_modal.melt_move_index_selection(false);
3280                    }
3281                    PivotMeltFocus::MeltStrategy => {
3282                        self.pivot_melt_modal.melt_move_strategy(false);
3283                    }
3284                    PivotMeltFocus::MeltType => {
3285                        self.pivot_melt_modal.melt_move_type_filter(false);
3286                    }
3287                    PivotMeltFocus::MeltExplicitList => {
3288                        self.pivot_melt_modal.melt_move_explicit_selection(false);
3289                    }
3290                    _ => {}
3291                },
3292                KeyCode::Down | KeyCode::Char('j') => match self.pivot_melt_modal.focus {
3293                    PivotMeltFocus::PivotIndexList => {
3294                        self.pivot_melt_modal.pivot_move_index_selection(true);
3295                    }
3296                    PivotMeltFocus::PivotPivotCol => {
3297                        self.pivot_melt_modal.pivot_move_pivot_selection(true);
3298                    }
3299                    PivotMeltFocus::PivotValueCol => {
3300                        self.pivot_melt_modal.pivot_move_value_selection(true);
3301                    }
3302                    PivotMeltFocus::PivotAggregation => {
3303                        self.pivot_melt_modal.pivot_move_aggregation(true);
3304                    }
3305                    PivotMeltFocus::MeltIndexList => {
3306                        self.pivot_melt_modal.melt_move_index_selection(true);
3307                    }
3308                    PivotMeltFocus::MeltStrategy => {
3309                        self.pivot_melt_modal.melt_move_strategy(true);
3310                    }
3311                    PivotMeltFocus::MeltType => {
3312                        self.pivot_melt_modal.melt_move_type_filter(true);
3313                    }
3314                    PivotMeltFocus::MeltExplicitList => {
3315                        self.pivot_melt_modal.melt_move_explicit_selection(true);
3316                    }
3317                    _ => {}
3318                },
3319                KeyCode::Char(' ') => match self.pivot_melt_modal.focus {
3320                    PivotMeltFocus::PivotIndexList => {
3321                        self.pivot_melt_modal.pivot_toggle_index_at_selection();
3322                    }
3323                    PivotMeltFocus::PivotSortToggle => {
3324                        self.pivot_melt_modal.sort_new_columns =
3325                            !self.pivot_melt_modal.sort_new_columns;
3326                    }
3327                    PivotMeltFocus::MeltIndexList => {
3328                        self.pivot_melt_modal.melt_toggle_index_at_selection();
3329                    }
3330                    PivotMeltFocus::MeltExplicitList => {
3331                        self.pivot_melt_modal.melt_toggle_explicit_at_selection();
3332                    }
3333                    _ => {}
3334                },
3335                KeyCode::Home
3336                | KeyCode::End
3337                | KeyCode::Char(_)
3338                | KeyCode::Backspace
3339                | KeyCode::Delete
3340                    if self.pivot_melt_modal.focus == PivotMeltFocus::PivotFilter =>
3341                {
3342                    self.pivot_melt_modal
3343                        .pivot_filter_input
3344                        .handle_key(event, None);
3345                    self.pivot_melt_modal.pivot_index_table.select(None);
3346                }
3347                KeyCode::Home
3348                | KeyCode::End
3349                | KeyCode::Char(_)
3350                | KeyCode::Backspace
3351                | KeyCode::Delete
3352                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltFilter =>
3353                {
3354                    self.pivot_melt_modal
3355                        .melt_filter_input
3356                        .handle_key(event, None);
3357                    self.pivot_melt_modal.melt_index_table.select(None);
3358                }
3359                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3360                    self.pivot_melt_modal.melt_pattern_cursor = 0;
3361                }
3362                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3363                    self.pivot_melt_modal.melt_pattern_cursor =
3364                        self.pivot_melt_modal.melt_pattern.chars().count();
3365                }
3366                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3367                    let byte_pos: usize = self
3368                        .pivot_melt_modal
3369                        .melt_pattern
3370                        .chars()
3371                        .take(self.pivot_melt_modal.melt_pattern_cursor)
3372                        .map(|ch| ch.len_utf8())
3373                        .sum();
3374                    self.pivot_melt_modal.melt_pattern.insert(byte_pos, c);
3375                    self.pivot_melt_modal.melt_pattern_cursor += 1;
3376                }
3377                KeyCode::Backspace
3378                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern =>
3379                {
3380                    if self.pivot_melt_modal.melt_pattern_cursor > 0 {
3381                        let prev_byte: usize = self
3382                            .pivot_melt_modal
3383                            .melt_pattern
3384                            .chars()
3385                            .take(self.pivot_melt_modal.melt_pattern_cursor - 1)
3386                            .map(|ch| ch.len_utf8())
3387                            .sum();
3388                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[prev_byte..]
3389                            .chars()
3390                            .next()
3391                        {
3392                            self.pivot_melt_modal
3393                                .melt_pattern
3394                                .drain(prev_byte..prev_byte + ch.len_utf8());
3395                            self.pivot_melt_modal.melt_pattern_cursor -= 1;
3396                        }
3397                    }
3398                }
3399                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltPattern => {
3400                    let n = self.pivot_melt_modal.melt_pattern.chars().count();
3401                    if self.pivot_melt_modal.melt_pattern_cursor < n {
3402                        let byte_pos: usize = self
3403                            .pivot_melt_modal
3404                            .melt_pattern
3405                            .chars()
3406                            .take(self.pivot_melt_modal.melt_pattern_cursor)
3407                            .map(|ch| ch.len_utf8())
3408                            .sum();
3409                        if let Some(ch) = self.pivot_melt_modal.melt_pattern[byte_pos..]
3410                            .chars()
3411                            .next()
3412                        {
3413                            self.pivot_melt_modal
3414                                .melt_pattern
3415                                .drain(byte_pos..byte_pos + ch.len_utf8());
3416                        }
3417                    }
3418                }
3419                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3420                    self.pivot_melt_modal.melt_variable_cursor = 0;
3421                }
3422                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3423                    self.pivot_melt_modal.melt_variable_cursor =
3424                        self.pivot_melt_modal.melt_variable_name.chars().count();
3425                }
3426                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3427                    let byte_pos: usize = self
3428                        .pivot_melt_modal
3429                        .melt_variable_name
3430                        .chars()
3431                        .take(self.pivot_melt_modal.melt_variable_cursor)
3432                        .map(|ch| ch.len_utf8())
3433                        .sum();
3434                    self.pivot_melt_modal.melt_variable_name.insert(byte_pos, c);
3435                    self.pivot_melt_modal.melt_variable_cursor += 1;
3436                }
3437                KeyCode::Backspace
3438                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName =>
3439                {
3440                    if self.pivot_melt_modal.melt_variable_cursor > 0 {
3441                        let prev_byte: usize = self
3442                            .pivot_melt_modal
3443                            .melt_variable_name
3444                            .chars()
3445                            .take(self.pivot_melt_modal.melt_variable_cursor - 1)
3446                            .map(|ch| ch.len_utf8())
3447                            .sum();
3448                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[prev_byte..]
3449                            .chars()
3450                            .next()
3451                        {
3452                            self.pivot_melt_modal
3453                                .melt_variable_name
3454                                .drain(prev_byte..prev_byte + ch.len_utf8());
3455                            self.pivot_melt_modal.melt_variable_cursor -= 1;
3456                        }
3457                    }
3458                }
3459                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltVarName => {
3460                    let n = self.pivot_melt_modal.melt_variable_name.chars().count();
3461                    if self.pivot_melt_modal.melt_variable_cursor < n {
3462                        let byte_pos: usize = self
3463                            .pivot_melt_modal
3464                            .melt_variable_name
3465                            .chars()
3466                            .take(self.pivot_melt_modal.melt_variable_cursor)
3467                            .map(|ch| ch.len_utf8())
3468                            .sum();
3469                        if let Some(ch) = self.pivot_melt_modal.melt_variable_name[byte_pos..]
3470                            .chars()
3471                            .next()
3472                        {
3473                            self.pivot_melt_modal
3474                                .melt_variable_name
3475                                .drain(byte_pos..byte_pos + ch.len_utf8());
3476                        }
3477                    }
3478                }
3479                KeyCode::Home if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3480                    self.pivot_melt_modal.melt_value_cursor = 0;
3481                }
3482                KeyCode::End if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3483                    self.pivot_melt_modal.melt_value_cursor =
3484                        self.pivot_melt_modal.melt_value_name.chars().count();
3485                }
3486                KeyCode::Char(c) if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3487                    let byte_pos: usize = self
3488                        .pivot_melt_modal
3489                        .melt_value_name
3490                        .chars()
3491                        .take(self.pivot_melt_modal.melt_value_cursor)
3492                        .map(|ch| ch.len_utf8())
3493                        .sum();
3494                    self.pivot_melt_modal.melt_value_name.insert(byte_pos, c);
3495                    self.pivot_melt_modal.melt_value_cursor += 1;
3496                }
3497                KeyCode::Backspace
3498                    if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName =>
3499                {
3500                    if self.pivot_melt_modal.melt_value_cursor > 0 {
3501                        let prev_byte: usize = self
3502                            .pivot_melt_modal
3503                            .melt_value_name
3504                            .chars()
3505                            .take(self.pivot_melt_modal.melt_value_cursor - 1)
3506                            .map(|ch| ch.len_utf8())
3507                            .sum();
3508                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[prev_byte..]
3509                            .chars()
3510                            .next()
3511                        {
3512                            self.pivot_melt_modal
3513                                .melt_value_name
3514                                .drain(prev_byte..prev_byte + ch.len_utf8());
3515                            self.pivot_melt_modal.melt_value_cursor -= 1;
3516                        }
3517                    }
3518                }
3519                KeyCode::Delete if self.pivot_melt_modal.focus == PivotMeltFocus::MeltValName => {
3520                    let n = self.pivot_melt_modal.melt_value_name.chars().count();
3521                    if self.pivot_melt_modal.melt_value_cursor < n {
3522                        let byte_pos: usize = self
3523                            .pivot_melt_modal
3524                            .melt_value_name
3525                            .chars()
3526                            .take(self.pivot_melt_modal.melt_value_cursor)
3527                            .map(|ch| ch.len_utf8())
3528                            .sum();
3529                        if let Some(ch) = self.pivot_melt_modal.melt_value_name[byte_pos..]
3530                            .chars()
3531                            .next()
3532                        {
3533                            self.pivot_melt_modal
3534                                .melt_value_name
3535                                .drain(byte_pos..byte_pos + ch.len_utf8());
3536                        }
3537                    }
3538                }
3539                _ => {}
3540            }
3541            return None;
3542        }
3543
3544        if self.input_mode == InputMode::Info {
3545            let on_tab_bar = self.info_modal.focus == InfoFocus::TabBar;
3546            let on_body = self.info_modal.focus == InfoFocus::Body;
3547            let schema_tab = self.info_modal.active_tab == InfoTab::Schema;
3548            let total_rows = self
3549                .data_table_state
3550                .as_ref()
3551                .map(|s| s.schema.len())
3552                .unwrap_or(0);
3553            let visible = self.info_modal.schema_visible_height;
3554
3555            match event.code {
3556                KeyCode::Esc | KeyCode::Char('i') if event.is_press() => {
3557                    self.info_modal.close();
3558                    self.input_mode = InputMode::Normal;
3559                }
3560                KeyCode::Tab if event.is_press() => {
3561                    if schema_tab {
3562                        self.info_modal.next_focus();
3563                    }
3564                }
3565                KeyCode::BackTab if event.is_press() => {
3566                    if schema_tab {
3567                        self.info_modal.prev_focus();
3568                    }
3569                }
3570                KeyCode::Left | KeyCode::Char('h') if event.is_press() && on_tab_bar => {
3571                    let has_partitions = self
3572                        .data_table_state
3573                        .as_ref()
3574                        .and_then(|s| s.partition_columns.as_ref())
3575                        .map(|v| !v.is_empty())
3576                        .unwrap_or(false);
3577                    self.info_modal.switch_tab_prev(has_partitions);
3578                }
3579                KeyCode::Right | KeyCode::Char('l') if event.is_press() && on_tab_bar => {
3580                    let has_partitions = self
3581                        .data_table_state
3582                        .as_ref()
3583                        .and_then(|s| s.partition_columns.as_ref())
3584                        .map(|v| !v.is_empty())
3585                        .unwrap_or(false);
3586                    self.info_modal.switch_tab(has_partitions);
3587                }
3588                KeyCode::Down | KeyCode::Char('j') if event.is_press() && on_body && schema_tab => {
3589                    self.info_modal.schema_table_down(total_rows, visible);
3590                }
3591                KeyCode::Up | KeyCode::Char('k') if event.is_press() && on_body && schema_tab => {
3592                    self.info_modal.schema_table_up(total_rows, visible);
3593                }
3594                _ => {}
3595            }
3596            return None;
3597        }
3598
3599        if self.input_mode == InputMode::Chart {
3600            // Chart export modal (sub-dialog within Chart mode)
3601            if self.chart_export_modal.active {
3602                match event.code {
3603                    KeyCode::Esc if event.is_press() => {
3604                        self.chart_export_modal.close();
3605                    }
3606                    KeyCode::Tab if event.is_press() => {
3607                        self.chart_export_modal.next_focus();
3608                    }
3609                    KeyCode::BackTab if event.is_press() => {
3610                        self.chart_export_modal.prev_focus();
3611                    }
3612                    // Only use h/j/k/l and arrows for format selector; when path input focused, pass all keys to path input
3613                    KeyCode::Left | KeyCode::Char('h')
3614                        if event.is_press()
3615                            && self.chart_export_modal.focus
3616                                == ChartExportFocus::FormatSelector =>
3617                    {
3618                        let idx = ChartExportFormat::ALL
3619                            .iter()
3620                            .position(|&f| f == self.chart_export_modal.selected_format)
3621                            .unwrap_or(0);
3622                        let prev = if idx == 0 {
3623                            ChartExportFormat::ALL.len() - 1
3624                        } else {
3625                            idx - 1
3626                        };
3627                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[prev];
3628                    }
3629                    KeyCode::Right | KeyCode::Char('l')
3630                        if event.is_press()
3631                            && self.chart_export_modal.focus
3632                                == ChartExportFocus::FormatSelector =>
3633                    {
3634                        let idx = ChartExportFormat::ALL
3635                            .iter()
3636                            .position(|&f| f == self.chart_export_modal.selected_format)
3637                            .unwrap_or(0);
3638                        let next = (idx + 1) % ChartExportFormat::ALL.len();
3639                        self.chart_export_modal.selected_format = ChartExportFormat::ALL[next];
3640                    }
3641                    KeyCode::Enter if event.is_press() => match self.chart_export_modal.focus {
3642                        ChartExportFocus::PathInput | ChartExportFocus::ExportButton => {
3643                            let path_str = self.chart_export_modal.path_input.value.trim();
3644                            if !path_str.is_empty() {
3645                                let title =
3646                                    self.chart_export_modal.title_input.value.trim().to_string();
3647                                let mut path = PathBuf::from(path_str);
3648                                let format = self.chart_export_modal.selected_format;
3649                                // Only add default extension when user did not provide one
3650                                if path.extension().is_none() {
3651                                    path.set_extension(format.extension());
3652                                }
3653                                let path_display = path.display().to_string();
3654                                if path.exists() {
3655                                    self.pending_chart_export = Some((path, format, title));
3656                                    self.chart_export_modal.close();
3657                                    self.confirmation_modal.show(format!(
3658                                            "File already exists:\n{}\n\nDo you wish to overwrite this file?",
3659                                            path_display
3660                                        ));
3661                                } else {
3662                                    self.chart_export_modal.close();
3663                                    return Some(AppEvent::ChartExport(path, format, title));
3664                                }
3665                            }
3666                        }
3667                        ChartExportFocus::CancelButton => {
3668                            self.chart_export_modal.close();
3669                        }
3670                        _ => {}
3671                    },
3672                    _ => {
3673                        if event.is_press() {
3674                            if self.chart_export_modal.focus == ChartExportFocus::TitleInput {
3675                                let _ = self.chart_export_modal.title_input.handle_key(event, None);
3676                            } else if self.chart_export_modal.focus == ChartExportFocus::PathInput {
3677                                let _ = self.chart_export_modal.path_input.handle_key(event, None);
3678                            }
3679                        }
3680                    }
3681                }
3682                return None;
3683            }
3684
3685            match event.code {
3686                KeyCode::Char('e')
3687                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3688                {
3689                    // Open chart export modal when there is something visible to export
3690                    if self.data_table_state.is_some() && self.chart_modal.can_export() {
3691                        self.chart_export_modal
3692                            .open(&self.theme, self.history_limit);
3693                    }
3694                }
3695                // q/Q do nothing in chart view (no exit)
3696                KeyCode::Char('?') if event.is_press() => {
3697                    self.show_help = true;
3698                }
3699                KeyCode::Esc if event.is_press() => {
3700                    self.chart_modal.close();
3701                    self.chart_cache.clear();
3702                    self.input_mode = InputMode::Normal;
3703                }
3704                KeyCode::Tab if event.is_press() => {
3705                    self.chart_modal.next_focus();
3706                }
3707                KeyCode::BackTab if event.is_press() => {
3708                    self.chart_modal.prev_focus();
3709                }
3710                KeyCode::Enter | KeyCode::Char(' ') if event.is_press() => {
3711                    match self.chart_modal.focus {
3712                        ChartFocus::YStartsAtZero => self.chart_modal.toggle_y_starts_at_zero(),
3713                        ChartFocus::LogScale => self.chart_modal.toggle_log_scale(),
3714                        ChartFocus::ShowLegend => self.chart_modal.toggle_show_legend(),
3715                        ChartFocus::XList => self.chart_modal.x_list_toggle(),
3716                        ChartFocus::YList => self.chart_modal.y_list_toggle(),
3717                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3718                        ChartFocus::HistList => self.chart_modal.hist_list_toggle(),
3719                        ChartFocus::BoxList => self.chart_modal.box_list_toggle(),
3720                        ChartFocus::KdeList => self.chart_modal.kde_list_toggle(),
3721                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_toggle(),
3722                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_toggle(),
3723                        _ => {}
3724                    }
3725                }
3726                KeyCode::Char('+') | KeyCode::Char('=')
3727                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3728                {
3729                    match self.chart_modal.focus {
3730                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3731                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3732                        ChartFocus::KdeBandwidth => self
3733                            .chart_modal
3734                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3735                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3736                        _ => {}
3737                    }
3738                }
3739                KeyCode::Char('-')
3740                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3741                {
3742                    match self.chart_modal.focus {
3743                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3744                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3745                        ChartFocus::KdeBandwidth => self
3746                            .chart_modal
3747                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3748                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3749                        _ => {}
3750                    }
3751                }
3752                KeyCode::Left | KeyCode::Char('h')
3753                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3754                {
3755                    match self.chart_modal.focus {
3756                        ChartFocus::TabBar => self.chart_modal.prev_chart_kind(),
3757                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3758                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(-1),
3759                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(-1),
3760                        ChartFocus::KdeBandwidth => self
3761                            .chart_modal
3762                            .adjust_kde_bandwidth_factor(-chart_modal::KDE_BANDWIDTH_STEP),
3763                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(-1),
3764                        _ => {}
3765                    }
3766                }
3767                KeyCode::Right | KeyCode::Char('l')
3768                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3769                {
3770                    match self.chart_modal.focus {
3771                        ChartFocus::TabBar => self.chart_modal.next_chart_kind(),
3772                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3773                        ChartFocus::HistBins => self.chart_modal.adjust_hist_bins(1),
3774                        ChartFocus::HeatmapBins => self.chart_modal.adjust_heatmap_bins(1),
3775                        ChartFocus::KdeBandwidth => self
3776                            .chart_modal
3777                            .adjust_kde_bandwidth_factor(chart_modal::KDE_BANDWIDTH_STEP),
3778                        ChartFocus::LimitRows => self.chart_modal.adjust_row_limit(1),
3779                        _ => {}
3780                    }
3781                }
3782                KeyCode::PageUp
3783                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3784                {
3785                    if self.chart_modal.focus == ChartFocus::LimitRows {
3786                        self.chart_modal.adjust_row_limit_page(1);
3787                    }
3788                }
3789                KeyCode::PageDown
3790                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3791                {
3792                    if self.chart_modal.focus == ChartFocus::LimitRows {
3793                        self.chart_modal.adjust_row_limit_page(-1);
3794                    }
3795                }
3796                KeyCode::Up | KeyCode::Char('k')
3797                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3798                {
3799                    match self.chart_modal.focus {
3800                        ChartFocus::ChartType => self.chart_modal.prev_chart_type(),
3801                        ChartFocus::XList => self.chart_modal.x_list_up(),
3802                        ChartFocus::YList => self.chart_modal.y_list_up(),
3803                        ChartFocus::HistList => self.chart_modal.hist_list_up(),
3804                        ChartFocus::BoxList => self.chart_modal.box_list_up(),
3805                        ChartFocus::KdeList => self.chart_modal.kde_list_up(),
3806                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_up(),
3807                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_up(),
3808                        _ => {}
3809                    }
3810                }
3811                KeyCode::Down | KeyCode::Char('j')
3812                    if event.is_press() && !self.chart_modal.is_text_input_focused() =>
3813                {
3814                    match self.chart_modal.focus {
3815                        ChartFocus::ChartType => self.chart_modal.next_chart_type(),
3816                        ChartFocus::XList => self.chart_modal.x_list_down(),
3817                        ChartFocus::YList => self.chart_modal.y_list_down(),
3818                        ChartFocus::HistList => self.chart_modal.hist_list_down(),
3819                        ChartFocus::BoxList => self.chart_modal.box_list_down(),
3820                        ChartFocus::KdeList => self.chart_modal.kde_list_down(),
3821                        ChartFocus::HeatmapXList => self.chart_modal.heatmap_x_list_down(),
3822                        ChartFocus::HeatmapYList => self.chart_modal.heatmap_y_list_down(),
3823                        _ => {}
3824                    }
3825                }
3826                _ => {
3827                    // Pass key to text inputs when focused (including h/j/k/l for typing)
3828                    if event.is_press() {
3829                        if self.chart_modal.focus == ChartFocus::XInput {
3830                            let _ = self.chart_modal.x_input.handle_key(event, None);
3831                        } else if self.chart_modal.focus == ChartFocus::YInput {
3832                            let _ = self.chart_modal.y_input.handle_key(event, None);
3833                        } else if self.chart_modal.focus == ChartFocus::HistInput {
3834                            let _ = self.chart_modal.hist_input.handle_key(event, None);
3835                        } else if self.chart_modal.focus == ChartFocus::BoxInput {
3836                            let _ = self.chart_modal.box_input.handle_key(event, None);
3837                        } else if self.chart_modal.focus == ChartFocus::KdeInput {
3838                            let _ = self.chart_modal.kde_input.handle_key(event, None);
3839                        } else if self.chart_modal.focus == ChartFocus::HeatmapXInput {
3840                            let _ = self.chart_modal.heatmap_x_input.handle_key(event, None);
3841                        } else if self.chart_modal.focus == ChartFocus::HeatmapYInput {
3842                            let _ = self.chart_modal.heatmap_y_input.handle_key(event, None);
3843                        }
3844                    }
3845                }
3846            }
3847            return None;
3848        }
3849
3850        if self.analysis_modal.active {
3851            match event.code {
3852                KeyCode::Esc => {
3853                    if self.analysis_modal.show_help {
3854                        self.analysis_modal.show_help = false;
3855                    } else if self.analysis_modal.view != analysis_modal::AnalysisView::Main {
3856                        // Close detail view
3857                        self.analysis_modal.close_detail();
3858                    } else {
3859                        self.analysis_modal.close();
3860                    }
3861                }
3862                KeyCode::Char('?') => {
3863                    self.analysis_modal.show_help = !self.analysis_modal.show_help;
3864                }
3865                KeyCode::Char('r') => {
3866                    if self.sampling_threshold.is_some() {
3867                        self.analysis_modal.recalculate();
3868                        // Invalidate current tool's cache so it recalculates with new seed
3869                        match self.analysis_modal.selected_tool {
3870                            Some(analysis_modal::AnalysisTool::Describe) => {
3871                                self.analysis_modal.describe_results = None;
3872                            }
3873                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3874                                self.analysis_modal.distribution_results = None;
3875                            }
3876                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3877                                self.analysis_modal.correlation_results = None;
3878                            }
3879                            None => {}
3880                        }
3881                    }
3882                }
3883                KeyCode::Tab => {
3884                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3885                        // Switch focus between main area and sidebar
3886                        self.analysis_modal.switch_focus();
3887                    } else if self.analysis_modal.view
3888                        == analysis_modal::AnalysisView::DistributionDetail
3889                    {
3890                        // In distribution detail view, only the distribution selector is focusable
3891                        // Tab does nothing - focus stays on the distribution selector
3892                    } else {
3893                        // In other detail views, Tab cycles through sections
3894                        self.analysis_modal.next_detail_section();
3895                    }
3896                }
3897                KeyCode::Enter => {
3898                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
3899                        if self.analysis_modal.focus == analysis_modal::AnalysisFocus::Sidebar {
3900                            // Select tool from sidebar
3901                            self.analysis_modal.select_tool();
3902                            // Trigger computation for the selected tool when that tool has no cached results
3903                            match self.analysis_modal.selected_tool {
3904                                Some(analysis_modal::AnalysisTool::Describe)
3905                                    if self.analysis_modal.describe_results.is_none() =>
3906                                {
3907                                    self.analysis_modal.computing = Some(AnalysisProgress {
3908                                        phase: "Describing data".to_string(),
3909                                        current: 0,
3910                                        total: 1,
3911                                    });
3912                                    self.analysis_computation = Some(AnalysisComputationState {
3913                                        df: None,
3914                                        schema: None,
3915                                        partial_stats: Vec::new(),
3916                                        current: 0,
3917                                        total: 0,
3918                                        total_rows: 0,
3919                                        sample_seed: self.analysis_modal.random_seed,
3920                                        sample_size: None,
3921                                    });
3922                                    self.busy = true;
3923                                    return Some(AppEvent::AnalysisChunk);
3924                                }
3925                                Some(analysis_modal::AnalysisTool::DistributionAnalysis)
3926                                    if self.analysis_modal.distribution_results.is_none() =>
3927                                {
3928                                    self.analysis_modal.computing = Some(AnalysisProgress {
3929                                        phase: "Distribution".to_string(),
3930                                        current: 0,
3931                                        total: 1,
3932                                    });
3933                                    self.busy = true;
3934                                    return Some(AppEvent::AnalysisDistributionCompute);
3935                                }
3936                                Some(analysis_modal::AnalysisTool::CorrelationMatrix)
3937                                    if self.analysis_modal.correlation_results.is_none() =>
3938                                {
3939                                    self.analysis_modal.computing = Some(AnalysisProgress {
3940                                        phase: "Correlation".to_string(),
3941                                        current: 0,
3942                                        total: 1,
3943                                    });
3944                                    self.busy = true;
3945                                    return Some(AppEvent::AnalysisCorrelationCompute);
3946                                }
3947                                _ => {}
3948                            }
3949                        } else {
3950                            // Enter in main area opens detail view if applicable
3951                            match self.analysis_modal.selected_tool {
3952                                Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
3953                                    self.analysis_modal.open_distribution_detail();
3954                                }
3955                                Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3956                                    self.analysis_modal.open_correlation_detail();
3957                                }
3958                                _ => {}
3959                            }
3960                        }
3961                    }
3962                }
3963                KeyCode::Down | KeyCode::Char('j') => {
3964                    match self.analysis_modal.view {
3965                        analysis_modal::AnalysisView::Main => {
3966                            match self.analysis_modal.focus {
3967                                analysis_modal::AnalysisFocus::Sidebar => {
3968                                    // Navigate sidebar tool list
3969                                    self.analysis_modal.next_tool();
3970                                }
3971                                analysis_modal::AnalysisFocus::Main => {
3972                                    // Navigate in main area based on selected tool
3973                                    match self.analysis_modal.selected_tool {
3974                                        Some(analysis_modal::AnalysisTool::Describe) => {
3975                                            if let Some(state) = &self.data_table_state {
3976                                                let max_rows = state.schema.len();
3977                                                self.analysis_modal.next_row(max_rows);
3978                                            }
3979                                        }
3980                                        Some(
3981                                            analysis_modal::AnalysisTool::DistributionAnalysis,
3982                                        ) => {
3983                                            if let Some(results) =
3984                                                self.analysis_modal.current_results()
3985                                            {
3986                                                let max_rows = results.distribution_analyses.len();
3987                                                self.analysis_modal.next_row(max_rows);
3988                                            }
3989                                        }
3990                                        Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
3991                                            if let Some(results) =
3992                                                self.analysis_modal.current_results()
3993                                            {
3994                                                if let Some(corr) = &results.correlation_matrix {
3995                                                    let max_rows = corr.columns.len();
3996                                                    // Calculate visible columns (same logic as horizontal moves)
3997                                                    let row_header_width = 20u16;
3998                                                    let cell_width = 12u16;
3999                                                    let column_spacing = 1u16;
4000                                                    let estimated_width = 80u16;
4001                                                    let available_width = estimated_width
4002                                                        .saturating_sub(row_header_width);
4003                                                    let mut calculated_visible = 0usize;
4004                                                    let mut used = 0u16;
4005                                                    let max_cols = corr.columns.len();
4006                                                    loop {
4007                                                        let needed = if calculated_visible == 0 {
4008                                                            cell_width
4009                                                        } else {
4010                                                            column_spacing + cell_width
4011                                                        };
4012                                                        if used + needed <= available_width
4013                                                            && calculated_visible < max_cols
4014                                                        {
4015                                                            used += needed;
4016                                                            calculated_visible += 1;
4017                                                        } else {
4018                                                            break;
4019                                                        }
4020                                                    }
4021                                                    let visible_cols =
4022                                                        calculated_visible.max(1).min(max_cols);
4023                                                    self.analysis_modal.move_correlation_cell(
4024                                                        (1, 0),
4025                                                        max_rows,
4026                                                        max_rows,
4027                                                        visible_cols,
4028                                                    );
4029                                                }
4030                                            }
4031                                        }
4032                                        None => {}
4033                                    }
4034                                }
4035                                _ => {}
4036                            }
4037                        }
4038                        analysis_modal::AnalysisView::DistributionDetail => {
4039                            if self.analysis_modal.focus
4040                                == analysis_modal::AnalysisFocus::DistributionSelector
4041                            {
4042                                self.analysis_modal.next_distribution();
4043                            }
4044                        }
4045                        _ => {}
4046                    }
4047                }
4048                KeyCode::Char('s') => {
4049                    // Toggle histogram scale (linear/log) in distribution detail view
4050                    if self.analysis_modal.view == analysis_modal::AnalysisView::DistributionDetail
4051                    {
4052                        self.analysis_modal.histogram_scale =
4053                            match self.analysis_modal.histogram_scale {
4054                                analysis_modal::HistogramScale::Linear => {
4055                                    analysis_modal::HistogramScale::Log
4056                                }
4057                                analysis_modal::HistogramScale::Log => {
4058                                    analysis_modal::HistogramScale::Linear
4059                                }
4060                            };
4061                    }
4062                }
4063                KeyCode::Up | KeyCode::Char('k') => {
4064                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4065                        self.analysis_modal.previous_row();
4066                    } else if self.analysis_modal.view
4067                        == analysis_modal::AnalysisView::DistributionDetail
4068                        && self.analysis_modal.focus
4069                            == analysis_modal::AnalysisFocus::DistributionSelector
4070                    {
4071                        self.analysis_modal.previous_distribution();
4072                    }
4073                }
4074                KeyCode::Left | KeyCode::Char('h')
4075                    if !event.modifiers.contains(KeyModifiers::CONTROL) =>
4076                {
4077                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4078                        match self.analysis_modal.focus {
4079                            analysis_modal::AnalysisFocus::Sidebar => {
4080                                // Sidebar navigation handled by Up/Down
4081                            }
4082                            analysis_modal::AnalysisFocus::DistributionSelector => {
4083                                // Distribution selector navigation handled by Up/Down
4084                            }
4085                            analysis_modal::AnalysisFocus::Main => {
4086                                match self.analysis_modal.selected_tool {
4087                                    Some(analysis_modal::AnalysisTool::Describe) => {
4088                                        self.analysis_modal.scroll_left();
4089                                    }
4090                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4091                                        self.analysis_modal.scroll_left();
4092                                    }
4093                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4094                                        if let Some(results) = self.analysis_modal.current_results()
4095                                        {
4096                                            if let Some(corr) = &results.correlation_matrix {
4097                                                let max_cols = corr.columns.len();
4098                                                // Calculate visible columns using same logic as render function
4099                                                // This matches the render_correlation_matrix calculation
4100                                                let row_header_width = 20u16;
4101                                                let cell_width = 12u16;
4102                                                let column_spacing = 1u16;
4103                                                // Use a conservative estimate for available width
4104                                                // In practice, main_area.width would be available, but we don't have access here
4105                                                // Using a reasonable default that works for most terminals
4106                                                let estimated_width = 80u16; // Conservative estimate (most terminals are 80+ wide)
4107                                                let available_width = estimated_width
4108                                                    .saturating_sub(row_header_width);
4109                                                // Match render logic: first column has no spacing, subsequent ones do
4110                                                let mut calculated_visible = 0usize;
4111                                                let mut used = 0u16;
4112                                                loop {
4113                                                    let needed = if calculated_visible == 0 {
4114                                                        cell_width
4115                                                    } else {
4116                                                        column_spacing + cell_width
4117                                                    };
4118                                                    if used + needed <= available_width
4119                                                        && calculated_visible < max_cols
4120                                                    {
4121                                                        used += needed;
4122                                                        calculated_visible += 1;
4123                                                    } else {
4124                                                        break;
4125                                                    }
4126                                                }
4127                                                let visible_cols =
4128                                                    calculated_visible.max(1).min(max_cols);
4129                                                self.analysis_modal.move_correlation_cell(
4130                                                    (0, -1),
4131                                                    max_cols,
4132                                                    max_cols,
4133                                                    visible_cols,
4134                                                );
4135                                            }
4136                                        }
4137                                    }
4138                                    None => {}
4139                                }
4140                            }
4141                        }
4142                    }
4143                }
4144                KeyCode::Right | KeyCode::Char('l') => {
4145                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4146                        match self.analysis_modal.focus {
4147                            analysis_modal::AnalysisFocus::Sidebar => {
4148                                // Sidebar navigation handled by Up/Down
4149                            }
4150                            analysis_modal::AnalysisFocus::DistributionSelector => {
4151                                // Distribution selector navigation handled by Up/Down
4152                            }
4153                            analysis_modal::AnalysisFocus::Main => {
4154                                match self.analysis_modal.selected_tool {
4155                                    Some(analysis_modal::AnalysisTool::Describe) => {
4156                                        // Number of statistics: count, null_count, mean, std, min, 25%, 50%, 75%, max, skewness, kurtosis, distribution
4157                                        let max_stats = 12;
4158                                        // Estimate visible stats based on terminal width (rough estimate)
4159                                        let visible_stats = 8; // Will be calculated more accurately in widget
4160                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4161                                    }
4162                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4163                                        // Number of statistics: Distribution, P-value, Shapiro-Wilk, SW p-value, CV, Outliers, Skewness, Kurtosis
4164                                        let max_stats = 8;
4165                                        // Estimate visible stats based on terminal width (rough estimate)
4166                                        let visible_stats = 6; // Will be calculated more accurately in widget
4167                                        self.analysis_modal.scroll_right(max_stats, visible_stats);
4168                                    }
4169                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4170                                        if let Some(results) = self.analysis_modal.current_results()
4171                                        {
4172                                            if let Some(corr) = &results.correlation_matrix {
4173                                                let max_cols = corr.columns.len();
4174                                                // Calculate visible columns using same logic as render function
4175                                                let row_header_width = 20u16;
4176                                                let cell_width = 12u16;
4177                                                let column_spacing = 1u16;
4178                                                let estimated_width = 80u16; // Conservative estimate
4179                                                let available_width = estimated_width
4180                                                    .saturating_sub(row_header_width);
4181                                                let mut calculated_visible = 0usize;
4182                                                let mut used = 0u16;
4183                                                loop {
4184                                                    let needed = if calculated_visible == 0 {
4185                                                        cell_width
4186                                                    } else {
4187                                                        column_spacing + cell_width
4188                                                    };
4189                                                    if used + needed <= available_width
4190                                                        && calculated_visible < max_cols
4191                                                    {
4192                                                        used += needed;
4193                                                        calculated_visible += 1;
4194                                                    } else {
4195                                                        break;
4196                                                    }
4197                                                }
4198                                                let visible_cols =
4199                                                    calculated_visible.max(1).min(max_cols);
4200                                                self.analysis_modal.move_correlation_cell(
4201                                                    (0, 1),
4202                                                    max_cols,
4203                                                    max_cols,
4204                                                    visible_cols,
4205                                                );
4206                                            }
4207                                        }
4208                                    }
4209                                    None => {}
4210                                }
4211                            }
4212                        }
4213                    }
4214                }
4215                KeyCode::PageDown => {
4216                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4217                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4218                    {
4219                        match self.analysis_modal.selected_tool {
4220                            Some(analysis_modal::AnalysisTool::Describe) => {
4221                                if let Some(state) = &self.data_table_state {
4222                                    let max_rows = state.schema.len();
4223                                    let page_size = 10;
4224                                    self.analysis_modal.page_down(max_rows, page_size);
4225                                }
4226                            }
4227                            Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4228                                if let Some(results) = self.analysis_modal.current_results() {
4229                                    let max_rows = results.distribution_analyses.len();
4230                                    let page_size = 10;
4231                                    self.analysis_modal.page_down(max_rows, page_size);
4232                                }
4233                            }
4234                            Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4235                                if let Some(results) = self.analysis_modal.current_results() {
4236                                    if let Some(corr) = &results.correlation_matrix {
4237                                        let max_rows = corr.columns.len();
4238                                        let page_size = 10;
4239                                        self.analysis_modal.page_down(max_rows, page_size);
4240                                    }
4241                                }
4242                            }
4243                            None => {}
4244                        }
4245                    }
4246                }
4247                KeyCode::PageUp => {
4248                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main
4249                        && self.analysis_modal.focus == analysis_modal::AnalysisFocus::Main
4250                    {
4251                        let page_size = 10;
4252                        self.analysis_modal.page_up(page_size);
4253                    }
4254                }
4255                KeyCode::Home => {
4256                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4257                        match self.analysis_modal.focus {
4258                            analysis_modal::AnalysisFocus::Sidebar => {
4259                                self.analysis_modal.sidebar_state.select(Some(0));
4260                            }
4261                            analysis_modal::AnalysisFocus::DistributionSelector => {
4262                                self.analysis_modal
4263                                    .distribution_selector_state
4264                                    .select(Some(0));
4265                            }
4266                            analysis_modal::AnalysisFocus::Main => {
4267                                match self.analysis_modal.selected_tool {
4268                                    Some(analysis_modal::AnalysisTool::Describe) => {
4269                                        self.analysis_modal.table_state.select(Some(0));
4270                                    }
4271                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4272                                        self.analysis_modal
4273                                            .distribution_table_state
4274                                            .select(Some(0));
4275                                    }
4276                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4277                                        self.analysis_modal.correlation_table_state.select(Some(0));
4278                                        self.analysis_modal.selected_correlation = Some((0, 0));
4279                                    }
4280                                    None => {}
4281                                }
4282                            }
4283                        }
4284                    }
4285                }
4286                KeyCode::End => {
4287                    if self.analysis_modal.view == analysis_modal::AnalysisView::Main {
4288                        match self.analysis_modal.focus {
4289                            analysis_modal::AnalysisFocus::Sidebar => {
4290                                self.analysis_modal.sidebar_state.select(Some(2));
4291                                // Last tool
4292                            }
4293                            analysis_modal::AnalysisFocus::DistributionSelector => {
4294                                self.analysis_modal
4295                                    .distribution_selector_state
4296                                    .select(Some(13)); // Last distribution (Weibull, index 13 of 14 total)
4297                            }
4298                            analysis_modal::AnalysisFocus::Main => {
4299                                match self.analysis_modal.selected_tool {
4300                                    Some(analysis_modal::AnalysisTool::Describe) => {
4301                                        if let Some(state) = &self.data_table_state {
4302                                            let max_rows = state.schema.len();
4303                                            if max_rows > 0 {
4304                                                self.analysis_modal
4305                                                    .table_state
4306                                                    .select(Some(max_rows - 1));
4307                                            }
4308                                        }
4309                                    }
4310                                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4311                                        if let Some(results) = self.analysis_modal.current_results()
4312                                        {
4313                                            let max_rows = results.distribution_analyses.len();
4314                                            if max_rows > 0 {
4315                                                self.analysis_modal
4316                                                    .distribution_table_state
4317                                                    .select(Some(max_rows - 1));
4318                                            }
4319                                        }
4320                                    }
4321                                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
4322                                        if let Some(results) = self.analysis_modal.current_results()
4323                                        {
4324                                            if let Some(corr) = &results.correlation_matrix {
4325                                                let max_rows = corr.columns.len();
4326                                                if max_rows > 0 {
4327                                                    self.analysis_modal
4328                                                        .correlation_table_state
4329                                                        .select(Some(max_rows - 1));
4330                                                    self.analysis_modal.selected_correlation =
4331                                                        Some((max_rows - 1, max_rows - 1));
4332                                                }
4333                                            }
4334                                        }
4335                                    }
4336                                    None => {}
4337                                }
4338                            }
4339                        }
4340                    }
4341                }
4342                _ => {}
4343            }
4344            return None;
4345        }
4346
4347        if self.template_modal.active {
4348            match event.code {
4349                KeyCode::Esc => {
4350                    if self.template_modal.show_score_details {
4351                        // Close score details popup
4352                        self.template_modal.show_score_details = false;
4353                    } else if self.template_modal.delete_confirm {
4354                        // Cancel delete confirmation
4355                        self.template_modal.delete_confirm = false;
4356                    } else if self.template_modal.mode == TemplateModalMode::Create
4357                        || self.template_modal.mode == TemplateModalMode::Edit
4358                    {
4359                        // In create/edit mode, Esc goes back to list mode
4360                        self.template_modal.exit_create_mode();
4361                    } else {
4362                        // In list mode, Esc closes modal
4363                        if self.template_modal.show_help {
4364                            self.template_modal.show_help = false;
4365                        } else {
4366                            self.template_modal.active = false;
4367                            self.template_modal.show_help = false;
4368                            self.template_modal.delete_confirm = false;
4369                        }
4370                    }
4371                }
4372                KeyCode::BackTab if self.template_modal.delete_confirm => {
4373                    // Toggle between Cancel and Delete buttons (reverse)
4374                    self.template_modal.delete_confirm_focus =
4375                        !self.template_modal.delete_confirm_focus;
4376                }
4377                KeyCode::Tab if !self.template_modal.delete_confirm => {
4378                    self.template_modal.next_focus();
4379                }
4380                KeyCode::BackTab => {
4381                    self.template_modal.prev_focus();
4382                }
4383                KeyCode::Char('s') if self.template_modal.mode == TemplateModalMode::List => {
4384                    // Switch to create mode from list mode
4385                    self.template_modal
4386                        .enter_create_mode(self.history_limit, &self.theme);
4387                    // Auto-populate fields
4388                    if let Some(ref path) = self.path {
4389                        // Auto-populate name
4390                        self.template_modal.create_name_input.value =
4391                            self.template_manager.generate_next_template_name();
4392                        self.template_modal.create_name_input.cursor =
4393                            self.template_modal.create_name_input.value.chars().count();
4394
4395                        // Auto-populate exact_path (absolute) - canonicalize to ensure absolute path
4396                        let absolute_path = if path.is_absolute() {
4397                            path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4398                        } else {
4399                            // If relative, make it absolute from current dir
4400                            if let Ok(cwd) = std::env::current_dir() {
4401                                let abs = cwd.join(path);
4402                                abs.canonicalize().unwrap_or(abs)
4403                            } else {
4404                                path.to_path_buf()
4405                            }
4406                        };
4407                        self.template_modal.create_exact_path_input.value =
4408                            absolute_path.to_string_lossy().to_string();
4409                        self.template_modal.create_exact_path_input.cursor = self
4410                            .template_modal
4411                            .create_exact_path_input
4412                            .value
4413                            .chars()
4414                            .count();
4415
4416                        // Auto-populate relative_path from current working directory
4417                        if let Ok(cwd) = std::env::current_dir() {
4418                            let abs_path = if path.is_absolute() {
4419                                path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4420                            } else {
4421                                let abs = cwd.join(path);
4422                                abs.canonicalize().unwrap_or(abs)
4423                            };
4424                            if let Ok(canonical_cwd) = cwd.canonicalize() {
4425                                if let Ok(rel_path) = abs_path.strip_prefix(&canonical_cwd) {
4426                                    // Ensure relative path starts with ./ or just the path
4427                                    let rel_str = rel_path.to_string_lossy().to_string();
4428                                    self.template_modal.create_relative_path_input.value =
4429                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4430                                    self.template_modal.create_relative_path_input.cursor = self
4431                                        .template_modal
4432                                        .create_relative_path_input
4433                                        .value
4434                                        .chars()
4435                                        .count();
4436                                } else {
4437                                    // Path is not under CWD, leave empty or use full path
4438                                    self.template_modal.create_relative_path_input.clear();
4439                                }
4440                            } else {
4441                                // Fallback: try without canonicalization
4442                                if let Ok(rel_path) = abs_path.strip_prefix(&cwd) {
4443                                    let rel_str = rel_path.to_string_lossy().to_string();
4444                                    self.template_modal.create_relative_path_input.value =
4445                                        rel_str.strip_prefix('/').unwrap_or(&rel_str).to_string();
4446                                    self.template_modal.create_relative_path_input.cursor = self
4447                                        .template_modal
4448                                        .create_relative_path_input
4449                                        .value
4450                                        .chars()
4451                                        .count();
4452                                } else {
4453                                    self.template_modal.create_relative_path_input.clear();
4454                                }
4455                            }
4456                        } else {
4457                            self.template_modal.create_relative_path_input.clear();
4458                        }
4459
4460                        // Suggest path pattern
4461                        if let Some(parent) = path.parent() {
4462                            if let Some(parent_str) = parent.to_str() {
4463                                if path.file_name().is_some() {
4464                                    if let Some(ext) = path.extension() {
4465                                        self.template_modal.create_path_pattern_input.value =
4466                                            format!("{}/*.{}", parent_str, ext.to_string_lossy());
4467                                        self.template_modal.create_path_pattern_input.cursor = self
4468                                            .template_modal
4469                                            .create_path_pattern_input
4470                                            .value
4471                                            .chars()
4472                                            .count();
4473                                    }
4474                                }
4475                            }
4476                        }
4477
4478                        // Suggest filename pattern
4479                        if let Some(filename) = path.file_name() {
4480                            if let Some(filename_str) = filename.to_str() {
4481                                // Try to create a pattern by replacing numbers/dates with *
4482                                let mut pattern = filename_str.to_string();
4483                                // Simple heuristic: replace sequences of digits with *
4484                                use regex::Regex;
4485                                if let Ok(re) = Regex::new(r"\d+") {
4486                                    pattern = re.replace_all(&pattern, "*").to_string();
4487                                }
4488                                self.template_modal.create_filename_pattern_input.value = pattern;
4489                                self.template_modal.create_filename_pattern_input.cursor = self
4490                                    .template_modal
4491                                    .create_filename_pattern_input
4492                                    .value
4493                                    .chars()
4494                                    .count();
4495                            }
4496                        }
4497                    }
4498
4499                    // Suggest schema match
4500                    if let Some(ref state) = self.data_table_state {
4501                        if !state.schema.is_empty() {
4502                            self.template_modal.create_schema_match_enabled = false;
4503                            // Not auto-enabled, just suggested
4504                        }
4505                    }
4506                }
4507                KeyCode::Char('e') if self.template_modal.mode == TemplateModalMode::List => {
4508                    // Edit selected template
4509                    if let Some(idx) = self.template_modal.table_state.selected() {
4510                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4511                            let template_clone = template.clone();
4512                            self.template_modal.enter_edit_mode(
4513                                &template_clone,
4514                                self.history_limit,
4515                                &self.theme,
4516                            );
4517                        }
4518                    }
4519                }
4520                KeyCode::Char('d')
4521                    if self.template_modal.mode == TemplateModalMode::List
4522                        && !self.template_modal.delete_confirm =>
4523                {
4524                    // Show delete confirmation
4525                    if let Some(_idx) = self.template_modal.table_state.selected() {
4526                        self.template_modal.delete_confirm = true;
4527                        self.template_modal.delete_confirm_focus = false; // Cancel is default
4528                    }
4529                }
4530                KeyCode::Char('?')
4531                    if self.template_modal.mode == TemplateModalMode::List
4532                        && !self.template_modal.delete_confirm =>
4533                {
4534                    // Show score details popup
4535                    self.template_modal.show_score_details = true;
4536                }
4537                KeyCode::Char('D') if self.template_modal.delete_confirm => {
4538                    // Delete with capital D
4539                    if let Some(idx) = self.template_modal.table_state.selected() {
4540                        if let Some((template, _)) = self.template_modal.templates.get(idx) {
4541                            if self.template_manager.delete_template(&template.id).is_err() {
4542                                // Delete failed; list will be unchanged
4543                            } else {
4544                                // Reload templates
4545                                if let Some(ref state) = self.data_table_state {
4546                                    if let Some(ref path) = self.path {
4547                                        self.template_modal.templates = self
4548                                            .template_manager
4549                                            .find_relevant_templates(path, &state.schema);
4550                                        if !self.template_modal.templates.is_empty() {
4551                                            let new_idx = idx.min(
4552                                                self.template_modal
4553                                                    .templates
4554                                                    .len()
4555                                                    .saturating_sub(1),
4556                                            );
4557                                            self.template_modal.table_state.select(Some(new_idx));
4558                                        } else {
4559                                            self.template_modal.table_state.select(None);
4560                                        }
4561                                    }
4562                                }
4563                            }
4564                            self.template_modal.delete_confirm = false;
4565                        }
4566                    }
4567                }
4568                KeyCode::Tab if self.template_modal.delete_confirm => {
4569                    // Toggle between Cancel and Delete buttons
4570                    self.template_modal.delete_confirm_focus =
4571                        !self.template_modal.delete_confirm_focus;
4572                }
4573                KeyCode::Enter if self.template_modal.delete_confirm => {
4574                    // Enter cancels by default (Cancel is selected)
4575                    if self.template_modal.delete_confirm_focus {
4576                        // Delete button is selected
4577                        if let Some(idx) = self.template_modal.table_state.selected() {
4578                            if let Some((template, _)) = self.template_modal.templates.get(idx) {
4579                                if self.template_manager.delete_template(&template.id).is_err() {
4580                                    // Delete failed; list will be unchanged
4581                                } else {
4582                                    // Reload templates
4583                                    if let Some(ref state) = self.data_table_state {
4584                                        if let Some(ref path) = self.path {
4585                                            self.template_modal.templates = self
4586                                                .template_manager
4587                                                .find_relevant_templates(path, &state.schema);
4588                                            if !self.template_modal.templates.is_empty() {
4589                                                let new_idx = idx.min(
4590                                                    self.template_modal
4591                                                        .templates
4592                                                        .len()
4593                                                        .saturating_sub(1),
4594                                                );
4595                                                self.template_modal
4596                                                    .table_state
4597                                                    .select(Some(new_idx));
4598                                            } else {
4599                                                self.template_modal.table_state.select(None);
4600                                            }
4601                                        }
4602                                    }
4603                                }
4604                                self.template_modal.delete_confirm = false;
4605                            }
4606                        }
4607                    } else {
4608                        // Cancel button is selected (default)
4609                        self.template_modal.delete_confirm = false;
4610                    }
4611                }
4612                KeyCode::Enter => {
4613                    match self.template_modal.mode {
4614                        TemplateModalMode::List => {
4615                            match self.template_modal.focus {
4616                                TemplateFocus::TemplateList => {
4617                                    // Apply selected template
4618                                    let template_idx = self.template_modal.table_state.selected();
4619                                    if let Some(idx) = template_idx {
4620                                        if let Some((template, _)) =
4621                                            self.template_modal.templates.get(idx)
4622                                        {
4623                                            let template_clone = template.clone();
4624                                            if let Err(e) = self.apply_template(&template_clone) {
4625                                                // Show error modal instead of just printing
4626                                                self.error_modal.show(format!(
4627                                                    "Error applying template: {}",
4628                                                    e
4629                                                ));
4630                                                // Keep template modal open so user can see what failed
4631                                            } else {
4632                                                // Only close template modal on success
4633                                                self.template_modal.active = false;
4634                                            }
4635                                        }
4636                                    }
4637                                }
4638                                TemplateFocus::CreateButton => {
4639                                    // Same as 's' key - enter create mode
4640                                    // (handled by 's' key handler above)
4641                                }
4642                                _ => {}
4643                            }
4644                        }
4645                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4646                            // If in description field, Enter adds a newline instead of moving to next field
4647                            if self.template_modal.create_focus == CreateFocus::Description {
4648                                let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
4649                                self.template_modal
4650                                    .create_description_input
4651                                    .handle_key(&event, None);
4652                                // Auto-scroll to keep cursor visible
4653                                let area_height = 10; // Estimate, will be adjusted in rendering
4654                                self.template_modal
4655                                    .create_description_input
4656                                    .ensure_cursor_visible(area_height, 80);
4657                                return None;
4658                            }
4659                            match self.template_modal.create_focus {
4660                                CreateFocus::SaveButton => {
4661                                    // Validate name
4662                                    self.template_modal.name_error = None;
4663                                    if self
4664                                        .template_modal
4665                                        .create_name_input
4666                                        .value
4667                                        .trim()
4668                                        .is_empty()
4669                                    {
4670                                        self.template_modal.name_error =
4671                                            Some("(required)".to_string());
4672                                        self.template_modal.create_focus = CreateFocus::Name;
4673                                        return None;
4674                                    }
4675
4676                                    // Check for duplicate name (only if creating new, not editing)
4677                                    if self.template_modal.editing_template_id.is_none()
4678                                        && self.template_manager.template_exists(
4679                                            self.template_modal.create_name_input.value.trim(),
4680                                        )
4681                                    {
4682                                        self.template_modal.name_error =
4683                                            Some("(name already exists)".to_string());
4684                                        self.template_modal.create_focus = CreateFocus::Name;
4685                                        return None;
4686                                    }
4687
4688                                    // Create template from current state
4689                                    let match_criteria = template::MatchCriteria {
4690                                        exact_path: if !self
4691                                            .template_modal
4692                                            .create_exact_path_input
4693                                            .value
4694                                            .trim()
4695                                            .is_empty()
4696                                        {
4697                                            Some(std::path::PathBuf::from(
4698                                                self.template_modal
4699                                                    .create_exact_path_input
4700                                                    .value
4701                                                    .trim(),
4702                                            ))
4703                                        } else {
4704                                            None
4705                                        },
4706                                        relative_path: if !self
4707                                            .template_modal
4708                                            .create_relative_path_input
4709                                            .value
4710                                            .trim()
4711                                            .is_empty()
4712                                        {
4713                                            Some(
4714                                                self.template_modal
4715                                                    .create_relative_path_input
4716                                                    .value
4717                                                    .trim()
4718                                                    .to_string(),
4719                                            )
4720                                        } else {
4721                                            None
4722                                        },
4723                                        path_pattern: if !self
4724                                            .template_modal
4725                                            .create_path_pattern_input
4726                                            .value
4727                                            .is_empty()
4728                                        {
4729                                            Some(
4730                                                self.template_modal
4731                                                    .create_path_pattern_input
4732                                                    .value
4733                                                    .clone(),
4734                                            )
4735                                        } else {
4736                                            None
4737                                        },
4738                                        filename_pattern: if !self
4739                                            .template_modal
4740                                            .create_filename_pattern_input
4741                                            .value
4742                                            .is_empty()
4743                                        {
4744                                            Some(
4745                                                self.template_modal
4746                                                    .create_filename_pattern_input
4747                                                    .value
4748                                                    .clone(),
4749                                            )
4750                                        } else {
4751                                            None
4752                                        },
4753                                        schema_columns: if self
4754                                            .template_modal
4755                                            .create_schema_match_enabled
4756                                        {
4757                                            self.data_table_state.as_ref().map(|state| {
4758                                                state
4759                                                    .schema
4760                                                    .iter_names()
4761                                                    .map(|s| s.to_string())
4762                                                    .collect()
4763                                            })
4764                                        } else {
4765                                            None
4766                                        },
4767                                        schema_types: None, // Can be enhanced later
4768                                    };
4769
4770                                    let description = if !self
4771                                        .template_modal
4772                                        .create_description_input
4773                                        .value
4774                                        .is_empty()
4775                                    {
4776                                        Some(
4777                                            self.template_modal
4778                                                .create_description_input
4779                                                .value
4780                                                .clone(),
4781                                        )
4782                                    } else {
4783                                        None
4784                                    };
4785
4786                                    if let Some(ref editing_id) =
4787                                        self.template_modal.editing_template_id
4788                                    {
4789                                        // Update existing template
4790                                        if let Some(mut template) = self
4791                                            .template_manager
4792                                            .get_template_by_id(editing_id)
4793                                            .cloned()
4794                                        {
4795                                            template.name = self
4796                                                .template_modal
4797                                                .create_name_input
4798                                                .value
4799                                                .trim()
4800                                                .to_string();
4801                                            template.description = description;
4802                                            template.match_criteria = match_criteria;
4803                                            // Update settings from current state
4804                                            if let Some(state) = &self.data_table_state {
4805                                                let (query, sql_query, fuzzy_query) =
4806                                                    active_query_settings(
4807                                                        state.get_active_query(),
4808                                                        state.get_active_sql_query(),
4809                                                        state.get_active_fuzzy_query(),
4810                                                    );
4811                                                template.settings = template::TemplateSettings {
4812                                                    query,
4813                                                    sql_query,
4814                                                    fuzzy_query,
4815                                                    filters: state.get_filters().to_vec(),
4816                                                    sort_columns: state.get_sort_columns().to_vec(),
4817                                                    sort_ascending: state.get_sort_ascending(),
4818                                                    column_order: state.get_column_order().to_vec(),
4819                                                    locked_columns_count: state
4820                                                        .locked_columns_count(),
4821                                                    pivot: state.last_pivot_spec().cloned(),
4822                                                    melt: state.last_melt_spec().cloned(),
4823                                                };
4824                                            }
4825
4826                                            match self.template_manager.update_template(&template) {
4827                                                Ok(_) => {
4828                                                    // Reload templates and go back to list mode
4829                                                    if let Some(ref state) = self.data_table_state {
4830                                                        if let Some(ref path) = self.path {
4831                                                            self.template_modal.templates = self
4832                                                                .template_manager
4833                                                                .find_relevant_templates(
4834                                                                    path,
4835                                                                    &state.schema,
4836                                                                );
4837                                                            self.template_modal.table_state.select(
4838                                                                if self
4839                                                                    .template_modal
4840                                                                    .templates
4841                                                                    .is_empty()
4842                                                                {
4843                                                                    None
4844                                                                } else {
4845                                                                    Some(0)
4846                                                                },
4847                                                            );
4848                                                        }
4849                                                    }
4850                                                    self.template_modal.exit_create_mode();
4851                                                }
4852                                                Err(_) => {
4853                                                    // Update failed; stay in edit mode
4854                                                }
4855                                            }
4856                                        }
4857                                    } else {
4858                                        // Create new template
4859                                        match self.create_template_from_current_state(
4860                                            self.template_modal
4861                                                .create_name_input
4862                                                .value
4863                                                .trim()
4864                                                .to_string(),
4865                                            description,
4866                                            match_criteria,
4867                                        ) {
4868                                            Ok(_) => {
4869                                                // Reload templates and go back to list mode
4870                                                if let Some(ref state) = self.data_table_state {
4871                                                    if let Some(ref path) = self.path {
4872                                                        self.template_modal.templates = self
4873                                                            .template_manager
4874                                                            .find_relevant_templates(
4875                                                                path,
4876                                                                &state.schema,
4877                                                            );
4878                                                        self.template_modal.table_state.select(
4879                                                            if self
4880                                                                .template_modal
4881                                                                .templates
4882                                                                .is_empty()
4883                                                            {
4884                                                                None
4885                                                            } else {
4886                                                                Some(0)
4887                                                            },
4888                                                        );
4889                                                    }
4890                                                }
4891                                                self.template_modal.exit_create_mode();
4892                                            }
4893                                            Err(_) => {
4894                                                // Create failed; stay in create mode
4895                                            }
4896                                        }
4897                                    }
4898                                }
4899                                CreateFocus::CancelButton => {
4900                                    self.template_modal.exit_create_mode();
4901                                }
4902                                _ => {
4903                                    // Move to next field
4904                                    self.template_modal.next_focus();
4905                                }
4906                            }
4907                        }
4908                    }
4909                }
4910                KeyCode::Up => {
4911                    match self.template_modal.mode {
4912                        TemplateModalMode::List => {
4913                            if self.template_modal.focus == TemplateFocus::TemplateList {
4914                                let i = match self.template_modal.table_state.selected() {
4915                                    Some(i) => {
4916                                        if i == 0 {
4917                                            self.template_modal.templates.len().saturating_sub(1)
4918                                        } else {
4919                                            i - 1
4920                                        }
4921                                    }
4922                                    None => 0,
4923                                };
4924                                self.template_modal.table_state.select(Some(i));
4925                            }
4926                        }
4927                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4928                            // If in description field, move cursor up one line
4929                            if self.template_modal.create_focus == CreateFocus::Description {
4930                                let event = KeyEvent::new(KeyCode::Up, KeyModifiers::empty());
4931                                self.template_modal
4932                                    .create_description_input
4933                                    .handle_key(&event, None);
4934                                // Auto-scroll to keep cursor visible
4935                                let area_height = 10; // Estimate, will be adjusted in rendering
4936                                self.template_modal
4937                                    .create_description_input
4938                                    .ensure_cursor_visible(area_height, 80);
4939                            } else {
4940                                // Move to previous field (works for all fields)
4941                                self.template_modal.prev_focus();
4942                            }
4943                        }
4944                    }
4945                }
4946                KeyCode::Down => {
4947                    match self.template_modal.mode {
4948                        TemplateModalMode::List => {
4949                            if self.template_modal.focus == TemplateFocus::TemplateList {
4950                                let i = match self.template_modal.table_state.selected() {
4951                                    Some(i) => {
4952                                        if i >= self
4953                                            .template_modal
4954                                            .templates
4955                                            .len()
4956                                            .saturating_sub(1)
4957                                        {
4958                                            0
4959                                        } else {
4960                                            i + 1
4961                                        }
4962                                    }
4963                                    None => 0,
4964                                };
4965                                self.template_modal.table_state.select(Some(i));
4966                            }
4967                        }
4968                        TemplateModalMode::Create | TemplateModalMode::Edit => {
4969                            // If in description field, move cursor down one line
4970                            if self.template_modal.create_focus == CreateFocus::Description {
4971                                let event = KeyEvent::new(KeyCode::Down, KeyModifiers::empty());
4972                                self.template_modal
4973                                    .create_description_input
4974                                    .handle_key(&event, None);
4975                                // Auto-scroll to keep cursor visible
4976                                let area_height = 10; // Estimate, will be adjusted in rendering
4977                                self.template_modal
4978                                    .create_description_input
4979                                    .ensure_cursor_visible(area_height, 80);
4980                            } else {
4981                                // Move to next field (works for all fields)
4982                                self.template_modal.next_focus();
4983                            }
4984                        }
4985                    }
4986                }
4987                KeyCode::Char(c)
4988                    if self.template_modal.mode == TemplateModalMode::Create
4989                        || self.template_modal.mode == TemplateModalMode::Edit =>
4990                {
4991                    match self.template_modal.create_focus {
4992                        CreateFocus::Name => {
4993                            // Clear error when user starts typing
4994                            self.template_modal.name_error = None;
4995                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
4996                            self.template_modal
4997                                .create_name_input
4998                                .handle_key(&event, None);
4999                        }
5000                        CreateFocus::Description => {
5001                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5002                            self.template_modal
5003                                .create_description_input
5004                                .handle_key(&event, None);
5005                            // Auto-scroll to keep cursor visible
5006                            let area_height = 10; // Estimate, will be adjusted in rendering
5007                            self.template_modal
5008                                .create_description_input
5009                                .ensure_cursor_visible(area_height, 80);
5010                        }
5011                        CreateFocus::ExactPath => {
5012                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5013                            self.template_modal
5014                                .create_exact_path_input
5015                                .handle_key(&event, None);
5016                        }
5017                        CreateFocus::RelativePath => {
5018                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5019                            self.template_modal
5020                                .create_relative_path_input
5021                                .handle_key(&event, None);
5022                        }
5023                        CreateFocus::PathPattern => {
5024                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5025                            self.template_modal
5026                                .create_path_pattern_input
5027                                .handle_key(&event, None);
5028                        }
5029                        CreateFocus::FilenamePattern => {
5030                            let event = KeyEvent::new(KeyCode::Char(c), KeyModifiers::empty());
5031                            self.template_modal
5032                                .create_filename_pattern_input
5033                                .handle_key(&event, None);
5034                        }
5035                        CreateFocus::SchemaMatch => {
5036                            // Space toggles
5037                            if c == ' ' {
5038                                self.template_modal.create_schema_match_enabled =
5039                                    !self.template_modal.create_schema_match_enabled;
5040                            }
5041                        }
5042                        _ => {}
5043                    }
5044                }
5045                KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End
5046                    if self.template_modal.mode == TemplateModalMode::Create
5047                        || self.template_modal.mode == TemplateModalMode::Edit =>
5048                {
5049                    match self.template_modal.create_focus {
5050                        CreateFocus::Name => {
5051                            self.template_modal
5052                                .create_name_input
5053                                .handle_key(event, None);
5054                        }
5055                        CreateFocus::Description => {
5056                            self.template_modal
5057                                .create_description_input
5058                                .handle_key(event, None);
5059                            // Auto-scroll to keep cursor visible
5060                            let area_height = 10;
5061                            self.template_modal
5062                                .create_description_input
5063                                .ensure_cursor_visible(area_height, 80);
5064                        }
5065                        CreateFocus::ExactPath => {
5066                            self.template_modal
5067                                .create_exact_path_input
5068                                .handle_key(event, None);
5069                        }
5070                        CreateFocus::RelativePath => {
5071                            self.template_modal
5072                                .create_relative_path_input
5073                                .handle_key(event, None);
5074                        }
5075                        CreateFocus::PathPattern => {
5076                            self.template_modal
5077                                .create_path_pattern_input
5078                                .handle_key(event, None);
5079                        }
5080                        CreateFocus::FilenamePattern => {
5081                            self.template_modal
5082                                .create_filename_pattern_input
5083                                .handle_key(event, None);
5084                        }
5085                        _ => {}
5086                    }
5087                }
5088                KeyCode::PageUp | KeyCode::PageDown
5089                    if self.template_modal.mode == TemplateModalMode::Create
5090                        || self.template_modal.mode == TemplateModalMode::Edit =>
5091                {
5092                    // PageUp/PageDown for description field - move cursor up/down by 5 lines
5093                    // This is handled manually since MultiLineTextInput doesn't have built-in PageUp/PageDown
5094                    if self.template_modal.create_focus == CreateFocus::Description {
5095                        let lines: Vec<&str> = self
5096                            .template_modal
5097                            .create_description_input
5098                            .value
5099                            .lines()
5100                            .collect();
5101                        let current_line = self.template_modal.create_description_input.cursor_line;
5102                        let current_col = self.template_modal.create_description_input.cursor_col;
5103
5104                        let target_line = if event.code == KeyCode::PageUp {
5105                            current_line.saturating_sub(5)
5106                        } else {
5107                            (current_line + 5).min(lines.len().saturating_sub(1))
5108                        };
5109
5110                        if target_line < lines.len() {
5111                            let target_line_str = lines.get(target_line).unwrap_or(&"");
5112                            let new_col = current_col.min(target_line_str.chars().count());
5113                            self.template_modal.create_description_input.cursor = self
5114                                .template_modal
5115                                .create_description_input
5116                                .line_col_to_cursor(target_line, new_col);
5117                            self.template_modal
5118                                .create_description_input
5119                                .update_line_col_from_cursor();
5120                            // Auto-scroll
5121                            let area_height = 10;
5122                            self.template_modal
5123                                .create_description_input
5124                                .ensure_cursor_visible(area_height, 80);
5125                        }
5126                    }
5127                }
5128                KeyCode::Backspace
5129                | KeyCode::Delete
5130                | KeyCode::Left
5131                | KeyCode::Right
5132                | KeyCode::Home
5133                | KeyCode::End
5134                    if self.template_modal.mode == TemplateModalMode::Create
5135                        || self.template_modal.mode == TemplateModalMode::Edit =>
5136                {
5137                    match self.template_modal.create_focus {
5138                        CreateFocus::Name => {
5139                            self.template_modal
5140                                .create_name_input
5141                                .handle_key(event, None);
5142                        }
5143                        CreateFocus::Description => {
5144                            self.template_modal
5145                                .create_description_input
5146                                .handle_key(event, None);
5147                            // Auto-scroll to keep cursor visible
5148                            let area_height = 10;
5149                            self.template_modal
5150                                .create_description_input
5151                                .ensure_cursor_visible(area_height, 80);
5152                        }
5153                        CreateFocus::ExactPath => {
5154                            self.template_modal
5155                                .create_exact_path_input
5156                                .handle_key(event, None);
5157                        }
5158                        CreateFocus::RelativePath => {
5159                            self.template_modal
5160                                .create_relative_path_input
5161                                .handle_key(event, None);
5162                        }
5163                        CreateFocus::PathPattern => {
5164                            self.template_modal
5165                                .create_path_pattern_input
5166                                .handle_key(event, None);
5167                        }
5168                        CreateFocus::FilenamePattern => {
5169                            self.template_modal
5170                                .create_filename_pattern_input
5171                                .handle_key(event, None);
5172                        }
5173                        _ => {}
5174                    }
5175                }
5176                _ => {}
5177            }
5178            return None;
5179        }
5180
5181        if self.input_mode == InputMode::Editing {
5182            if self.input_type == Some(InputType::Search) {
5183                const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5184                const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5185
5186                if self.query_focus == QueryFocus::TabBar && event.is_press() {
5187                    if event.code == KeyCode::BackTab
5188                        || (event.code == KeyCode::Tab
5189                            && !event.modifiers.contains(KeyModifiers::SHIFT))
5190                    {
5191                        self.query_focus = QueryFocus::Input;
5192                        if let Some(state) = &self.data_table_state {
5193                            if self.query_tab == QueryTab::SqlLike {
5194                                self.query_input.value = state.get_active_query().to_string();
5195                                self.query_input.cursor = self.query_input.value.chars().count();
5196                                self.sql_input.set_focused(false);
5197                                self.fuzzy_input.set_focused(false);
5198                                self.query_input.set_focused(true);
5199                            } else if self.query_tab == QueryTab::Fuzzy {
5200                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5201                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5202                                self.query_input.set_focused(false);
5203                                self.sql_input.set_focused(false);
5204                                self.fuzzy_input.set_focused(true);
5205                            } else if self.query_tab == QueryTab::Sql {
5206                                self.sql_input.value = state.get_active_sql_query().to_string();
5207                                self.sql_input.cursor = self.sql_input.value.chars().count();
5208                                self.query_input.set_focused(false);
5209                                self.fuzzy_input.set_focused(false);
5210                                self.sql_input.set_focused(true);
5211                            }
5212                        }
5213                        return None;
5214                    }
5215                    if RIGHT_KEYS.contains(&event.code) {
5216                        self.query_tab = self.query_tab.next();
5217                        if let Some(state) = &self.data_table_state {
5218                            if self.query_tab == QueryTab::SqlLike {
5219                                self.query_input.value = state.get_active_query().to_string();
5220                                self.query_input.cursor = self.query_input.value.chars().count();
5221                            } else if self.query_tab == QueryTab::Fuzzy {
5222                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5223                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5224                            } else if self.query_tab == QueryTab::Sql {
5225                                self.sql_input.value = state.get_active_sql_query().to_string();
5226                                self.sql_input.cursor = self.sql_input.value.chars().count();
5227                            }
5228                        }
5229                        self.query_input.set_focused(false);
5230                        self.sql_input.set_focused(false);
5231                        self.fuzzy_input.set_focused(false);
5232                        return None;
5233                    }
5234                    if LEFT_KEYS.contains(&event.code) {
5235                        self.query_tab = self.query_tab.prev();
5236                        if let Some(state) = &self.data_table_state {
5237                            if self.query_tab == QueryTab::SqlLike {
5238                                self.query_input.value = state.get_active_query().to_string();
5239                                self.query_input.cursor = self.query_input.value.chars().count();
5240                            } else if self.query_tab == QueryTab::Fuzzy {
5241                                self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5242                                self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5243                            } else if self.query_tab == QueryTab::Sql {
5244                                self.sql_input.value = state.get_active_sql_query().to_string();
5245                                self.sql_input.cursor = self.sql_input.value.chars().count();
5246                            }
5247                        }
5248                        self.query_input.set_focused(false);
5249                        self.sql_input.set_focused(false);
5250                        self.fuzzy_input.set_focused(false);
5251                        return None;
5252                    }
5253                    if event.code == KeyCode::Esc {
5254                        self.query_input.clear();
5255                        self.sql_input.clear();
5256                        self.fuzzy_input.clear();
5257                        self.query_input.set_focused(false);
5258                        self.sql_input.set_focused(false);
5259                        self.fuzzy_input.set_focused(false);
5260                        self.input_mode = InputMode::Normal;
5261                        self.input_type = None;
5262                        if let Some(state) = &mut self.data_table_state {
5263                            state.error = None;
5264                            state.suppress_error_display = false;
5265                        }
5266                        return None;
5267                    }
5268                    return None;
5269                }
5270
5271                if event.is_press()
5272                    && event.code == KeyCode::Tab
5273                    && !event.modifiers.contains(KeyModifiers::SHIFT)
5274                {
5275                    self.query_focus = QueryFocus::TabBar;
5276                    self.query_input.set_focused(false);
5277                    self.sql_input.set_focused(false);
5278                    self.fuzzy_input.set_focused(false);
5279                    return None;
5280                }
5281
5282                if self.query_focus != QueryFocus::Input {
5283                    return None;
5284                }
5285
5286                if self.query_tab == QueryTab::Sql {
5287                    self.query_input.set_focused(false);
5288                    self.fuzzy_input.set_focused(false);
5289                    self.sql_input.set_focused(true);
5290                    let result = self.sql_input.handle_key(event, Some(&self.cache));
5291                    match result {
5292                        TextInputEvent::Submit => {
5293                            let _ = self.sql_input.save_to_history(&self.cache);
5294                            let sql = self.sql_input.value.clone();
5295                            self.sql_input.set_focused(false);
5296                            return Some(AppEvent::SqlSearch(sql));
5297                        }
5298                        TextInputEvent::Cancel => {
5299                            self.sql_input.clear();
5300                            self.sql_input.set_focused(false);
5301                            self.input_mode = InputMode::Normal;
5302                            self.input_type = None;
5303                            if let Some(state) = &mut self.data_table_state {
5304                                state.error = None;
5305                                state.suppress_error_display = false;
5306                            }
5307                        }
5308                        TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5309                    }
5310                    return None;
5311                }
5312
5313                if self.query_tab == QueryTab::Fuzzy {
5314                    self.query_input.set_focused(false);
5315                    self.sql_input.set_focused(false);
5316                    self.fuzzy_input.set_focused(true);
5317                    let result = self.fuzzy_input.handle_key(event, Some(&self.cache));
5318                    match result {
5319                        TextInputEvent::Submit => {
5320                            let _ = self.fuzzy_input.save_to_history(&self.cache);
5321                            let query = self.fuzzy_input.value.clone();
5322                            self.fuzzy_input.set_focused(false);
5323                            return Some(AppEvent::FuzzySearch(query));
5324                        }
5325                        TextInputEvent::Cancel => {
5326                            self.fuzzy_input.clear();
5327                            self.fuzzy_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::SqlLike {
5341                    return None;
5342                }
5343
5344                self.sql_input.set_focused(false);
5345                self.fuzzy_input.set_focused(false);
5346                self.query_input.set_focused(true);
5347                let result = self.query_input.handle_key(event, Some(&self.cache));
5348
5349                match result {
5350                    TextInputEvent::Submit => {
5351                        // Save to history and execute query
5352                        let _ = self.query_input.save_to_history(&self.cache);
5353                        let query = self.query_input.value.clone();
5354                        self.query_input.set_focused(false);
5355                        return Some(AppEvent::Search(query));
5356                    }
5357                    TextInputEvent::Cancel => {
5358                        // Clear and exit input mode
5359                        self.query_input.clear();
5360                        self.query_input.set_focused(false);
5361                        self.input_mode = InputMode::Normal;
5362                        if let Some(state) = &mut self.data_table_state {
5363                            // Clear error and re-enable error display in main view
5364                            state.error = None;
5365                            state.suppress_error_display = false;
5366                        }
5367                    }
5368                    TextInputEvent::HistoryChanged => {
5369                        // History navigation occurred, nothing special needed
5370                    }
5371                    TextInputEvent::None => {
5372                        // Regular input, nothing special needed
5373                    }
5374                }
5375                return None;
5376            }
5377
5378            // Line number input (GoToLine): ":" then type line number, Enter to jump, Esc to cancel
5379            if self.input_type == Some(InputType::GoToLine) {
5380                self.query_input.set_focused(true);
5381                let result = self.query_input.handle_key(event, None);
5382                match result {
5383                    TextInputEvent::Submit => {
5384                        let value = self.query_input.value.trim().to_string();
5385                        self.query_input.clear();
5386                        self.query_input.set_focused(false);
5387                        self.input_mode = InputMode::Normal;
5388                        self.input_type = None;
5389                        if let Some(state) = &mut self.data_table_state {
5390                            if let Ok(display_line) = value.parse::<usize>() {
5391                                let row_index =
5392                                    display_line.saturating_sub(state.row_start_index());
5393                                let would_collect = state.scroll_would_trigger_collect(
5394                                    row_index as i64 - state.start_row as i64,
5395                                );
5396                                if would_collect {
5397                                    self.busy = true;
5398                                    return Some(AppEvent::GoToLine(row_index));
5399                                }
5400                                state.scroll_to_row_centered(row_index);
5401                            }
5402                        }
5403                    }
5404                    TextInputEvent::Cancel => {
5405                        self.query_input.clear();
5406                        self.query_input.set_focused(false);
5407                        self.input_mode = InputMode::Normal;
5408                        self.input_type = None;
5409                    }
5410                    TextInputEvent::HistoryChanged | TextInputEvent::None => {}
5411                }
5412                return None;
5413            }
5414
5415            // For other input types (Filter, etc.), keep old behavior for now
5416            // TODO: Migrate these in later phases
5417            return None;
5418        }
5419
5420        const RIGHT_KEYS: [KeyCode; 2] = [KeyCode::Right, KeyCode::Char('l')];
5421
5422        const LEFT_KEYS: [KeyCode; 2] = [KeyCode::Left, KeyCode::Char('h')];
5423
5424        const DOWN_KEYS: [KeyCode; 2] = [KeyCode::Down, KeyCode::Char('j')];
5425
5426        const UP_KEYS: [KeyCode; 2] = [KeyCode::Up, KeyCode::Char('k')];
5427
5428        match event.code {
5429            KeyCode::Char('q') | KeyCode::Char('Q') => Some(AppEvent::Exit),
5430            KeyCode::Char('c') if event.modifiers.contains(KeyModifiers::CONTROL) => {
5431                Some(AppEvent::Exit)
5432            }
5433            KeyCode::Char('R') => Some(AppEvent::Reset),
5434            KeyCode::Char('N') => {
5435                if let Some(ref mut state) = self.data_table_state {
5436                    state.toggle_row_numbers();
5437                }
5438                None
5439            }
5440            KeyCode::Esc => {
5441                // First check if we're in drill-down mode
5442                if let Some(ref mut state) = self.data_table_state {
5443                    if state.is_drilled_down() {
5444                        let _ = state.drill_up();
5445                        return None;
5446                    }
5447                }
5448                // Escape no longer exits - use 'q' or Ctrl-C to exit
5449                // (Info modal handles Esc in its own block)
5450                None
5451            }
5452            code if RIGHT_KEYS.contains(&code) => {
5453                if let Some(ref mut state) = self.data_table_state {
5454                    state.scroll_right();
5455                    if self.debug.enabled {
5456                        self.debug.last_action = "scroll_right".to_string();
5457                    }
5458                }
5459                None
5460            }
5461            code if LEFT_KEYS.contains(&code) => {
5462                if let Some(ref mut state) = self.data_table_state {
5463                    state.scroll_left();
5464                    if self.debug.enabled {
5465                        self.debug.last_action = "scroll_left".to_string();
5466                    }
5467                }
5468                None
5469            }
5470            code if event.is_press() && DOWN_KEYS.contains(&code) => {
5471                let would_collect = self
5472                    .data_table_state
5473                    .as_ref()
5474                    .map(|s| s.scroll_would_trigger_collect(1))
5475                    .unwrap_or(false);
5476                if would_collect {
5477                    self.busy = true;
5478                    Some(AppEvent::DoScrollNext)
5479                } else {
5480                    if let Some(ref mut s) = self.data_table_state {
5481                        s.select_next();
5482                    }
5483                    None
5484                }
5485            }
5486            code if event.is_press() && UP_KEYS.contains(&code) => {
5487                let would_collect = self
5488                    .data_table_state
5489                    .as_ref()
5490                    .map(|s| s.scroll_would_trigger_collect(-1))
5491                    .unwrap_or(false);
5492                if would_collect {
5493                    self.busy = true;
5494                    Some(AppEvent::DoScrollPrev)
5495                } else {
5496                    if let Some(ref mut s) = self.data_table_state {
5497                        s.select_previous();
5498                    }
5499                    None
5500                }
5501            }
5502            KeyCode::PageDown if event.is_press() => {
5503                let would_collect = self
5504                    .data_table_state
5505                    .as_ref()
5506                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5507                    .unwrap_or(false);
5508                if would_collect {
5509                    self.busy = true;
5510                    Some(AppEvent::DoScrollDown)
5511                } else {
5512                    if let Some(ref mut s) = self.data_table_state {
5513                        s.page_down();
5514                    }
5515                    None
5516                }
5517            }
5518            KeyCode::Home if event.is_press() => {
5519                if let Some(ref mut state) = self.data_table_state {
5520                    if state.start_row > 0 {
5521                        state.scroll_to(0);
5522                    }
5523                    state.table_state.select(Some(0));
5524                }
5525                None
5526            }
5527            KeyCode::End if event.is_press() => {
5528                if self.data_table_state.is_some() {
5529                    self.busy = true;
5530                    Some(AppEvent::DoScrollEnd)
5531                } else {
5532                    None
5533                }
5534            }
5535            KeyCode::Char('G') if event.is_press() => {
5536                if self.data_table_state.is_some() {
5537                    self.busy = true;
5538                    Some(AppEvent::DoScrollEnd)
5539                } else {
5540                    None
5541                }
5542            }
5543            KeyCode::Char('f')
5544                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5545            {
5546                let would_collect = self
5547                    .data_table_state
5548                    .as_ref()
5549                    .map(|s| s.scroll_would_trigger_collect(s.visible_rows as i64))
5550                    .unwrap_or(false);
5551                if would_collect {
5552                    self.busy = true;
5553                    Some(AppEvent::DoScrollDown)
5554                } else {
5555                    if let Some(ref mut s) = self.data_table_state {
5556                        s.page_down();
5557                    }
5558                    None
5559                }
5560            }
5561            KeyCode::Char('b')
5562                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5563            {
5564                let would_collect = self
5565                    .data_table_state
5566                    .as_ref()
5567                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5568                    .unwrap_or(false);
5569                if would_collect {
5570                    self.busy = true;
5571                    Some(AppEvent::DoScrollUp)
5572                } else {
5573                    if let Some(ref mut s) = self.data_table_state {
5574                        s.page_up();
5575                    }
5576                    None
5577                }
5578            }
5579            KeyCode::Char('d')
5580                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5581            {
5582                let half = self
5583                    .data_table_state
5584                    .as_ref()
5585                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5586                    .unwrap_or(1);
5587                let would_collect = self
5588                    .data_table_state
5589                    .as_ref()
5590                    .map(|s| s.scroll_would_trigger_collect(half))
5591                    .unwrap_or(false);
5592                if would_collect {
5593                    self.busy = true;
5594                    Some(AppEvent::DoScrollHalfDown)
5595                } else {
5596                    if let Some(ref mut s) = self.data_table_state {
5597                        s.half_page_down();
5598                    }
5599                    None
5600                }
5601            }
5602            KeyCode::Char('u')
5603                if event.modifiers.contains(KeyModifiers::CONTROL) && event.is_press() =>
5604            {
5605                let half = self
5606                    .data_table_state
5607                    .as_ref()
5608                    .map(|s| (s.visible_rows / 2).max(1) as i64)
5609                    .unwrap_or(1);
5610                let would_collect = self
5611                    .data_table_state
5612                    .as_ref()
5613                    .map(|s| s.scroll_would_trigger_collect(-half))
5614                    .unwrap_or(false);
5615                if would_collect {
5616                    self.busy = true;
5617                    Some(AppEvent::DoScrollHalfUp)
5618                } else {
5619                    if let Some(ref mut s) = self.data_table_state {
5620                        s.half_page_up();
5621                    }
5622                    None
5623                }
5624            }
5625            KeyCode::PageUp if event.is_press() => {
5626                let would_collect = self
5627                    .data_table_state
5628                    .as_ref()
5629                    .map(|s| s.scroll_would_trigger_collect(-(s.visible_rows as i64)))
5630                    .unwrap_or(false);
5631                if would_collect {
5632                    self.busy = true;
5633                    Some(AppEvent::DoScrollUp)
5634                } else {
5635                    if let Some(ref mut s) = self.data_table_state {
5636                        s.page_up();
5637                    }
5638                    None
5639                }
5640            }
5641            KeyCode::Enter if event.is_press() => {
5642                // Only drill down if not in a modal and viewing grouped data
5643                if self.input_mode == InputMode::Normal {
5644                    if let Some(ref mut state) = self.data_table_state {
5645                        if state.is_grouped() && !state.is_drilled_down() {
5646                            if let Some(selected) = state.table_state.selected() {
5647                                let group_index = state.start_row + selected;
5648                                let _ = state.drill_down_into_group(group_index);
5649                            }
5650                        }
5651                    }
5652                }
5653                None
5654            }
5655            KeyCode::Tab if event.is_press() => {
5656                self.focus = (self.focus + 1) % 2;
5657                None
5658            }
5659            KeyCode::BackTab if event.is_press() => {
5660                self.focus = (self.focus + 1) % 2;
5661                None
5662            }
5663            KeyCode::Char('i') if event.is_press() => {
5664                if self.data_table_state.is_some() {
5665                    self.info_modal.open();
5666                    self.input_mode = InputMode::Info;
5667                    // Defer Parquet metadata load so UI can show throbber; avoid blocking in render
5668                    if self.path.is_some()
5669                        && self.original_file_format == Some(ExportFormat::Parquet)
5670                        && self.parquet_metadata_cache.is_none()
5671                    {
5672                        self.busy = true;
5673                        return Some(AppEvent::DoLoadParquetMetadata);
5674                    }
5675                }
5676                None
5677            }
5678            KeyCode::Char('/') => {
5679                self.input_mode = InputMode::Editing;
5680                self.input_type = Some(InputType::Search);
5681                self.query_tab = QueryTab::SqlLike;
5682                self.query_focus = QueryFocus::Input;
5683                if let Some(state) = &mut self.data_table_state {
5684                    self.query_input.value = state.active_query.clone();
5685                    self.query_input.cursor = self.query_input.value.chars().count();
5686                    self.sql_input.value = state.get_active_sql_query().to_string();
5687                    self.fuzzy_input.value = state.get_active_fuzzy_query().to_string();
5688                    self.fuzzy_input.cursor = self.fuzzy_input.value.chars().count();
5689                    self.sql_input.cursor = self.sql_input.value.chars().count();
5690                    state.suppress_error_display = true;
5691                } else {
5692                    self.query_input.clear();
5693                    self.sql_input.clear();
5694                    self.fuzzy_input.clear();
5695                }
5696                self.sql_input.set_focused(false);
5697                self.fuzzy_input.set_focused(false);
5698                self.query_input.set_focused(true);
5699                None
5700            }
5701            KeyCode::Char(':') if event.is_press() => {
5702                if self.data_table_state.is_some() {
5703                    self.input_mode = InputMode::Editing;
5704                    self.input_type = Some(InputType::GoToLine);
5705                    self.query_input.value.clear();
5706                    self.query_input.cursor = 0;
5707                    self.query_input.set_focused(true);
5708                }
5709                None
5710            }
5711            KeyCode::Char('T') => {
5712                // Apply most relevant template immediately (no modal)
5713                if let Some(ref state) = self.data_table_state {
5714                    if let Some(ref path) = self.path {
5715                        if let Some(template) =
5716                            self.template_manager.get_most_relevant(path, &state.schema)
5717                        {
5718                            // Apply template settings
5719                            if let Err(e) = self.apply_template(&template) {
5720                                // Show error modal instead of just printing
5721                                self.error_modal
5722                                    .show(format!("Error applying template: {}", e));
5723                            }
5724                        }
5725                    }
5726                }
5727                None
5728            }
5729            KeyCode::Char('t') => {
5730                // Open template modal
5731                if let Some(ref state) = self.data_table_state {
5732                    if let Some(ref path) = self.path {
5733                        // Load relevant templates
5734                        self.template_modal.templates = self
5735                            .template_manager
5736                            .find_relevant_templates(path, &state.schema);
5737                        self.template_modal.table_state.select(
5738                            if self.template_modal.templates.is_empty() {
5739                                None
5740                            } else {
5741                                Some(0)
5742                            },
5743                        );
5744                        self.template_modal.active = true;
5745                        self.template_modal.mode = TemplateModalMode::List;
5746                        self.template_modal.focus = TemplateFocus::TemplateList;
5747                    }
5748                }
5749                None
5750            }
5751            KeyCode::Char('s') => {
5752                if let Some(state) = &self.data_table_state {
5753                    let headers: Vec<String> =
5754                        state.schema.iter_names().map(|s| s.to_string()).collect();
5755                    let locked_count = state.locked_columns_count();
5756
5757                    // Populate sort tab
5758                    let mut existing_columns: std::collections::HashMap<String, SortColumn> = self
5759                        .sort_filter_modal
5760                        .sort
5761                        .columns
5762                        .iter()
5763                        .map(|c| (c.name.clone(), c.clone()))
5764                        .collect();
5765                    self.sort_filter_modal.sort.columns = headers
5766                        .iter()
5767                        .enumerate()
5768                        .map(|(i, h)| {
5769                            if let Some(mut col) = existing_columns.remove(h) {
5770                                col.display_order = i;
5771                                col.is_locked = i < locked_count;
5772                                col.is_to_be_locked = false;
5773                                col
5774                            } else {
5775                                SortColumn {
5776                                    name: h.clone(),
5777                                    sort_order: None,
5778                                    display_order: i,
5779                                    is_locked: i < locked_count,
5780                                    is_to_be_locked: false,
5781                                    is_visible: true,
5782                                }
5783                            }
5784                        })
5785                        .collect();
5786                    self.sort_filter_modal.sort.filter_input.clear();
5787                    self.sort_filter_modal.sort.focus = SortFocus::ColumnList;
5788
5789                    // Populate filter tab
5790                    self.sort_filter_modal.filter.available_columns = state.headers();
5791                    if !self.sort_filter_modal.filter.available_columns.is_empty() {
5792                        self.sort_filter_modal.filter.new_column_idx =
5793                            self.sort_filter_modal.filter.new_column_idx.min(
5794                                self.sort_filter_modal
5795                                    .filter
5796                                    .available_columns
5797                                    .len()
5798                                    .saturating_sub(1),
5799                            );
5800                    } else {
5801                        self.sort_filter_modal.filter.new_column_idx = 0;
5802                    }
5803
5804                    self.sort_filter_modal.open(self.history_limit, &self.theme);
5805                    self.input_mode = InputMode::SortFilter;
5806                }
5807                None
5808            }
5809            KeyCode::Char('r') => {
5810                if let Some(state) = &mut self.data_table_state {
5811                    state.reverse();
5812                }
5813                None
5814            }
5815            KeyCode::Char('a') => {
5816                // Open analysis modal; no computation until user selects a tool from the sidebar (Enter)
5817                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5818                    self.analysis_modal.open();
5819                }
5820                None
5821            }
5822            KeyCode::Char('c') => {
5823                if let Some(state) = &self.data_table_state {
5824                    if self.input_mode == InputMode::Normal {
5825                        let numeric_columns: Vec<String> = state
5826                            .schema
5827                            .iter()
5828                            .filter(|(_, dtype)| dtype.is_numeric())
5829                            .map(|(name, _)| name.to_string())
5830                            .collect();
5831                        let datetime_columns: Vec<String> = state
5832                            .schema
5833                            .iter()
5834                            .filter(|(_, dtype)| {
5835                                matches!(
5836                                    dtype,
5837                                    DataType::Datetime(_, _) | DataType::Date | DataType::Time
5838                                )
5839                            })
5840                            .map(|(name, _)| name.to_string())
5841                            .collect();
5842                        self.chart_modal.open(
5843                            &numeric_columns,
5844                            &datetime_columns,
5845                            self.app_config.chart.row_limit,
5846                        );
5847                        self.chart_modal.x_input =
5848                            std::mem::take(&mut self.chart_modal.x_input).with_theme(&self.theme);
5849                        self.chart_modal.y_input =
5850                            std::mem::take(&mut self.chart_modal.y_input).with_theme(&self.theme);
5851                        self.chart_modal.hist_input =
5852                            std::mem::take(&mut self.chart_modal.hist_input)
5853                                .with_theme(&self.theme);
5854                        self.chart_modal.box_input =
5855                            std::mem::take(&mut self.chart_modal.box_input).with_theme(&self.theme);
5856                        self.chart_modal.kde_input =
5857                            std::mem::take(&mut self.chart_modal.kde_input).with_theme(&self.theme);
5858                        self.chart_modal.heatmap_x_input =
5859                            std::mem::take(&mut self.chart_modal.heatmap_x_input)
5860                                .with_theme(&self.theme);
5861                        self.chart_modal.heatmap_y_input =
5862                            std::mem::take(&mut self.chart_modal.heatmap_y_input)
5863                                .with_theme(&self.theme);
5864                        self.chart_cache.clear();
5865                        self.input_mode = InputMode::Chart;
5866                    }
5867                }
5868                None
5869            }
5870            KeyCode::Char('p') => {
5871                if let Some(state) = &self.data_table_state {
5872                    if self.input_mode == InputMode::Normal {
5873                        self.pivot_melt_modal.available_columns =
5874                            state.schema.iter_names().map(|s| s.to_string()).collect();
5875                        self.pivot_melt_modal.column_dtypes = state
5876                            .schema
5877                            .iter()
5878                            .map(|(n, d)| (n.to_string(), d.clone()))
5879                            .collect();
5880                        self.pivot_melt_modal.open(self.history_limit, &self.theme);
5881                        self.input_mode = InputMode::PivotMelt;
5882                    }
5883                }
5884                None
5885            }
5886            KeyCode::Char('e') => {
5887                if self.data_table_state.is_some() && self.input_mode == InputMode::Normal {
5888                    // Load config to get delimiter preference
5889                    let config_delimiter = AppConfig::load(APP_NAME)
5890                        .ok()
5891                        .and_then(|config| config.file_loading.delimiter);
5892                    self.export_modal.open(
5893                        self.original_file_format,
5894                        self.history_limit,
5895                        &self.theme,
5896                        self.original_file_delimiter,
5897                        config_delimiter,
5898                    );
5899                    self.input_mode = InputMode::Export;
5900                }
5901                None
5902            }
5903            _ => None,
5904        }
5905    }
5906
5907    pub fn event(&mut self, event: &AppEvent) -> Option<AppEvent> {
5908        self.debug.num_events += 1;
5909        match event {
5910            AppEvent::Key(key) => {
5911                let is_column_scroll = matches!(
5912                    key.code,
5913                    KeyCode::Left | KeyCode::Right | KeyCode::Char('h') | KeyCode::Char('l')
5914                );
5915                let is_help_key = key.code == KeyCode::F(1);
5916                // When busy (e.g. loading), still process column scroll, F1, and confirmation modal keys.
5917                if self.busy && !is_column_scroll && !is_help_key && !self.confirmation_modal.active
5918                {
5919                    return None;
5920                }
5921                self.key(key)
5922            }
5923            AppEvent::Open(paths, options) => {
5924                if paths.is_empty() {
5925                    return Some(AppEvent::Crash("No paths provided".to_string()));
5926                }
5927                #[cfg(feature = "http")]
5928                if let Some(ref p) = self.http_temp_path.take() {
5929                    let _ = std::fs::remove_file(p);
5930                }
5931                self.busy = true;
5932                let first = &paths[0];
5933                let file_size = match source::input_source(first) {
5934                    source::InputSource::Local(_) => {
5935                        std::fs::metadata(first).map(|m| m.len()).unwrap_or(0)
5936                    }
5937                    source::InputSource::S3(_)
5938                    | source::InputSource::Gcs(_)
5939                    | source::InputSource::Http(_) => 0,
5940                };
5941                let path_str = first.as_os_str().to_string_lossy();
5942                let _is_partitioned_path = paths.len() == 1
5943                    && options.hive
5944                    && (first.is_dir() || path_str.contains('*') || path_str.contains("**"));
5945                let phase = "Scanning input";
5946
5947                self.loading_state = LoadingState::Loading {
5948                    file_path: Some(first.clone()),
5949                    file_size,
5950                    current_phase: phase.to_string(),
5951                    progress_percent: 10,
5952                };
5953
5954                Some(AppEvent::DoLoadScanPaths(paths.clone(), options.clone()))
5955            }
5956            AppEvent::OpenLazyFrame(lf, options) => {
5957                self.busy = true;
5958                self.loading_state = LoadingState::Loading {
5959                    file_path: None,
5960                    file_size: 0,
5961                    current_phase: "Scanning input".to_string(),
5962                    progress_percent: 10,
5963                };
5964                Some(AppEvent::DoLoadSchema(lf.clone(), None, options.clone()))
5965            }
5966            AppEvent::DoLoadScanPaths(paths, options) => {
5967                let first = &paths[0];
5968                let src = source::input_source(first);
5969                if paths.len() > 1 {
5970                    match &src {
5971                        source::InputSource::S3(_) => {
5972                            return Some(AppEvent::Crash(
5973                                "Only one S3 URL at a time. Open a single s3:// path.".to_string(),
5974                            ));
5975                        }
5976                        source::InputSource::Gcs(_) => {
5977                            return Some(AppEvent::Crash(
5978                                "Only one GCS URL at a time. Open a single gs:// path.".to_string(),
5979                            ));
5980                        }
5981                        source::InputSource::Http(_) => {
5982                            return Some(AppEvent::Crash(
5983                                "Only one HTTP/HTTPS URL at a time. Open a single URL.".to_string(),
5984                            ));
5985                        }
5986                        source::InputSource::Local(_) => {}
5987                    }
5988                }
5989                let compression = options
5990                    .compression
5991                    .or_else(|| CompressionFormat::from_extension(first));
5992                let is_csv = first
5993                    .file_stem()
5994                    .and_then(|stem| stem.to_str())
5995                    .map(|stem| {
5996                        stem.ends_with(".csv")
5997                            || first
5998                                .extension()
5999                                .and_then(|e| e.to_str())
6000                                .map(|e| e.eq_ignore_ascii_case("csv"))
6001                                .unwrap_or(false)
6002                    })
6003                    .unwrap_or(false);
6004                let is_compressed_csv = matches!(src, source::InputSource::Local(_))
6005                    && paths.len() == 1
6006                    && compression.is_some()
6007                    && is_csv;
6008                if is_compressed_csv {
6009                    if let LoadingState::Loading {
6010                        file_path,
6011                        file_size,
6012                        ..
6013                    } = &self.loading_state
6014                    {
6015                        self.loading_state = LoadingState::Loading {
6016                            file_path: file_path.clone(),
6017                            file_size: *file_size,
6018                            current_phase: "Decompressing".to_string(),
6019                            progress_percent: 30,
6020                        };
6021                    }
6022                    Some(AppEvent::DoLoad(paths.clone(), options.clone()))
6023                } else {
6024                    #[cfg(feature = "http")]
6025                    if let source::InputSource::Http(ref url) = src {
6026                        let size = Self::fetch_remote_size_http(url).unwrap_or(None);
6027                        let size_str = size
6028                            .map(Self::format_bytes)
6029                            .unwrap_or_else(|| "unknown".to_string());
6030                        let dest_dir = options
6031                            .temp_dir
6032                            .as_deref()
6033                            .map(|p| p.display().to_string())
6034                            .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6035                        let message = format!(
6036                            "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6037                            url, size_str, dest_dir
6038                        );
6039                        self.pending_download = Some(PendingDownload::Http {
6040                            url: url.clone(),
6041                            size,
6042                            options: options.clone(),
6043                        });
6044                        self.confirmation_modal.show(message);
6045                        return None;
6046                    }
6047                    #[cfg(feature = "cloud")]
6048                    if let source::InputSource::S3(ref url) = src {
6049                        let full = format!("s3://{url}");
6050                        let (_, ext) = source::url_path_extension(&full);
6051                        let is_glob = full.contains('*') || full.ends_with('/');
6052                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6053                            let size =
6054                                Self::fetch_remote_size_s3(&full, &self.app_config.cloud, options)
6055                                    .unwrap_or(None);
6056                            let size_str = size
6057                                .map(Self::format_bytes)
6058                                .unwrap_or_else(|| "unknown".to_string());
6059                            let dest_dir = options
6060                                .temp_dir
6061                                .as_deref()
6062                                .map(|p| p.display().to_string())
6063                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6064                            let message = format!(
6065                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6066                                full, size_str, dest_dir
6067                            );
6068                            self.pending_download = Some(PendingDownload::S3 {
6069                                url: full,
6070                                size,
6071                                options: options.clone(),
6072                            });
6073                            self.confirmation_modal.show(message);
6074                            return None;
6075                        }
6076                    }
6077                    #[cfg(feature = "cloud")]
6078                    if let source::InputSource::Gcs(ref url) = src {
6079                        let full = format!("gs://{url}");
6080                        let (_, ext) = source::url_path_extension(&full);
6081                        let is_glob = full.contains('*') || full.ends_with('/');
6082                        if source::cloud_path_should_download(ext.as_deref(), is_glob) {
6083                            let size = Self::fetch_remote_size_gcs(&full, options).unwrap_or(None);
6084                            let size_str = size
6085                                .map(Self::format_bytes)
6086                                .unwrap_or_else(|| "unknown".to_string());
6087                            let dest_dir = options
6088                                .temp_dir
6089                                .as_deref()
6090                                .map(|p| p.display().to_string())
6091                                .unwrap_or_else(|| std::env::temp_dir().display().to_string());
6092                            let message = format!(
6093                                "URL: {}\nFile size: {}\nDestination: {} (temporary file)\n\nContinue with download?",
6094                                full, size_str, dest_dir
6095                            );
6096                            self.pending_download = Some(PendingDownload::Gcs {
6097                                url: full,
6098                                size,
6099                                options: options.clone(),
6100                            });
6101                            self.confirmation_modal.show(message);
6102                            return None;
6103                        }
6104                    }
6105                    let first = paths[0].clone();
6106                    #[allow(clippy::needless_borrow)]
6107                    match self.build_lazyframe_from_paths(&paths, options) {
6108                        Ok(lf) => {
6109                            if let LoadingState::Loading {
6110                                file_path,
6111                                file_size,
6112                                ..
6113                            } = &self.loading_state
6114                            {
6115                                self.loading_state = LoadingState::Loading {
6116                                    file_path: file_path.clone(),
6117                                    file_size: *file_size,
6118                                    current_phase: "Caching schema".to_string(),
6119                                    progress_percent: 40,
6120                                };
6121                            }
6122                            Some(AppEvent::DoLoadSchema(
6123                                Box::new(lf),
6124                                Some(first),
6125                                options.clone(),
6126                            ))
6127                        }
6128                        Err(e) => {
6129                            self.loading_state = LoadingState::Idle;
6130                            self.busy = false;
6131                            self.drain_keys_on_next_loop = true;
6132                            let msg = crate::error_display::user_message_from_report(
6133                                &e,
6134                                paths.first().map(|p| p.as_path()),
6135                            );
6136                            Some(AppEvent::Crash(msg))
6137                        }
6138                    }
6139                }
6140            }
6141            #[cfg(feature = "http")]
6142            AppEvent::DoDownloadHttp(url, options) => {
6143                let (_, ext) = source::url_path_extension(url.as_str());
6144                match Self::download_http_to_temp(
6145                    url.as_str(),
6146                    options.temp_dir.as_deref(),
6147                    ext.as_deref(),
6148                ) {
6149                    Ok(temp_path) => {
6150                        self.http_temp_path = Some(temp_path.clone());
6151                        if let LoadingState::Loading {
6152                            file_path,
6153                            file_size,
6154                            ..
6155                        } = &self.loading_state
6156                        {
6157                            self.loading_state = LoadingState::Loading {
6158                                file_path: file_path.clone(),
6159                                file_size: *file_size,
6160                                current_phase: "Scanning".to_string(),
6161                                progress_percent: 30,
6162                            };
6163                        }
6164                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6165                    }
6166                    Err(e) => {
6167                        self.loading_state = LoadingState::Idle;
6168                        self.busy = false;
6169                        self.drain_keys_on_next_loop = true;
6170                        let msg = crate::error_display::user_message_from_report(&e, None);
6171                        Some(AppEvent::Crash(msg))
6172                    }
6173                }
6174            }
6175            #[cfg(feature = "cloud")]
6176            AppEvent::DoDownloadS3ToTemp(s3_url, options) => {
6177                match Self::download_s3_to_temp(s3_url, &self.app_config.cloud, options) {
6178                    Ok(temp_path) => {
6179                        self.http_temp_path = Some(temp_path.clone());
6180                        if let LoadingState::Loading {
6181                            file_path,
6182                            file_size,
6183                            ..
6184                        } = &self.loading_state
6185                        {
6186                            self.loading_state = LoadingState::Loading {
6187                                file_path: file_path.clone(),
6188                                file_size: *file_size,
6189                                current_phase: "Scanning".to_string(),
6190                                progress_percent: 30,
6191                            };
6192                        }
6193                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6194                    }
6195                    Err(e) => {
6196                        self.loading_state = LoadingState::Idle;
6197                        self.busy = false;
6198                        self.drain_keys_on_next_loop = true;
6199                        let msg = crate::error_display::user_message_from_report(&e, None);
6200                        Some(AppEvent::Crash(msg))
6201                    }
6202                }
6203            }
6204            #[cfg(feature = "cloud")]
6205            AppEvent::DoDownloadGcsToTemp(gs_url, options) => {
6206                match Self::download_gcs_to_temp(gs_url, options) {
6207                    Ok(temp_path) => {
6208                        self.http_temp_path = Some(temp_path.clone());
6209                        if let LoadingState::Loading {
6210                            file_path,
6211                            file_size,
6212                            ..
6213                        } = &self.loading_state
6214                        {
6215                            self.loading_state = LoadingState::Loading {
6216                                file_path: file_path.clone(),
6217                                file_size: *file_size,
6218                                current_phase: "Scanning".to_string(),
6219                                progress_percent: 30,
6220                            };
6221                        }
6222                        Some(AppEvent::DoLoadFromHttpTemp(temp_path, options.clone()))
6223                    }
6224                    Err(e) => {
6225                        self.loading_state = LoadingState::Idle;
6226                        self.busy = false;
6227                        self.drain_keys_on_next_loop = true;
6228                        let msg = crate::error_display::user_message_from_report(&e, None);
6229                        Some(AppEvent::Crash(msg))
6230                    }
6231                }
6232            }
6233            #[cfg(any(feature = "http", feature = "cloud"))]
6234            AppEvent::DoLoadFromHttpTemp(temp_path, options) => {
6235                self.http_temp_path = Some(temp_path.clone());
6236                let display_path = match &self.loading_state {
6237                    LoadingState::Loading { file_path, .. } => file_path.clone(),
6238                    _ => None,
6239                };
6240                if let LoadingState::Loading {
6241                    file_path,
6242                    file_size,
6243                    ..
6244                } = &self.loading_state
6245                {
6246                    self.loading_state = LoadingState::Loading {
6247                        file_path: file_path.clone(),
6248                        file_size: *file_size,
6249                        current_phase: "Scanning".to_string(),
6250                        progress_percent: 30,
6251                    };
6252                }
6253                #[allow(clippy::cloned_ref_to_slice_refs)]
6254                match self.build_lazyframe_from_paths(&[temp_path.clone()], options) {
6255                    Ok(lf) => {
6256                        if let LoadingState::Loading {
6257                            file_path,
6258                            file_size,
6259                            ..
6260                        } = &self.loading_state
6261                        {
6262                            self.loading_state = LoadingState::Loading {
6263                                file_path: file_path.clone(),
6264                                file_size: *file_size,
6265                                current_phase: "Caching schema".to_string(),
6266                                progress_percent: 40,
6267                            };
6268                        }
6269                        Some(AppEvent::DoLoadSchema(
6270                            Box::new(lf),
6271                            display_path,
6272                            options.clone(),
6273                        ))
6274                    }
6275                    Err(e) => {
6276                        self.loading_state = LoadingState::Idle;
6277                        self.busy = false;
6278                        self.drain_keys_on_next_loop = true;
6279                        let msg = crate::error_display::user_message_from_report(
6280                            &e,
6281                            Some(temp_path.as_path()),
6282                        );
6283                        Some(AppEvent::Crash(msg))
6284                    }
6285                }
6286            }
6287            AppEvent::DoLoadSchema(lf, path, options) => {
6288                // Set "Caching schema" and return so the UI draws this phase before we block in DoLoadSchemaBlocking
6289                if let LoadingState::Loading {
6290                    file_path,
6291                    file_size,
6292                    ..
6293                } = &self.loading_state
6294                {
6295                    self.loading_state = LoadingState::Loading {
6296                        file_path: file_path.clone(),
6297                        file_size: *file_size,
6298                        current_phase: "Caching schema".to_string(),
6299                        progress_percent: 40,
6300                    };
6301                }
6302                Some(AppEvent::DoLoadSchemaBlocking(
6303                    lf.clone(),
6304                    path.clone(),
6305                    options.clone(),
6306                ))
6307            }
6308            AppEvent::DoLoadSchemaBlocking(lf, path, options) => {
6309                self.debug.schema_load = None;
6310                // Fast path for hive directory: infer schema from one parquet file instead of collect_schema() over all files.
6311                if options.single_spine_schema
6312                    && path.as_ref().is_some_and(|p| p.is_dir() && options.hive)
6313                {
6314                    let p = path.as_ref().expect("path set by caller");
6315                    if let Ok((merged_schema, partition_columns)) =
6316                        DataTableState::schema_from_one_hive_parquet(p)
6317                    {
6318                        if let Ok(lf_owned) =
6319                            DataTableState::scan_parquet_hive_with_schema(p, merged_schema.clone())
6320                        {
6321                            match DataTableState::from_schema_and_lazyframe(
6322                                merged_schema,
6323                                lf_owned,
6324                                options,
6325                                Some(partition_columns),
6326                            ) {
6327                                Ok(state) => {
6328                                    self.debug.schema_load = Some("one-file (local)".to_string());
6329                                    self.parquet_metadata_cache = None;
6330                                    self.export_df = None;
6331                                    self.data_table_state = Some(state);
6332                                    self.path = path.clone();
6333                                    if let Some(ref path_p) = path {
6334                                        self.original_file_format = path_p
6335                                            .extension()
6336                                            .and_then(|e| e.to_str())
6337                                            .and_then(|ext| {
6338                                                if ext.eq_ignore_ascii_case("parquet") {
6339                                                    Some(ExportFormat::Parquet)
6340                                                } else if ext.eq_ignore_ascii_case("csv") {
6341                                                    Some(ExportFormat::Csv)
6342                                                } else if ext.eq_ignore_ascii_case("json") {
6343                                                    Some(ExportFormat::Json)
6344                                                } else if ext.eq_ignore_ascii_case("jsonl")
6345                                                    || ext.eq_ignore_ascii_case("ndjson")
6346                                                {
6347                                                    Some(ExportFormat::Ndjson)
6348                                                } else if ext.eq_ignore_ascii_case("arrow")
6349                                                    || ext.eq_ignore_ascii_case("ipc")
6350                                                    || ext.eq_ignore_ascii_case("feather")
6351                                                {
6352                                                    Some(ExportFormat::Ipc)
6353                                                } else if ext.eq_ignore_ascii_case("avro") {
6354                                                    Some(ExportFormat::Avro)
6355                                                } else {
6356                                                    None
6357                                                }
6358                                            });
6359                                        self.original_file_delimiter =
6360                                            Some(options.delimiter.unwrap_or(b','));
6361                                    } else {
6362                                        self.original_file_format = None;
6363                                        self.original_file_delimiter = None;
6364                                    }
6365                                    self.sort_filter_modal = SortFilterModal::new();
6366                                    self.pivot_melt_modal = PivotMeltModal::new();
6367                                    if let LoadingState::Loading {
6368                                        file_path,
6369                                        file_size,
6370                                        ..
6371                                    } = &self.loading_state
6372                                    {
6373                                        self.loading_state = LoadingState::Loading {
6374                                            file_path: file_path.clone(),
6375                                            file_size: *file_size,
6376                                            current_phase: "Loading buffer".to_string(),
6377                                            progress_percent: 70,
6378                                        };
6379                                    }
6380                                    return Some(AppEvent::DoLoadBuffer);
6381                                }
6382                                Err(e) => {
6383                                    self.loading_state = LoadingState::Idle;
6384                                    self.busy = false;
6385                                    self.drain_keys_on_next_loop = true;
6386                                    let msg =
6387                                        crate::error_display::user_message_from_report(&e, None);
6388                                    return Some(AppEvent::Crash(msg));
6389                                }
6390                            }
6391                        }
6392                    }
6393                }
6394
6395                #[cfg(feature = "cloud")]
6396                {
6397                    // Use fast path for directory/glob cloud URLs (same as build_lazyframe_from_paths).
6398                    // Don't require --hive: path shape already implies hive scan.
6399                    if options.single_spine_schema
6400                        && path.as_ref().is_some_and(|p| {
6401                            let s = p.as_os_str().to_string_lossy();
6402                            let is_cloud = s.starts_with("s3://") || s.starts_with("gs://");
6403                            let looks_like_hive = s.ends_with('/') || s.contains('*');
6404                            is_cloud && (options.hive || looks_like_hive)
6405                        })
6406                    {
6407                        self.debug.schema_load = Some("trying one-file (cloud)".to_string());
6408                        let src = source::input_source(path.as_ref().expect("path set by caller"));
6409                        let try_cloud = match &src {
6410                            source::InputSource::S3(url) => {
6411                                let full = format!("s3://{url}");
6412                                let (path_part, _) = source::url_path_extension(&full);
6413                                let key = path_part
6414                                    .split_once('/')
6415                                    .map(|(_, k)| k.trim_end_matches('/'))
6416                                    .unwrap_or("");
6417                                let cloud_opts =
6418                                    Self::build_s3_cloud_options(&self.app_config.cloud, options);
6419                                Self::build_s3_object_store(&full, &self.app_config.cloud, options)
6420                                    .ok()
6421                                    .and_then(|store| {
6422                                        let rt = tokio::runtime::Runtime::new().ok()?;
6423                                        let (merged_schema, partition_columns) = rt
6424                                            .block_on(cloud_hive::schema_from_one_cloud_hive(
6425                                                store, key,
6426                                            ))
6427                                            .ok()?;
6428                                        let pl_path = PlPathRef::new(&full).into_owned();
6429                                        let args = ScanArgsParquet {
6430                                            schema: Some(merged_schema.clone()),
6431                                            cloud_options: Some(cloud_opts),
6432                                            hive_options: polars::io::HiveOptions::new_enabled(),
6433                                            glob: true,
6434                                            ..Default::default()
6435                                        };
6436                                        let mut lf_owned =
6437                                            LazyFrame::scan_parquet(pl_path, args).ok()?;
6438                                        if !partition_columns.is_empty() {
6439                                            let exprs: Vec<_> = partition_columns
6440                                                .iter()
6441                                                .map(|s| col(s.as_str()))
6442                                                .chain(
6443                                                    merged_schema
6444                                                        .iter_names()
6445                                                        .map(|s| s.to_string())
6446                                                        .filter(|c| !partition_columns.contains(c))
6447                                                        .map(|s| col(s.as_str())),
6448                                                )
6449                                                .collect();
6450                                            lf_owned = lf_owned.select(exprs);
6451                                        }
6452                                        DataTableState::from_schema_and_lazyframe(
6453                                            merged_schema,
6454                                            lf_owned,
6455                                            options,
6456                                            Some(partition_columns),
6457                                        )
6458                                        .ok()
6459                                    })
6460                            }
6461                            source::InputSource::Gcs(url) => {
6462                                let full = format!("gs://{url}");
6463                                let (path_part, _) = source::url_path_extension(&full);
6464                                let key = path_part
6465                                    .split_once('/')
6466                                    .map(|(_, k)| k.trim_end_matches('/'))
6467                                    .unwrap_or("");
6468                                Self::build_gcs_object_store(&full).ok().and_then(|store| {
6469                                    let rt = tokio::runtime::Runtime::new().ok()?;
6470                                    let (merged_schema, partition_columns) = rt
6471                                        .block_on(cloud_hive::schema_from_one_cloud_hive(
6472                                            store, key,
6473                                        ))
6474                                        .ok()?;
6475                                    let pl_path = PlPathRef::new(&full).into_owned();
6476                                    let args = ScanArgsParquet {
6477                                        schema: Some(merged_schema.clone()),
6478                                        cloud_options: Some(CloudOptions::default()),
6479                                        hive_options: polars::io::HiveOptions::new_enabled(),
6480                                        glob: true,
6481                                        ..Default::default()
6482                                    };
6483                                    let mut lf_owned =
6484                                        LazyFrame::scan_parquet(pl_path, args).ok()?;
6485                                    if !partition_columns.is_empty() {
6486                                        let exprs: Vec<_> = partition_columns
6487                                            .iter()
6488                                            .map(|s| col(s.as_str()))
6489                                            .chain(
6490                                                merged_schema
6491                                                    .iter_names()
6492                                                    .map(|s| s.to_string())
6493                                                    .filter(|c| !partition_columns.contains(c))
6494                                                    .map(|s| col(s.as_str())),
6495                                            )
6496                                            .collect();
6497                                        lf_owned = lf_owned.select(exprs);
6498                                    }
6499                                    DataTableState::from_schema_and_lazyframe(
6500                                        merged_schema,
6501                                        lf_owned,
6502                                        options,
6503                                        Some(partition_columns),
6504                                    )
6505                                    .ok()
6506                                })
6507                            }
6508                            _ => None,
6509                        };
6510                        if let Some(state) = try_cloud {
6511                            self.debug.schema_load = Some("one-file (cloud)".to_string());
6512                            self.parquet_metadata_cache = None;
6513                            self.export_df = None;
6514                            self.data_table_state = Some(state);
6515                            self.path = path.clone();
6516                            if let Some(ref path_p) = path {
6517                                self.original_file_format =
6518                                    path_p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6519                                        if ext.eq_ignore_ascii_case("parquet") {
6520                                            Some(ExportFormat::Parquet)
6521                                        } else if ext.eq_ignore_ascii_case("csv") {
6522                                            Some(ExportFormat::Csv)
6523                                        } else if ext.eq_ignore_ascii_case("json") {
6524                                            Some(ExportFormat::Json)
6525                                        } else if ext.eq_ignore_ascii_case("jsonl")
6526                                            || ext.eq_ignore_ascii_case("ndjson")
6527                                        {
6528                                            Some(ExportFormat::Ndjson)
6529                                        } else if ext.eq_ignore_ascii_case("arrow")
6530                                            || ext.eq_ignore_ascii_case("ipc")
6531                                            || ext.eq_ignore_ascii_case("feather")
6532                                        {
6533                                            Some(ExportFormat::Ipc)
6534                                        } else if ext.eq_ignore_ascii_case("avro") {
6535                                            Some(ExportFormat::Avro)
6536                                        } else {
6537                                            None
6538                                        }
6539                                    });
6540                                self.original_file_delimiter =
6541                                    Some(options.delimiter.unwrap_or(b','));
6542                            } else {
6543                                self.original_file_format = None;
6544                                self.original_file_delimiter = None;
6545                            }
6546                            self.sort_filter_modal = SortFilterModal::new();
6547                            self.pivot_melt_modal = PivotMeltModal::new();
6548                            if let LoadingState::Loading {
6549                                file_path,
6550                                file_size,
6551                                ..
6552                            } = &self.loading_state
6553                            {
6554                                self.loading_state = LoadingState::Loading {
6555                                    file_path: file_path.clone(),
6556                                    file_size: *file_size,
6557                                    current_phase: "Loading buffer".to_string(),
6558                                    progress_percent: 70,
6559                                };
6560                            }
6561                            return Some(AppEvent::DoLoadBuffer);
6562                        } else {
6563                            self.debug.schema_load = Some("fallback (cloud)".to_string());
6564                        }
6565                    }
6566                }
6567
6568                if self.debug.schema_load.is_none() {
6569                    self.debug.schema_load = Some("full scan".to_string());
6570                }
6571                let mut lf_owned = (**lf).clone();
6572                match lf_owned.collect_schema() {
6573                    Ok(schema) => {
6574                        let partition_columns = if path.as_ref().is_some_and(|p| {
6575                            options.hive
6576                                && (p.is_dir() || p.as_os_str().to_string_lossy().contains('*'))
6577                        }) {
6578                            let discovered = DataTableState::discover_hive_partition_columns(
6579                                path.as_ref().expect("path set by caller"),
6580                            );
6581                            discovered
6582                                .into_iter()
6583                                .filter(|c| schema.contains(c.as_str()))
6584                                .collect::<Vec<_>>()
6585                        } else {
6586                            Vec::new()
6587                        };
6588                        if !partition_columns.is_empty() {
6589                            let exprs: Vec<_> = partition_columns
6590                                .iter()
6591                                .map(|s| col(s.as_str()))
6592                                .chain(
6593                                    schema
6594                                        .iter_names()
6595                                        .map(|s| s.to_string())
6596                                        .filter(|c| !partition_columns.contains(c))
6597                                        .map(|s| col(s.as_str())),
6598                                )
6599                                .collect();
6600                            lf_owned = lf_owned.select(exprs);
6601                        }
6602                        let part_cols_opt = if partition_columns.is_empty() {
6603                            None
6604                        } else {
6605                            Some(partition_columns)
6606                        };
6607                        match DataTableState::from_schema_and_lazyframe(
6608                            schema,
6609                            lf_owned,
6610                            options,
6611                            part_cols_opt,
6612                        ) {
6613                            Ok(state) => {
6614                                self.parquet_metadata_cache = None;
6615                                self.export_df = None;
6616                                self.data_table_state = Some(state);
6617                                self.path = path.clone();
6618                                if let Some(ref p) = path {
6619                                    self.original_file_format =
6620                                        p.extension().and_then(|e| e.to_str()).and_then(|ext| {
6621                                            if ext.eq_ignore_ascii_case("parquet") {
6622                                                Some(ExportFormat::Parquet)
6623                                            } else if ext.eq_ignore_ascii_case("csv") {
6624                                                Some(ExportFormat::Csv)
6625                                            } else if ext.eq_ignore_ascii_case("json") {
6626                                                Some(ExportFormat::Json)
6627                                            } else if ext.eq_ignore_ascii_case("jsonl")
6628                                                || ext.eq_ignore_ascii_case("ndjson")
6629                                            {
6630                                                Some(ExportFormat::Ndjson)
6631                                            } else if ext.eq_ignore_ascii_case("arrow")
6632                                                || ext.eq_ignore_ascii_case("ipc")
6633                                                || ext.eq_ignore_ascii_case("feather")
6634                                            {
6635                                                Some(ExportFormat::Ipc)
6636                                            } else if ext.eq_ignore_ascii_case("avro") {
6637                                                Some(ExportFormat::Avro)
6638                                            } else {
6639                                                None
6640                                            }
6641                                        });
6642                                    self.original_file_delimiter =
6643                                        Some(options.delimiter.unwrap_or(b','));
6644                                } else {
6645                                    self.original_file_format = None;
6646                                    self.original_file_delimiter = None;
6647                                }
6648                                self.sort_filter_modal = SortFilterModal::new();
6649                                self.pivot_melt_modal = PivotMeltModal::new();
6650                                if let LoadingState::Loading {
6651                                    file_path,
6652                                    file_size,
6653                                    ..
6654                                } = &self.loading_state
6655                                {
6656                                    self.loading_state = LoadingState::Loading {
6657                                        file_path: file_path.clone(),
6658                                        file_size: *file_size,
6659                                        current_phase: "Loading buffer".to_string(),
6660                                        progress_percent: 70,
6661                                    };
6662                                }
6663                                Some(AppEvent::DoLoadBuffer)
6664                            }
6665                            Err(e) => {
6666                                self.loading_state = LoadingState::Idle;
6667                                self.busy = false;
6668                                self.drain_keys_on_next_loop = true;
6669                                let msg = crate::error_display::user_message_from_report(&e, None);
6670                                Some(AppEvent::Crash(msg))
6671                            }
6672                        }
6673                    }
6674                    Err(e) => {
6675                        self.loading_state = LoadingState::Idle;
6676                        self.busy = false;
6677                        self.drain_keys_on_next_loop = true;
6678                        let report = color_eyre::eyre::Report::from(e);
6679                        let msg = crate::error_display::user_message_from_report(&report, None);
6680                        Some(AppEvent::Crash(msg))
6681                    }
6682                }
6683            }
6684            AppEvent::DoLoadBuffer => {
6685                if let Some(state) = &mut self.data_table_state {
6686                    state.collect();
6687                    if let Some(e) = state.error.take() {
6688                        self.loading_state = LoadingState::Idle;
6689                        self.busy = false;
6690                        self.drain_keys_on_next_loop = true;
6691                        let msg = crate::error_display::user_message_from_polars(&e);
6692                        return Some(AppEvent::Crash(msg));
6693                    }
6694                }
6695                self.loading_state = LoadingState::Idle;
6696                self.busy = false;
6697                self.drain_keys_on_next_loop = true;
6698                Some(AppEvent::Collect)
6699            }
6700            AppEvent::DoLoad(paths, options) => {
6701                let first = &paths[0];
6702                // Check if file is compressed (only single-file compressed CSV supported for now)
6703                let compression = options
6704                    .compression
6705                    .or_else(|| CompressionFormat::from_extension(first));
6706                let is_csv = first
6707                    .file_stem()
6708                    .and_then(|stem| stem.to_str())
6709                    .map(|stem| {
6710                        stem.ends_with(".csv")
6711                            || first
6712                                .extension()
6713                                .and_then(|e| e.to_str())
6714                                .map(|e| e.eq_ignore_ascii_case("csv"))
6715                                .unwrap_or(false)
6716                    })
6717                    .unwrap_or(false);
6718                let is_compressed_csv = paths.len() == 1 && compression.is_some() && is_csv;
6719
6720                if is_compressed_csv {
6721                    // Set "Decompressing" phase and return event to trigger render
6722                    if let LoadingState::Loading {
6723                        file_path,
6724                        file_size,
6725                        ..
6726                    } = &self.loading_state
6727                    {
6728                        self.loading_state = LoadingState::Loading {
6729                            file_path: file_path.clone(),
6730                            file_size: *file_size,
6731                            current_phase: "Decompressing".to_string(),
6732                            progress_percent: 30,
6733                        };
6734                    }
6735                    // Return DoDecompress to allow UI to render "Decompressing" before blocking
6736                    Some(AppEvent::DoDecompress(paths.clone(), options.clone()))
6737                } else {
6738                    // For non-compressed files, proceed with normal loading
6739                    match self.load(paths, options) {
6740                        Ok(_) => {
6741                            self.busy = false;
6742                            self.drain_keys_on_next_loop = true;
6743                            Some(AppEvent::Collect)
6744                        }
6745                        Err(e) => {
6746                            self.loading_state = LoadingState::Idle;
6747                            self.busy = false;
6748                            self.drain_keys_on_next_loop = true;
6749                            let msg = crate::error_display::user_message_from_report(
6750                                &e,
6751                                paths.first().map(|p| p.as_path()),
6752                            );
6753                            Some(AppEvent::Crash(msg))
6754                        }
6755                    }
6756                }
6757            }
6758            AppEvent::DoDecompress(paths, options) => {
6759                // Actually perform decompression now (after UI has rendered "Decompressing")
6760                match self.load(paths, options) {
6761                    Ok(_) => Some(AppEvent::DoLoadBuffer),
6762                    Err(e) => {
6763                        self.loading_state = LoadingState::Idle;
6764                        self.busy = false;
6765                        self.drain_keys_on_next_loop = true;
6766                        let msg = crate::error_display::user_message_from_report(
6767                            &e,
6768                            paths.first().map(|p| p.as_path()),
6769                        );
6770                        Some(AppEvent::Crash(msg))
6771                    }
6772                }
6773            }
6774            AppEvent::Resize(_cols, rows) => {
6775                self.busy = true;
6776                if let Some(state) = &mut self.data_table_state {
6777                    state.visible_rows = *rows as usize;
6778                    state.collect();
6779                }
6780                self.busy = false;
6781                self.drain_keys_on_next_loop = true;
6782                None
6783            }
6784            AppEvent::Collect => {
6785                self.busy = true;
6786                if let Some(ref mut state) = self.data_table_state {
6787                    state.collect();
6788                }
6789                self.busy = false;
6790                self.drain_keys_on_next_loop = true;
6791                None
6792            }
6793            AppEvent::DoScrollDown => {
6794                if let Some(state) = &mut self.data_table_state {
6795                    state.page_down();
6796                }
6797                self.busy = false;
6798                self.drain_keys_on_next_loop = true;
6799                None
6800            }
6801            AppEvent::DoScrollUp => {
6802                if let Some(state) = &mut self.data_table_state {
6803                    state.page_up();
6804                }
6805                self.busy = false;
6806                self.drain_keys_on_next_loop = true;
6807                None
6808            }
6809            AppEvent::DoScrollNext => {
6810                if let Some(state) = &mut self.data_table_state {
6811                    state.select_next();
6812                }
6813                self.busy = false;
6814                self.drain_keys_on_next_loop = true;
6815                None
6816            }
6817            AppEvent::DoScrollPrev => {
6818                if let Some(state) = &mut self.data_table_state {
6819                    state.select_previous();
6820                }
6821                self.busy = false;
6822                self.drain_keys_on_next_loop = true;
6823                None
6824            }
6825            AppEvent::DoScrollEnd => {
6826                if let Some(state) = &mut self.data_table_state {
6827                    state.scroll_to_end();
6828                }
6829                self.busy = false;
6830                self.drain_keys_on_next_loop = true;
6831                None
6832            }
6833            AppEvent::DoScrollHalfDown => {
6834                if let Some(state) = &mut self.data_table_state {
6835                    state.half_page_down();
6836                }
6837                self.busy = false;
6838                self.drain_keys_on_next_loop = true;
6839                None
6840            }
6841            AppEvent::DoScrollHalfUp => {
6842                if let Some(state) = &mut self.data_table_state {
6843                    state.half_page_up();
6844                }
6845                self.busy = false;
6846                self.drain_keys_on_next_loop = true;
6847                None
6848            }
6849            AppEvent::GoToLine(n) => {
6850                if let Some(state) = &mut self.data_table_state {
6851                    state.scroll_to_row_centered(*n);
6852                }
6853                self.busy = false;
6854                self.drain_keys_on_next_loop = true;
6855                None
6856            }
6857            AppEvent::AnalysisChunk => {
6858                let lf = match &self.data_table_state {
6859                    Some(state) => state.lf.clone(),
6860                    None => {
6861                        self.analysis_computation = None;
6862                        self.analysis_modal.computing = None;
6863                        self.busy = false;
6864                        return None;
6865                    }
6866                };
6867                let comp = self.analysis_computation.take()?;
6868                if comp.df.is_none() {
6869                    // First chunk: get row count then run describe (lazy aggregation, no full collect)
6870                    // Reuse cached row count from control bar when valid to avoid extra full scan.
6871                    let total_rows = match self
6872                        .data_table_state
6873                        .as_ref()
6874                        .and_then(|s| s.num_rows_if_valid())
6875                    {
6876                        Some(n) => n,
6877                        None => match crate::statistics::collect_lazy(
6878                            lf.clone().select([len()]),
6879                            self.app_config.performance.polars_streaming,
6880                        ) {
6881                            Ok(count_df) => {
6882                                if let Some(col) = count_df.get(0) {
6883                                    match col.first() {
6884                                        Some(AnyValue::UInt32(n)) => *n as usize,
6885                                        _ => 0,
6886                                    }
6887                                } else {
6888                                    0
6889                                }
6890                            }
6891                            Err(_e) => {
6892                                self.analysis_modal.computing = None;
6893                                self.busy = false;
6894                                self.drain_keys_on_next_loop = true;
6895                                return None;
6896                            }
6897                        },
6898                    };
6899                    match crate::statistics::compute_describe_from_lazy(
6900                        &lf,
6901                        total_rows,
6902                        self.sampling_threshold,
6903                        comp.sample_seed,
6904                        self.app_config.performance.polars_streaming,
6905                    ) {
6906                        Ok(results) => {
6907                            self.analysis_modal.describe_results = Some(results);
6908                            self.analysis_modal.computing = None;
6909                            self.busy = false;
6910                            self.drain_keys_on_next_loop = true;
6911                            None
6912                        }
6913                        Err(_e) => {
6914                            self.analysis_modal.computing = None;
6915                            self.busy = false;
6916                            self.drain_keys_on_next_loop = true;
6917                            None
6918                        }
6919                    }
6920                } else {
6921                    None
6922                }
6923            }
6924            AppEvent::AnalysisDistributionCompute => {
6925                if let Some(state) = &self.data_table_state {
6926                    let options = crate::statistics::ComputeOptions {
6927                        include_distribution_info: true,
6928                        include_distribution_analyses: true,
6929                        include_correlation_matrix: false,
6930                        include_skewness_kurtosis_outliers: true,
6931                        polars_streaming: self.app_config.performance.polars_streaming,
6932                    };
6933                    if let Ok(results) = crate::statistics::compute_statistics_with_options(
6934                        &state.lf,
6935                        self.sampling_threshold,
6936                        self.analysis_modal.random_seed,
6937                        options,
6938                    ) {
6939                        self.analysis_modal.distribution_results = Some(results);
6940                    }
6941                }
6942                self.analysis_modal.computing = None;
6943                self.busy = false;
6944                self.drain_keys_on_next_loop = true;
6945                None
6946            }
6947            AppEvent::AnalysisCorrelationCompute => {
6948                if let Some(state) = &self.data_table_state {
6949                    if let Ok(df) =
6950                        crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
6951                    {
6952                        if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
6953                            self.analysis_modal.correlation_results =
6954                                Some(crate::statistics::AnalysisResults {
6955                                    column_statistics: vec![],
6956                                    total_rows: df.height(),
6957                                    sample_size: None,
6958                                    sample_seed: self.analysis_modal.random_seed,
6959                                    correlation_matrix: Some(matrix),
6960                                    distribution_analyses: vec![],
6961                                });
6962                        }
6963                    }
6964                }
6965                self.analysis_modal.computing = None;
6966                self.busy = false;
6967                self.drain_keys_on_next_loop = true;
6968                None
6969            }
6970            AppEvent::Search(query) => {
6971                let query_succeeded = if let Some(state) = &mut self.data_table_state {
6972                    state.query(query.clone());
6973                    state.error.is_none()
6974                } else {
6975                    false
6976                };
6977
6978                // Only close input mode if query succeeded (no error after execution)
6979                if query_succeeded {
6980                    // History was already saved in TextInputEvent::Submit handler
6981                    self.input_mode = InputMode::Normal;
6982                    self.query_input.set_focused(false);
6983                    // Re-enable error display in main view when closing query input
6984                    if let Some(state) = &mut self.data_table_state {
6985                        state.suppress_error_display = false;
6986                    }
6987                }
6988                // If there's an error, keep input mode open so user can fix the query
6989                // suppress_error_display remains true to keep main view clean
6990                None
6991            }
6992            AppEvent::SqlSearch(sql) => {
6993                let sql_succeeded = if let Some(state) = &mut self.data_table_state {
6994                    state.sql_query(sql.clone());
6995                    state.error.is_none()
6996                } else {
6997                    false
6998                };
6999                if sql_succeeded {
7000                    self.input_mode = InputMode::Normal;
7001                    self.sql_input.set_focused(false);
7002                    if let Some(state) = &mut self.data_table_state {
7003                        state.suppress_error_display = false;
7004                    }
7005                    Some(AppEvent::Collect)
7006                } else {
7007                    None
7008                }
7009            }
7010            AppEvent::FuzzySearch(query) => {
7011                let fuzzy_succeeded = if let Some(state) = &mut self.data_table_state {
7012                    state.fuzzy_search(query.clone());
7013                    state.error.is_none()
7014                } else {
7015                    false
7016                };
7017                if fuzzy_succeeded {
7018                    self.input_mode = InputMode::Normal;
7019                    self.fuzzy_input.set_focused(false);
7020                    if let Some(state) = &mut self.data_table_state {
7021                        state.suppress_error_display = false;
7022                    }
7023                    Some(AppEvent::Collect)
7024                } else {
7025                    None
7026                }
7027            }
7028            AppEvent::Filter(statements) => {
7029                if let Some(state) = &mut self.data_table_state {
7030                    state.filter(statements.clone());
7031                }
7032                None
7033            }
7034            AppEvent::Sort(columns, ascending) => {
7035                if let Some(state) = &mut self.data_table_state {
7036                    state.sort(columns.clone(), *ascending);
7037                }
7038                None
7039            }
7040            AppEvent::Reset => {
7041                if let Some(state) = &mut self.data_table_state {
7042                    state.reset();
7043                }
7044                // Clear active template when resetting
7045                self.active_template_id = None;
7046                None
7047            }
7048            AppEvent::ColumnOrder(order, locked_count) => {
7049                if let Some(state) = &mut self.data_table_state {
7050                    state.set_column_order(order.clone());
7051                    state.set_locked_columns(*locked_count);
7052                }
7053                None
7054            }
7055            AppEvent::Pivot(spec) => {
7056                self.busy = true;
7057                if let Some(state) = &mut self.data_table_state {
7058                    match state.pivot(spec) {
7059                        Ok(()) => {
7060                            self.pivot_melt_modal.close();
7061                            self.input_mode = InputMode::Normal;
7062                            Some(AppEvent::Collect)
7063                        }
7064                        Err(e) => {
7065                            self.busy = false;
7066                            self.error_modal
7067                                .show(crate::error_display::user_message_from_report(&e, None));
7068                            None
7069                        }
7070                    }
7071                } else {
7072                    self.busy = false;
7073                    None
7074                }
7075            }
7076            AppEvent::Melt(spec) => {
7077                self.busy = true;
7078                if let Some(state) = &mut self.data_table_state {
7079                    match state.melt(spec) {
7080                        Ok(()) => {
7081                            self.pivot_melt_modal.close();
7082                            self.input_mode = InputMode::Normal;
7083                            Some(AppEvent::Collect)
7084                        }
7085                        Err(e) => {
7086                            self.busy = false;
7087                            self.error_modal
7088                                .show(crate::error_display::user_message_from_report(&e, None));
7089                            None
7090                        }
7091                    }
7092                } else {
7093                    self.busy = false;
7094                    None
7095                }
7096            }
7097            AppEvent::ChartExport(path, format, title) => {
7098                self.busy = true;
7099                self.loading_state = LoadingState::Exporting {
7100                    file_path: path.clone(),
7101                    current_phase: "Exporting chart".to_string(),
7102                    progress_percent: 0,
7103                };
7104                Some(AppEvent::DoChartExport(
7105                    path.clone(),
7106                    *format,
7107                    title.clone(),
7108                ))
7109            }
7110            AppEvent::DoChartExport(path, format, title) => {
7111                let result = self.do_chart_export(path, *format, title);
7112                self.loading_state = LoadingState::Idle;
7113                self.busy = false;
7114                self.drain_keys_on_next_loop = true;
7115                match result {
7116                    Ok(()) => {
7117                        self.success_modal.show(format!(
7118                            "Chart exported successfully to\n{}",
7119                            path.display()
7120                        ));
7121                        self.chart_export_modal.close();
7122                    }
7123                    Err(e) => {
7124                        self.error_modal
7125                            .show(crate::error_display::user_message_from_report(
7126                                &e,
7127                                Some(path),
7128                            ));
7129                        self.chart_export_modal.reopen_with_path(path, *format);
7130                    }
7131                }
7132                None
7133            }
7134            AppEvent::Export(path, format, options) => {
7135                if let Some(_state) = &self.data_table_state {
7136                    self.busy = true;
7137                    // Show progress immediately
7138                    self.loading_state = LoadingState::Exporting {
7139                        file_path: path.clone(),
7140                        current_phase: "Preparing export".to_string(),
7141                        progress_percent: 0,
7142                    };
7143                    // Return DoExport to allow UI to render progress before blocking
7144                    Some(AppEvent::DoExport(path.clone(), *format, options.clone()))
7145                } else {
7146                    None
7147                }
7148            }
7149            AppEvent::DoExport(path, format, options) => {
7150                if let Some(_state) = &self.data_table_state {
7151                    // Phase 1: show "Collecting data" so UI can redraw before blocking collect
7152                    self.loading_state = LoadingState::Exporting {
7153                        file_path: path.clone(),
7154                        current_phase: "Collecting data".to_string(),
7155                        progress_percent: 10,
7156                    };
7157                    Some(AppEvent::DoExportCollect(
7158                        path.clone(),
7159                        *format,
7160                        options.clone(),
7161                    ))
7162                } else {
7163                    self.busy = false;
7164                    None
7165                }
7166            }
7167            AppEvent::DoExportCollect(path, format, options) => {
7168                if let Some(state) = &self.data_table_state {
7169                    match crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
7170                    {
7171                        Ok(df) => {
7172                            self.export_df = Some(df);
7173                            let has_compression = match format {
7174                                ExportFormat::Csv => options.csv_compression.is_some(),
7175                                ExportFormat::Json => options.json_compression.is_some(),
7176                                ExportFormat::Ndjson => options.ndjson_compression.is_some(),
7177                                ExportFormat::Parquet | ExportFormat::Ipc | ExportFormat::Avro => {
7178                                    false
7179                                }
7180                            };
7181                            let phase = if has_compression {
7182                                "Writing and compressing file"
7183                            } else {
7184                                "Writing file"
7185                            };
7186                            self.loading_state = LoadingState::Exporting {
7187                                file_path: path.clone(),
7188                                current_phase: phase.to_string(),
7189                                progress_percent: 50,
7190                            };
7191                            Some(AppEvent::DoExportWrite(
7192                                path.clone(),
7193                                *format,
7194                                options.clone(),
7195                            ))
7196                        }
7197                        Err(e) => {
7198                            self.loading_state = LoadingState::Idle;
7199                            self.busy = false;
7200                            self.drain_keys_on_next_loop = true;
7201                            self.error_modal.show(format!(
7202                                "Export failed: {}",
7203                                crate::error_display::user_message_from_polars(&e)
7204                            ));
7205                            None
7206                        }
7207                    }
7208                } else {
7209                    self.busy = false;
7210                    None
7211                }
7212            }
7213            AppEvent::DoExportWrite(path, format, options) => {
7214                let result = self
7215                    .export_df
7216                    .take()
7217                    .map(|mut df| Self::export_data_from_df(&mut df, path, *format, options));
7218                self.loading_state = LoadingState::Idle;
7219                self.busy = false;
7220                self.drain_keys_on_next_loop = true;
7221                match result {
7222                    Some(Ok(())) => {
7223                        self.success_modal
7224                            .show(format!("Data exported successfully to\n{}", path.display()));
7225                    }
7226                    Some(Err(e)) => {
7227                        let error_msg = Self::format_export_error(&e, path);
7228                        self.error_modal.show(error_msg);
7229                    }
7230                    None => {}
7231                }
7232                None
7233            }
7234            AppEvent::DoLoadParquetMetadata => {
7235                let path = self.path.clone();
7236                if let Some(p) = &path {
7237                    if let Some(meta) = read_parquet_metadata(p) {
7238                        self.parquet_metadata_cache = Some(meta);
7239                    }
7240                }
7241                self.busy = false;
7242                self.drain_keys_on_next_loop = true;
7243                None
7244            }
7245            _ => None,
7246        }
7247    }
7248
7249    /// Perform chart export to file. Exports what is currently visible (effective x + y).
7250    /// Title is optional; blank or whitespace means no chart title on export.
7251    fn do_chart_export(
7252        &self,
7253        path: &Path,
7254        format: ChartExportFormat,
7255        title: &str,
7256    ) -> color_eyre::Result<()> {
7257        let state = self
7258            .data_table_state
7259            .as_ref()
7260            .ok_or_else(|| color_eyre::eyre::eyre!("No data loaded"))?;
7261        let chart_title = title.trim();
7262        let chart_title = if chart_title.is_empty() {
7263            None
7264        } else {
7265            Some(chart_title.to_string())
7266        };
7267
7268        match self.chart_modal.chart_kind {
7269            ChartKind::XY => {
7270                let x_column = self
7271                    .chart_modal
7272                    .effective_x_column()
7273                    .ok_or_else(|| color_eyre::eyre::eyre!("No X axis column selected"))?;
7274                let y_columns = self.chart_modal.effective_y_columns();
7275                if y_columns.is_empty() {
7276                    return Err(color_eyre::eyre::eyre!("No Y axis columns selected"));
7277                }
7278
7279                let row_limit_opt = self.chart_modal.row_limit;
7280                let row_limit = self.chart_modal.effective_row_limit();
7281                let cache_matches = self.chart_cache.xy.as_ref().is_some_and(|c| {
7282                    c.x_column == *x_column
7283                        && c.y_columns == y_columns
7284                        && c.row_limit == row_limit_opt
7285                });
7286
7287                let (series_vec, x_axis_kind_export, from_cache) = if cache_matches {
7288                    if let Some(cache) = self.chart_cache.xy.as_ref() {
7289                        let pts = if self.chart_modal.log_scale {
7290                            cache.series_log.as_ref().cloned().unwrap_or_else(|| {
7291                                cache
7292                                    .series
7293                                    .iter()
7294                                    .map(|s| {
7295                                        s.iter().map(|&(x, y)| (x, y.max(0.0).ln_1p())).collect()
7296                                    })
7297                                    .collect()
7298                            })
7299                        } else {
7300                            cache.series.clone()
7301                        };
7302                        (pts, cache.x_axis_kind, true)
7303                    } else {
7304                        let r = chart_data::prepare_chart_data(
7305                            &state.lf,
7306                            &state.schema,
7307                            x_column,
7308                            &y_columns,
7309                            row_limit,
7310                        )?;
7311                        (r.series, r.x_axis_kind, false)
7312                    }
7313                } else {
7314                    let r = chart_data::prepare_chart_data(
7315                        &state.lf,
7316                        &state.schema,
7317                        x_column,
7318                        &y_columns,
7319                        row_limit,
7320                    )?;
7321                    (r.series, r.x_axis_kind, false)
7322                };
7323
7324                let log_scale = self.chart_modal.log_scale;
7325                let series: Vec<ChartExportSeries> = series_vec
7326                    .iter()
7327                    .zip(y_columns.iter())
7328                    .filter(|(points, _)| !points.is_empty())
7329                    .map(|(points, name)| {
7330                        let pts = if log_scale && !from_cache {
7331                            points
7332                                .iter()
7333                                .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
7334                                .collect()
7335                        } else {
7336                            points.clone()
7337                        };
7338                        ChartExportSeries {
7339                            name: name.clone(),
7340                            points: pts,
7341                        }
7342                    })
7343                    .collect();
7344
7345                if series.is_empty() {
7346                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7347                }
7348
7349                let mut all_x_min = f64::INFINITY;
7350                let mut all_x_max = f64::NEG_INFINITY;
7351                let mut all_y_min = f64::INFINITY;
7352                let mut all_y_max = f64::NEG_INFINITY;
7353                for s in &series {
7354                    for &(x, y) in &s.points {
7355                        all_x_min = all_x_min.min(x);
7356                        all_x_max = all_x_max.max(x);
7357                        all_y_min = all_y_min.min(y);
7358                        all_y_max = all_y_max.max(y);
7359                    }
7360                }
7361
7362                let chart_type = self.chart_modal.chart_type;
7363                let y_starts_at_zero = self.chart_modal.y_starts_at_zero;
7364                let y_min_bounds = if chart_type == ChartType::Bar {
7365                    0.0_f64.min(all_y_min)
7366                } else if y_starts_at_zero {
7367                    0.0
7368                } else {
7369                    all_y_min
7370                };
7371                let y_max_bounds = if all_y_max > y_min_bounds {
7372                    all_y_max
7373                } else {
7374                    y_min_bounds + 1.0
7375                };
7376                let x_min_bounds = if all_x_max > all_x_min {
7377                    all_x_min
7378                } else {
7379                    all_x_min - 0.5
7380                };
7381                let x_max_bounds = if all_x_max > all_x_min {
7382                    all_x_max
7383                } else {
7384                    all_x_min + 0.5
7385                };
7386
7387                let x_label = x_column.to_string();
7388                let y_label = y_columns.join(", ");
7389                let bounds = ChartExportBounds {
7390                    x_min: x_min_bounds,
7391                    x_max: x_max_bounds,
7392                    y_min: y_min_bounds,
7393                    y_max: y_max_bounds,
7394                    x_label: x_label.clone(),
7395                    y_label: y_label.clone(),
7396                    x_axis_kind: x_axis_kind_export,
7397                    log_scale: self.chart_modal.log_scale,
7398                    chart_title,
7399                };
7400
7401                match format {
7402                    ChartExportFormat::Png => write_chart_png(path, &series, chart_type, &bounds),
7403                    ChartExportFormat::Eps => write_chart_eps(path, &series, chart_type, &bounds),
7404                }
7405            }
7406            ChartKind::Histogram => {
7407                let column = self
7408                    .chart_modal
7409                    .effective_hist_column()
7410                    .ok_or_else(|| color_eyre::eyre::eyre!("No histogram column selected"))?;
7411                let row_limit = self.chart_modal.effective_row_limit();
7412                let data = if let Some(c) = self.chart_cache.histogram.as_ref().filter(|c| {
7413                    c.column == column
7414                        && c.bins == self.chart_modal.hist_bins
7415                        && c.row_limit == self.chart_modal.row_limit
7416                }) {
7417                    c.data.clone()
7418                } else {
7419                    chart_data::prepare_histogram_data(
7420                        &state.lf,
7421                        &column,
7422                        self.chart_modal.hist_bins,
7423                        row_limit,
7424                    )?
7425                };
7426                if data.bins.is_empty() {
7427                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7428                }
7429                let points: Vec<(f64, f64)> =
7430                    data.bins.iter().map(|b| (b.center, b.count)).collect();
7431                let series = vec![ChartExportSeries {
7432                    name: column.clone(),
7433                    points,
7434                }];
7435                let x_max = if data.x_max > data.x_min {
7436                    data.x_max
7437                } else {
7438                    data.x_min + 1.0
7439                };
7440                let y_max = if data.max_count > 0.0 {
7441                    data.max_count
7442                } else {
7443                    1.0
7444                };
7445                let bounds = ChartExportBounds {
7446                    x_min: data.x_min,
7447                    x_max,
7448                    y_min: 0.0,
7449                    y_max,
7450                    x_label: column.clone(),
7451                    y_label: "Count".to_string(),
7452                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7453                    log_scale: false,
7454                    chart_title,
7455                };
7456                match format {
7457                    ChartExportFormat::Png => {
7458                        write_chart_png(path, &series, ChartType::Bar, &bounds)
7459                    }
7460                    ChartExportFormat::Eps => {
7461                        write_chart_eps(path, &series, ChartType::Bar, &bounds)
7462                    }
7463                }
7464            }
7465            ChartKind::BoxPlot => {
7466                let column = self
7467                    .chart_modal
7468                    .effective_box_column()
7469                    .ok_or_else(|| color_eyre::eyre::eyre!("No box plot column selected"))?;
7470                let row_limit = self.chart_modal.effective_row_limit();
7471                let data = if let Some(c) = self
7472                    .chart_cache
7473                    .box_plot
7474                    .as_ref()
7475                    .filter(|c| c.column == column && c.row_limit == self.chart_modal.row_limit)
7476                {
7477                    c.data.clone()
7478                } else {
7479                    chart_data::prepare_box_plot_data(
7480                        &state.lf,
7481                        std::slice::from_ref(&column),
7482                        row_limit,
7483                    )?
7484                };
7485                if data.stats.is_empty() {
7486                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7487                }
7488                let bounds = BoxPlotExportBounds {
7489                    y_min: data.y_min,
7490                    y_max: data.y_max,
7491                    x_labels: vec![column.clone()],
7492                    x_label: "Columns".to_string(),
7493                    y_label: "Value".to_string(),
7494                    chart_title,
7495                };
7496                match format {
7497                    ChartExportFormat::Png => write_box_plot_png(path, &data, &bounds),
7498                    ChartExportFormat::Eps => write_box_plot_eps(path, &data, &bounds),
7499                }
7500            }
7501            ChartKind::Kde => {
7502                let column = self
7503                    .chart_modal
7504                    .effective_kde_column()
7505                    .ok_or_else(|| color_eyre::eyre::eyre!("No KDE column selected"))?;
7506                let row_limit = self.chart_modal.effective_row_limit();
7507                let data = if let Some(c) = self.chart_cache.kde.as_ref().filter(|c| {
7508                    c.column == column
7509                        && c.bandwidth_factor == self.chart_modal.kde_bandwidth_factor
7510                        && c.row_limit == self.chart_modal.row_limit
7511                }) {
7512                    c.data.clone()
7513                } else {
7514                    chart_data::prepare_kde_data(
7515                        &state.lf,
7516                        std::slice::from_ref(&column),
7517                        self.chart_modal.kde_bandwidth_factor,
7518                        row_limit,
7519                    )?
7520                };
7521                if data.series.is_empty() {
7522                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7523                }
7524                let series: Vec<ChartExportSeries> = data
7525                    .series
7526                    .iter()
7527                    .map(|s| ChartExportSeries {
7528                        name: s.name.clone(),
7529                        points: s.points.clone(),
7530                    })
7531                    .collect();
7532                let bounds = ChartExportBounds {
7533                    x_min: data.x_min,
7534                    x_max: data.x_max,
7535                    y_min: 0.0,
7536                    y_max: data.y_max,
7537                    x_label: column.clone(),
7538                    y_label: "Density".to_string(),
7539                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7540                    log_scale: false,
7541                    chart_title,
7542                };
7543                match format {
7544                    ChartExportFormat::Png => {
7545                        write_chart_png(path, &series, ChartType::Line, &bounds)
7546                    }
7547                    ChartExportFormat::Eps => {
7548                        write_chart_eps(path, &series, ChartType::Line, &bounds)
7549                    }
7550                }
7551            }
7552            ChartKind::Heatmap => {
7553                let x_column = self
7554                    .chart_modal
7555                    .effective_heatmap_x_column()
7556                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap X column selected"))?;
7557                let y_column = self
7558                    .chart_modal
7559                    .effective_heatmap_y_column()
7560                    .ok_or_else(|| color_eyre::eyre::eyre!("No heatmap Y column selected"))?;
7561                let row_limit = self.chart_modal.effective_row_limit();
7562                let data = if let Some(c) = self.chart_cache.heatmap.as_ref().filter(|c| {
7563                    c.x_column == *x_column
7564                        && c.y_column == *y_column
7565                        && c.bins == self.chart_modal.heatmap_bins
7566                        && c.row_limit == self.chart_modal.row_limit
7567                }) {
7568                    c.data.clone()
7569                } else {
7570                    chart_data::prepare_heatmap_data(
7571                        &state.lf,
7572                        &x_column,
7573                        &y_column,
7574                        self.chart_modal.heatmap_bins,
7575                        row_limit,
7576                    )?
7577                };
7578                if data.counts.is_empty() || data.max_count <= 0.0 {
7579                    return Err(color_eyre::eyre::eyre!("No valid data points to export"));
7580                }
7581                let bounds = ChartExportBounds {
7582                    x_min: data.x_min,
7583                    x_max: data.x_max,
7584                    y_min: data.y_min,
7585                    y_max: data.y_max,
7586                    x_label: x_column.clone(),
7587                    y_label: y_column.clone(),
7588                    x_axis_kind: chart_data::XAxisTemporalKind::Numeric,
7589                    log_scale: false,
7590                    chart_title,
7591                };
7592                match format {
7593                    ChartExportFormat::Png => write_heatmap_png(path, &data, &bounds),
7594                    ChartExportFormat::Eps => write_heatmap_eps(path, &data, &bounds),
7595                }
7596            }
7597        }
7598    }
7599
7600    fn apply_template(&mut self, template: &Template) -> Result<()> {
7601        // Save state before applying template so we can restore on failure
7602        let saved_state = self
7603            .data_table_state
7604            .as_ref()
7605            .map(|state| TemplateApplicationState {
7606                lf: state.lf.clone(),
7607                schema: state.schema.clone(),
7608                active_query: state.active_query.clone(),
7609                active_sql_query: state.get_active_sql_query().to_string(),
7610                active_fuzzy_query: state.get_active_fuzzy_query().to_string(),
7611                filters: state.get_filters().to_vec(),
7612                sort_columns: state.get_sort_columns().to_vec(),
7613                sort_ascending: state.get_sort_ascending(),
7614                column_order: state.get_column_order().to_vec(),
7615                locked_columns_count: state.locked_columns_count(),
7616            });
7617        let saved_active_template_id = self.active_template_id.clone();
7618
7619        if let Some(state) = &mut self.data_table_state {
7620            state.error = None;
7621
7622            // At most one of SQL or DSL query is stored per template; then fuzzy. Apply in that order.
7623            let sql_trimmed = template.settings.sql_query.as_deref().unwrap_or("").trim();
7624            let query_opt = template.settings.query.as_deref().filter(|s| !s.is_empty());
7625            let fuzzy_trimmed = template
7626                .settings
7627                .fuzzy_query
7628                .as_deref()
7629                .unwrap_or("")
7630                .trim();
7631
7632            if !sql_trimmed.is_empty() {
7633                state.sql_query(template.settings.sql_query.clone().unwrap_or_default());
7634            } else if let Some(q) = query_opt {
7635                state.query(q.to_string());
7636            }
7637            if let Some(error) = state.error.clone() {
7638                if let Some(saved) = saved_state {
7639                    self.restore_state(saved);
7640                }
7641                self.active_template_id = saved_active_template_id;
7642                return Err(color_eyre::eyre::eyre!(
7643                    "{}",
7644                    crate::error_display::user_message_from_polars(&error)
7645                ));
7646            }
7647
7648            if !fuzzy_trimmed.is_empty() {
7649                state.fuzzy_search(template.settings.fuzzy_query.clone().unwrap_or_default());
7650                if let Some(error) = state.error.clone() {
7651                    if let Some(saved) = saved_state {
7652                        self.restore_state(saved);
7653                    }
7654                    self.active_template_id = saved_active_template_id;
7655                    return Err(color_eyre::eyre::eyre!(
7656                        "{}",
7657                        crate::error_display::user_message_from_polars(&error)
7658                    ));
7659                }
7660            }
7661
7662            // Apply filters
7663            if !template.settings.filters.is_empty() {
7664                state.filter(template.settings.filters.clone());
7665                // Check for errors after filter
7666                let error_opt = state.error.clone();
7667                if let Some(error) = error_opt {
7668                    // End the if let block to drop the borrow
7669                    if let Some(saved) = saved_state {
7670                        self.restore_state(saved);
7671                    }
7672                    self.active_template_id = saved_active_template_id;
7673                    return Err(color_eyre::eyre::eyre!("{}", error));
7674                }
7675            }
7676
7677            // Apply sort
7678            if !template.settings.sort_columns.is_empty() {
7679                state.sort(
7680                    template.settings.sort_columns.clone(),
7681                    template.settings.sort_ascending,
7682                );
7683                // Check for errors after sort
7684                let error_opt = state.error.clone();
7685                if let Some(error) = error_opt {
7686                    // End the if let block to drop the borrow
7687                    if let Some(saved) = saved_state {
7688                        self.restore_state(saved);
7689                    }
7690                    self.active_template_id = saved_active_template_id;
7691                    return Err(color_eyre::eyre::eyre!("{}", error));
7692                }
7693            }
7694
7695            // Apply pivot or melt (reshape) if present. Order: query → filters → sort → reshape → column_order.
7696            if let Some(ref spec) = template.settings.pivot {
7697                if let Err(e) = state.pivot(spec) {
7698                    if let Some(saved) = saved_state {
7699                        self.restore_state(saved);
7700                    }
7701                    self.active_template_id = saved_active_template_id;
7702                    return Err(color_eyre::eyre::eyre!(
7703                        "{}",
7704                        crate::error_display::user_message_from_report(&e, None)
7705                    ));
7706                }
7707            } else if let Some(ref spec) = template.settings.melt {
7708                if let Err(e) = state.melt(spec) {
7709                    if let Some(saved) = saved_state {
7710                        self.restore_state(saved);
7711                    }
7712                    self.active_template_id = saved_active_template_id;
7713                    return Err(color_eyre::eyre::eyre!(
7714                        "{}",
7715                        crate::error_display::user_message_from_report(&e, None)
7716                    ));
7717                }
7718            }
7719
7720            // Apply column order and locks
7721            if !template.settings.column_order.is_empty() {
7722                state.set_column_order(template.settings.column_order.clone());
7723                // Check for errors after set_column_order
7724                let error_opt = state.error.clone();
7725                if let Some(error) = error_opt {
7726                    // End the if let block to drop the borrow
7727                    if let Some(saved) = saved_state {
7728                        self.restore_state(saved);
7729                    }
7730                    self.active_template_id = saved_active_template_id;
7731                    return Err(color_eyre::eyre::eyre!("{}", error));
7732                }
7733                state.set_locked_columns(template.settings.locked_columns_count);
7734                // Check for errors after set_locked_columns
7735                let error_opt = state.error.clone();
7736                if let Some(error) = error_opt {
7737                    // End the if let block to drop the borrow
7738                    if let Some(saved) = saved_state {
7739                        self.restore_state(saved);
7740                    }
7741                    self.active_template_id = saved_active_template_id;
7742                    return Err(color_eyre::eyre::eyre!("{}", error));
7743                }
7744            }
7745        }
7746
7747        // Update template usage statistics
7748        // Note: We need to clone and update the template, then save it
7749        // For now, we'll update the template manager's internal state
7750        // A more complete implementation would reload templates after saving
7751        if let Some(path) = &self.path {
7752            let mut updated_template = template.clone();
7753            updated_template.last_used = Some(std::time::SystemTime::now());
7754            updated_template.usage_count += 1;
7755            updated_template.last_matched_file = Some(path.clone());
7756
7757            // Save updated template
7758            let _ = self.template_manager.save_template(&updated_template);
7759        }
7760
7761        // Track active template
7762        self.active_template_id = Some(template.id.clone());
7763
7764        Ok(())
7765    }
7766
7767    /// Format export error messages to be more user-friendly using type-based handling.
7768    fn format_export_error(error: &color_eyre::eyre::Report, path: &Path) -> String {
7769        use std::io;
7770
7771        for cause in error.chain() {
7772            if let Some(io_err) = cause.downcast_ref::<io::Error>() {
7773                let msg = crate::error_display::user_message_from_io(io_err, None);
7774                return format!("Cannot write to {}: {}", path.display(), msg);
7775            }
7776            if let Some(pe) = cause.downcast_ref::<polars::prelude::PolarsError>() {
7777                let msg = crate::error_display::user_message_from_polars(pe);
7778                return format!("Export failed: {}", msg);
7779            }
7780        }
7781        let error_str = error.to_string();
7782        let first_line = error_str.lines().next().unwrap_or("Unknown error").trim();
7783        format!("Export failed: {}", first_line)
7784    }
7785
7786    /// Write an already-collected DataFrame to file. Used by two-phase export (DoExportWrite).
7787    fn export_data_from_df(
7788        df: &mut DataFrame,
7789        path: &Path,
7790        format: ExportFormat,
7791        options: &ExportOptions,
7792    ) -> Result<()> {
7793        use polars::prelude::*;
7794        use std::fs::File;
7795        use std::io::{BufWriter, Write};
7796
7797        match format {
7798            ExportFormat::Csv => {
7799                use polars::prelude::CsvWriter;
7800                if let Some(compression) = options.csv_compression {
7801                    // Write to compressed file
7802                    let file = File::create(path)?;
7803                    let writer: Box<dyn Write> = match compression {
7804                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7805                            file,
7806                            flate2::Compression::default(),
7807                        )),
7808                        CompressionFormat::Zstd => {
7809                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7810                        }
7811                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7812                            file,
7813                            bzip2::Compression::default(),
7814                        )),
7815                        CompressionFormat::Xz => {
7816                            Box::new(xz2::write::XzEncoder::new(
7817                                file, 6, // compression level
7818                            ))
7819                        }
7820                    };
7821                    CsvWriter::new(writer)
7822                        .with_separator(options.csv_delimiter)
7823                        .include_header(options.csv_include_header)
7824                        .finish(df)?;
7825                } else {
7826                    // Write uncompressed
7827                    let file = File::create(path)?;
7828                    CsvWriter::new(file)
7829                        .with_separator(options.csv_delimiter)
7830                        .include_header(options.csv_include_header)
7831                        .finish(df)?;
7832                }
7833            }
7834            ExportFormat::Parquet => {
7835                use polars::prelude::ParquetWriter;
7836                let file = File::create(path)?;
7837                let mut writer = BufWriter::new(file);
7838                ParquetWriter::new(&mut writer).finish(df)?;
7839            }
7840            ExportFormat::Json => {
7841                use polars::prelude::JsonWriter;
7842                if let Some(compression) = options.json_compression {
7843                    // Write to compressed file
7844                    let file = File::create(path)?;
7845                    let writer: Box<dyn Write> = match compression {
7846                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7847                            file,
7848                            flate2::Compression::default(),
7849                        )),
7850                        CompressionFormat::Zstd => {
7851                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7852                        }
7853                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7854                            file,
7855                            bzip2::Compression::default(),
7856                        )),
7857                        CompressionFormat::Xz => {
7858                            Box::new(xz2::write::XzEncoder::new(
7859                                file, 6, // compression level
7860                            ))
7861                        }
7862                    };
7863                    JsonWriter::new(writer)
7864                        .with_json_format(JsonFormat::Json)
7865                        .finish(df)?;
7866                } else {
7867                    // Write uncompressed
7868                    let file = File::create(path)?;
7869                    JsonWriter::new(file)
7870                        .with_json_format(JsonFormat::Json)
7871                        .finish(df)?;
7872                }
7873            }
7874            ExportFormat::Ndjson => {
7875                use polars::prelude::{JsonFormat, JsonWriter};
7876                if let Some(compression) = options.ndjson_compression {
7877                    // Write to compressed file
7878                    let file = File::create(path)?;
7879                    let writer: Box<dyn Write> = match compression {
7880                        CompressionFormat::Gzip => Box::new(flate2::write::GzEncoder::new(
7881                            file,
7882                            flate2::Compression::default(),
7883                        )),
7884                        CompressionFormat::Zstd => {
7885                            Box::new(zstd::Encoder::new(file, 0)?.auto_finish())
7886                        }
7887                        CompressionFormat::Bzip2 => Box::new(bzip2::write::BzEncoder::new(
7888                            file,
7889                            bzip2::Compression::default(),
7890                        )),
7891                        CompressionFormat::Xz => {
7892                            Box::new(xz2::write::XzEncoder::new(
7893                                file, 6, // compression level
7894                            ))
7895                        }
7896                    };
7897                    JsonWriter::new(writer)
7898                        .with_json_format(JsonFormat::JsonLines)
7899                        .finish(df)?;
7900                } else {
7901                    // Write uncompressed
7902                    let file = File::create(path)?;
7903                    JsonWriter::new(file)
7904                        .with_json_format(JsonFormat::JsonLines)
7905                        .finish(df)?;
7906                }
7907            }
7908            ExportFormat::Ipc => {
7909                use polars::prelude::IpcWriter;
7910                let file = File::create(path)?;
7911                let mut writer = BufWriter::new(file);
7912                IpcWriter::new(&mut writer).finish(df)?;
7913            }
7914            ExportFormat::Avro => {
7915                use polars::io::avro::AvroWriter;
7916                let file = File::create(path)?;
7917                let mut writer = BufWriter::new(file);
7918                AvroWriter::new(&mut writer).finish(df)?;
7919            }
7920        }
7921
7922        Ok(())
7923    }
7924
7925    #[allow(dead_code)] // Used only when not using two-phase export; kept for tests/single-shot use
7926    fn export_data(
7927        state: &DataTableState,
7928        path: &Path,
7929        format: ExportFormat,
7930        options: &ExportOptions,
7931    ) -> Result<()> {
7932        let mut df = crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)?;
7933        Self::export_data_from_df(&mut df, path, format, options)
7934    }
7935
7936    fn restore_state(&mut self, saved: TemplateApplicationState) {
7937        if let Some(state) = &mut self.data_table_state {
7938            // Clone saved lf and schema so we can restore them after applying methods
7939            let saved_lf = saved.lf.clone();
7940            let saved_schema = saved.schema.clone();
7941
7942            // Restore lf and schema directly (these are public fields)
7943            // This preserves the exact LazyFrame state from before template application
7944            state.lf = saved.lf;
7945            state.schema = saved.schema;
7946            state.active_query = saved.active_query;
7947            state.active_sql_query = saved.active_sql_query;
7948            state.active_fuzzy_query = saved.active_fuzzy_query;
7949            // Clear error
7950            state.error = None;
7951            // Restore private fields using public methods
7952            // Note: These methods will modify lf by applying transformations, but since
7953            // we've already restored lf to the saved state, we need to restore it again after
7954            state.filter(saved.filters.clone());
7955            if state.error.is_none() {
7956                state.sort(saved.sort_columns.clone(), saved.sort_ascending);
7957            }
7958            if state.error.is_none() {
7959                state.set_column_order(saved.column_order.clone());
7960            }
7961            if state.error.is_none() {
7962                state.set_locked_columns(saved.locked_columns_count);
7963            }
7964            // Restore the exact saved lf and schema (in case filter/sort modified them)
7965            state.lf = saved_lf;
7966            state.schema = saved_schema;
7967            state.collect();
7968        }
7969    }
7970
7971    pub fn create_template_from_current_state(
7972        &mut self,
7973        name: String,
7974        description: Option<String>,
7975        match_criteria: template::MatchCriteria,
7976    ) -> Result<template::Template> {
7977        let settings = if let Some(state) = &self.data_table_state {
7978            let (query, sql_query, fuzzy_query) = active_query_settings(
7979                state.get_active_query(),
7980                state.get_active_sql_query(),
7981                state.get_active_fuzzy_query(),
7982            );
7983            template::TemplateSettings {
7984                query,
7985                sql_query,
7986                fuzzy_query,
7987                filters: state.get_filters().to_vec(),
7988                sort_columns: state.get_sort_columns().to_vec(),
7989                sort_ascending: state.get_sort_ascending(),
7990                column_order: state.get_column_order().to_vec(),
7991                locked_columns_count: state.locked_columns_count(),
7992                pivot: state.last_pivot_spec().cloned(),
7993                melt: state.last_melt_spec().cloned(),
7994            }
7995        } else {
7996            template::TemplateSettings {
7997                query: None,
7998                sql_query: None,
7999                fuzzy_query: None,
8000                filters: Vec::new(),
8001                sort_columns: Vec::new(),
8002                sort_ascending: true,
8003                column_order: Vec::new(),
8004                locked_columns_count: 0,
8005                pivot: None,
8006                melt: None,
8007            }
8008        };
8009
8010        self.template_manager
8011            .create_template(name, description, match_criteria, settings)
8012    }
8013
8014    fn get_help_info(&self) -> (String, String) {
8015        let (title, content) = match self.input_mode {
8016            InputMode::Normal => ("Main View Help", help_strings::main_view()),
8017            InputMode::Editing => match self.input_type {
8018                Some(InputType::Search) => ("Query Help", help_strings::query()),
8019                _ => ("Editing Help", help_strings::editing()),
8020            },
8021            InputMode::SortFilter => ("Sort & Filter Help", help_strings::sort_filter()),
8022            InputMode::PivotMelt => ("Pivot / Melt Help", help_strings::pivot_melt()),
8023            InputMode::Export => ("Export Help", help_strings::export()),
8024            InputMode::Info => ("Info Panel Help", help_strings::info_panel()),
8025            InputMode::Chart => ("Chart Help", help_strings::chart()),
8026        };
8027        (title.to_string(), content.to_string())
8028    }
8029}
8030
8031impl Widget for &mut App {
8032    fn render(self, area: Rect, buf: &mut Buffer) {
8033        self.debug.num_frames += 1;
8034        if self.debug.enabled {
8035            self.debug.show_help_at_render = self.show_help;
8036        }
8037
8038        // Clear entire area first so no ghost text from any widget (loading gauge label,
8039        // modals, controls, etc.) can persist when layout or visibility changes (e.g. after pivot).
8040        Clear.render(area, buf);
8041
8042        // Set background color for the entire application area
8043        let background_color = self.color("background");
8044        Block::default()
8045            .style(Style::default().bg(background_color))
8046            .render(area, buf);
8047
8048        let mut constraints = vec![Constraint::Fill(1)];
8049
8050        // Adjust layout if sorting to show panel on the right
8051        let mut has_error = false;
8052        let mut err_msg = String::new();
8053        if let Some(state) = &self.data_table_state {
8054            if let Some(e) = &state.error {
8055                has_error = true;
8056                err_msg = crate::error_display::user_message_from_polars(e);
8057            }
8058        }
8059
8060        if self.input_mode == InputMode::Editing {
8061            let height = if self.input_type == Some(InputType::Search) {
8062                if has_error {
8063                    9
8064                } else {
8065                    5
8066                }
8067            } else if has_error {
8068                6
8069            } else {
8070                3
8071            };
8072            constraints.insert(1, Constraint::Length(height));
8073        }
8074        constraints.push(Constraint::Length(1)); // Controls
8075        if self.debug.enabled {
8076            constraints.push(Constraint::Length(1));
8077        }
8078        let layout = Layout::default()
8079            .direction(Direction::Vertical)
8080            .constraints(constraints)
8081            .split(area);
8082
8083        let main_area = layout[0];
8084        // Clear entire main content so no ghost text from modals or previous layout persists (e.g. after pivot).
8085        Clear.render(main_area, buf);
8086        let mut data_area = main_area;
8087        let mut sort_area = Rect::default();
8088
8089        if self.sort_filter_modal.active {
8090            let chunks = Layout::default()
8091                .direction(Direction::Horizontal)
8092                .constraints([Constraint::Min(0), Constraint::Length(50)])
8093                .split(main_area);
8094            data_area = chunks[0];
8095            sort_area = chunks[1];
8096        }
8097        if self.template_modal.active {
8098            let chunks = Layout::default()
8099                .direction(Direction::Horizontal)
8100                .constraints([Constraint::Min(0), Constraint::Length(80)]) // Wider for 30 char descriptions
8101                .split(main_area);
8102            data_area = chunks[0];
8103            sort_area = chunks[1]; // Reuse sort_area for template modal
8104        }
8105        if self.pivot_melt_modal.active {
8106            let chunks = Layout::default()
8107                .direction(Direction::Horizontal)
8108                .constraints([Constraint::Min(0), Constraint::Length(50)])
8109                .split(main_area);
8110            data_area = chunks[0];
8111            sort_area = chunks[1];
8112        }
8113        if self.info_modal.active {
8114            let chunks = Layout::default()
8115                .direction(Direction::Horizontal)
8116                .constraints([Constraint::Min(0), Constraint::Max(72)])
8117                .split(main_area);
8118            data_area = chunks[0];
8119            sort_area = chunks[1];
8120        }
8121
8122        // Extract colors and table config before mutable borrow to avoid borrow checker issues
8123        let primary_color = self.color("keybind_hints");
8124        let _controls_bg_color = self.color("controls_bg");
8125        let table_header_color = self.color("table_header");
8126        let row_numbers_color = self.color("row_numbers");
8127        let column_separator_color = self.color("column_separator");
8128        let table_header_bg_color = self.color("table_header_bg");
8129        let modal_border_color = self.color("modal_border");
8130        let info_active_color = self.color("modal_border_active");
8131        let info_primary_color = self.color("text_primary");
8132        let table_cell_padding = self.table_cell_padding;
8133        let alternate_row_bg = self.theme.get_optional("alternate_row_color");
8134        let column_colors = self.column_colors;
8135        let (str_col, int_col, float_col, bool_col, temporal_col) = if column_colors {
8136            (
8137                self.theme.get("str_col"),
8138                self.theme.get("int_col"),
8139                self.theme.get("float_col"),
8140                self.theme.get("bool_col"),
8141                self.theme.get("temporal_col"),
8142            )
8143        } else {
8144            (
8145                Color::Reset,
8146                Color::Reset,
8147                Color::Reset,
8148                Color::Reset,
8149                Color::Reset,
8150            )
8151        };
8152
8153        // Parquet metadata is loaded via DoLoadParquetMetadata when info panel is opened (not in render)
8154
8155        match &mut self.data_table_state {
8156            Some(state) => {
8157                // Render breadcrumb if drilled down
8158                let mut table_area = data_area;
8159                if state.is_drilled_down() {
8160                    if let Some(ref key_values) = state.drilled_down_group_key {
8161                        let breadcrumb_layout = Layout::default()
8162                            .direction(Direction::Vertical)
8163                            .constraints([Constraint::Length(3), Constraint::Fill(1)])
8164                            .split(data_area);
8165
8166                        // Render breadcrumb with better styling
8167                        let empty_vec = Vec::new();
8168                        let key_columns = state
8169                            .drilled_down_group_key_columns
8170                            .as_ref()
8171                            .unwrap_or(&empty_vec);
8172                        let breadcrumb_parts: Vec<String> = key_columns
8173                            .iter()
8174                            .zip(key_values.iter())
8175                            .map(|(col, val)| format!("{}={}", col, val))
8176                            .collect();
8177                        let breadcrumb_text = format!(
8178                            "← Group: {} (Press Esc to go back)",
8179                            breadcrumb_parts.join(" | ")
8180                        );
8181
8182                        Block::default()
8183                            .borders(Borders::ALL)
8184                            .border_type(BorderType::Rounded)
8185                            .border_style(Style::default().fg(primary_color))
8186                            .title("Breadcrumb")
8187                            .render(breadcrumb_layout[0], buf);
8188
8189                        let inner = Block::default().inner(breadcrumb_layout[0]);
8190                        Paragraph::new(breadcrumb_text)
8191                            .style(
8192                                Style::default()
8193                                    .fg(primary_color)
8194                                    .add_modifier(Modifier::BOLD),
8195                            )
8196                            .wrap(ratatui::widgets::Wrap { trim: true })
8197                            .render(inner, buf);
8198
8199                        table_area = breadcrumb_layout[1];
8200                    }
8201                }
8202
8203                Clear.render(table_area, buf);
8204                let mut dt = DataTable::new()
8205                    .with_colors(
8206                        table_header_bg_color,
8207                        table_header_color,
8208                        row_numbers_color,
8209                        column_separator_color,
8210                    )
8211                    .with_cell_padding(table_cell_padding)
8212                    .with_alternate_row_bg(alternate_row_bg);
8213                if column_colors {
8214                    dt = dt.with_column_type_colors(
8215                        str_col,
8216                        int_col,
8217                        float_col,
8218                        bool_col,
8219                        temporal_col,
8220                    );
8221                }
8222                dt.render(table_area, buf, state);
8223                if self.info_modal.active {
8224                    let ctx = InfoContext {
8225                        path: self.path.as_deref(),
8226                        format: self.original_file_format,
8227                        parquet_metadata: self.parquet_metadata_cache.as_ref(),
8228                    };
8229                    let mut info_widget = DataTableInfo::new(
8230                        state,
8231                        ctx,
8232                        &mut self.info_modal,
8233                        modal_border_color,
8234                        info_active_color,
8235                        info_primary_color,
8236                    );
8237                    info_widget.render(sort_area, buf);
8238                }
8239            }
8240            None => {
8241                Paragraph::new("No data loaded").render(layout[0], buf);
8242            }
8243        }
8244
8245        let mut controls_area = layout[1];
8246        let debug_area_index = layout.len() - 1;
8247
8248        if self.input_mode == InputMode::Editing {
8249            let input_area = layout[1];
8250            controls_area = layout[layout.len() - 1];
8251
8252            let title = match self.input_type {
8253                Some(InputType::Search) => "Query",
8254                Some(InputType::Filter) => "Filter",
8255                Some(InputType::GoToLine) => "Go to line",
8256                None => "Input",
8257            };
8258
8259            let mut border_style = Style::default();
8260            if has_error {
8261                border_style = Style::default().fg(self.color("error"));
8262            }
8263
8264            if self.debug.enabled {
8265                controls_area = layout[layout.len() - 2];
8266            }
8267
8268            let block = Block::default()
8269                .borders(Borders::ALL)
8270                .border_type(BorderType::Rounded)
8271                .title(title)
8272                .border_style(border_style);
8273            let inner_area = block.inner(input_area);
8274            block.render(input_area, buf);
8275
8276            if self.input_type == Some(InputType::Search) {
8277                let border_c = self.color("modal_border");
8278                let active_c = self.color("modal_border_active");
8279                let tab_bar_focused = self.query_focus == QueryFocus::TabBar;
8280
8281                let chunks = Layout::default()
8282                    .direction(Direction::Vertical)
8283                    .constraints([Constraint::Length(2), Constraint::Min(1)])
8284                    .split(inner_area);
8285
8286                let tab_line_chunks = Layout::default()
8287                    .direction(Direction::Vertical)
8288                    .constraints([Constraint::Length(1), Constraint::Length(1)])
8289                    .split(chunks[0]);
8290                let tab_row_chunks = Layout::default()
8291                    .direction(Direction::Horizontal)
8292                    .constraints([Constraint::Min(0), Constraint::Max(40)])
8293                    .split(tab_line_chunks[0]);
8294                let tab_titles = vec!["SQL-Like", "Fuzzy", "SQL"];
8295                let tabs = Tabs::new(tab_titles)
8296                    .style(Style::default().fg(border_c))
8297                    .highlight_style(
8298                        Style::default()
8299                            .fg(active_c)
8300                            .add_modifier(Modifier::REVERSED),
8301                    )
8302                    .select(self.query_tab.index());
8303                tabs.render(tab_row_chunks[0], buf);
8304                let desc_text = match self.query_tab {
8305                    QueryTab::SqlLike => "select [cols] [by ...] [where ...]",
8306                    QueryTab::Fuzzy => "Search text to find matching rows",
8307                    QueryTab::Sql => {
8308                        #[cfg(feature = "sql")]
8309                        {
8310                            "Table: df"
8311                        }
8312                        #[cfg(not(feature = "sql"))]
8313                        {
8314                            ""
8315                        }
8316                    }
8317                };
8318                if !desc_text.is_empty() {
8319                    Paragraph::new(desc_text)
8320                        .style(Style::default().fg(self.color("text_secondary")))
8321                        .alignment(Alignment::Right)
8322                        .render(tab_row_chunks[1], buf);
8323                }
8324                let line_style = if tab_bar_focused {
8325                    Style::default().fg(active_c)
8326                } else {
8327                    Style::default().fg(border_c)
8328                };
8329                Block::default()
8330                    .borders(Borders::BOTTOM)
8331                    .border_type(BorderType::Rounded)
8332                    .border_style(line_style)
8333                    .render(tab_line_chunks[1], buf);
8334
8335                match self.query_tab {
8336                    QueryTab::SqlLike => {
8337                        if has_error {
8338                            let body_chunks = Layout::default()
8339                                .direction(Direction::Vertical)
8340                                .constraints([Constraint::Length(1), Constraint::Min(1)])
8341                                .split(chunks[1]);
8342                            self.query_input
8343                                .set_focused(self.query_focus == QueryFocus::Input);
8344                            (&self.query_input).render(body_chunks[0], buf);
8345                            Paragraph::new(err_msg)
8346                                .style(Style::default().fg(self.color("error")))
8347                                .wrap(ratatui::widgets::Wrap { trim: true })
8348                                .render(body_chunks[1], buf);
8349                        } else {
8350                            self.query_input
8351                                .set_focused(self.query_focus == QueryFocus::Input);
8352                            (&self.query_input).render(chunks[1], buf);
8353                        }
8354                    }
8355                    QueryTab::Fuzzy => {
8356                        self.query_input.set_focused(false);
8357                        self.sql_input.set_focused(false);
8358                        self.fuzzy_input
8359                            .set_focused(self.query_focus == QueryFocus::Input);
8360                        (&self.fuzzy_input).render(chunks[1], buf);
8361                    }
8362                    QueryTab::Sql => {
8363                        self.query_input.set_focused(false);
8364                        #[cfg(feature = "sql")]
8365                        {
8366                            if has_error {
8367                                let body_chunks = Layout::default()
8368                                    .direction(Direction::Vertical)
8369                                    .constraints([Constraint::Length(1), Constraint::Min(1)])
8370                                    .split(chunks[1]);
8371                                self.sql_input
8372                                    .set_focused(self.query_focus == QueryFocus::Input);
8373                                (&self.sql_input).render(body_chunks[0], buf);
8374                                Paragraph::new(err_msg)
8375                                    .style(Style::default().fg(self.color("error")))
8376                                    .wrap(ratatui::widgets::Wrap { trim: true })
8377                                    .render(body_chunks[1], buf);
8378                            } else {
8379                                self.sql_input
8380                                    .set_focused(self.query_focus == QueryFocus::Input);
8381                                (&self.sql_input).render(chunks[1], buf);
8382                            }
8383                        }
8384                        #[cfg(not(feature = "sql"))]
8385                        {
8386                            self.sql_input.set_focused(false);
8387                            Paragraph::new(
8388                                "SQL support not compiled in (build with --features sql)",
8389                            )
8390                            .style(Style::default().fg(self.color("text_secondary")))
8391                            .render(chunks[1], buf);
8392                        }
8393                    }
8394                }
8395            } else if has_error {
8396                let chunks = Layout::default()
8397                    .direction(Direction::Vertical)
8398                    .constraints([
8399                        Constraint::Length(1),
8400                        Constraint::Length(1),
8401                        Constraint::Min(1),
8402                    ])
8403                    .split(inner_area);
8404
8405                (&self.query_input).render(chunks[0], buf);
8406                Paragraph::new(err_msg)
8407                    .style(Style::default().fg(self.color("error")))
8408                    .wrap(ratatui::widgets::Wrap { trim: true })
8409                    .render(chunks[2], buf);
8410            } else {
8411                (&self.query_input).render(inner_area, buf);
8412            }
8413        }
8414
8415        if self.sort_filter_modal.active {
8416            Clear.render(sort_area, buf);
8417            let block = Block::default()
8418                .borders(Borders::ALL)
8419                .border_type(BorderType::Rounded)
8420                .title("Sort & Filter");
8421            let inner_area = block.inner(sort_area);
8422            block.render(sort_area, buf);
8423
8424            let chunks = Layout::default()
8425                .direction(Direction::Vertical)
8426                .constraints([
8427                    Constraint::Length(2), // Tab bar + line
8428                    Constraint::Min(0),    // Body
8429                    Constraint::Length(3), // Footer
8430                ])
8431                .split(inner_area);
8432
8433            // Tab bar + line
8434            let tab_line_chunks = Layout::default()
8435                .direction(Direction::Vertical)
8436                .constraints([Constraint::Length(1), Constraint::Length(1)])
8437                .split(chunks[0]);
8438            let tab_selected = match self.sort_filter_modal.active_tab {
8439                SortFilterTab::Sort => 0,
8440                SortFilterTab::Filter => 1,
8441            };
8442            let border_c = self.color("modal_border");
8443            let active_c = self.color("modal_border_active");
8444            let tabs = Tabs::new(vec!["Sort", "Filter"])
8445                .style(Style::default().fg(border_c))
8446                .highlight_style(
8447                    Style::default()
8448                        .fg(active_c)
8449                        .add_modifier(Modifier::REVERSED),
8450                )
8451                .select(tab_selected);
8452            tabs.render(tab_line_chunks[0], buf);
8453            let line_style = if self.sort_filter_modal.focus == SortFilterFocus::TabBar {
8454                Style::default().fg(active_c)
8455            } else {
8456                Style::default().fg(border_c)
8457            };
8458            Block::default()
8459                .borders(Borders::BOTTOM)
8460                .border_type(BorderType::Rounded)
8461                .border_style(line_style)
8462                .render(tab_line_chunks[1], buf);
8463
8464            if self.sort_filter_modal.active_tab == SortFilterTab::Filter {
8465                let fchunks = Layout::default()
8466                    .direction(Direction::Vertical)
8467                    .constraints([
8468                        Constraint::Length(3),
8469                        Constraint::Length(3),
8470                        Constraint::Min(0),
8471                    ])
8472                    .split(chunks[1]);
8473
8474                let row_layout = Layout::default()
8475                    .direction(Direction::Horizontal)
8476                    .constraints([
8477                        Constraint::Percentage(30),
8478                        Constraint::Percentage(20),
8479                        Constraint::Percentage(30),
8480                        Constraint::Percentage(20),
8481                    ])
8482                    .split(fchunks[0]);
8483
8484                let col_name = if self.sort_filter_modal.filter.available_columns.is_empty() {
8485                    ""
8486                } else {
8487                    &self.sort_filter_modal.filter.available_columns
8488                        [self.sort_filter_modal.filter.new_column_idx]
8489                };
8490                let col_style = if self.sort_filter_modal.filter.focus == FilterFocus::Column {
8491                    Style::default().fg(active_c)
8492                } else {
8493                    Style::default().fg(border_c)
8494                };
8495                Paragraph::new(col_name)
8496                    .block(
8497                        Block::default()
8498                            .borders(Borders::ALL)
8499                            .border_type(BorderType::Rounded)
8500                            .title("Col")
8501                            .border_style(col_style),
8502                    )
8503                    .render(row_layout[0], buf);
8504
8505                let op_name = FilterOperator::iterator()
8506                    .nth(self.sort_filter_modal.filter.new_operator_idx)
8507                    .unwrap_or(FilterOperator::Eq)
8508                    .as_str();
8509                let op_style = if self.sort_filter_modal.filter.focus == FilterFocus::Operator {
8510                    Style::default().fg(active_c)
8511                } else {
8512                    Style::default().fg(border_c)
8513                };
8514                Paragraph::new(op_name)
8515                    .block(
8516                        Block::default()
8517                            .borders(Borders::ALL)
8518                            .border_type(BorderType::Rounded)
8519                            .title("Op")
8520                            .border_style(op_style),
8521                    )
8522                    .render(row_layout[1], buf);
8523
8524                let val_style = if self.sort_filter_modal.filter.focus == FilterFocus::Value {
8525                    Style::default().fg(active_c)
8526                } else {
8527                    Style::default().fg(border_c)
8528                };
8529                Paragraph::new(self.sort_filter_modal.filter.new_value.as_str())
8530                    .block(
8531                        Block::default()
8532                            .borders(Borders::ALL)
8533                            .border_type(BorderType::Rounded)
8534                            .title("Val")
8535                            .border_style(val_style),
8536                    )
8537                    .render(row_layout[2], buf);
8538
8539                let log_name = LogicalOperator::iterator()
8540                    .nth(self.sort_filter_modal.filter.new_logical_idx)
8541                    .unwrap_or(LogicalOperator::And)
8542                    .as_str();
8543                let log_style = if self.sort_filter_modal.filter.focus == FilterFocus::Logical {
8544                    Style::default().fg(active_c)
8545                } else {
8546                    Style::default().fg(border_c)
8547                };
8548                Paragraph::new(log_name)
8549                    .block(
8550                        Block::default()
8551                            .borders(Borders::ALL)
8552                            .border_type(BorderType::Rounded)
8553                            .title("Logic")
8554                            .border_style(log_style),
8555                    )
8556                    .render(row_layout[3], buf);
8557
8558                let add_style = if self.sort_filter_modal.filter.focus == FilterFocus::Add {
8559                    Style::default().fg(active_c)
8560                } else {
8561                    Style::default().fg(border_c)
8562                };
8563                Paragraph::new("Add Filter")
8564                    .block(
8565                        Block::default()
8566                            .borders(Borders::ALL)
8567                            .border_type(BorderType::Rounded)
8568                            .border_style(add_style),
8569                    )
8570                    .centered()
8571                    .render(fchunks[1], buf);
8572
8573                let items: Vec<ListItem> = self
8574                    .sort_filter_modal
8575                    .filter
8576                    .statements
8577                    .iter()
8578                    .enumerate()
8579                    .map(|(i, s)| {
8580                        let prefix = if i > 0 {
8581                            format!("{} ", s.logical_op.as_str())
8582                        } else {
8583                            "".to_string()
8584                        };
8585                        ListItem::new(format!(
8586                            "{}{}{}{}",
8587                            prefix,
8588                            s.column,
8589                            s.operator.as_str(),
8590                            s.value
8591                        ))
8592                    })
8593                    .collect();
8594                let list_style = if self.sort_filter_modal.filter.focus == FilterFocus::Statements {
8595                    Style::default().fg(active_c)
8596                } else {
8597                    Style::default().fg(border_c)
8598                };
8599                let list = List::new(items)
8600                    .block(
8601                        Block::default()
8602                            .borders(Borders::ALL)
8603                            .border_type(BorderType::Rounded)
8604                            .title("Current Filters")
8605                            .border_style(list_style),
8606                    )
8607                    .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
8608                StatefulWidget::render(
8609                    list,
8610                    fchunks[2],
8611                    buf,
8612                    &mut self.sort_filter_modal.filter.list_state,
8613                );
8614            } else {
8615                // Sort tab body
8616                let schunks = Layout::default()
8617                    .direction(Direction::Vertical)
8618                    .constraints([
8619                        Constraint::Length(3),
8620                        Constraint::Min(0),
8621                        Constraint::Length(2),
8622                        Constraint::Length(3),
8623                    ])
8624                    .split(chunks[1]);
8625
8626                let filter_block_title = "Filter Columns";
8627                let mut filter_block_border_style = Style::default().fg(border_c);
8628                if self.sort_filter_modal.sort.focus == SortFocus::Filter {
8629                    filter_block_border_style = filter_block_border_style.fg(active_c);
8630                }
8631                let filter_block = Block::default()
8632                    .borders(Borders::ALL)
8633                    .border_type(BorderType::Rounded)
8634                    .title(filter_block_title)
8635                    .border_style(filter_block_border_style);
8636                let filter_inner_area = filter_block.inner(schunks[0]);
8637                filter_block.render(schunks[0], buf);
8638
8639                // Render filter input using TextInput widget
8640                let is_focused = self.sort_filter_modal.sort.focus == SortFocus::Filter;
8641                self.sort_filter_modal
8642                    .sort
8643                    .filter_input
8644                    .set_focused(is_focused);
8645                (&self.sort_filter_modal.sort.filter_input).render(filter_inner_area, buf);
8646
8647                let filtered = self.sort_filter_modal.sort.filtered_columns();
8648                let rows: Vec<Row> = filtered
8649                    .iter()
8650                    .map(|(_, col)| {
8651                        let lock_cell = if col.is_locked {
8652                            "●" // Full circle for locked
8653                        } else if col.is_to_be_locked {
8654                            "◐" // Half circle to indicate pending lock
8655                        } else {
8656                            " "
8657                        };
8658                        let lock_style = if col.is_locked {
8659                            Style::default()
8660                        } else if col.is_to_be_locked {
8661                            Style::default().fg(self.color("dimmed")) // Dimmed style for pending lock
8662                        } else {
8663                            Style::default()
8664                        };
8665                        let order_cell = if col.is_visible && col.display_order < 9999 {
8666                            format!("{:2}", col.display_order + 1)
8667                        } else {
8668                            "  ".to_string()
8669                        };
8670                        let sort_cell = if let Some(order) = col.sort_order {
8671                            format!("{:2}", order)
8672                        } else {
8673                            "  ".to_string()
8674                        };
8675                        let name_cell = Cell::from(col.name.clone());
8676
8677                        // Apply dimmed style to hidden columns
8678                        let row_style = if col.is_visible {
8679                            Style::default()
8680                        } else {
8681                            Style::default().fg(self.color("dimmed"))
8682                        };
8683
8684                        Row::new(vec![
8685                            Cell::from(lock_cell).style(lock_style),
8686                            Cell::from(order_cell).style(row_style),
8687                            Cell::from(sort_cell).style(row_style),
8688                            name_cell.style(row_style),
8689                        ])
8690                    })
8691                    .collect();
8692
8693                let header = Row::new(vec![
8694                    Cell::from("🔒").style(Style::default()),
8695                    Cell::from("Order").style(Style::default()),
8696                    Cell::from("Sort").style(Style::default()),
8697                    Cell::from("Name").style(Style::default()),
8698                ])
8699                .style(Style::default().add_modifier(Modifier::UNDERLINED));
8700
8701                let table_border_style =
8702                    if self.sort_filter_modal.sort.focus == SortFocus::ColumnList {
8703                        Style::default().fg(active_c)
8704                    } else {
8705                        Style::default().fg(border_c)
8706                    };
8707                let table = Table::new(
8708                    rows,
8709                    [
8710                        Constraint::Length(2),
8711                        Constraint::Length(6),
8712                        Constraint::Length(6),
8713                        Constraint::Min(0),
8714                    ],
8715                )
8716                .header(header)
8717                .block(
8718                    Block::default()
8719                        .borders(Borders::ALL)
8720                        .border_type(BorderType::Rounded)
8721                        .title("Columns")
8722                        .border_style(table_border_style),
8723                )
8724                .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
8725
8726                StatefulWidget::render(
8727                    table,
8728                    schunks[1],
8729                    buf,
8730                    &mut self.sort_filter_modal.sort.table_state,
8731                );
8732
8733                // Keybind Hints
8734                use ratatui::text::{Line, Span};
8735                let mut hint_line1 = Line::default();
8736                hint_line1.spans.push(Span::raw("Sort:    "));
8737                hint_line1.spans.push(Span::styled(
8738                    "Space",
8739                    Style::default()
8740                        .fg(self.color("keybind_hints"))
8741                        .add_modifier(Modifier::BOLD),
8742                ));
8743                hint_line1.spans.push(Span::raw(" Toggle "));
8744                hint_line1.spans.push(Span::styled(
8745                    "[]",
8746                    Style::default()
8747                        .fg(self.color("keybind_hints"))
8748                        .add_modifier(Modifier::BOLD),
8749                ));
8750                hint_line1.spans.push(Span::raw(" Reorder "));
8751                hint_line1.spans.push(Span::styled(
8752                    "1-9",
8753                    Style::default()
8754                        .fg(self.color("keybind_hints"))
8755                        .add_modifier(Modifier::BOLD),
8756                ));
8757                hint_line1.spans.push(Span::raw(" Jump"));
8758
8759                let mut hint_line2 = Line::default();
8760                hint_line2.spans.push(Span::raw("Display: "));
8761                hint_line2.spans.push(Span::styled(
8762                    "L",
8763                    Style::default()
8764                        .fg(self.color("keybind_hints"))
8765                        .add_modifier(Modifier::BOLD),
8766                ));
8767                hint_line2.spans.push(Span::raw(" Lock "));
8768                hint_line2.spans.push(Span::styled(
8769                    "+-",
8770                    Style::default()
8771                        .fg(self.color("keybind_hints"))
8772                        .add_modifier(Modifier::BOLD),
8773                ));
8774                hint_line2.spans.push(Span::raw(" Reorder"));
8775
8776                Paragraph::new(vec![hint_line1, hint_line2]).render(schunks[2], buf);
8777
8778                let order_border_style = if self.sort_filter_modal.sort.focus == SortFocus::Order {
8779                    Style::default().fg(active_c)
8780                } else {
8781                    Style::default().fg(border_c)
8782                };
8783
8784                let order_block = Block::default()
8785                    .borders(Borders::ALL)
8786                    .border_type(BorderType::Rounded)
8787                    .title("Order")
8788                    .border_style(order_border_style);
8789                let order_inner = order_block.inner(schunks[3]);
8790                order_block.render(schunks[3], buf);
8791
8792                let order_layout = Layout::default()
8793                    .direction(Direction::Horizontal)
8794                    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
8795                    .split(order_inner);
8796
8797                // Ascending option
8798                let ascending_indicator = if self.sort_filter_modal.sort.ascending {
8799                    "●"
8800                } else {
8801                    "○"
8802                };
8803                let ascending_text = format!("{} Ascending", ascending_indicator);
8804                let ascending_style = if self.sort_filter_modal.sort.ascending {
8805                    Style::default().add_modifier(Modifier::BOLD)
8806                } else {
8807                    Style::default()
8808                };
8809                Paragraph::new(ascending_text)
8810                    .style(ascending_style)
8811                    .centered()
8812                    .render(order_layout[0], buf);
8813
8814                // Descending option
8815                let descending_indicator = if !self.sort_filter_modal.sort.ascending {
8816                    "●"
8817                } else {
8818                    "○"
8819                };
8820                let descending_text = format!("{} Descending", descending_indicator);
8821                let descending_style = if !self.sort_filter_modal.sort.ascending {
8822                    Style::default().add_modifier(Modifier::BOLD)
8823                } else {
8824                    Style::default()
8825                };
8826                Paragraph::new(descending_text)
8827                    .style(descending_style)
8828                    .centered()
8829                    .render(order_layout[1], buf);
8830            }
8831
8832            // Shared footer
8833            let footer_chunks = Layout::default()
8834                .direction(Direction::Horizontal)
8835                .constraints([
8836                    Constraint::Percentage(33),
8837                    Constraint::Percentage(33),
8838                    Constraint::Percentage(34),
8839                ])
8840                .split(chunks[2]);
8841
8842            let mut apply_text_style = Style::default();
8843            let mut apply_border_style = Style::default();
8844            if self.sort_filter_modal.focus == SortFilterFocus::Apply {
8845                apply_text_style = apply_text_style.fg(active_c);
8846                apply_border_style = apply_border_style.fg(active_c);
8847            } else {
8848                apply_text_style = apply_text_style.fg(border_c);
8849                apply_border_style = apply_border_style.fg(border_c);
8850            }
8851            if self.sort_filter_modal.active_tab == SortFilterTab::Sort
8852                && self.sort_filter_modal.sort.has_unapplied_changes
8853            {
8854                apply_text_style = apply_text_style.add_modifier(Modifier::BOLD);
8855            }
8856
8857            Paragraph::new("Apply")
8858                .style(apply_text_style)
8859                .block(
8860                    Block::default()
8861                        .borders(Borders::ALL)
8862                        .border_type(BorderType::Rounded)
8863                        .border_style(apply_border_style),
8864                )
8865                .centered()
8866                .render(footer_chunks[0], buf);
8867
8868            let cancel_style = if self.sort_filter_modal.focus == SortFilterFocus::Cancel {
8869                Style::default().fg(active_c)
8870            } else {
8871                Style::default().fg(border_c)
8872            };
8873            Paragraph::new("Cancel")
8874                .block(
8875                    Block::default()
8876                        .borders(Borders::ALL)
8877                        .border_type(BorderType::Rounded)
8878                        .border_style(cancel_style),
8879                )
8880                .centered()
8881                .render(footer_chunks[1], buf);
8882
8883            let clear_style = if self.sort_filter_modal.focus == SortFilterFocus::Clear {
8884                Style::default().fg(active_c)
8885            } else {
8886                Style::default().fg(border_c)
8887            };
8888            Paragraph::new("Clear")
8889                .block(
8890                    Block::default()
8891                        .borders(Borders::ALL)
8892                        .border_type(BorderType::Rounded)
8893                        .border_style(clear_style),
8894                )
8895                .centered()
8896                .render(footer_chunks[2], buf);
8897        }
8898
8899        if self.template_modal.active {
8900            Clear.render(sort_area, buf);
8901            let modal_title = match self.template_modal.mode {
8902                TemplateModalMode::List => "Templates",
8903                TemplateModalMode::Create => "Create Template",
8904                TemplateModalMode::Edit => "Edit Template",
8905            };
8906            let block = Block::default()
8907                .borders(Borders::ALL)
8908                .border_type(BorderType::Rounded)
8909                .title(modal_title);
8910            let inner_area = block.inner(sort_area);
8911            block.render(sort_area, buf);
8912
8913            match self.template_modal.mode {
8914                TemplateModalMode::List => {
8915                    // List Mode: Show templates as a table with relevance scores
8916                    let chunks = Layout::default()
8917                        .direction(Direction::Vertical)
8918                        .constraints([
8919                            Constraint::Min(0),    // Template table
8920                            Constraint::Length(1), // Hints
8921                        ])
8922                        .split(inner_area);
8923
8924                    // Template Table
8925                    // Find max score for normalization
8926                    let max_score = self
8927                        .template_modal
8928                        .templates
8929                        .iter()
8930                        .map(|(_, score)| *score)
8931                        .fold(0.0, f64::max);
8932
8933                    // Calculate column widths
8934                    // Score column: 2 chars, Active column: 1 char, Name column: 20 chars, Description: remaining
8935                    let score_col_width = 2;
8936                    let active_col_width = 1;
8937                    let name_col_width = 20;
8938
8939                    let rows: Vec<Row> = self
8940                        .template_modal
8941                        .templates
8942                        .iter()
8943                        .map(|(template, score)| {
8944                            // Check if this template is active
8945                            let is_active = self
8946                                .active_template_id
8947                                .as_ref()
8948                                .map(|id| id == &template.id)
8949                                .unwrap_or(false);
8950
8951                            // Visual score indicator (circle with fill) - color foreground only
8952                            let score_ratio = if max_score > 0.0 {
8953                                score / max_score
8954                            } else {
8955                                0.0
8956                            };
8957                            let (circle_char, circle_color) = if score_ratio >= 0.8 {
8958                                // High scores: green, filled circles
8959                                if score_ratio >= 0.95 {
8960                                    ('●', self.color("success"))
8961                                } else if score_ratio >= 0.9 {
8962                                    ('◉', self.color("success"))
8963                                } else {
8964                                    ('◐', self.color("success"))
8965                                }
8966                            } else if score_ratio >= 0.4 {
8967                                // Medium scores: yellow
8968                                if score_ratio >= 0.7 {
8969                                    ('◐', self.color("warning"))
8970                                } else if score_ratio >= 0.55 {
8971                                    ('◑', self.color("warning"))
8972                                } else {
8973                                    ('○', self.color("warning"))
8974                                }
8975                            } else {
8976                                // Low scores: uncolored
8977                                if score_ratio >= 0.2 {
8978                                    ('○', self.color("text_primary"))
8979                                } else {
8980                                    ('○', self.color("dimmed"))
8981                                }
8982                            };
8983
8984                            // Score cell with colored circle (foreground only)
8985                            let score_cell = Cell::from(circle_char.to_string())
8986                                .style(Style::default().fg(circle_color));
8987
8988                            // Active indicator cell (checkmark)
8989                            let active_cell = if is_active {
8990                                Cell::from("✓")
8991                            } else {
8992                                Cell::from(" ")
8993                            };
8994
8995                            // Name cell
8996                            let name_cell = Cell::from(template.name.clone());
8997
8998                            // Description cell - get first line and truncate if needed
8999                            // Note: actual truncation will be handled by the table widget based on available space
9000                            let desc = template.description.as_deref().unwrap_or("");
9001                            let first_line = desc.lines().next().unwrap_or("");
9002                            let desc_display = first_line.to_string();
9003                            let desc_cell = Cell::from(desc_display);
9004
9005                            // Create row with cells (no highlighting)
9006                            Row::new(vec![score_cell, active_cell, name_cell, desc_cell])
9007                        })
9008                        .collect();
9009
9010                    // Header row
9011                    let header = Row::new(vec![
9012                        Cell::from("●").style(Style::default()),
9013                        Cell::from(" ").style(Style::default()), // Active column header (empty)
9014                        Cell::from("Name").style(Style::default()),
9015                        Cell::from("Description").style(Style::default()),
9016                    ])
9017                    .style(Style::default().add_modifier(Modifier::UNDERLINED));
9018
9019                    let table_border_style =
9020                        if self.template_modal.focus == TemplateFocus::TemplateList {
9021                            Style::default().fg(self.color("modal_border_active"))
9022                        } else {
9023                            Style::default()
9024                        };
9025
9026                    let table = Table::new(
9027                        rows,
9028                        [
9029                            Constraint::Length(score_col_width),
9030                            Constraint::Length(active_col_width),
9031                            Constraint::Length(name_col_width),
9032                            Constraint::Min(0), // Description takes remaining space
9033                        ],
9034                    )
9035                    .header(header)
9036                    .block(
9037                        Block::default()
9038                            .borders(Borders::ALL)
9039                            .border_type(BorderType::Rounded)
9040                            .title("Templates")
9041                            .border_style(table_border_style),
9042                    )
9043                    .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED));
9044
9045                    StatefulWidget::render(
9046                        table,
9047                        chunks[0],
9048                        buf,
9049                        &mut self.template_modal.table_state,
9050                    );
9051
9052                    // Keybind Hints - Single line
9053                    use ratatui::text::{Line, Span};
9054                    let mut hint_line = Line::default();
9055                    hint_line.spans.push(Span::styled(
9056                        "Enter",
9057                        Style::default()
9058                            .fg(self.color("keybind_hints"))
9059                            .add_modifier(Modifier::BOLD),
9060                    ));
9061                    hint_line.spans.push(Span::raw(" Apply "));
9062                    hint_line.spans.push(Span::styled(
9063                        "s",
9064                        Style::default()
9065                            .fg(self.color("keybind_hints"))
9066                            .add_modifier(Modifier::BOLD),
9067                    ));
9068                    hint_line.spans.push(Span::raw(" Create "));
9069                    hint_line.spans.push(Span::styled(
9070                        "e",
9071                        Style::default()
9072                            .fg(self.color("keybind_hints"))
9073                            .add_modifier(Modifier::BOLD),
9074                    ));
9075                    hint_line.spans.push(Span::raw(" Edit "));
9076                    hint_line.spans.push(Span::styled(
9077                        "d",
9078                        Style::default()
9079                            .fg(self.color("keybind_hints"))
9080                            .add_modifier(Modifier::BOLD),
9081                    ));
9082                    hint_line.spans.push(Span::raw(" Delete "));
9083                    hint_line.spans.push(Span::styled(
9084                        "Esc",
9085                        Style::default()
9086                            .fg(self.color("keybind_hints"))
9087                            .add_modifier(Modifier::BOLD),
9088                    ));
9089                    hint_line.spans.push(Span::raw(" Close"));
9090
9091                    Paragraph::new(vec![hint_line]).render(chunks[1], buf);
9092                }
9093                TemplateModalMode::Create | TemplateModalMode::Edit => {
9094                    // Create/Edit Mode: Multi-step dialog
9095                    let chunks = Layout::default()
9096                        .direction(Direction::Vertical)
9097                        .constraints([
9098                            Constraint::Length(3), // Name
9099                            Constraint::Length(6), // Description (taller for multi-line)
9100                            Constraint::Length(3), // Exact Path
9101                            Constraint::Length(3), // Relative Path
9102                            Constraint::Length(3), // Path Pattern
9103                            Constraint::Length(3), // Filename Pattern
9104                            Constraint::Length(3), // Schema Match
9105                            Constraint::Length(3), // Buttons
9106                        ])
9107                        .split(inner_area);
9108
9109                    // Name input
9110                    let name_style = if self.template_modal.create_focus == CreateFocus::Name {
9111                        Style::default().fg(self.color("modal_border_active"))
9112                    } else {
9113                        Style::default()
9114                    };
9115                    let name_title = if let Some(error) = &self.template_modal.name_error {
9116                        format!("Name {}", error)
9117                    } else {
9118                        "Name".to_string()
9119                    };
9120                    let name_block = Block::default()
9121                        .borders(Borders::ALL)
9122                        .border_type(BorderType::Rounded)
9123                        .title(name_title)
9124                        .title_style(if self.template_modal.name_error.is_some() {
9125                            Style::default().fg(self.color("error"))
9126                        } else {
9127                            Style::default().add_modifier(Modifier::BOLD)
9128                        })
9129                        .border_style(name_style);
9130                    let name_inner = name_block.inner(chunks[0]);
9131                    name_block.render(chunks[0], buf);
9132                    // Render name input using TextInput widget
9133                    let is_focused = self.template_modal.create_focus == CreateFocus::Name;
9134                    self.template_modal
9135                        .create_name_input
9136                        .set_focused(is_focused);
9137                    (&self.template_modal.create_name_input).render(name_inner, buf);
9138
9139                    // Description input (scrollable, multi-line)
9140                    let desc_style = if self.template_modal.create_focus == CreateFocus::Description
9141                    {
9142                        Style::default().fg(self.color("modal_border_active"))
9143                    } else {
9144                        Style::default()
9145                    };
9146                    let desc_block = Block::default()
9147                        .borders(Borders::ALL)
9148                        .border_type(BorderType::Rounded)
9149                        .title("Description")
9150                        .border_style(desc_style);
9151                    let desc_inner = desc_block.inner(chunks[1]);
9152                    desc_block.render(chunks[1], buf);
9153
9154                    // Render description input using MultiLineTextInput widget
9155                    let is_focused = self.template_modal.create_focus == CreateFocus::Description;
9156                    self.template_modal
9157                        .create_description_input
9158                        .set_focused(is_focused);
9159                    // Auto-scroll to keep cursor visible
9160                    self.template_modal
9161                        .create_description_input
9162                        .ensure_cursor_visible(desc_inner.height, desc_inner.width);
9163                    (&self.template_modal.create_description_input).render(desc_inner, buf);
9164
9165                    // Exact Path
9166                    let exact_path_style =
9167                        if self.template_modal.create_focus == CreateFocus::ExactPath {
9168                            Style::default().fg(self.color("modal_border_active"))
9169                        } else {
9170                            Style::default()
9171                        };
9172                    let exact_path_block = Block::default()
9173                        .borders(Borders::ALL)
9174                        .border_type(BorderType::Rounded)
9175                        .title("Exact Path")
9176                        .border_style(exact_path_style);
9177                    let exact_path_inner = exact_path_block.inner(chunks[2]);
9178                    exact_path_block.render(chunks[2], buf);
9179                    // Render exact path input using TextInput widget
9180                    let is_focused = self.template_modal.create_focus == CreateFocus::ExactPath;
9181                    self.template_modal
9182                        .create_exact_path_input
9183                        .set_focused(is_focused);
9184                    (&self.template_modal.create_exact_path_input).render(exact_path_inner, buf);
9185
9186                    // Relative Path
9187                    let relative_path_style =
9188                        if self.template_modal.create_focus == CreateFocus::RelativePath {
9189                            Style::default().fg(self.color("modal_border_active"))
9190                        } else {
9191                            Style::default()
9192                        };
9193                    let relative_path_block = Block::default()
9194                        .borders(Borders::ALL)
9195                        .border_type(BorderType::Rounded)
9196                        .title("Relative Path")
9197                        .border_style(relative_path_style);
9198                    let relative_path_inner = relative_path_block.inner(chunks[3]);
9199                    relative_path_block.render(chunks[3], buf);
9200                    // Render relative path input using TextInput widget
9201                    let is_focused = self.template_modal.create_focus == CreateFocus::RelativePath;
9202                    self.template_modal
9203                        .create_relative_path_input
9204                        .set_focused(is_focused);
9205                    (&self.template_modal.create_relative_path_input)
9206                        .render(relative_path_inner, buf);
9207
9208                    // Path Pattern
9209                    let path_pattern_style =
9210                        if self.template_modal.create_focus == CreateFocus::PathPattern {
9211                            Style::default().fg(self.color("modal_border_active"))
9212                        } else {
9213                            Style::default()
9214                        };
9215                    let path_pattern_block = Block::default()
9216                        .borders(Borders::ALL)
9217                        .border_type(BorderType::Rounded)
9218                        .title("Path Pattern")
9219                        .border_style(path_pattern_style);
9220                    let path_pattern_inner = path_pattern_block.inner(chunks[4]);
9221                    path_pattern_block.render(chunks[4], buf);
9222                    // Render path pattern input using TextInput widget
9223                    let is_focused = self.template_modal.create_focus == CreateFocus::PathPattern;
9224                    self.template_modal
9225                        .create_path_pattern_input
9226                        .set_focused(is_focused);
9227                    (&self.template_modal.create_path_pattern_input)
9228                        .render(path_pattern_inner, buf);
9229
9230                    // Filename Pattern
9231                    let filename_pattern_style =
9232                        if self.template_modal.create_focus == CreateFocus::FilenamePattern {
9233                            Style::default().fg(self.color("modal_border_active"))
9234                        } else {
9235                            Style::default()
9236                        };
9237                    let filename_pattern_block = Block::default()
9238                        .borders(Borders::ALL)
9239                        .border_type(BorderType::Rounded)
9240                        .title("Filename Pattern")
9241                        .border_style(filename_pattern_style);
9242                    let filename_pattern_inner = filename_pattern_block.inner(chunks[5]);
9243                    filename_pattern_block.render(chunks[5], buf);
9244                    // Render filename pattern input using TextInput widget
9245                    let is_focused =
9246                        self.template_modal.create_focus == CreateFocus::FilenamePattern;
9247                    self.template_modal
9248                        .create_filename_pattern_input
9249                        .set_focused(is_focused);
9250                    (&self.template_modal.create_filename_pattern_input)
9251                        .render(filename_pattern_inner, buf);
9252
9253                    // Schema Match
9254                    let schema_style =
9255                        if self.template_modal.create_focus == CreateFocus::SchemaMatch {
9256                            Style::default().fg(self.color("modal_border_active"))
9257                        } else {
9258                            Style::default()
9259                        };
9260                    let schema_text = if self.template_modal.create_schema_match_enabled {
9261                        "Enabled"
9262                    } else {
9263                        "Disabled"
9264                    };
9265                    Paragraph::new(schema_text)
9266                        .block(
9267                            Block::default()
9268                                .borders(Borders::ALL)
9269                                .border_type(BorderType::Rounded)
9270                                .title("Schema Match")
9271                                .border_style(schema_style),
9272                        )
9273                        .render(chunks[6], buf);
9274
9275                    // Buttons
9276                    let btn_layout = Layout::default()
9277                        .direction(Direction::Horizontal)
9278                        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
9279                        .split(chunks[7]);
9280
9281                    let save_style = if self.template_modal.create_focus == CreateFocus::SaveButton
9282                    {
9283                        Style::default().fg(self.color("modal_border_active"))
9284                    } else {
9285                        Style::default()
9286                    };
9287                    Paragraph::new("Save")
9288                        .block(
9289                            Block::default()
9290                                .borders(Borders::ALL)
9291                                .border_type(BorderType::Rounded)
9292                                .border_style(save_style),
9293                        )
9294                        .centered()
9295                        .render(btn_layout[0], buf);
9296
9297                    let cancel_create_style =
9298                        if self.template_modal.create_focus == CreateFocus::CancelButton {
9299                            Style::default().fg(self.color("modal_border_active"))
9300                        } else {
9301                            Style::default()
9302                        };
9303                    Paragraph::new("Cancel")
9304                        .block(
9305                            Block::default()
9306                                .borders(Borders::ALL)
9307                                .border_type(BorderType::Rounded)
9308                                .border_style(cancel_create_style),
9309                        )
9310                        .centered()
9311                        .render(btn_layout[1], buf);
9312                }
9313            }
9314
9315            // Delete Confirmation Dialog
9316            if self.template_modal.delete_confirm {
9317                if let Some(template) = self.template_modal.selected_template() {
9318                    let confirm_area = centered_rect(sort_area, 50, 20);
9319                    Clear.render(confirm_area, buf);
9320                    let block = Block::default()
9321                        .borders(Borders::ALL)
9322                        .border_type(BorderType::Rounded)
9323                        .title("Delete Template");
9324                    let inner_area = block.inner(confirm_area);
9325                    block.render(confirm_area, buf);
9326
9327                    let chunks = Layout::default()
9328                        .direction(Direction::Vertical)
9329                        .constraints([
9330                            Constraint::Min(0),    // Message
9331                            Constraint::Length(3), // Buttons
9332                        ])
9333                        .split(inner_area);
9334
9335                    let message = format!(
9336                        "Are you sure you want to delete the template \"{}\"?\n\nThis action cannot be undone.",
9337                        template.name
9338                    );
9339                    Paragraph::new(message)
9340                        .wrap(ratatui::widgets::Wrap { trim: false })
9341                        .render(chunks[0], buf);
9342
9343                    let btn_layout = Layout::default()
9344                        .direction(Direction::Horizontal)
9345                        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
9346                        .split(chunks[1]);
9347
9348                    // Delete button - highlight "D" in blue
9349                    use ratatui::text::{Line, Span};
9350                    let mut delete_line = Line::default();
9351                    delete_line.spans.push(Span::styled(
9352                        "D",
9353                        Style::default()
9354                            .fg(self.color("keybind_hints"))
9355                            .add_modifier(Modifier::BOLD),
9356                    ));
9357                    delete_line.spans.push(Span::raw("elete"));
9358
9359                    let delete_style = if self.template_modal.delete_confirm_focus {
9360                        Style::default().fg(self.color("modal_border_active"))
9361                    } else {
9362                        Style::default()
9363                    };
9364                    Paragraph::new(vec![delete_line])
9365                        .block(
9366                            Block::default()
9367                                .borders(Borders::ALL)
9368                                .border_type(BorderType::Rounded)
9369                                .border_style(delete_style),
9370                        )
9371                        .centered()
9372                        .render(btn_layout[0], buf);
9373
9374                    // Cancel button (default selected)
9375                    let cancel_style = if !self.template_modal.delete_confirm_focus {
9376                        Style::default().fg(self.color("modal_border_active"))
9377                    } else {
9378                        Style::default()
9379                    };
9380                    Paragraph::new("Cancel")
9381                        .block(
9382                            Block::default()
9383                                .borders(Borders::ALL)
9384                                .border_type(BorderType::Rounded)
9385                                .border_style(cancel_style),
9386                        )
9387                        .centered()
9388                        .render(btn_layout[1], buf);
9389                }
9390            }
9391
9392            // Score Details Dialog
9393            if self.template_modal.show_score_details {
9394                if let Some((template, score)) = self
9395                    .template_modal
9396                    .table_state
9397                    .selected()
9398                    .and_then(|idx| self.template_modal.templates.get(idx))
9399                {
9400                    if let Some(ref state) = self.data_table_state {
9401                        if let Some(ref path) = self.path {
9402                            let details_area = centered_rect(sort_area, 60, 50);
9403                            Clear.render(details_area, buf);
9404                            let block = Block::default()
9405                                .borders(Borders::ALL)
9406                                .border_type(BorderType::Rounded)
9407                                .title(format!("Score Details: {}", template.name));
9408                            let inner_area = block.inner(details_area);
9409                            block.render(details_area, buf);
9410
9411                            // Calculate score components
9412                            let exact_path_match = template
9413                                .match_criteria
9414                                .exact_path
9415                                .as_ref()
9416                                .map(|exact| exact == path)
9417                                .unwrap_or(false);
9418
9419                            let relative_path_match = if let Some(relative_path) =
9420                                &template.match_criteria.relative_path
9421                            {
9422                                if let Ok(cwd) = std::env::current_dir() {
9423                                    if let Ok(rel_path) = path.strip_prefix(&cwd) {
9424                                        rel_path.to_string_lossy() == *relative_path
9425                                    } else {
9426                                        false
9427                                    }
9428                                } else {
9429                                    false
9430                                }
9431                            } else {
9432                                false
9433                            };
9434
9435                            let exact_schema_match = if let Some(required_cols) =
9436                                &template.match_criteria.schema_columns
9437                            {
9438                                let file_cols: std::collections::HashSet<&str> =
9439                                    state.schema.iter_names().map(|s| s.as_str()).collect();
9440                                let required_cols_set: std::collections::HashSet<&str> =
9441                                    required_cols.iter().map(|s| s.as_str()).collect();
9442                                required_cols_set.is_subset(&file_cols)
9443                                    && file_cols.len() == required_cols_set.len()
9444                            } else {
9445                                false
9446                            };
9447
9448                            // Build score details text
9449                            let mut details = format!("Total Score: {:.1}\n\n", score);
9450
9451                            if exact_path_match && exact_schema_match {
9452                                details.push_str("Exact Path + Exact Schema: 2000.0\n");
9453                            } else if exact_path_match {
9454                                details.push_str("Exact Path: 1000.0\n");
9455                            } else if relative_path_match && exact_schema_match {
9456                                details.push_str("Relative Path + Exact Schema: 1950.0\n");
9457                            } else if relative_path_match {
9458                                details.push_str("Relative Path: 950.0\n");
9459                            } else if exact_schema_match {
9460                                details.push_str("Exact Schema: 900.0\n");
9461                            } else {
9462                                // For non-exact matches, show component breakdown
9463                                if let Some(pattern) = &template.match_criteria.path_pattern {
9464                                    if path
9465                                        .to_str()
9466                                        .map(|p| p.contains(pattern.trim_end_matches("/*")))
9467                                        .unwrap_or(false)
9468                                    {
9469                                        details.push_str("Path Pattern Match: 50.0+\n");
9470                                    }
9471                                }
9472                                if let Some(pattern) = &template.match_criteria.filename_pattern {
9473                                    if path
9474                                        .file_name()
9475                                        .and_then(|f| f.to_str())
9476                                        .map(|f| {
9477                                            f.contains(pattern.trim_end_matches("*"))
9478                                                || pattern == "*"
9479                                        })
9480                                        .unwrap_or(false)
9481                                    {
9482                                        details.push_str("Filename Pattern Match: 30.0+\n");
9483                                    }
9484                                }
9485                                if let Some(required_cols) = &template.match_criteria.schema_columns
9486                                {
9487                                    let file_cols: std::collections::HashSet<&str> =
9488                                        state.schema.iter_names().map(|s| s.as_str()).collect();
9489                                    let matching_count = required_cols
9490                                        .iter()
9491                                        .filter(|col| file_cols.contains(col.as_str()))
9492                                        .count();
9493                                    if matching_count > 0 {
9494                                        details.push_str(&format!(
9495                                            "Partial Schema Match: {:.1} ({} columns)\n",
9496                                            matching_count as f64 * 2.0,
9497                                            matching_count
9498                                        ));
9499                                    }
9500                                }
9501                            }
9502
9503                            if template.usage_count > 0 {
9504                                details.push_str(&format!(
9505                                    "Usage Count: {:.1}\n",
9506                                    (template.usage_count.min(10) as f64) * 1.0
9507                                ));
9508                            }
9509                            if let Some(last_used) = template.last_used {
9510                                if let Ok(duration) =
9511                                    std::time::SystemTime::now().duration_since(last_used)
9512                                {
9513                                    let days_since = duration.as_secs() / 86400;
9514                                    if days_since <= 7 {
9515                                        details.push_str("Recent Usage: 5.0\n");
9516                                    } else if days_since <= 30 {
9517                                        details.push_str("Recent Usage: 2.0\n");
9518                                    }
9519                                }
9520                            }
9521                            if let Ok(duration) =
9522                                std::time::SystemTime::now().duration_since(template.created)
9523                            {
9524                                let months_old = (duration.as_secs() / (30 * 86400)) as f64;
9525                                if months_old > 0.0 {
9526                                    details.push_str(&format!(
9527                                        "Age Penalty: -{:.1}\n",
9528                                        months_old * 1.0
9529                                    ));
9530                                }
9531                            }
9532
9533                            Paragraph::new(details)
9534                                .wrap(ratatui::widgets::Wrap { trim: false })
9535                                .render(inner_area, buf);
9536                        }
9537                    }
9538                }
9539            }
9540        }
9541
9542        if self.pivot_melt_modal.active {
9543            let border = self.color("modal_border");
9544            let active = self.color("modal_border_active");
9545            let text_primary = self.color("text_primary");
9546            let text_inverse = self.color("text_inverse");
9547            pivot_melt::render_shell(
9548                sort_area,
9549                buf,
9550                &mut self.pivot_melt_modal,
9551                border,
9552                active,
9553                text_primary,
9554                text_inverse,
9555            );
9556        }
9557
9558        if self.export_modal.active {
9559            let border = self.color("modal_border");
9560            let active = self.color("modal_border_active");
9561            let text_primary = self.color("text_primary");
9562            let text_inverse = self.color("text_inverse");
9563            // Center the modal
9564            let modal_width = (area.width * 3 / 4).min(80);
9565            let modal_height = 20;
9566            let modal_x = (area.width.saturating_sub(modal_width)) / 2;
9567            let modal_y = (area.height.saturating_sub(modal_height)) / 2;
9568            let modal_area = Rect {
9569                x: modal_x,
9570                y: modal_y,
9571                width: modal_width,
9572                height: modal_height,
9573            };
9574            export::render_export_modal(
9575                modal_area,
9576                buf,
9577                &mut self.export_modal,
9578                border,
9579                active,
9580                text_primary,
9581                text_inverse,
9582            );
9583        }
9584
9585        // Render analysis modal (full screen in main area, leaving toolbar visible)
9586        if self.analysis_modal.active {
9587            // Use main_area so toolbar remains visible at bottom
9588            let analysis_area = main_area;
9589
9590            // Progress overlay when chunked describe is running
9591            if let Some(ref progress) = self.analysis_modal.computing {
9592                let border = self.color("modal_border");
9593                let text_primary = self.color("text_primary");
9594                let label = self.color("label");
9595                let percent = if progress.total > 0 {
9596                    (progress.current as u16).saturating_mul(100) / progress.total as u16
9597                } else {
9598                    0
9599                };
9600                Clear.render(analysis_area, buf);
9601                let block = Block::default()
9602                    .borders(Borders::ALL)
9603                    .border_type(BorderType::Rounded)
9604                    .border_style(Style::default().fg(border))
9605                    .title(" Analysis ");
9606                let inner = block.inner(analysis_area);
9607                block.render(analysis_area, buf);
9608                let text = format!(
9609                    "{}: {} / {}",
9610                    progress.phase, progress.current, progress.total
9611                );
9612                Paragraph::new(text)
9613                    .style(Style::default().fg(text_primary))
9614                    .render(
9615                        Rect {
9616                            x: inner.x,
9617                            y: inner.y,
9618                            width: inner.width,
9619                            height: 1,
9620                        },
9621                        buf,
9622                    );
9623                Gauge::default()
9624                    .gauge_style(Style::default().fg(label))
9625                    .ratio(percent as f64 / 100.0)
9626                    .render(
9627                        Rect {
9628                            x: inner.x,
9629                            y: inner.y + 1,
9630                            width: inner.width,
9631                            height: 1,
9632                        },
9633                        buf,
9634                    );
9635            } else if let Some(state) = &self.data_table_state {
9636                // Per-tool sync fallback: when the selected tool has no cached results or seed changed, compute for that tool only.
9637                let seed = self.analysis_modal.random_seed;
9638                let needs_describe = self.analysis_modal.selected_tool
9639                    == Some(analysis_modal::AnalysisTool::Describe)
9640                    && (self.analysis_modal.describe_results.is_none()
9641                        || self
9642                            .analysis_modal
9643                            .describe_results
9644                            .as_ref()
9645                            .is_some_and(|r| r.sample_seed != seed));
9646                let needs_distribution = self.analysis_modal.selected_tool
9647                    == Some(analysis_modal::AnalysisTool::DistributionAnalysis)
9648                    && (self.analysis_modal.distribution_results.is_none()
9649                        || self
9650                            .analysis_modal
9651                            .distribution_results
9652                            .as_ref()
9653                            .is_some_and(|r| r.sample_seed != seed));
9654                let needs_correlation = self.analysis_modal.selected_tool
9655                    == Some(analysis_modal::AnalysisTool::CorrelationMatrix)
9656                    && (self.analysis_modal.correlation_results.is_none()
9657                        || self
9658                            .analysis_modal
9659                            .correlation_results
9660                            .as_ref()
9661                            .is_some_and(|r| r.sample_seed != seed));
9662
9663                if needs_describe {
9664                    self.busy = true;
9665                    let lf = state.lf.clone();
9666                    let options = crate::statistics::ComputeOptions {
9667                        include_distribution_info: false,
9668                        include_distribution_analyses: false,
9669                        include_correlation_matrix: false,
9670                        include_skewness_kurtosis_outliers: false,
9671                        polars_streaming: self.app_config.performance.polars_streaming,
9672                    };
9673                    match crate::statistics::compute_statistics_with_options(
9674                        &lf,
9675                        self.sampling_threshold,
9676                        seed,
9677                        options,
9678                    ) {
9679                        Ok(results) => {
9680                            self.analysis_modal.describe_results = Some(results);
9681                        }
9682                        Err(e) => {
9683                            Clear.render(analysis_area, buf);
9684                            let error_msg = format!(
9685                                "Error computing statistics: {}",
9686                                crate::error_display::user_message_from_report(&e, None)
9687                            );
9688                            Paragraph::new(error_msg)
9689                                .centered()
9690                                .style(Style::default().fg(self.color("error")))
9691                                .render(analysis_area, buf);
9692                        }
9693                    }
9694                    self.busy = false;
9695                    self.drain_keys_on_next_loop = true;
9696                } else if needs_distribution {
9697                    self.busy = true;
9698                    let lf = state.lf.clone();
9699                    let options = crate::statistics::ComputeOptions {
9700                        include_distribution_info: true,
9701                        include_distribution_analyses: true,
9702                        include_correlation_matrix: false,
9703                        include_skewness_kurtosis_outliers: true,
9704                        polars_streaming: self.app_config.performance.polars_streaming,
9705                    };
9706                    match crate::statistics::compute_statistics_with_options(
9707                        &lf,
9708                        self.sampling_threshold,
9709                        seed,
9710                        options,
9711                    ) {
9712                        Ok(results) => {
9713                            self.analysis_modal.distribution_results = Some(results);
9714                        }
9715                        Err(e) => {
9716                            Clear.render(analysis_area, buf);
9717                            let error_msg = format!(
9718                                "Error computing distribution: {}",
9719                                crate::error_display::user_message_from_report(&e, None)
9720                            );
9721                            Paragraph::new(error_msg)
9722                                .centered()
9723                                .style(Style::default().fg(self.color("error")))
9724                                .render(analysis_area, buf);
9725                        }
9726                    }
9727                    self.busy = false;
9728                    self.drain_keys_on_next_loop = true;
9729                } else if needs_correlation {
9730                    self.busy = true;
9731                    if let Ok(df) =
9732                        crate::statistics::collect_lazy(state.lf.clone(), state.polars_streaming)
9733                    {
9734                        if let Ok(matrix) = crate::statistics::compute_correlation_matrix(&df) {
9735                            self.analysis_modal.correlation_results =
9736                                Some(crate::statistics::AnalysisResults {
9737                                    column_statistics: vec![],
9738                                    total_rows: df.height(),
9739                                    sample_size: None,
9740                                    sample_seed: seed,
9741                                    correlation_matrix: Some(matrix),
9742                                    distribution_analyses: vec![],
9743                                });
9744                        }
9745                    }
9746                    self.busy = false;
9747                    self.drain_keys_on_next_loop = true;
9748                }
9749
9750                // Always render the analysis widget when we have data (with or without results: widget shows
9751                // "Select an analysis tool", "Computing...", or the selected tool content).
9752                let context = state.get_analysis_context();
9753                Clear.render(analysis_area, buf);
9754                let column_offset = match self.analysis_modal.selected_tool {
9755                    Some(analysis_modal::AnalysisTool::Describe) => {
9756                        self.analysis_modal.describe_column_offset
9757                    }
9758                    Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
9759                        self.analysis_modal.distribution_column_offset
9760                    }
9761                    Some(analysis_modal::AnalysisTool::CorrelationMatrix) => {
9762                        self.analysis_modal.correlation_column_offset
9763                    }
9764                    None => 0,
9765                };
9766
9767                // 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).
9768                let results_for_widget = self.analysis_modal.current_results().cloned();
9769                let config = widgets::analysis::AnalysisWidgetConfig {
9770                    state,
9771                    results: results_for_widget.as_ref(),
9772                    context: &context,
9773                    view: self.analysis_modal.view,
9774                    selected_tool: self.analysis_modal.selected_tool,
9775                    column_offset,
9776                    selected_correlation: self.analysis_modal.selected_correlation,
9777                    focus: self.analysis_modal.focus,
9778                    selected_theoretical_distribution: self
9779                        .analysis_modal
9780                        .selected_theoretical_distribution,
9781                    histogram_scale: self.analysis_modal.histogram_scale,
9782                    theme: &self.theme,
9783                    table_cell_padding: self.table_cell_padding,
9784                };
9785                let widget = widgets::analysis::AnalysisWidget::new(
9786                    config,
9787                    &mut self.analysis_modal.table_state,
9788                    &mut self.analysis_modal.distribution_table_state,
9789                    &mut self.analysis_modal.correlation_table_state,
9790                    &mut self.analysis_modal.sidebar_state,
9791                    &mut self.analysis_modal.distribution_selector_state,
9792                );
9793                widget.render(analysis_area, buf);
9794            } else {
9795                // No data available
9796                Clear.render(analysis_area, buf);
9797                Paragraph::new("No data available for analysis")
9798                    .centered()
9799                    .style(Style::default().fg(self.color("warning")))
9800                    .render(analysis_area, buf);
9801            }
9802            // Don't return - continue to render toolbar and other UI elements
9803        }
9804
9805        // Render chart view (full screen in main area)
9806        if self.input_mode == InputMode::Chart {
9807            let chart_area = main_area;
9808            Clear.render(chart_area, buf);
9809            let mut xy_series: Option<&Vec<Vec<(f64, f64)>>> = None;
9810            let mut x_axis_kind = chart_data::XAxisTemporalKind::Numeric;
9811            let mut x_bounds: Option<(f64, f64)> = None;
9812            let mut hist_data: Option<&chart_data::HistogramData> = None;
9813            let mut box_data: Option<&chart_data::BoxPlotData> = None;
9814            let mut kde_data: Option<&chart_data::KdeData> = None;
9815            let mut heatmap_data: Option<&chart_data::HeatmapData> = None;
9816
9817            let row_limit_opt = self.chart_modal.row_limit;
9818            let row_limit = self.chart_modal.effective_row_limit();
9819            match self.chart_modal.chart_kind {
9820                ChartKind::XY => {
9821                    if let Some(x_column) = self.chart_modal.effective_x_column() {
9822                        let x_key = x_column.to_string();
9823                        let y_columns = self.chart_modal.effective_y_columns();
9824                        if !y_columns.is_empty() {
9825                            let use_cache = self.chart_cache.xy.as_ref().filter(|c| {
9826                                c.x_column == x_key
9827                                    && c.y_columns == y_columns
9828                                    && c.row_limit == row_limit_opt
9829                            });
9830                            if use_cache.is_none() {
9831                                if let Some(state) = self.data_table_state.as_ref() {
9832                                    if let Ok(result) = chart_data::prepare_chart_data(
9833                                        &state.lf,
9834                                        &state.schema,
9835                                        x_column,
9836                                        &y_columns,
9837                                        row_limit,
9838                                    ) {
9839                                        self.chart_cache.xy = Some(ChartCacheXY {
9840                                            x_column: x_key.clone(),
9841                                            y_columns: y_columns.clone(),
9842                                            row_limit: row_limit_opt,
9843                                            series: result.series,
9844                                            series_log: None,
9845                                            x_axis_kind: result.x_axis_kind,
9846                                        });
9847                                    }
9848                                }
9849                            }
9850                            if self.chart_modal.log_scale {
9851                                if let Some(cache) = self.chart_cache.xy.as_mut() {
9852                                    if cache.x_column == x_key
9853                                        && cache.y_columns == y_columns
9854                                        && cache.row_limit == row_limit_opt
9855                                        && cache.series_log.is_none()
9856                                        && cache.series.iter().any(|s| !s.is_empty())
9857                                    {
9858                                        cache.series_log = Some(
9859                                            cache
9860                                                .series
9861                                                .iter()
9862                                                .map(|pts| {
9863                                                    pts.iter()
9864                                                        .map(|&(x, y)| (x, y.max(0.0).ln_1p()))
9865                                                        .collect()
9866                                                })
9867                                                .collect(),
9868                                        );
9869                                    }
9870                                }
9871                            }
9872                            if let Some(cache) = self.chart_cache.xy.as_ref() {
9873                                if cache.x_column == x_key
9874                                    && cache.y_columns == y_columns
9875                                    && cache.row_limit == row_limit_opt
9876                                {
9877                                    x_axis_kind = cache.x_axis_kind;
9878                                    if self.chart_modal.log_scale {
9879                                        if let Some(ref log) = cache.series_log {
9880                                            if log.iter().any(|v| !v.is_empty()) {
9881                                                xy_series = Some(log);
9882                                            }
9883                                        }
9884                                    } else if cache.series.iter().any(|s| !s.is_empty()) {
9885                                        xy_series = Some(&cache.series);
9886                                    }
9887                                }
9888                            }
9889                        } else {
9890                            // Only X selected: cache x range for axis bounds
9891                            let use_cache =
9892                                self.chart_cache.x_range.as_ref().filter(|c| {
9893                                    c.x_column == x_key && c.row_limit == row_limit_opt
9894                                });
9895                            if use_cache.is_none() {
9896                                if let Some(state) = self.data_table_state.as_ref() {
9897                                    if let Ok(result) = chart_data::prepare_chart_x_range(
9898                                        &state.lf,
9899                                        &state.schema,
9900                                        x_column,
9901                                        row_limit,
9902                                    ) {
9903                                        self.chart_cache.x_range = Some(ChartCacheXRange {
9904                                            x_column: x_key.clone(),
9905                                            row_limit: row_limit_opt,
9906                                            x_min: result.x_min,
9907                                            x_max: result.x_max,
9908                                            x_axis_kind: result.x_axis_kind,
9909                                        });
9910                                    }
9911                                }
9912                            }
9913                            if let Some(cache) = self.chart_cache.x_range.as_ref() {
9914                                if cache.x_column == x_key && cache.row_limit == row_limit_opt {
9915                                    x_axis_kind = cache.x_axis_kind;
9916                                    x_bounds = Some((cache.x_min, cache.x_max));
9917                                }
9918                            } else if let Some(state) = self.data_table_state.as_ref() {
9919                                x_axis_kind = chart_data::x_axis_temporal_kind_for_column(
9920                                    &state.schema,
9921                                    x_column,
9922                                );
9923                            }
9924                        }
9925                    }
9926                }
9927                ChartKind::Histogram => {
9928                    if let (Some(state), Some(column)) = (
9929                        self.data_table_state.as_ref(),
9930                        self.chart_modal.effective_hist_column(),
9931                    ) {
9932                        let bins = self.chart_modal.hist_bins;
9933                        let use_cache = self.chart_cache.histogram.as_ref().filter(|c| {
9934                            c.column == column && c.bins == bins && c.row_limit == row_limit_opt
9935                        });
9936                        if use_cache.is_none() {
9937                            if let Ok(data) = chart_data::prepare_histogram_data(
9938                                &state.lf, &column, bins, row_limit,
9939                            ) {
9940                                self.chart_cache.histogram = Some(ChartCacheHistogram {
9941                                    column: column.clone(),
9942                                    bins,
9943                                    row_limit: row_limit_opt,
9944                                    data,
9945                                });
9946                            }
9947                        }
9948                        hist_data = self
9949                            .chart_cache
9950                            .histogram
9951                            .as_ref()
9952                            .filter(|c| {
9953                                c.column == column && c.bins == bins && c.row_limit == row_limit_opt
9954                            })
9955                            .map(|c| &c.data);
9956                    }
9957                }
9958                ChartKind::BoxPlot => {
9959                    if let (Some(state), Some(column)) = (
9960                        self.data_table_state.as_ref(),
9961                        self.chart_modal.effective_box_column(),
9962                    ) {
9963                        let use_cache = self
9964                            .chart_cache
9965                            .box_plot
9966                            .as_ref()
9967                            .filter(|c| c.column == column && c.row_limit == row_limit_opt);
9968                        if use_cache.is_none() {
9969                            if let Ok(data) = chart_data::prepare_box_plot_data(
9970                                &state.lf,
9971                                std::slice::from_ref(&column),
9972                                row_limit,
9973                            ) {
9974                                self.chart_cache.box_plot = Some(ChartCacheBoxPlot {
9975                                    column: column.clone(),
9976                                    row_limit: row_limit_opt,
9977                                    data,
9978                                });
9979                            }
9980                        }
9981                        box_data = self
9982                            .chart_cache
9983                            .box_plot
9984                            .as_ref()
9985                            .filter(|c| c.column == column && c.row_limit == row_limit_opt)
9986                            .map(|c| &c.data);
9987                    }
9988                }
9989                ChartKind::Kde => {
9990                    if let (Some(state), Some(column)) = (
9991                        self.data_table_state.as_ref(),
9992                        self.chart_modal.effective_kde_column(),
9993                    ) {
9994                        let bandwidth = self.chart_modal.kde_bandwidth_factor;
9995                        let use_cache = self.chart_cache.kde.as_ref().filter(|c| {
9996                            c.column == column
9997                                && c.bandwidth_factor == bandwidth
9998                                && c.row_limit == row_limit_opt
9999                        });
10000                        if use_cache.is_none() {
10001                            if let Ok(data) = chart_data::prepare_kde_data(
10002                                &state.lf,
10003                                std::slice::from_ref(&column),
10004                                bandwidth,
10005                                row_limit,
10006                            ) {
10007                                self.chart_cache.kde = Some(ChartCacheKde {
10008                                    column: column.clone(),
10009                                    bandwidth_factor: bandwidth,
10010                                    row_limit: row_limit_opt,
10011                                    data,
10012                                });
10013                            }
10014                        }
10015                        kde_data = self
10016                            .chart_cache
10017                            .kde
10018                            .as_ref()
10019                            .filter(|c| {
10020                                c.column == column
10021                                    && c.bandwidth_factor == bandwidth
10022                                    && c.row_limit == row_limit_opt
10023                            })
10024                            .map(|c| &c.data);
10025                    }
10026                }
10027                ChartKind::Heatmap => {
10028                    if let (Some(state), Some(x_column), Some(y_column)) = (
10029                        self.data_table_state.as_ref(),
10030                        self.chart_modal.effective_heatmap_x_column(),
10031                        self.chart_modal.effective_heatmap_y_column(),
10032                    ) {
10033                        let bins = self.chart_modal.heatmap_bins;
10034                        let use_cache = self.chart_cache.heatmap.as_ref().filter(|c| {
10035                            c.x_column == x_column
10036                                && c.y_column == y_column
10037                                && c.bins == bins
10038                                && c.row_limit == row_limit_opt
10039                        });
10040                        if use_cache.is_none() {
10041                            if let Ok(data) = chart_data::prepare_heatmap_data(
10042                                &state.lf, &x_column, &y_column, bins, row_limit,
10043                            ) {
10044                                self.chart_cache.heatmap = Some(ChartCacheHeatmap {
10045                                    x_column: x_column.clone(),
10046                                    y_column: y_column.clone(),
10047                                    bins,
10048                                    row_limit: row_limit_opt,
10049                                    data,
10050                                });
10051                            }
10052                        }
10053                        heatmap_data = self
10054                            .chart_cache
10055                            .heatmap
10056                            .as_ref()
10057                            .filter(|c| {
10058                                c.x_column == x_column
10059                                    && c.y_column == y_column
10060                                    && c.bins == bins
10061                                    && c.row_limit == row_limit_opt
10062                            })
10063                            .map(|c| &c.data);
10064                    }
10065                }
10066            }
10067
10068            let render_data = match self.chart_modal.chart_kind {
10069                ChartKind::XY => widgets::chart::ChartRenderData::XY {
10070                    series: xy_series,
10071                    x_axis_kind,
10072                    x_bounds,
10073                },
10074                ChartKind::Histogram => {
10075                    widgets::chart::ChartRenderData::Histogram { data: hist_data }
10076                }
10077                ChartKind::BoxPlot => widgets::chart::ChartRenderData::BoxPlot { data: box_data },
10078                ChartKind::Kde => widgets::chart::ChartRenderData::Kde { data: kde_data },
10079                ChartKind::Heatmap => {
10080                    widgets::chart::ChartRenderData::Heatmap { data: heatmap_data }
10081                }
10082            };
10083
10084            widgets::chart::render_chart_view(
10085                chart_area,
10086                buf,
10087                &mut self.chart_modal,
10088                &self.theme,
10089                render_data,
10090            );
10091
10092            if self.chart_export_modal.active {
10093                let border = self.color("modal_border");
10094                let active = self.color("modal_border_active");
10095                // 4 rows (format, title, path, buttons) of 3 lines each + 2 for outer border = 14
10096                const CHART_EXPORT_MODAL_HEIGHT: u16 = 14;
10097                let modal_width = (chart_area.width * 3 / 4).clamp(40, 54);
10098                let modal_height = CHART_EXPORT_MODAL_HEIGHT
10099                    .min(chart_area.height)
10100                    .max(CHART_EXPORT_MODAL_HEIGHT);
10101                let modal_x = chart_area.x + chart_area.width.saturating_sub(modal_width) / 2;
10102                let modal_y = chart_area.y + chart_area.height.saturating_sub(modal_height) / 2;
10103                let modal_area = Rect {
10104                    x: modal_x,
10105                    y: modal_y,
10106                    width: modal_width,
10107                    height: modal_height,
10108                };
10109                widgets::chart_export_modal::render_chart_export_modal(
10110                    modal_area,
10111                    buf,
10112                    &mut self.chart_export_modal,
10113                    border,
10114                    active,
10115                );
10116            }
10117        }
10118
10119        // Render loading progress popover (min 25 chars wide, max 25% of area; throbber spins via busy in controls)
10120        if matches!(self.loading_state, LoadingState::Loading { .. }) {
10121            let popover_rect = centered_rect_loading(area);
10122            App::render_loading_gauge(&self.loading_state, popover_rect, buf, &self.theme);
10123        }
10124        // Render export progress bar (overlay when exporting)
10125        if matches!(self.loading_state, LoadingState::Exporting { .. }) {
10126            App::render_loading_gauge(&self.loading_state, area, buf, &self.theme);
10127        }
10128
10129        // Render confirmation modal (highest priority)
10130        if self.confirmation_modal.active {
10131            let popup_area = centered_rect_with_min(area, 64, 26, 50, 12);
10132            Clear.render(popup_area, buf);
10133
10134            // Set background color for the modal
10135            let bg_color = self.color("background");
10136            Block::default()
10137                .style(Style::default().bg(bg_color))
10138                .render(popup_area, buf);
10139
10140            let block = Block::default()
10141                .borders(Borders::ALL)
10142                .border_type(BorderType::Rounded)
10143                .title("Confirm")
10144                .border_style(Style::default().fg(self.color("modal_border_active")))
10145                .style(Style::default().bg(bg_color));
10146            let inner_area = block.inner(popup_area);
10147            block.render(popup_area, buf);
10148
10149            // Split inner area into message and buttons
10150            let chunks = Layout::default()
10151                .direction(Direction::Vertical)
10152                .constraints([
10153                    Constraint::Min(6),    // Message (minimum 6 lines for file path + question)
10154                    Constraint::Length(3), // Buttons
10155                ])
10156                .split(inner_area);
10157
10158            // Render confirmation message (wrapped)
10159            Paragraph::new(self.confirmation_modal.message.as_str())
10160                .style(Style::default().fg(self.color("text_primary")).bg(bg_color))
10161                .wrap(ratatui::widgets::Wrap { trim: true })
10162                .render(chunks[0], buf);
10163
10164            // Render Yes/No buttons
10165            let button_chunks = Layout::default()
10166                .direction(Direction::Horizontal)
10167                .constraints([
10168                    Constraint::Fill(1),
10169                    Constraint::Length(12), // Yes button
10170                    Constraint::Length(2),  // Spacing
10171                    Constraint::Length(12), // No button
10172                    Constraint::Fill(1),
10173                ])
10174                .split(chunks[1]);
10175
10176            let yes_style = if self.confirmation_modal.focus_yes {
10177                Style::default().fg(self.color("modal_border_active"))
10178            } else {
10179                Style::default()
10180            };
10181            let no_style = if !self.confirmation_modal.focus_yes {
10182                Style::default().fg(self.color("modal_border_active"))
10183            } else {
10184                Style::default()
10185            };
10186
10187            Paragraph::new("Yes")
10188                .centered()
10189                .block(
10190                    Block::default()
10191                        .borders(Borders::ALL)
10192                        .border_type(BorderType::Rounded)
10193                        .border_style(yes_style),
10194                )
10195                .render(button_chunks[1], buf);
10196
10197            Paragraph::new("No")
10198                .centered()
10199                .block(
10200                    Block::default()
10201                        .borders(Borders::ALL)
10202                        .border_type(BorderType::Rounded)
10203                        .border_style(no_style),
10204                )
10205                .render(button_chunks[3], buf);
10206        }
10207
10208        // Render success modal
10209        if self.success_modal.active {
10210            let popup_area = centered_rect(area, 70, 40);
10211            Clear.render(popup_area, buf);
10212            let block = Block::default()
10213                .borders(Borders::ALL)
10214                .border_type(BorderType::Rounded)
10215                .title("Success");
10216            let inner_area = block.inner(popup_area);
10217            block.render(popup_area, buf);
10218
10219            // Split inner area into message and button
10220            let chunks = Layout::default()
10221                .direction(Direction::Vertical)
10222                .constraints([
10223                    Constraint::Min(0),    // Message (takes available space)
10224                    Constraint::Length(3), // OK button
10225                ])
10226                .split(inner_area);
10227
10228            // Render success message (wrapped)
10229            Paragraph::new(self.success_modal.message.as_str())
10230                .style(Style::default().fg(self.color("text_primary")))
10231                .wrap(ratatui::widgets::Wrap { trim: true })
10232                .render(chunks[0], buf);
10233
10234            // Render OK button
10235            let ok_style = Style::default().fg(self.color("modal_border_active"));
10236            Paragraph::new("OK")
10237                .centered()
10238                .block(
10239                    Block::default()
10240                        .borders(Borders::ALL)
10241                        .border_type(BorderType::Rounded)
10242                        .border_style(ok_style),
10243                )
10244                .render(chunks[1], buf);
10245        }
10246
10247        // Render error modal
10248        if self.error_modal.active {
10249            let popup_area = centered_rect(area, 70, 40);
10250            Clear.render(popup_area, buf);
10251            let block = Block::default()
10252                .borders(Borders::ALL)
10253                .border_type(BorderType::Rounded)
10254                .title("Error")
10255                .border_style(Style::default().fg(self.color("modal_border_error")));
10256            let inner_area = block.inner(popup_area);
10257            block.render(popup_area, buf);
10258
10259            // Split inner area into message and button
10260            let chunks = Layout::default()
10261                .direction(Direction::Vertical)
10262                .constraints([
10263                    Constraint::Min(0),    // Message (takes available space)
10264                    Constraint::Length(3), // OK button
10265                ])
10266                .split(inner_area);
10267
10268            // Render error message (wrapped)
10269            Paragraph::new(self.error_modal.message.as_str())
10270                .style(Style::default().fg(self.color("error")))
10271                .wrap(ratatui::widgets::Wrap { trim: true })
10272                .render(chunks[0], buf);
10273
10274            // Render OK button
10275            let ok_style = Style::default().fg(self.color("modal_border_active"));
10276            Paragraph::new("OK")
10277                .centered()
10278                .block(
10279                    Block::default()
10280                        .borders(Borders::ALL)
10281                        .border_type(BorderType::Rounded)
10282                        .border_style(ok_style),
10283                )
10284                .render(chunks[1], buf);
10285        }
10286
10287        if self.show_help
10288            || (self.template_modal.active && self.template_modal.show_help)
10289            || (self.analysis_modal.active && self.analysis_modal.show_help)
10290        {
10291            let popup_area = centered_rect(area, 80, 80);
10292            Clear.render(popup_area, buf);
10293            let (title, text): (String, String) = if self.analysis_modal.active
10294                && self.analysis_modal.show_help
10295            {
10296                match self.analysis_modal.view {
10297                    analysis_modal::AnalysisView::DistributionDetail => (
10298                        "Distribution Detail Help".to_string(),
10299                        help_strings::analysis_distribution_detail().to_string(),
10300                    ),
10301                    analysis_modal::AnalysisView::CorrelationDetail => (
10302                        "Correlation Detail Help".to_string(),
10303                        help_strings::analysis_correlation_detail().to_string(),
10304                    ),
10305                    analysis_modal::AnalysisView::Main => match self.analysis_modal.selected_tool {
10306                        Some(analysis_modal::AnalysisTool::DistributionAnalysis) => (
10307                            "Distribution Analysis Help".to_string(),
10308                            help_strings::analysis_distribution().to_string(),
10309                        ),
10310                        Some(analysis_modal::AnalysisTool::Describe) => (
10311                            "Describe Tool Help".to_string(),
10312                            help_strings::analysis_describe().to_string(),
10313                        ),
10314                        Some(analysis_modal::AnalysisTool::CorrelationMatrix) => (
10315                            "Correlation Matrix Help".to_string(),
10316                            help_strings::analysis_correlation_matrix().to_string(),
10317                        ),
10318                        None => (
10319                            "Analysis Help".to_string(),
10320                            "Select an analysis tool from the sidebar.".to_string(),
10321                        ),
10322                    },
10323                }
10324            } else if self.template_modal.active {
10325                (
10326                    "Template Help".to_string(),
10327                    help_strings::template().to_string(),
10328                )
10329            } else {
10330                let (t, txt) = self.get_help_info();
10331                (t.to_string(), txt.to_string())
10332            };
10333
10334            // Create layout with scrollbar
10335            let help_layout = Layout::default()
10336                .direction(Direction::Horizontal)
10337                .constraints([Constraint::Fill(1), Constraint::Length(1)])
10338                .split(popup_area);
10339
10340            let text_area = help_layout[0];
10341            let scrollbar_area = help_layout[1];
10342
10343            // Render text with scroll offset
10344            let block = Block::default()
10345                .title(title)
10346                .borders(Borders::ALL)
10347                .border_type(BorderType::Rounded);
10348            let inner_area = block.inner(text_area);
10349            block.render(text_area, buf);
10350
10351            // Split text into source lines
10352            let text_lines: Vec<&str> = text.as_str().lines().collect();
10353            let available_width = inner_area.width as usize;
10354            let available_height = inner_area.height as usize;
10355
10356            // Calculate wrapped lines for each source line
10357            let mut wrapped_lines = Vec::new();
10358            for line in &text_lines {
10359                if line.len() <= available_width {
10360                    wrapped_lines.push(*line);
10361                } else {
10362                    // Split long lines into wrapped segments (at char boundaries so UTF-8 is safe)
10363                    let mut remaining = *line;
10364                    while !remaining.is_empty() {
10365                        let mut take = remaining.len().min(available_width);
10366                        while take > 0 && !remaining.is_char_boundary(take) {
10367                            take -= 1;
10368                        }
10369                        // If take is 0 (e.g. first char is multi-byte and width is 1), advance by one char
10370                        let take_len = if take == 0 {
10371                            remaining.chars().next().map_or(0, |c| c.len_utf8())
10372                        } else {
10373                            take
10374                        };
10375                        let (chunk, rest) = remaining.split_at(take_len);
10376                        wrapped_lines.push(chunk);
10377                        remaining = rest;
10378                    }
10379                }
10380            }
10381
10382            let total_wrapped_lines = wrapped_lines.len();
10383
10384            // Clamp scroll position
10385            let max_scroll = total_wrapped_lines.saturating_sub(available_height).max(0);
10386            // Use analysis modal's help scroll if in analysis help, otherwise use main help scroll
10387            let current_scroll = if self.analysis_modal.active && self.analysis_modal.show_help {
10388                // For now, use main help_scroll - could add separate scroll for analysis if needed
10389                self.help_scroll
10390            } else {
10391                self.help_scroll
10392            };
10393            let clamped_scroll = current_scroll.min(max_scroll);
10394            if self.analysis_modal.active && self.analysis_modal.show_help {
10395                // Could store in analysis_modal if needed, but for now use main help_scroll
10396                self.help_scroll = clamped_scroll;
10397            } else {
10398                self.help_scroll = clamped_scroll;
10399            }
10400
10401            // Get visible lines (use clamped scroll)
10402            let scroll_pos = self.help_scroll;
10403            let visible_lines: Vec<&str> = wrapped_lines
10404                .iter()
10405                .skip(scroll_pos)
10406                .take(available_height)
10407                .copied()
10408                .collect();
10409
10410            let visible_text = visible_lines.join("\n");
10411            Paragraph::new(visible_text)
10412                .wrap(ratatui::widgets::Wrap { trim: false })
10413                .render(inner_area, buf);
10414
10415            // Render scrollbar if content is scrollable
10416            if total_wrapped_lines > available_height {
10417                let scrollbar_height = scrollbar_area.height;
10418                let scroll_pos = self.help_scroll;
10419                let scrollbar_pos = if max_scroll > 0 {
10420                    ((scroll_pos as f64 / max_scroll as f64)
10421                        * (scrollbar_height.saturating_sub(1) as f64)) as u16
10422                } else {
10423                    0
10424                };
10425
10426                // Calculate thumb size (proportion of visible content)
10427                let thumb_size = ((available_height as f64 / total_wrapped_lines as f64)
10428                    * scrollbar_height as f64)
10429                    .max(1.0) as u16;
10430                let thumb_size = thumb_size.min(scrollbar_height);
10431
10432                // Draw scrollbar track
10433                for y in 0..scrollbar_height {
10434                    let is_thumb = y >= scrollbar_pos && y < scrollbar_pos + thumb_size;
10435                    let style = if is_thumb {
10436                        Style::default().bg(self.color("text_primary"))
10437                    } else {
10438                        Style::default().bg(self.color("surface"))
10439                    };
10440                    buf.set_string(scrollbar_area.x, scrollbar_area.y + y, "█", style);
10441                }
10442            }
10443        }
10444
10445        // Get row count from state if available
10446        let row_count = self.data_table_state.as_ref().map(|s| s.num_rows);
10447        // Check if query is active
10448        let query_active = self
10449            .data_table_state
10450            .as_ref()
10451            .map(|s| !s.active_query.trim().is_empty())
10452            .unwrap_or(false);
10453        // Dim controls when any modal is active (except analysis/chart modals use their own controls)
10454        let is_modal_active = self.show_help
10455            || self.input_mode == InputMode::Editing
10456            || self.input_mode == InputMode::SortFilter
10457            || self.input_mode == InputMode::PivotMelt
10458            || self.input_mode == InputMode::Info
10459            || self.sort_filter_modal.active;
10460
10461        // Build controls - use analysis-specific controls if analysis modal is active
10462        let use_unicode_throbber = std::env::var("LANG")
10463            .map(|l| l.to_uppercase().contains("UTF-8"))
10464            .unwrap_or(false);
10465        let mut controls = Controls::with_row_count(row_count.unwrap_or(0))
10466            .with_colors(
10467                self.color("controls_bg"),
10468                self.color("keybind_hints"),
10469                self.color("keybind_labels"),
10470                self.color("throbber"),
10471            )
10472            .with_unicode_throbber(use_unicode_throbber);
10473
10474        if self.analysis_modal.active {
10475            // Build analysis-specific controls based on view
10476            let mut analysis_controls = vec![
10477                ("Esc", "Back"),
10478                ("↑↓", "Navigate"),
10479                ("←→", "Scroll Columns"),
10480                ("Tab", "Sidebar"),
10481                ("Enter", "Select"),
10482            ];
10483
10484            // Show r Resample only when sampling is enabled and current tool's data was sampled
10485            if self.sampling_threshold.is_some() {
10486                if let Some(results) = self.analysis_modal.current_results() {
10487                    if results.sample_size.is_some() {
10488                        analysis_controls.push(("r", "Resample"));
10489                    }
10490                }
10491            }
10492
10493            controls = controls.with_custom_controls(analysis_controls);
10494        } else if self.input_mode == InputMode::Chart {
10495            let chart_controls = vec![("Esc", "Back"), ("e", "Export")];
10496            controls = controls.with_custom_controls(chart_controls);
10497        } else {
10498            controls = controls
10499                .with_dimmed(is_modal_active)
10500                .with_query_active(query_active);
10501        }
10502
10503        if self.busy {
10504            self.throbber_frame = self.throbber_frame.wrapping_add(1);
10505        }
10506        controls = controls.with_busy(self.busy, self.throbber_frame);
10507        controls.render(controls_area, buf);
10508        if self.debug.enabled && layout.len() > debug_area_index {
10509            self.debug.render(layout[debug_area_index], buf);
10510        }
10511    }
10512}
10513
10514fn centered_rect(r: Rect, percent_x: u16, percent_y: u16) -> Rect {
10515    let popup_layout = Layout::default()
10516        .direction(Direction::Vertical)
10517        .constraints([
10518            Constraint::Percentage((100 - percent_y) / 2),
10519            Constraint::Percentage(percent_y),
10520            Constraint::Percentage((100 - percent_y) / 2),
10521        ])
10522        .split(r);
10523
10524    Layout::default()
10525        .direction(Direction::Horizontal)
10526        .constraints([
10527            Constraint::Percentage((100 - percent_x) / 2),
10528            Constraint::Percentage(percent_x),
10529            Constraint::Percentage((100 - percent_x) / 2),
10530        ])
10531        .split(popup_layout[1])[1]
10532}
10533
10534/// Like `centered_rect` but enforces minimum width and height so the dialog
10535/// stays usable on very small terminals.
10536fn centered_rect_with_min(
10537    r: Rect,
10538    percent_x: u16,
10539    percent_y: u16,
10540    min_width: u16,
10541    min_height: u16,
10542) -> Rect {
10543    let inner = centered_rect(r, percent_x, percent_y);
10544    let width = inner.width.max(min_width).min(r.width);
10545    let height = inner.height.max(min_height).min(r.height);
10546    let x = r.x + r.width.saturating_sub(width) / 2;
10547    let y = r.y + r.height.saturating_sub(height) / 2;
10548    Rect::new(x, y, width, height)
10549}
10550
10551/// Rect for the loading progress popover: at least 25 characters wide, at most 25% of area width.
10552/// Height is at least 5 lines, at most 20% of area height.
10553fn centered_rect_loading(r: Rect) -> Rect {
10554    const MIN_WIDTH: u16 = 25;
10555    const MAX_WIDTH_PERCENT: u16 = 25;
10556    const MIN_HEIGHT: u16 = 5;
10557    const MAX_HEIGHT_PERCENT: u16 = 20;
10558
10559    let width = (r.width * MAX_WIDTH_PERCENT / 100)
10560        .max(MIN_WIDTH)
10561        .min(r.width);
10562    let height = (r.height * MAX_HEIGHT_PERCENT / 100)
10563        .max(MIN_HEIGHT)
10564        .min(r.height);
10565
10566    let x = r.x + r.width.saturating_sub(width) / 2;
10567    let y = r.y + r.height.saturating_sub(height) / 2;
10568    Rect::new(x, y, width, height)
10569}
10570
10571/// Run the TUI with either file paths or an existing LazyFrame. Single event loop used by CLI and Python binding.
10572pub fn run(input: RunInput, config: Option<AppConfig>, debug: bool) -> Result<()> {
10573    use std::io::Write;
10574    use std::sync::{mpsc, Mutex, Once};
10575
10576    let config = match config {
10577        Some(c) => c,
10578        None => AppConfig::load(APP_NAME)?,
10579    };
10580
10581    let theme = Theme::from_config(&config.theme)
10582        .or_else(|e| Theme::from_config(&AppConfig::default().theme).map_err(|_| e))?;
10583
10584    // Install color_eyre at most once per process (e.g. first datui.view() in Python).
10585    // Subsequent run() calls skip install and reuse the result; no error-message detection.
10586    static COLOR_EYRE_INIT: Once = Once::new();
10587    static INSTALL_RESULT: Mutex<Option<Result<(), color_eyre::Report>>> = Mutex::new(None);
10588    COLOR_EYRE_INIT.call_once(|| {
10589        *INSTALL_RESULT.lock().unwrap_or_else(|e| e.into_inner()) = Some(color_eyre::install());
10590    });
10591    if let Some(Err(e)) = INSTALL_RESULT
10592        .lock()
10593        .unwrap_or_else(|e| e.into_inner())
10594        .as_ref()
10595    {
10596        return Err(color_eyre::eyre::eyre!(e.to_string()));
10597    }
10598    // Require at least one path so event handlers can safely use paths[0].
10599    if let RunInput::Paths(ref paths, _) = input {
10600        if paths.is_empty() {
10601            return Err(color_eyre::eyre::eyre!("At least one path is required"));
10602        }
10603        for path in paths {
10604            let s = path.to_string_lossy();
10605            let is_remote = s.starts_with("s3://")
10606                || s.starts_with("gs://")
10607                || s.starts_with("http://")
10608                || s.starts_with("https://");
10609            let is_glob = s.contains('*');
10610            if !is_remote && !is_glob && !path.exists() {
10611                return Err(std::io::Error::new(
10612                    std::io::ErrorKind::NotFound,
10613                    format!("File not found: {}", path.display()),
10614                )
10615                .into());
10616            }
10617        }
10618    }
10619    let mut terminal = ratatui::try_init().map_err(|e| {
10620        color_eyre::eyre::eyre!(
10621            "datui requires an interactive terminal (TTY). No terminal detected: {}. \
10622             Run from a terminal or ensure stdout is connected to a TTY.",
10623            e
10624        )
10625    })?;
10626    let (tx, rx) = mpsc::channel::<AppEvent>();
10627    let mut app = App::new_with_config(tx.clone(), theme, config.clone());
10628    if debug {
10629        app.enable_debug();
10630    }
10631
10632    terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10633
10634    match input {
10635        RunInput::Paths(paths, opts) => {
10636            tx.send(AppEvent::Open(paths, opts))?;
10637        }
10638        RunInput::LazyFrame(lf, opts) => {
10639            // Show loading dialog immediately so it is visible when launch is from Python/LazyFrame
10640            // (before sending the event and before any blocking work in the event handler).
10641            app.set_loading_phase("Scanning input", 10);
10642            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10643            let _ = std::io::stdout().flush();
10644            // Brief pause so the terminal can display the frame when run from Python (e.g. maturin).
10645            std::thread::sleep(std::time::Duration::from_millis(150));
10646            tx.send(AppEvent::OpenLazyFrame(lf, opts))?;
10647        }
10648    }
10649
10650    // Process load events and draw so the loading progress dialog updates (e.g. "Caching schema")
10651    // before any blocking work. Keeps processing until no event is received (timeout).
10652    loop {
10653        let event = match rx.recv_timeout(std::time::Duration::from_millis(50)) {
10654            Ok(ev) => ev,
10655            Err(mpsc::RecvTimeoutError::Timeout) => break,
10656            Err(mpsc::RecvTimeoutError::Disconnected) => break,
10657        };
10658        match event {
10659            AppEvent::Exit => break,
10660            AppEvent::Crash(msg) => {
10661                ratatui::restore();
10662                return Err(color_eyre::eyre::eyre!(msg));
10663            }
10664            ev => {
10665                if let Some(next) = app.event(&ev) {
10666                    let _ = tx.send(next);
10667                }
10668                terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10669                let _ = std::io::stdout().flush();
10670                // After processing DoLoadSchema we've drawn "Caching schema"; next event is DoLoadSchemaBlocking (blocking).
10671                // Leave it for the main loop so we don't block here.
10672                if matches!(ev, AppEvent::DoLoadSchema(..)) {
10673                    break;
10674                }
10675            }
10676        }
10677    }
10678
10679    loop {
10680        if crossterm::event::poll(std::time::Duration::from_millis(
10681            config.performance.event_poll_interval_ms,
10682        ))? {
10683            match crossterm::event::read()? {
10684                crossterm::event::Event::Key(key) => tx.send(AppEvent::Key(key))?,
10685                crossterm::event::Event::Resize(cols, rows) => {
10686                    tx.send(AppEvent::Resize(cols, rows))?
10687                }
10688                _ => {}
10689            }
10690        }
10691
10692        let updated = match rx.recv_timeout(std::time::Duration::from_millis(0)) {
10693            Ok(event) => {
10694                match event {
10695                    AppEvent::Exit => break,
10696                    AppEvent::Crash(msg) => {
10697                        ratatui::restore();
10698                        return Err(color_eyre::eyre::eyre!(msg));
10699                    }
10700                    event => {
10701                        if let Some(next) = app.event(&event) {
10702                            tx.send(next)?;
10703                        }
10704                    }
10705                }
10706                true
10707            }
10708            Err(mpsc::RecvTimeoutError::Timeout) => false,
10709            Err(mpsc::RecvTimeoutError::Disconnected) => break,
10710        };
10711
10712        if updated {
10713            terminal.draw(|frame| frame.render_widget(&mut app, frame.area()))?;
10714            if app.should_drain_keys() {
10715                while crossterm::event::poll(std::time::Duration::from_millis(0))? {
10716                    let _ = crossterm::event::read();
10717                }
10718                app.clear_drain_keys_request();
10719            }
10720        }
10721    }
10722
10723    ratatui::restore();
10724    Ok(())
10725}