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
78pub const APP_NAME: &str = "datui";
80
81pub 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 pub fn ensure_sample_data() {
96 INIT.call_once(|| {
97 let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
99 let sample_data_dir = repo_root.join("tests/sample-data");
100
101 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 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 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 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 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 #[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 pub hive: bool,
211 pub single_spine_schema: bool,
213 pub parse_dates: bool,
215 pub decompress_in_memory: bool,
217 pub temp_dir: Option<std::path::PathBuf>,
219 pub excel_sheet: Option<String>,
221 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 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 pub fn from_args_and_config(args: &cli::Args, config: &AppConfig) -> Self {
295 let mut opts = OpenOptions::new();
296
297 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 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 opts.compression = args.compression;
311
312 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 opts.row_numbers = args.row_numbers || config.display.row_numbers;
322
323 opts.row_start_index = args
325 .row_start_index
326 .unwrap_or(config.display.row_start_index);
327
328 opts.hive = args.hive;
330
331 opts.single_spine_schema = args
333 .single_spine_schema
334 .or(config.file_loading.single_spine_schema)
335 .unwrap_or(true);
336
337 opts.parse_dates = args
339 .parse_dates
340 .or(config.file_loading.parse_dates)
341 .unwrap_or(true);
342
343 opts.decompress_in_memory = args
345 .decompress_in_memory
346 .or(config.file_loading.decompress_in_memory)
347 .unwrap_or(false);
348
349 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 opts.excel_sheet = args.excel_sheet.clone();
360
361 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 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 OpenLazyFrame(Box<LazyFrame>, OpenOptions),
400 DoLoad(Vec<PathBuf>, OpenOptions), DoLoadScanPaths(Vec<PathBuf>, OpenOptions),
403 #[cfg(feature = "http")]
405 DoDownloadHttp(String, OpenOptions),
406 #[cfg(feature = "cloud")]
408 DoDownloadS3ToTemp(String, OpenOptions),
409 #[cfg(feature = "cloud")]
411 DoDownloadGcsToTemp(String, OpenOptions),
412 #[cfg(any(feature = "http", feature = "cloud"))]
414 DoLoadFromHttpTemp(PathBuf, OpenOptions),
415 DoLoadSchema(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
417 DoLoadSchemaBlocking(Box<LazyFrame>, Option<PathBuf>, OpenOptions),
419 DoLoadBuffer,
421 DoDecompress(Vec<PathBuf>, OpenOptions), DoExport(PathBuf, ExportFormat, ExportOptions), DoExportCollect(PathBuf, ExportFormat, ExportOptions), DoExportWrite(PathBuf, ExportFormat, ExportOptions), DoLoadParquetMetadata, Exit,
427 Crash(String),
428 Search(String),
429 SqlSearch(String),
430 FuzzySearch(String),
431 Filter(Vec<FilterStatement>),
432 Sort(Vec<String>, bool), ColumnOrder(Vec<String>, usize), Pivot(PivotSpec),
435 Melt(MeltSpec),
436 Export(PathBuf, ExportFormat, ExportOptions), ChartExport(PathBuf, ChartExportFormat, String), DoChartExport(PathBuf, ChartExportFormat, String), Collect,
440 Update,
441 Reset,
442 Resize(u16, u16), DoScrollDown, DoScrollUp, DoScrollNext, DoScrollPrev, DoScrollEnd, DoScrollHalfDown, DoScrollHalfUp, GoToLine(usize), AnalysisChunk,
453 AnalysisDistributionCompute,
455 AnalysisCorrelationCompute,
457}
458
459#[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>, }
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#[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#[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, }
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; }
597
598 pub fn hide(&mut self) {
599 self.active = false;
600 self.message.clear();
601 self.focus_yes = true;
602 }
603}
604
605#[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 file_path: Option<PathBuf>,
636 file_size: u64, current_phase: String, progress_percent: u16, },
640 Exporting {
641 file_path: PathBuf,
642 current_phase: String, progress_percent: u16, },
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#[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
669fn 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
690struct 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 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>, original_file_delimiter: Option<u8>, events: Sender<AppEvent>,
772 focus: u32,
773 debug: DebugState,
774 info_modal: InfoModal,
775 parquet_metadata_cache: Option<ParquetMetadataCache>,
776 query_input: TextInput, sql_input: TextInput, fuzzy_input: TextInput, 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)>, export_df: Option<DataFrame>,
797 pending_chart_export: Option<(PathBuf, ChartExportFormat, String)>,
798 #[cfg(any(feature = "http", feature = "cloud"))]
800 pending_download: Option<PendingDownload>,
801 show_help: bool,
802 help_scroll: usize, cache: CacheManager,
804 template_manager: TemplateManager,
805 active_template_id: Option<String>, loading_state: LoadingState, theme: Theme, sampling_threshold: Option<usize>, history_limit: usize, table_cell_padding: u16, column_colors: bool, busy: bool, throbber_frame: u8, drain_keys_on_next_loop: bool, analysis_computation: Option<AnalysisComputationState>,
816 app_config: AppConfig,
817 #[cfg(feature = "http")]
819 http_temp_path: Option<PathBuf>,
820}
821
822impl App {
823 pub fn should_drain_keys(&self) -> bool {
825 self.drain_keys_on_next_loop
826 }
827
828 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 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 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 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 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 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 }
908 }
910
911 new_path
912 }
913
914 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 let theme = Theme::from_config(&AppConfig::default().theme).unwrap_or_else(|_| {
964 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 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]; 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 if is_compressed_csv {
1096 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)?; 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 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 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 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 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 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 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)? }
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 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 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 self.original_file_delimiter = match path.extension().and_then(|e| e.to_str()) {
1506 Some(ext) if ext.eq_ignore_ascii_case("csv") => {
1507 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, };
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 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 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 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 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 if event.code == KeyCode::F(1) {
2258 self.open_help_overlay();
2259 return None;
2260 }
2261
2262 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 self.confirmation_modal.focus_yes = !self.confirmation_modal.focus_yes;
2275 }
2276 KeyCode::Enter => {
2277 if self.confirmation_modal.focus_yes {
2278 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 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 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 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 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 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 }
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 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 _ 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 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 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 self.export_modal.path_input.handle_key(event, None);
2887 }
2888 ExportFocus::CsvDelimiter => {
2889 self.export_modal
2891 .csv_delimiter_input
2892 .handle_key(event, None);
2893 }
2894 ExportFocus::CsvCompression
2895 | ExportFocus::JsonCompression
2896 | ExportFocus::NdjsonCompression => {
2897 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 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 self.export_modal.path_input.handle_key(event, None);
2919 }
2920 ExportFocus::CsvDelimiter => {
2921 self.export_modal
2923 .csv_delimiter_input
2924 .handle_key(event, None);
2925 }
2926 ExportFocus::CsvCompression
2927 | ExportFocus::JsonCompression
2928 | ExportFocus::NdjsonCompression => {
2929 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 }
2950 ExportFocus::CsvCompression
2951 | ExportFocus::JsonCompression
2952 | ExportFocus::NdjsonCompression => {
2953 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 }
2972 ExportFocus::CsvCompression
2973 | ExportFocus::JsonCompression
2974 | ExportFocus::NdjsonCompression => {
2975 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 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 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 let path_with_ext =
3000 Self::ensure_file_extension(&path, format, compression);
3001 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 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 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 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 let path_with_ext =
3056 Self::ensure_file_extension(&path, format, compression);
3057 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 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 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 }
3111 _ => {}
3112 }
3113 }
3114 KeyCode::Char(' ') => {
3115 match self.export_modal.focus {
3117 ExportFocus::PathInput => {
3118 self.export_modal.path_input.handle_key(event, None);
3120 }
3121 ExportFocus::CsvDelimiter => {
3122 self.export_modal
3124 .csv_delimiter_input
3125 .handle_key(event, None);
3126 }
3127 ExportFocus::CsvIncludeHeader => {
3128 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 }
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 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 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 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 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 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 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 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 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 self.analysis_modal.switch_focus();
3887 } else if self.analysis_modal.view
3888 == analysis_modal::AnalysisView::DistributionDetail
3889 {
3890 } else {
3893 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 self.analysis_modal.select_tool();
3902 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 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 self.analysis_modal.next_tool();
3970 }
3971 analysis_modal::AnalysisFocus::Main => {
3972 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 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 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 }
4082 analysis_modal::AnalysisFocus::DistributionSelector => {
4083 }
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 let row_header_width = 20u16;
4101 let cell_width = 12u16;
4102 let column_spacing = 1u16;
4103 let estimated_width = 80u16; let available_width = estimated_width
4108 .saturating_sub(row_header_width);
4109 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 }
4150 analysis_modal::AnalysisFocus::DistributionSelector => {
4151 }
4153 analysis_modal::AnalysisFocus::Main => {
4154 match self.analysis_modal.selected_tool {
4155 Some(analysis_modal::AnalysisTool::Describe) => {
4156 let max_stats = 12;
4158 let visible_stats = 8; self.analysis_modal.scroll_right(max_stats, visible_stats);
4161 }
4162 Some(analysis_modal::AnalysisTool::DistributionAnalysis) => {
4163 let max_stats = 8;
4165 let visible_stats = 6; 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 let row_header_width = 20u16;
4176 let cell_width = 12u16;
4177 let column_spacing = 1u16;
4178 let estimated_width = 80u16; 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 }
4293 analysis_modal::AnalysisFocus::DistributionSelector => {
4294 self.analysis_modal
4295 .distribution_selector_state
4296 .select(Some(13)); }
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 self.template_modal.show_score_details = false;
4353 } else if self.template_modal.delete_confirm {
4354 self.template_modal.delete_confirm = false;
4356 } else if self.template_modal.mode == TemplateModalMode::Create
4357 || self.template_modal.mode == TemplateModalMode::Edit
4358 {
4359 self.template_modal.exit_create_mode();
4361 } else {
4362 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 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 self.template_modal
4386 .enter_create_mode(self.history_limit, &self.theme);
4387 if let Some(ref path) = self.path {
4389 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 let absolute_path = if path.is_absolute() {
4397 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
4398 } else {
4399 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 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 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 self.template_modal.create_relative_path_input.clear();
4439 }
4440 } else {
4441 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 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 if let Some(filename) = path.file_name() {
4480 if let Some(filename_str) = filename.to_str() {
4481 let mut pattern = filename_str.to_string();
4483 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 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 }
4505 }
4506 }
4507 KeyCode::Char('e') if self.template_modal.mode == TemplateModalMode::List => {
4508 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 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; }
4529 }
4530 KeyCode::Char('?')
4531 if self.template_modal.mode == TemplateModalMode::List
4532 && !self.template_modal.delete_confirm =>
4533 {
4534 self.template_modal.show_score_details = true;
4536 }
4537 KeyCode::Char('D') if self.template_modal.delete_confirm => {
4538 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 } else {
4544 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 self.template_modal.delete_confirm_focus =
4571 !self.template_modal.delete_confirm_focus;
4572 }
4573 KeyCode::Enter if self.template_modal.delete_confirm => {
4574 if self.template_modal.delete_confirm_focus {
4576 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 } else {
4582 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 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 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 self.error_modal.show(format!(
4627 "Error applying template: {}",
4628 e
4629 ));
4630 } else {
4632 self.template_modal.active = false;
4634 }
4635 }
4636 }
4637 }
4638 TemplateFocus::CreateButton => {
4639 }
4642 _ => {}
4643 }
4644 }
4645 TemplateModalMode::Create | TemplateModalMode::Edit => {
4646 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 let area_height = 10; 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 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 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 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, };
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 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 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 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 }
4855 }
4856 }
4857 } else {
4858 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 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 }
4896 }
4897 }
4898 }
4899 CreateFocus::CancelButton => {
4900 self.template_modal.exit_create_mode();
4901 }
4902 _ => {
4903 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 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 let area_height = 10; self.template_modal
4937 .create_description_input
4938 .ensure_cursor_visible(area_height, 80);
4939 } else {
4940 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 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 let area_height = 10; self.template_modal
4978 .create_description_input
4979 .ensure_cursor_visible(area_height, 80);
4980 } else {
4981 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 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 let area_height = 10; 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 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 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 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 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 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 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 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 state.error = None;
5365 state.suppress_error_display = false;
5366 }
5367 }
5368 TextInputEvent::HistoryChanged => {
5369 }
5371 TextInputEvent::None => {
5372 }
5374 }
5375 return None;
5376 }
5377
5378 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 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 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 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 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 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 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 if let Err(e) = self.apply_template(&template) {
5720 self.error_modal
5722 .show(format!("Error applying template: {}", e));
5723 }
5724 }
5725 }
5726 }
5727 None
5728 }
5729 KeyCode::Char('t') => {
5730 if let Some(ref state) = self.data_table_state {
5732 if let Some(ref path) = self.path {
5733 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 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 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 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 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 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 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 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 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 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 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 Some(AppEvent::DoDecompress(paths.clone(), options.clone()))
6737 } else {
6738 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 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 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 if query_succeeded {
6980 self.input_mode = InputMode::Normal;
6982 self.query_input.set_focused(false);
6983 if let Some(state) = &mut self.data_table_state {
6985 state.suppress_error_display = false;
6986 }
6987 }
6988 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 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 self.loading_state = LoadingState::Exporting {
7139 file_path: path.clone(),
7140 current_phase: "Preparing export".to_string(),
7141 progress_percent: 0,
7142 };
7143 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 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 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 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 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 if !template.settings.filters.is_empty() {
7664 state.filter(template.settings.filters.clone());
7665 let error_opt = state.error.clone();
7667 if let Some(error) = error_opt {
7668 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 if !template.settings.sort_columns.is_empty() {
7679 state.sort(
7680 template.settings.sort_columns.clone(),
7681 template.settings.sort_ascending,
7682 );
7683 let error_opt = state.error.clone();
7685 if let Some(error) = error_opt {
7686 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 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 if !template.settings.column_order.is_empty() {
7722 state.set_column_order(template.settings.column_order.clone());
7723 let error_opt = state.error.clone();
7725 if let Some(error) = error_opt {
7726 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 let error_opt = state.error.clone();
7736 if let Some(error) = error_opt {
7737 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 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 let _ = self.template_manager.save_template(&updated_template);
7759 }
7760
7761 self.active_template_id = Some(template.id.clone());
7763
7764 Ok(())
7765 }
7766
7767 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 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 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, ))
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 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 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, ))
7861 }
7862 };
7863 JsonWriter::new(writer)
7864 .with_json_format(JsonFormat::Json)
7865 .finish(df)?;
7866 } else {
7867 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 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, ))
7895 }
7896 };
7897 JsonWriter::new(writer)
7898 .with_json_format(JsonFormat::JsonLines)
7899 .finish(df)?;
7900 } else {
7901 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)] 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 let saved_lf = saved.lf.clone();
7940 let saved_schema = saved.schema.clone();
7941
7942 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 state.error = None;
7951 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 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.render(area, buf);
8041
8042 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 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)); 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.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)]) .split(main_area);
8102 data_area = chunks[0];
8103 sort_area = chunks[1]; }
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 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 match &mut self.data_table_state {
8156 Some(state) => {
8157 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 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), Constraint::Min(0), Constraint::Length(3), ])
8431 .split(inner_area);
8432
8433 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 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 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 "●" } else if col.is_to_be_locked {
8654 "◐" } 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")) } 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 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 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 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 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 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 let chunks = Layout::default()
8917 .direction(Direction::Vertical)
8918 .constraints([
8919 Constraint::Min(0), Constraint::Length(1), ])
8922 .split(inner_area);
8923
8924 let max_score = self
8927 .template_modal
8928 .templates
8929 .iter()
8930 .map(|(_, score)| *score)
8931 .fold(0.0, f64::max);
8932
8933 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 let is_active = self
8946 .active_template_id
8947 .as_ref()
8948 .map(|id| id == &template.id)
8949 .unwrap_or(false);
8950
8951 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 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 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 if score_ratio >= 0.2 {
8978 ('○', self.color("text_primary"))
8979 } else {
8980 ('○', self.color("dimmed"))
8981 }
8982 };
8983
8984 let score_cell = Cell::from(circle_char.to_string())
8986 .style(Style::default().fg(circle_color));
8987
8988 let active_cell = if is_active {
8990 Cell::from("✓")
8991 } else {
8992 Cell::from(" ")
8993 };
8994
8995 let name_cell = Cell::from(template.name.clone());
8997
8998 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 Row::new(vec![score_cell, active_cell, name_cell, desc_cell])
9007 })
9008 .collect();
9009
9010 let header = Row::new(vec![
9012 Cell::from("●").style(Style::default()),
9013 Cell::from(" ").style(Style::default()), 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), ],
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 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 let chunks = Layout::default()
9096 .direction(Direction::Vertical)
9097 .constraints([
9098 Constraint::Length(3), Constraint::Length(6), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), Constraint::Length(3), ])
9107 .split(inner_area);
9108
9109 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 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 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 let is_focused = self.template_modal.create_focus == CreateFocus::Description;
9156 self.template_modal
9157 .create_description_input
9158 .set_focused(is_focused);
9159 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 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 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 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 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 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 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 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 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 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 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 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), Constraint::Length(3), ])
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 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 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 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 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 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 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 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 if self.analysis_modal.active {
9587 let analysis_area = main_area;
9589
9590 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 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 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 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 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 }
9804
9805 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 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 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 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 if matches!(self.loading_state, LoadingState::Exporting { .. }) {
10126 App::render_loading_gauge(&self.loading_state, area, buf, &self.theme);
10127 }
10128
10129 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 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 let chunks = Layout::default()
10151 .direction(Direction::Vertical)
10152 .constraints([
10153 Constraint::Min(6), Constraint::Length(3), ])
10156 .split(inner_area);
10157
10158 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 let button_chunks = Layout::default()
10166 .direction(Direction::Horizontal)
10167 .constraints([
10168 Constraint::Fill(1),
10169 Constraint::Length(12), Constraint::Length(2), Constraint::Length(12), 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 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 let chunks = Layout::default()
10221 .direction(Direction::Vertical)
10222 .constraints([
10223 Constraint::Min(0), Constraint::Length(3), ])
10226 .split(inner_area);
10227
10228 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 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 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 let chunks = Layout::default()
10261 .direction(Direction::Vertical)
10262 .constraints([
10263 Constraint::Min(0), Constraint::Length(3), ])
10266 .split(inner_area);
10267
10268 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 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 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 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 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 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 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 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 let max_scroll = total_wrapped_lines.saturating_sub(available_height).max(0);
10386 let current_scroll = if self.analysis_modal.active && self.analysis_modal.show_help {
10388 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 self.help_scroll = clamped_scroll;
10397 } else {
10398 self.help_scroll = clamped_scroll;
10399 }
10400
10401 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 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 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 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 let row_count = self.data_table_state.as_ref().map(|s| s.num_rows);
10447 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 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 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 let mut analysis_controls = vec![
10477 ("Esc", "Back"),
10478 ("↑↓", "Navigate"),
10479 ("←→", "Scroll Columns"),
10480 ("Tab", "Sidebar"),
10481 ("Enter", "Select"),
10482 ];
10483
10484 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
10534fn 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
10551fn 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
10571pub 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(150));
10646 tx.send(AppEvent::OpenLazyFrame(lf, opts))?;
10647 }
10648 }
10649
10650 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 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}