1use color_eyre::eyre::eyre;
2use color_eyre::Result;
3use ratatui::style::Color;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use supports_color::Stream;
8
9#[derive(Clone)]
11pub struct ConfigManager {
12 pub(crate) config_dir: PathBuf,
13}
14
15impl ConfigManager {
16 pub fn with_dir(config_dir: PathBuf) -> Self {
18 Self { config_dir }
19 }
20
21 pub fn new(app_name: &str) -> Result<Self> {
23 let config_dir = dirs::config_dir()
24 .ok_or_else(|| eyre!("Could not determine config directory"))?
25 .join(app_name);
26
27 Ok(Self { config_dir })
28 }
29
30 pub fn config_dir(&self) -> &Path {
32 &self.config_dir
33 }
34
35 pub fn config_path(&self, path: &str) -> PathBuf {
37 self.config_dir.join(path)
38 }
39
40 pub fn ensure_config_dir(&self) -> Result<()> {
42 if !self.config_dir.exists() {
43 std::fs::create_dir_all(&self.config_dir)?;
44 }
45 Ok(())
46 }
47
48 pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
50 let subdir_path = self.config_dir.join(subdir);
51 if !subdir_path.exists() {
52 std::fs::create_dir_all(&subdir_path)?;
53 }
54 Ok(subdir_path)
55 }
56
57 pub fn generate_default_config(&self) -> String {
60 let config = AppConfig::default();
62 let toml_str = toml::to_string_pretty(&config)
63 .unwrap_or_else(|e| panic!("Failed to serialize default config: {}", e));
64
65 let comments = Self::collect_all_comments();
67
68 Self::comment_all_fields(toml_str, comments)
70 }
71
72 fn collect_all_comments() -> std::collections::HashMap<String, String> {
74 let mut comments = std::collections::HashMap::new();
75
76 for (field, comment) in APP_COMMENTS {
78 comments.insert(field.to_string(), comment.to_string());
79 }
80
81 for (field, comment) in CLOUD_COMMENTS {
83 comments.insert(format!("cloud.{}", field), comment.to_string());
84 }
85
86 for (field, comment) in FILE_LOADING_COMMENTS {
88 comments.insert(format!("file_loading.{}", field), comment.to_string());
89 }
90
91 for (field, comment) in DISPLAY_COMMENTS {
93 comments.insert(format!("display.{}", field), comment.to_string());
94 }
95
96 for (field, comment) in PERFORMANCE_COMMENTS {
98 comments.insert(format!("performance.{}", field), comment.to_string());
99 }
100
101 for (field, comment) in CHART_COMMENTS {
103 comments.insert(format!("chart.{}", field), comment.to_string());
104 }
105
106 for (field, comment) in THEME_COMMENTS {
108 comments.insert(format!("theme.{}", field), comment.to_string());
109 }
110
111 for (field, comment) in COLOR_COMMENTS {
113 comments.insert(format!("theme.colors.{}", field), comment.to_string());
114 }
115
116 for (field, comment) in CONTROLS_COMMENTS {
118 comments.insert(format!("ui.controls.{}", field), comment.to_string());
119 }
120
121 for (field, comment) in QUERY_COMMENTS {
123 comments.insert(format!("query.{}", field), comment.to_string());
124 }
125
126 for (field, comment) in TEMPLATE_COMMENTS {
128 comments.insert(format!("templates.{}", field), comment.to_string());
129 }
130
131 for (field, comment) in DEBUG_COMMENTS {
133 comments.insert(format!("debug.{}", field), comment.to_string());
134 }
135
136 comments
137 }
138
139 fn comment_all_fields(
142 toml: String,
143 comments: std::collections::HashMap<String, String>,
144 ) -> String {
145 let mut result = String::new();
146 result.push_str("# datui configuration file\n");
147 result
148 .push_str("# This file uses TOML format. See https://toml.io/ for syntax reference.\n");
149 result.push('\n');
150
151 let lines: Vec<&str> = toml.lines().collect();
152 let mut i = 0;
153 let mut current_section = String::new();
154 let mut seen_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
155
156 while i < lines.len() {
158 let line = lines[i];
159
160 if let Some(section) = Self::extract_section_name(line) {
162 current_section = section.clone();
163
164 if let Some(header) = SECTION_HEADERS.iter().find(|(s, _)| s == §ion) {
166 result.push_str(header.1);
167 result.push('\n');
168 }
169
170 result.push_str("# ");
172 result.push_str(line);
173 result.push('\n');
174 i += 1;
175 continue;
176 }
177
178 if let Some(field_path) = Self::extract_field_path_simple(line, ¤t_section) {
180 seen_fields.insert(field_path.clone());
181
182 if let Some(comment) = comments.get(&field_path) {
184 for comment_line in comment.lines() {
185 result.push_str("# ");
186 result.push_str(comment_line);
187 result.push('\n');
188 }
189 }
190
191 result.push_str("# ");
193 result.push_str(line);
194 result.push('\n');
195 } else {
196 result.push_str(line);
198 result.push('\n');
199 }
200
201 i += 1;
202 }
203
204 result = Self::add_missing_option_fields(result, &comments, &seen_fields);
206
207 result
208 }
209
210 fn add_missing_option_fields(
212 mut result: String,
213 comments: &std::collections::HashMap<String, String>,
214 seen_fields: &std::collections::HashSet<String>,
215 ) -> String {
216 let option_fields = [
218 "cloud.s3_endpoint_url",
219 "cloud.s3_access_key_id",
220 "cloud.s3_secret_access_key",
221 "cloud.s3_region",
222 "file_loading.delimiter",
223 "file_loading.has_header",
224 "file_loading.skip_lines",
225 "file_loading.skip_rows",
226 "file_loading.single_spine_schema",
227 "chart.row_limit",
228 "ui.controls.custom_controls",
229 ];
230
231 let mut missing_by_section: std::collections::HashMap<String, Vec<&str>> =
233 std::collections::HashMap::new();
234
235 for field_path in &option_fields {
236 if !seen_fields.contains(*field_path) && comments.contains_key(*field_path) {
237 if let Some(dot_pos) = field_path.find('.') {
238 let section = &field_path[..dot_pos];
239 missing_by_section
240 .entry(section.to_string())
241 .or_default()
242 .push(field_path);
243 }
244 }
245 }
246
247 for (section, fields) in &missing_by_section {
249 let section_header = format!("[{}]", section);
250 if let Some(section_pos) = result.find(§ion_header) {
251 let after_header_start = section_pos + section_header.len();
253 let after_header = &result[after_header_start..];
254
255 let newline_pos = after_header.find('\n').unwrap_or(0);
257 let insert_pos = after_header_start + newline_pos + 1;
258
259 let mut new_content = String::new();
261 for field_path in fields {
262 if let Some(comment) = comments.get(*field_path) {
263 for comment_line in comment.lines() {
264 new_content.push_str("# ");
265 new_content.push_str(comment_line);
266 new_content.push('\n');
267 }
268 }
269 let field_name = field_path.rsplit('.').next().unwrap_or(field_path);
270 new_content.push_str(&format!("# {} = null\n", field_name));
271 new_content.push('\n');
272 }
273
274 result.insert_str(insert_pos, &new_content);
275 }
276 }
277
278 result
279 }
280
281 fn extract_section_name(line: &str) -> Option<String> {
283 let trimmed = line.trim();
284 if trimmed.starts_with('[') && trimmed.ends_with(']') {
285 Some(trimmed[1..trimmed.len() - 1].to_string())
286 } else {
287 None
288 }
289 }
290
291 fn extract_field_path_simple(line: &str, current_section: &str) -> Option<String> {
293 let trimmed = line.trim();
294 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
295 return None;
296 }
297
298 if let Some(eq_pos) = trimmed.find('=') {
300 let field_name = trimmed[..eq_pos].trim();
301 if current_section.is_empty() {
302 Some(field_name.to_string())
303 } else {
304 Some(format!("{}.{}", current_section, field_name))
305 }
306 } else {
307 None
308 }
309 }
310
311 pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
313 let config_path = self.config_path("config.toml");
314
315 if config_path.exists() && !force {
316 return Err(eyre!(
317 "Config file already exists at {}. Use --force to overwrite.",
318 config_path.display()
319 ));
320 }
321
322 self.ensure_config_dir()?;
324
325 let template = self.generate_default_config();
327 std::fs::write(&config_path, template)?;
328
329 Ok(config_path)
330 }
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(default)]
336pub struct AppConfig {
337 pub version: String,
339 pub cloud: CloudConfig,
340 pub file_loading: FileLoadingConfig,
341 pub display: DisplayConfig,
342 pub performance: PerformanceConfig,
343 pub chart: ChartConfig,
344 pub theme: ThemeConfig,
345 pub ui: UiConfig,
346 pub query: QueryConfig,
347 pub templates: TemplateConfig,
348 pub debug: DebugConfig,
349}
350
351const APP_COMMENTS: &[(&str, &str)] = &[(
353 "version",
354 "Configuration format version (for future compatibility)",
355)];
356
357const SECTION_HEADERS: &[(&str, &str)] = &[
359 (
360 "cloud",
361 "# ============================================================================\n# Cloud / Object Storage (S3, MinIO)\n# ============================================================================\n# Optional overrides for s3:// URLs. Leave unset to use AWS defaults (env, ~/.aws/).\n# Set endpoint_url to use MinIO or other S3-compatible backends.",
362 ),
363 (
364 "file_loading",
365 "# ============================================================================\n# File Loading Defaults\n# ============================================================================",
366 ),
367 (
368 "display",
369 "# ============================================================================\n# Display Settings\n# ============================================================================",
370 ),
371 (
372 "performance",
373 "# ============================================================================\n# Performance Settings\n# ============================================================================",
374 ),
375 (
376 "chart",
377 "# ============================================================================\n# Chart View\n# ============================================================================",
378 ),
379 (
380 "theme",
381 "# ============================================================================\n# Color Theme\n# ============================================================================",
382 ),
383 (
384 "theme.colors",
385 "# Color definitions\n# Supported formats:\n# - Named colors: \"red\", \"blue\", \"bright_red\", \"dark_gray\", etc. (case-insensitive)\n# - Hex colors: \"#ff0000\" or \"#FF0000\" (case-insensitive)\n# - Indexed colors: \"indexed(0-255)\" for specific xterm 256-color palette entries\n# Colors automatically adapt to your terminal's capabilities",
386 ),
387 (
388 "ui",
389 "# ============================================================================\n# UI Layout\n# ============================================================================",
390 ),
391 ("ui.controls", "# Control bar settings"),
392 (
393 "query",
394 "# ============================================================================\n# Query System\n# ============================================================================",
395 ),
396 (
397 "templates",
398 "# ============================================================================\n# Template Settings\n# ============================================================================",
399 ),
400 (
401 "debug",
402 "# ============================================================================\n# Debug Settings\n# ============================================================================",
403 ),
404];
405
406#[derive(Debug, Clone, Serialize, Deserialize, Default)]
407#[serde(default)]
408pub struct CloudConfig {
409 pub s3_endpoint_url: Option<String>,
411 pub s3_access_key_id: Option<String>,
413 pub s3_secret_access_key: Option<String>,
415 pub s3_region: Option<String>,
417}
418
419const CLOUD_COMMENTS: &[(&str, &str)] = &[
420 (
421 "s3_endpoint_url",
422 "Custom endpoint for S3-compatible storage (MinIO, etc.). Example: \"http://localhost:9000\". Unset = AWS.",
423 ),
424 (
425 "s3_access_key_id",
426 "Access key when using custom endpoint (or set AWS_ACCESS_KEY_ID).",
427 ),
428 (
429 "s3_secret_access_key",
430 "Secret key when using custom endpoint (or set AWS_SECRET_ACCESS_KEY).",
431 ),
432 (
433 "s3_region",
434 "Region (e.g. us-east-1). Required for custom endpoints; MinIO often uses us-east-1.",
435 ),
436];
437
438impl CloudConfig {
439 pub fn merge(&mut self, other: Self) {
440 if other.s3_endpoint_url.is_some() {
441 self.s3_endpoint_url = other.s3_endpoint_url;
442 }
443 if other.s3_access_key_id.is_some() {
444 self.s3_access_key_id = other.s3_access_key_id;
445 }
446 if other.s3_secret_access_key.is_some() {
447 self.s3_secret_access_key = other.s3_secret_access_key;
448 }
449 if other.s3_region.is_some() {
450 self.s3_region = other.s3_region;
451 }
452 }
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, Default)]
456#[serde(default)]
457pub struct FileLoadingConfig {
458 pub delimiter: Option<u8>,
459 pub has_header: Option<bool>,
460 pub skip_lines: Option<usize>,
461 pub skip_rows: Option<usize>,
462 pub skip_tail_rows: Option<usize>,
464 pub parse_dates: Option<bool>,
466 pub decompress_in_memory: Option<bool>,
468 pub temp_dir: Option<String>,
470 pub single_spine_schema: Option<bool>,
472 pub null_values: Option<Vec<String>>,
474 pub parse_strings: Option<bool>,
476 pub parse_strings_sample_rows: Option<usize>,
478 pub infer_schema_length: Option<usize>,
480 pub ignore_errors: Option<bool>,
482}
483
484const FILE_LOADING_COMMENTS: &[(&str, &str)] = &[
487 (
488 "delimiter",
489 "Default delimiter for CSV files (as ASCII value, e.g., 44 for comma)\nIf not specified, auto-detection is used",
490 ),
491 (
492 "has_header",
493 "Whether files have headers by default\nnull = auto-detect, true = has header, false = no header",
494 ),
495 ("skip_lines", "Number of lines to skip at the start of files"),
496 ("skip_rows", "Number of rows to skip when reading files"),
497 (
498 "skip_tail_rows",
499 "Number of rows to skip at the end of the file (e.g. vendor footer or trailing garbage). CSV only.",
500 ),
501 (
502 "parse_dates",
503 "When true (default), CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime)",
504 ),
505 (
506 "decompress_in_memory",
507 "When true, decompress compressed CSV into memory (eager). When false (default), decompress to a temp file and use lazy scan",
508 ),
509 (
510 "temp_dir",
511 "Directory for decompression temp files. null = system default (e.g. TMPDIR)",
512 ),
513 (
514 "single_spine_schema",
515 "When true (default), infer Hive/partitioned Parquet schema from one file for faster load. When false, use full schema scan (Polars collect_schema).",
516 ),
517 (
518 "null_values",
519 "CSV: values to treat as null. Plain string = all columns; \"COL=VAL\" = column COL only. Example: [\"NA\", \"amount=\"]",
520 ),
521 (
522 "parse_strings",
523 "When false, disable parse-strings. When true or unset, parse all CSV string columns (default). Use CLI --parse-strings=COL or --no-parse-strings.",
524 ),
525 (
526 "parse_strings_sample_rows",
527 "Rows to sample for parse_strings type inference (default 1000).",
528 ),
529 (
530 "infer_schema_length",
531 "Number of rows to use when inferring CSV schema (default 1000). Larger values reduce risk of wrong type (e.g. int then N/A).",
532 ),
533 (
534 "ignore_errors",
535 "When true, CSV reader ignores parse errors and continues with the next batch (default false).",
536 ),
537];
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(default)]
541pub struct DisplayConfig {
542 pub pages_lookahead: usize,
543 pub pages_lookback: usize,
544 pub max_buffered_rows: usize,
546 pub max_buffered_mb: usize,
548 pub row_numbers: bool,
549 pub row_start_index: usize,
550 pub table_cell_padding: usize,
551 pub column_colors: bool,
553 #[serde(default)]
555 pub sidebar_width: Option<u16>,
556}
557
558const DISPLAY_COMMENTS: &[(&str, &str)] = &[
560 (
561 "pages_lookahead",
562 "Number of pages to buffer ahead of visible area\nLarger values = smoother scrolling but more memory",
563 ),
564 (
565 "pages_lookback",
566 "Number of pages to buffer behind visible area\nLarger values = smoother scrolling but more memory",
567 ),
568 (
569 "max_buffered_rows",
570 "Maximum rows in scroll buffer (0 = no limit)\nPrevents unbounded memory use when scrolling",
571 ),
572 (
573 "max_buffered_mb",
574 "Maximum buffer size in MB (0 = no limit)\nUses estimated memory; helps with very wide tables",
575 ),
576 ("row_numbers", "Display row numbers on the left side of the table"),
577 ("row_start_index", "Starting index for row numbers (0 or 1)"),
578 (
579 "table_cell_padding",
580 "Number of spaces between columns in the main data table (>= 0)\nDefault 2",
581 ),
582 (
583 "column_colors",
584 "Colorize main table cells by column type (string, int, float, bool, date/datetime)\nSet to false to use default text color for all cells",
585 ),
586 (
587 "sidebar_width",
588 "Optional: fixed width in characters for all sidebars (Info, Sort & Filter, Templates, Pivot & Melt). When unset, each sidebar uses its default width. Example: sidebar_width = 70",
589 ),
590];
591
592#[derive(Debug, Clone, Serialize, Deserialize)]
593#[serde(default)]
594pub struct PerformanceConfig {
595 pub sampling_threshold: Option<usize>,
597 pub event_poll_interval_ms: u64,
598 pub polars_streaming: bool,
600}
601
602const PERFORMANCE_COMMENTS: &[(&str, &str)] = &[
604 (
605 "sampling_threshold",
606 "Optional: when set, datasets with >= this many rows are sampled for analysis (faster, less memory).\nWhen unset or omitted, full dataset is used. Example: sampling_threshold = 10000",
607 ),
608 (
609 "event_poll_interval_ms",
610 "Event polling interval in milliseconds\nLower values = more responsive but higher CPU usage",
611 ),
612 (
613 "polars_streaming",
614 "Use Polars streaming engine for LazyFrame collect when available (default: true). Reduces memory and can improve performance on large or partitioned data.",
615 ),
616];
617
618pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
620pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
624#[serde(default)]
625pub struct ChartConfig {
626 pub row_limit: Option<usize>,
628}
629
630const CHART_COMMENTS: &[(&str, &str)] = &[
632 (
633 "row_limit",
634 "Maximum rows used when building charts (display and export).\nSet to null for unlimited (uses full dataset). Set to a number (e.g. 10000) to cap. Can also be changed in chart view (Limit Rows). Example: row_limit = 10000",
635 ),
636];
637
638impl Default for ChartConfig {
639 fn default() -> Self {
640 Self {
641 row_limit: Some(DEFAULT_CHART_ROW_LIMIT),
642 }
643 }
644}
645
646impl ChartConfig {
647 pub fn merge(&mut self, other: Self) {
648 if other.row_limit.is_some() {
649 self.row_limit = other.row_limit;
650 }
651 }
652}
653
654#[derive(Debug, Clone, Serialize, Deserialize, Default)]
655#[serde(default)]
656pub struct ThemeConfig {
657 pub colors: ColorConfig,
658}
659
660const THEME_COMMENTS: &[(&str, &str)] = &[];
662
663fn default_row_numbers_color() -> String {
664 "dark_gray".to_string()
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
668#[serde(default)]
669pub struct ColorConfig {
717 pub keybind_hints: String,
718 pub keybind_labels: String,
719 pub throbber: String,
720 pub primary_chart_series_color: String,
721 pub secondary_chart_series_color: String,
722 pub success: String,
723 pub error: String,
724 pub warning: String,
725 pub dimmed: String,
726 pub background: String,
727 pub surface: String,
728 pub controls_bg: String,
729 pub text_primary: String,
730 pub text_secondary: String,
731 pub text_inverse: String,
732 pub table_header: String,
733 pub table_header_bg: String,
734 #[serde(default = "default_row_numbers_color")]
736 pub row_numbers: String,
737 pub column_separator: String,
738 pub table_selected: String,
739 pub sidebar_border: String,
740 pub modal_border_active: String,
741 pub modal_border_error: String,
742 pub distribution_normal: String,
743 pub distribution_skewed: String,
744 pub distribution_other: String,
745 pub outlier_marker: String,
746 pub cursor_focused: String,
747 pub cursor_dimmed: String,
748 pub alternate_row_color: String,
750 pub str_col: String,
752 pub int_col: String,
753 pub float_col: String,
754 pub bool_col: String,
755 pub temporal_col: String,
756 pub chart_series_color_1: String,
758 pub chart_series_color_2: String,
759 pub chart_series_color_3: String,
760 pub chart_series_color_4: String,
761 pub chart_series_color_5: String,
762 pub chart_series_color_6: String,
763 pub chart_series_color_7: String,
764}
765
766const COLOR_COMMENTS: &[(&str, &str)] = &[
768 (
769 "keybind_hints",
770 "Keybind hints (modals, breadcrumb, correlation matrix)",
771 ),
772 ("keybind_labels", "Action labels in controls bar"),
773 ("throbber", "Busy indicator (spinner) in control bar"),
774 (
775 "primary_chart_series_color",
776 "Chart data (histogram bars, Q-Q plot data points)",
777 ),
778 (
779 "secondary_chart_series_color",
780 "Chart theory (histogram overlays, Q-Q plot reference line)",
781 ),
782 ("success", "Success indicators, normal distributions"),
783 ("error", "Error messages, outliers"),
784 ("warning", "Warnings, skewed distributions"),
785 ("dimmed", "Dimmed elements, axis lines"),
786 ("background", "Main background"),
787 ("surface", "Modal/surface backgrounds"),
788 ("controls_bg", "Controls bar background"),
789 ("text_primary", "Primary text"),
790 ("text_secondary", "Secondary text"),
791 ("text_inverse", "Text on light backgrounds"),
792 ("table_header", "Table column header text"),
793 ("table_header_bg", "Table column header background"),
794 ("row_numbers", "Row numbers column text; use \"default\" for terminal default"),
795 ("column_separator", "Vertical line between columns"),
796 ("table_selected", "Selected row style"),
797 ("sidebar_border", "Sidebar borders"),
798 ("modal_border_active", "Active modal elements"),
799 ("modal_border_error", "Error modal borders"),
800 ("distribution_normal", "Normal distribution indicator"),
801 ("distribution_skewed", "Skewed distribution indicator"),
802 ("distribution_other", "Other distribution types"),
803 ("outlier_marker", "Outlier indicators"),
804 (
805 "cursor_focused",
806 "Cursor color when text input is focused\nText under cursor uses reverse of this color",
807 ),
808 (
809 "cursor_dimmed",
810 "Cursor color when text input is unfocused (currently unused - unfocused inputs hide cursor)",
811 ),
812 (
813 "alternate_row_color",
814 "Background color for every other row in the main data table\nSet to \"default\" to disable alternate row coloring",
815 ),
816 ("str_col", "Main table: string column text color"),
817 ("int_col", "Main table: integer column text color"),
818 ("float_col", "Main table: float column text color"),
819 ("bool_col", "Main table: boolean column text color"),
820 ("temporal_col", "Main table: date/datetime/time column text color"),
821 ("chart_series_color_1", "Chart view: first series color"),
822 ("chart_series_color_2", "Chart view: second series color"),
823 ("chart_series_color_3", "Chart view: third series color"),
824 ("chart_series_color_4", "Chart view: fourth series color"),
825 ("chart_series_color_5", "Chart view: fifth series color"),
826 ("chart_series_color_6", "Chart view: sixth series color"),
827 ("chart_series_color_7", "Chart view: seventh series color"),
828];
829
830#[derive(Debug, Clone, Serialize, Deserialize, Default)]
831#[serde(default)]
832pub struct UiConfig {
833 pub controls: ControlsConfig,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize)]
837#[serde(default)]
838pub struct ControlsConfig {
839 pub custom_controls: Option<Vec<(String, String)>>,
840 pub row_count_width: usize,
841}
842
843const CONTROLS_COMMENTS: &[(&str, &str)] = &[
845 (
846 "custom_controls",
847 "Custom control keybindings (optional)\nFormat: [[\"key\", \"label\"], [\"key\", \"label\"], ...]\nIf not specified, uses default controls",
848 ),
849 ("row_count_width", "Row count display width in characters"),
850];
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
853#[serde(default)]
854pub struct QueryConfig {
855 pub history_limit: usize,
856 pub enable_history: bool,
857}
858
859const QUERY_COMMENTS: &[(&str, &str)] = &[
861 (
862 "history_limit",
863 "Maximum number of queries to keep in history",
864 ),
865 ("enable_history", "Enable query history caching"),
866];
867
868#[derive(Debug, Clone, Serialize, Deserialize, Default)]
869#[serde(default)]
870pub struct TemplateConfig {
871 pub auto_apply: bool,
872}
873
874const TEMPLATE_COMMENTS: &[(&str, &str)] = &[(
876 "auto_apply",
877 "Auto-apply most relevant template on file open",
878)];
879
880#[derive(Debug, Clone, Serialize, Deserialize)]
881#[serde(default)]
882pub struct DebugConfig {
883 pub enabled: bool,
884 pub show_performance: bool,
885 pub show_query: bool,
886 pub show_transformations: bool,
887}
888
889const DEBUG_COMMENTS: &[(&str, &str)] = &[
891 ("enabled", "Enable debug overlay by default"),
892 (
893 "show_performance",
894 "Show performance metrics in debug overlay",
895 ),
896 ("show_query", "Show LazyFrame query in debug overlay"),
897 (
898 "show_transformations",
899 "Show transformation state in debug overlay",
900 ),
901];
902
903impl Default for AppConfig {
905 fn default() -> Self {
906 Self {
907 version: "0.2".to_string(),
908 cloud: CloudConfig::default(),
909 file_loading: FileLoadingConfig::default(),
910 display: DisplayConfig::default(),
911 performance: PerformanceConfig::default(),
912 chart: ChartConfig::default(),
913 theme: ThemeConfig::default(),
914 ui: UiConfig::default(),
915 query: QueryConfig::default(),
916 templates: TemplateConfig::default(),
917 debug: DebugConfig::default(),
918 }
919 }
920}
921
922impl Default for DisplayConfig {
923 fn default() -> Self {
924 Self {
925 pages_lookahead: 3,
926 pages_lookback: 3,
927 max_buffered_rows: 100_000,
928 max_buffered_mb: 512,
929 row_numbers: false,
930 row_start_index: 1,
931 table_cell_padding: 2,
932 column_colors: true,
933 sidebar_width: None,
934 }
935 }
936}
937
938impl Default for PerformanceConfig {
939 fn default() -> Self {
940 Self {
941 sampling_threshold: None,
942 event_poll_interval_ms: 25,
943 polars_streaming: true,
944 }
945 }
946}
947
948impl Default for ColorConfig {
949 fn default() -> Self {
950 Self {
951 keybind_hints: "cyan".to_string(),
952 keybind_labels: "indexed(252)".to_string(),
953 throbber: "cyan".to_string(),
954 primary_chart_series_color: "cyan".to_string(),
955 secondary_chart_series_color: "indexed(245)".to_string(),
956 success: "green".to_string(),
957 error: "red".to_string(),
958 warning: "yellow".to_string(),
959 dimmed: "dark_gray".to_string(),
960 background: "default".to_string(),
961 surface: "default".to_string(),
962 controls_bg: "indexed(235)".to_string(),
963 text_primary: "default".to_string(),
964 text_secondary: "indexed(240)".to_string(),
965 text_inverse: "black".to_string(),
966 table_header: "white".to_string(),
967 table_header_bg: "indexed(235)".to_string(),
968 row_numbers: "dark_gray".to_string(),
969 column_separator: "cyan".to_string(),
970 table_selected: "reversed".to_string(),
971 sidebar_border: "indexed(235)".to_string(),
972 modal_border_active: "yellow".to_string(),
973 modal_border_error: "red".to_string(),
974 distribution_normal: "green".to_string(),
975 distribution_skewed: "yellow".to_string(),
976 distribution_other: "white".to_string(),
977 outlier_marker: "red".to_string(),
978 cursor_focused: "default".to_string(),
979 cursor_dimmed: "default".to_string(),
980 alternate_row_color: "indexed(235)".to_string(),
981 str_col: "green".to_string(),
982 int_col: "cyan".to_string(),
983 float_col: "blue".to_string(),
984 bool_col: "yellow".to_string(),
985 temporal_col: "magenta".to_string(),
986 chart_series_color_1: "cyan".to_string(),
987 chart_series_color_2: "magenta".to_string(),
988 chart_series_color_3: "green".to_string(),
989 chart_series_color_4: "yellow".to_string(),
990 chart_series_color_5: "blue".to_string(),
991 chart_series_color_6: "red".to_string(),
992 chart_series_color_7: "bright_cyan".to_string(),
993 }
994 }
995}
996
997impl Default for ControlsConfig {
998 fn default() -> Self {
999 Self {
1000 custom_controls: None,
1001 row_count_width: 20,
1002 }
1003 }
1004}
1005
1006impl Default for QueryConfig {
1007 fn default() -> Self {
1008 Self {
1009 history_limit: 1000,
1010 enable_history: true,
1011 }
1012 }
1013}
1014
1015impl Default for DebugConfig {
1016 fn default() -> Self {
1017 Self {
1018 enabled: false,
1019 show_performance: true,
1020 show_query: true,
1021 show_transformations: true,
1022 }
1023 }
1024}
1025
1026impl AppConfig {
1028 pub fn load(app_name: &str) -> Result<Self> {
1030 let mut config = AppConfig::default();
1031
1032 let config_path = ConfigManager::new(app_name)
1034 .ok()
1035 .map(|m| m.config_path("config.toml"));
1036 if let Ok(user_config) = Self::load_user_config(app_name) {
1037 config.merge(user_config);
1038 }
1039
1040 config.validate().map_err(|e| {
1042 let path_hint = config_path
1043 .as_ref()
1044 .map(|p| format!(" in {}", p.display()))
1045 .unwrap_or_default();
1046 eyre!("Invalid configuration{}: {}", path_hint, e)
1047 })?;
1048
1049 Ok(config)
1050 }
1051
1052 fn load_user_config(app_name: &str) -> Result<AppConfig> {
1054 let config_manager = ConfigManager::new(app_name)?;
1055 let config_path = config_manager.config_path("config.toml");
1056
1057 if !config_path.exists() {
1058 return Ok(AppConfig::default());
1059 }
1060
1061 let content = std::fs::read_to_string(&config_path).map_err(|e| {
1062 eyre!(
1063 "Failed to read config file at {}: {}",
1064 config_path.display(),
1065 e
1066 )
1067 })?;
1068
1069 toml::from_str(&content).map_err(|e| {
1070 eyre!(
1071 "Failed to parse config file at {}: {}",
1072 config_path.display(),
1073 e
1074 )
1075 })
1076 }
1077
1078 pub fn merge(&mut self, other: AppConfig) {
1080 if other.version != AppConfig::default().version {
1082 self.version = other.version;
1083 }
1084
1085 self.cloud.merge(other.cloud);
1087 self.file_loading.merge(other.file_loading);
1088 self.display.merge(other.display);
1089 self.performance.merge(other.performance);
1090 self.chart.merge(other.chart);
1091 self.theme.merge(other.theme);
1092 self.ui.merge(other.ui);
1093 self.query.merge(other.query);
1094 self.templates.merge(other.templates);
1095 self.debug.merge(other.debug);
1096 }
1097
1098 pub fn validate(&self) -> Result<()> {
1100 if !self.version.starts_with("0.2") {
1102 return Err(eyre!(
1103 "Unsupported config version: {}. Expected 0.2.x",
1104 self.version
1105 ));
1106 }
1107
1108 if let Some(t) = self.performance.sampling_threshold {
1110 if t == 0 {
1111 return Err(eyre!("sampling_threshold must be greater than 0 when set"));
1112 }
1113 }
1114
1115 if self.performance.event_poll_interval_ms == 0 {
1116 return Err(eyre!("event_poll_interval_ms must be greater than 0"));
1117 }
1118
1119 if let Some(n) = self.chart.row_limit {
1120 if n == 0 || n > MAX_CHART_ROW_LIMIT {
1121 return Err(eyre!(
1122 "chart.row_limit must be between 1 and {} when set, got {}",
1123 MAX_CHART_ROW_LIMIT,
1124 n
1125 ));
1126 }
1127 }
1128
1129 let parser = ColorParser::new();
1131 self.theme.colors.validate(&parser)?;
1132
1133 Ok(())
1134 }
1135}
1136
1137impl FileLoadingConfig {
1139 pub fn merge(&mut self, other: Self) {
1140 if other.delimiter.is_some() {
1141 self.delimiter = other.delimiter;
1142 }
1143 if other.has_header.is_some() {
1144 self.has_header = other.has_header;
1145 }
1146 if other.skip_lines.is_some() {
1147 self.skip_lines = other.skip_lines;
1148 }
1149 if other.skip_rows.is_some() {
1150 self.skip_rows = other.skip_rows;
1151 }
1152 if other.skip_tail_rows.is_some() {
1153 self.skip_tail_rows = other.skip_tail_rows;
1154 }
1155 if other.parse_dates.is_some() {
1156 self.parse_dates = other.parse_dates;
1157 }
1158 if other.decompress_in_memory.is_some() {
1159 self.decompress_in_memory = other.decompress_in_memory;
1160 }
1161 if other.temp_dir.is_some() {
1162 self.temp_dir = other.temp_dir.clone();
1163 }
1164 if other.single_spine_schema.is_some() {
1165 self.single_spine_schema = other.single_spine_schema;
1166 }
1167 if other.null_values.is_some() {
1168 self.null_values = other.null_values.clone();
1169 }
1170 if other.parse_strings.is_some() {
1171 self.parse_strings = other.parse_strings;
1172 }
1173 if other.parse_strings_sample_rows.is_some() {
1174 self.parse_strings_sample_rows = other.parse_strings_sample_rows;
1175 }
1176 if other.infer_schema_length.is_some() {
1177 self.infer_schema_length = other.infer_schema_length;
1178 }
1179 if other.ignore_errors.is_some() {
1180 self.ignore_errors = other.ignore_errors;
1181 }
1182 }
1183}
1184
1185impl DisplayConfig {
1186 pub fn merge(&mut self, other: Self) {
1187 let default = DisplayConfig::default();
1188 if other.pages_lookahead != default.pages_lookahead {
1189 self.pages_lookahead = other.pages_lookahead;
1190 }
1191 if other.pages_lookback != default.pages_lookback {
1192 self.pages_lookback = other.pages_lookback;
1193 }
1194 if other.max_buffered_rows != default.max_buffered_rows {
1195 self.max_buffered_rows = other.max_buffered_rows;
1196 }
1197 if other.max_buffered_mb != default.max_buffered_mb {
1198 self.max_buffered_mb = other.max_buffered_mb;
1199 }
1200 if other.row_numbers != default.row_numbers {
1201 self.row_numbers = other.row_numbers;
1202 }
1203 if other.row_start_index != default.row_start_index {
1204 self.row_start_index = other.row_start_index;
1205 }
1206 if other.table_cell_padding != default.table_cell_padding {
1207 self.table_cell_padding = other.table_cell_padding;
1208 }
1209 if other.column_colors != default.column_colors {
1210 self.column_colors = other.column_colors;
1211 }
1212 if other.sidebar_width != default.sidebar_width {
1213 self.sidebar_width = other.sidebar_width;
1214 }
1215 }
1216}
1217
1218impl PerformanceConfig {
1219 pub fn merge(&mut self, other: Self) {
1220 let default = PerformanceConfig::default();
1221 if other.sampling_threshold != default.sampling_threshold {
1222 self.sampling_threshold = other.sampling_threshold;
1223 }
1224 if other.event_poll_interval_ms != default.event_poll_interval_ms {
1225 self.event_poll_interval_ms = other.event_poll_interval_ms;
1226 }
1227 if other.polars_streaming != default.polars_streaming {
1228 self.polars_streaming = other.polars_streaming;
1229 }
1230 }
1231}
1232
1233impl ThemeConfig {
1234 pub fn merge(&mut self, other: Self) {
1235 self.colors.merge(other.colors);
1236 }
1237}
1238
1239impl ColorConfig {
1240 fn validate(&self, parser: &ColorParser) -> Result<()> {
1242 macro_rules! validate_color {
1244 ($field:expr, $name:expr) => {
1245 parser.parse($field).map_err(|e| {
1246 eyre!(
1247 "theme.colors.{}: {}. Use a valid color name (e.g. red, cyan, bright_red), \
1248 hex (#rrggbb), or indexed(0-255)",
1249 $name,
1250 e
1251 )
1252 })?;
1253 };
1254 }
1255
1256 validate_color!(&self.keybind_hints, "keybind_hints");
1257 validate_color!(&self.keybind_labels, "keybind_labels");
1258 validate_color!(&self.throbber, "throbber");
1259 validate_color!(
1260 &self.primary_chart_series_color,
1261 "primary_chart_series_color"
1262 );
1263 validate_color!(
1264 &self.secondary_chart_series_color,
1265 "secondary_chart_series_color"
1266 );
1267 validate_color!(&self.success, "success");
1268 validate_color!(&self.error, "error");
1269 validate_color!(&self.warning, "warning");
1270 validate_color!(&self.dimmed, "dimmed");
1271 validate_color!(&self.background, "background");
1272 validate_color!(&self.surface, "surface");
1273 validate_color!(&self.controls_bg, "controls_bg");
1274 validate_color!(&self.text_primary, "text_primary");
1275 validate_color!(&self.text_secondary, "text_secondary");
1276 validate_color!(&self.text_inverse, "text_inverse");
1277 validate_color!(&self.table_header, "table_header");
1278 validate_color!(&self.table_header_bg, "table_header_bg");
1279 validate_color!(&self.row_numbers, "row_numbers");
1280 validate_color!(&self.column_separator, "column_separator");
1281 validate_color!(&self.table_selected, "table_selected");
1282 validate_color!(&self.sidebar_border, "sidebar_border");
1283 validate_color!(&self.modal_border_active, "modal_border_active");
1284 validate_color!(&self.modal_border_error, "modal_border_error");
1285 validate_color!(&self.distribution_normal, "distribution_normal");
1286 validate_color!(&self.distribution_skewed, "distribution_skewed");
1287 validate_color!(&self.distribution_other, "distribution_other");
1288 validate_color!(&self.outlier_marker, "outlier_marker");
1289 validate_color!(&self.cursor_focused, "cursor_focused");
1290 validate_color!(&self.cursor_dimmed, "cursor_dimmed");
1291 if self.alternate_row_color != "default" {
1292 validate_color!(&self.alternate_row_color, "alternate_row_color");
1293 }
1294 validate_color!(&self.str_col, "str_col");
1295 validate_color!(&self.int_col, "int_col");
1296 validate_color!(&self.float_col, "float_col");
1297 validate_color!(&self.bool_col, "bool_col");
1298 validate_color!(&self.temporal_col, "temporal_col");
1299 validate_color!(&self.chart_series_color_1, "chart_series_color_1");
1300 validate_color!(&self.chart_series_color_2, "chart_series_color_2");
1301 validate_color!(&self.chart_series_color_3, "chart_series_color_3");
1302 validate_color!(&self.chart_series_color_4, "chart_series_color_4");
1303 validate_color!(&self.chart_series_color_5, "chart_series_color_5");
1304 validate_color!(&self.chart_series_color_6, "chart_series_color_6");
1305 validate_color!(&self.chart_series_color_7, "chart_series_color_7");
1306
1307 Ok(())
1308 }
1309
1310 pub fn merge(&mut self, other: Self) {
1311 let default = ColorConfig::default();
1312
1313 if other.keybind_hints != default.keybind_hints {
1315 self.keybind_hints = other.keybind_hints;
1316 }
1317 if other.keybind_labels != default.keybind_labels {
1318 self.keybind_labels = other.keybind_labels;
1319 }
1320 if other.throbber != default.throbber {
1321 self.throbber = other.throbber;
1322 }
1323 if other.primary_chart_series_color != default.primary_chart_series_color {
1324 self.primary_chart_series_color = other.primary_chart_series_color;
1325 }
1326 if other.secondary_chart_series_color != default.secondary_chart_series_color {
1327 self.secondary_chart_series_color = other.secondary_chart_series_color;
1328 }
1329 if other.success != default.success {
1330 self.success = other.success;
1331 }
1332 if other.error != default.error {
1333 self.error = other.error;
1334 }
1335 if other.warning != default.warning {
1336 self.warning = other.warning;
1337 }
1338 if other.dimmed != default.dimmed {
1339 self.dimmed = other.dimmed;
1340 }
1341 if other.background != default.background {
1342 self.background = other.background;
1343 }
1344 if other.surface != default.surface {
1345 self.surface = other.surface;
1346 }
1347 if other.controls_bg != default.controls_bg {
1348 self.controls_bg = other.controls_bg;
1349 }
1350 if other.text_primary != default.text_primary {
1351 self.text_primary = other.text_primary;
1352 }
1353 if other.text_secondary != default.text_secondary {
1354 self.text_secondary = other.text_secondary;
1355 }
1356 if other.text_inverse != default.text_inverse {
1357 self.text_inverse = other.text_inverse;
1358 }
1359 if other.table_header != default.table_header {
1360 self.table_header = other.table_header;
1361 }
1362 if other.table_header_bg != default.table_header_bg {
1363 self.table_header_bg = other.table_header_bg;
1364 }
1365 if other.row_numbers != default.row_numbers {
1366 self.row_numbers = other.row_numbers;
1367 }
1368 if other.column_separator != default.column_separator {
1369 self.column_separator = other.column_separator;
1370 }
1371 if other.table_selected != default.table_selected {
1372 self.table_selected = other.table_selected;
1373 }
1374 if other.sidebar_border != default.sidebar_border {
1375 self.sidebar_border = other.sidebar_border;
1376 }
1377 if other.modal_border_active != default.modal_border_active {
1378 self.modal_border_active = other.modal_border_active;
1379 }
1380 if other.modal_border_error != default.modal_border_error {
1381 self.modal_border_error = other.modal_border_error;
1382 }
1383 if other.distribution_normal != default.distribution_normal {
1384 self.distribution_normal = other.distribution_normal;
1385 }
1386 if other.distribution_skewed != default.distribution_skewed {
1387 self.distribution_skewed = other.distribution_skewed;
1388 }
1389 if other.distribution_other != default.distribution_other {
1390 self.distribution_other = other.distribution_other;
1391 }
1392 if other.outlier_marker != default.outlier_marker {
1393 self.outlier_marker = other.outlier_marker;
1394 }
1395 if other.cursor_focused != default.cursor_focused {
1396 self.cursor_focused = other.cursor_focused;
1397 }
1398 if other.cursor_dimmed != default.cursor_dimmed {
1399 self.cursor_dimmed = other.cursor_dimmed;
1400 }
1401 if other.alternate_row_color != default.alternate_row_color {
1402 self.alternate_row_color = other.alternate_row_color;
1403 }
1404 if other.str_col != default.str_col {
1405 self.str_col = other.str_col;
1406 }
1407 if other.int_col != default.int_col {
1408 self.int_col = other.int_col;
1409 }
1410 if other.float_col != default.float_col {
1411 self.float_col = other.float_col;
1412 }
1413 if other.bool_col != default.bool_col {
1414 self.bool_col = other.bool_col;
1415 }
1416 if other.temporal_col != default.temporal_col {
1417 self.temporal_col = other.temporal_col;
1418 }
1419 if other.chart_series_color_1 != default.chart_series_color_1 {
1420 self.chart_series_color_1 = other.chart_series_color_1;
1421 }
1422 if other.chart_series_color_2 != default.chart_series_color_2 {
1423 self.chart_series_color_2 = other.chart_series_color_2;
1424 }
1425 if other.chart_series_color_3 != default.chart_series_color_3 {
1426 self.chart_series_color_3 = other.chart_series_color_3;
1427 }
1428 if other.chart_series_color_4 != default.chart_series_color_4 {
1429 self.chart_series_color_4 = other.chart_series_color_4;
1430 }
1431 if other.chart_series_color_5 != default.chart_series_color_5 {
1432 self.chart_series_color_5 = other.chart_series_color_5;
1433 }
1434 if other.chart_series_color_6 != default.chart_series_color_6 {
1435 self.chart_series_color_6 = other.chart_series_color_6;
1436 }
1437 if other.chart_series_color_7 != default.chart_series_color_7 {
1438 self.chart_series_color_7 = other.chart_series_color_7;
1439 }
1440 }
1441}
1442
1443impl UiConfig {
1444 pub fn merge(&mut self, other: Self) {
1445 self.controls.merge(other.controls);
1446 }
1447}
1448
1449impl ControlsConfig {
1450 pub fn merge(&mut self, other: Self) {
1451 if other.custom_controls.is_some() {
1452 self.custom_controls = other.custom_controls;
1453 }
1454 let default = ControlsConfig::default();
1455 if other.row_count_width != default.row_count_width {
1456 self.row_count_width = other.row_count_width;
1457 }
1458 }
1459}
1460
1461impl QueryConfig {
1462 pub fn merge(&mut self, other: Self) {
1463 let default = QueryConfig::default();
1464 if other.history_limit != default.history_limit {
1465 self.history_limit = other.history_limit;
1466 }
1467 if other.enable_history != default.enable_history {
1468 self.enable_history = other.enable_history;
1469 }
1470 }
1471}
1472
1473impl TemplateConfig {
1474 pub fn merge(&mut self, other: Self) {
1475 let default = TemplateConfig::default();
1476 if other.auto_apply != default.auto_apply {
1477 self.auto_apply = other.auto_apply;
1478 }
1479 }
1480}
1481
1482impl DebugConfig {
1483 pub fn merge(&mut self, other: Self) {
1484 let default = DebugConfig::default();
1485 if other.enabled != default.enabled {
1486 self.enabled = other.enabled;
1487 }
1488 if other.show_performance != default.show_performance {
1489 self.show_performance = other.show_performance;
1490 }
1491 if other.show_query != default.show_query {
1492 self.show_query = other.show_query;
1493 }
1494 if other.show_transformations != default.show_transformations {
1495 self.show_transformations = other.show_transformations;
1496 }
1497 }
1498}
1499
1500pub struct ColorParser {
1502 supports_true_color: bool,
1503 supports_256: bool,
1504 no_color: bool,
1505}
1506
1507impl ColorParser {
1508 pub fn new() -> Self {
1510 let no_color = std::env::var("NO_COLOR").is_ok();
1511 let support = supports_color::on(Stream::Stdout);
1512
1513 Self {
1514 supports_true_color: support.as_ref().map(|s| s.has_16m).unwrap_or(false),
1515 supports_256: support.as_ref().map(|s| s.has_256).unwrap_or(false),
1516 no_color,
1517 }
1518 }
1519
1520 pub fn parse(&self, s: &str) -> Result<Color> {
1522 if self.no_color {
1523 return Ok(Color::Reset);
1524 }
1525
1526 let trimmed = s.trim();
1527
1528 if trimmed.starts_with('#') && trimmed.len() == 7 {
1530 let (r, g, b) = parse_hex(trimmed)?;
1531 return Ok(self.convert_rgb_to_terminal_color(r, g, b));
1532 }
1533
1534 if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
1536 let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
1538 eyre!(
1539 "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
1540 trimmed
1541 )
1542 })?;
1543 return Ok(Color::Indexed(num));
1544 }
1545
1546 let lower = trimmed.to_lowercase();
1548 match lower.as_str() {
1549 "black" => Ok(Color::Black),
1551 "red" => Ok(Color::Red),
1552 "green" => Ok(Color::Green),
1553 "yellow" => Ok(Color::Yellow),
1554 "blue" => Ok(Color::Blue),
1555 "magenta" => Ok(Color::Magenta),
1556 "cyan" => Ok(Color::Cyan),
1557 "white" => Ok(Color::White),
1558
1559 "bright_black" | "bright black" => Ok(Color::Indexed(8)),
1561 "bright_red" | "bright red" => Ok(Color::Indexed(9)),
1562 "bright_green" | "bright green" => Ok(Color::Indexed(10)),
1563 "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
1564 "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
1565 "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
1566 "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
1567 "bright_white" | "bright white" => Ok(Color::Indexed(15)),
1568
1569 "gray" | "grey" => Ok(Color::Indexed(8)),
1571 "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
1572 "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
1573
1574 "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
1576
1577 _ => Err(eyre!(
1578 "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
1579 bright variants (bright_red, etc.), or hex colors (#ff0000)",
1580 trimmed
1581 )),
1582 }
1583 }
1584
1585 fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
1587 if self.supports_true_color {
1588 Color::Rgb(r, g, b)
1589 } else if self.supports_256 {
1590 Color::Indexed(rgb_to_256_color(r, g, b))
1591 } else {
1592 rgb_to_basic_ansi(r, g, b)
1593 }
1594 }
1595}
1596
1597impl Default for ColorParser {
1598 fn default() -> Self {
1599 Self::new()
1600 }
1601}
1602
1603fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
1605 if !s.starts_with('#') || s.len() != 7 {
1606 return Err(eyre!(
1607 "Invalid hex color format: '{}'. Expected format: #rrggbb",
1608 s
1609 ));
1610 }
1611
1612 let r = u8::from_str_radix(&s[1..3], 16)
1613 .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
1614 let g = u8::from_str_radix(&s[3..5], 16)
1615 .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
1616 let b = u8::from_str_radix(&s[5..7], 16)
1617 .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
1618
1619 Ok((r, g, b))
1620}
1621
1622pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
1625 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1627 if max_diff < 10 {
1628 let gray = (r as u16 + g as u16 + b as u16) / 3;
1630 if gray < 8 {
1631 return 16; } else if gray > 247 {
1633 return 231; } else {
1635 return 232 + ((gray - 8) * 24 / 240) as u8;
1636 }
1637 }
1638
1639 let r_idx = (r as u16 * 5 / 255) as u8;
1641 let g_idx = (g as u16 * 5 / 255) as u8;
1642 let b_idx = (b as u16 * 5 / 255) as u8;
1643
1644 16 + 36 * r_idx + 6 * g_idx + b_idx
1645}
1646
1647pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
1649 let r_bright = r > 128;
1651 let g_bright = g > 128;
1652 let b_bright = b > 128;
1653
1654 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1656 if max_diff < 30 {
1657 let avg = (r as u16 + g as u16 + b as u16) / 3;
1658 return if avg < 64 { Color::Black } else { Color::White };
1659 }
1660
1661 match (r_bright, g_bright, b_bright) {
1663 (false, false, false) => Color::Black,
1664 (true, false, false) => Color::Red,
1665 (false, true, false) => Color::Green,
1666 (true, true, false) => Color::Yellow,
1667 (false, false, true) => Color::Blue,
1668 (true, false, true) => Color::Magenta,
1669 (false, true, true) => Color::Cyan,
1670 (true, true, true) => Color::White,
1671 }
1672}
1673
1674#[derive(Debug, Clone)]
1676pub struct Theme {
1677 pub colors: HashMap<String, Color>,
1678}
1679
1680impl Theme {
1681 pub fn from_config(config: &ThemeConfig) -> Result<Self> {
1683 let parser = ColorParser::new();
1684 let mut colors = HashMap::new();
1685
1686 colors.insert(
1688 "keybind_hints".to_string(),
1689 parser.parse(&config.colors.keybind_hints)?,
1690 );
1691 colors.insert(
1692 "keybind_labels".to_string(),
1693 parser.parse(&config.colors.keybind_labels)?,
1694 );
1695 colors.insert(
1696 "throbber".to_string(),
1697 parser.parse(&config.colors.throbber)?,
1698 );
1699 colors.insert(
1700 "primary_chart_series_color".to_string(),
1701 parser.parse(&config.colors.primary_chart_series_color)?,
1702 );
1703 colors.insert(
1704 "secondary_chart_series_color".to_string(),
1705 parser.parse(&config.colors.secondary_chart_series_color)?,
1706 );
1707 colors.insert("success".to_string(), parser.parse(&config.colors.success)?);
1708 colors.insert("error".to_string(), parser.parse(&config.colors.error)?);
1709 colors.insert("warning".to_string(), parser.parse(&config.colors.warning)?);
1710 colors.insert("dimmed".to_string(), parser.parse(&config.colors.dimmed)?);
1711 colors.insert(
1712 "background".to_string(),
1713 parser.parse(&config.colors.background)?,
1714 );
1715 colors.insert("surface".to_string(), parser.parse(&config.colors.surface)?);
1716 colors.insert(
1717 "controls_bg".to_string(),
1718 parser.parse(&config.colors.controls_bg)?,
1719 );
1720 colors.insert(
1721 "text_primary".to_string(),
1722 parser.parse(&config.colors.text_primary)?,
1723 );
1724 colors.insert(
1725 "text_secondary".to_string(),
1726 parser.parse(&config.colors.text_secondary)?,
1727 );
1728 colors.insert(
1729 "text_inverse".to_string(),
1730 parser.parse(&config.colors.text_inverse)?,
1731 );
1732 colors.insert(
1733 "table_header".to_string(),
1734 parser.parse(&config.colors.table_header)?,
1735 );
1736 colors.insert(
1737 "table_header_bg".to_string(),
1738 parser.parse(&config.colors.table_header_bg)?,
1739 );
1740 colors.insert(
1741 "row_numbers".to_string(),
1742 parser.parse(&config.colors.row_numbers)?,
1743 );
1744 colors.insert(
1745 "column_separator".to_string(),
1746 parser.parse(&config.colors.column_separator)?,
1747 );
1748 colors.insert(
1749 "table_selected".to_string(),
1750 parser.parse(&config.colors.table_selected)?,
1751 );
1752 colors.insert(
1753 "sidebar_border".to_string(),
1754 parser.parse(&config.colors.sidebar_border)?,
1755 );
1756 colors.insert(
1757 "modal_border_active".to_string(),
1758 parser.parse(&config.colors.modal_border_active)?,
1759 );
1760 colors.insert(
1761 "modal_border_error".to_string(),
1762 parser.parse(&config.colors.modal_border_error)?,
1763 );
1764 colors.insert(
1765 "distribution_normal".to_string(),
1766 parser.parse(&config.colors.distribution_normal)?,
1767 );
1768 colors.insert(
1769 "distribution_skewed".to_string(),
1770 parser.parse(&config.colors.distribution_skewed)?,
1771 );
1772 colors.insert(
1773 "distribution_other".to_string(),
1774 parser.parse(&config.colors.distribution_other)?,
1775 );
1776 colors.insert(
1777 "outlier_marker".to_string(),
1778 parser.parse(&config.colors.outlier_marker)?,
1779 );
1780 colors.insert(
1781 "cursor_focused".to_string(),
1782 parser.parse(&config.colors.cursor_focused)?,
1783 );
1784 colors.insert(
1785 "cursor_dimmed".to_string(),
1786 parser.parse(&config.colors.cursor_dimmed)?,
1787 );
1788 if config.colors.alternate_row_color != "default" {
1789 colors.insert(
1790 "alternate_row_color".to_string(),
1791 parser.parse(&config.colors.alternate_row_color)?,
1792 );
1793 }
1794 colors.insert("str_col".to_string(), parser.parse(&config.colors.str_col)?);
1795 colors.insert("int_col".to_string(), parser.parse(&config.colors.int_col)?);
1796 colors.insert(
1797 "float_col".to_string(),
1798 parser.parse(&config.colors.float_col)?,
1799 );
1800 colors.insert(
1801 "bool_col".to_string(),
1802 parser.parse(&config.colors.bool_col)?,
1803 );
1804 colors.insert(
1805 "temporal_col".to_string(),
1806 parser.parse(&config.colors.temporal_col)?,
1807 );
1808 colors.insert(
1809 "chart_series_color_1".to_string(),
1810 parser.parse(&config.colors.chart_series_color_1)?,
1811 );
1812 colors.insert(
1813 "chart_series_color_2".to_string(),
1814 parser.parse(&config.colors.chart_series_color_2)?,
1815 );
1816 colors.insert(
1817 "chart_series_color_3".to_string(),
1818 parser.parse(&config.colors.chart_series_color_3)?,
1819 );
1820 colors.insert(
1821 "chart_series_color_4".to_string(),
1822 parser.parse(&config.colors.chart_series_color_4)?,
1823 );
1824 colors.insert(
1825 "chart_series_color_5".to_string(),
1826 parser.parse(&config.colors.chart_series_color_5)?,
1827 );
1828 colors.insert(
1829 "chart_series_color_6".to_string(),
1830 parser.parse(&config.colors.chart_series_color_6)?,
1831 );
1832 colors.insert(
1833 "chart_series_color_7".to_string(),
1834 parser.parse(&config.colors.chart_series_color_7)?,
1835 );
1836
1837 Ok(Self { colors })
1838 }
1839
1840 pub fn get(&self, name: &str) -> Color {
1842 self.colors.get(name).copied().unwrap_or(Color::Reset)
1843 }
1844
1845 pub fn get_optional(&self, name: &str) -> Option<Color> {
1847 self.colors.get(name).copied()
1848 }
1849}