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