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 parse_dates: Option<bool>,
464 pub decompress_in_memory: Option<bool>,
466 pub temp_dir: Option<String>,
468 pub single_spine_schema: Option<bool>,
470 pub null_values: Option<Vec<String>>,
472}
473
474const FILE_LOADING_COMMENTS: &[(&str, &str)] = &[
477 (
478 "delimiter",
479 "Default delimiter for CSV files (as ASCII value, e.g., 44 for comma)\nIf not specified, auto-detection is used",
480 ),
481 (
482 "has_header",
483 "Whether files have headers by default\nnull = auto-detect, true = has header, false = no header",
484 ),
485 ("skip_lines", "Number of lines to skip at the start of files"),
486 ("skip_rows", "Number of rows to skip when reading files"),
487 (
488 "parse_dates",
489 "When true (default), CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime)",
490 ),
491 (
492 "decompress_in_memory",
493 "When true, decompress compressed CSV into memory (eager). When false (default), decompress to a temp file and use lazy scan",
494 ),
495 (
496 "temp_dir",
497 "Directory for decompression temp files. null = system default (e.g. TMPDIR)",
498 ),
499 (
500 "single_spine_schema",
501 "When true (default), infer Hive/partitioned Parquet schema from one file for faster load. When false, use full schema scan (Polars collect_schema).",
502 ),
503 (
504 "null_values",
505 "CSV: values to treat as null. Plain string = all columns; \"COL=VAL\" = column COL only. Example: [\"NA\", \"amount=\"]",
506 ),
507];
508
509#[derive(Debug, Clone, Serialize, Deserialize)]
510#[serde(default)]
511pub struct DisplayConfig {
512 pub pages_lookahead: usize,
513 pub pages_lookback: usize,
514 pub max_buffered_rows: usize,
516 pub max_buffered_mb: usize,
518 pub row_numbers: bool,
519 pub row_start_index: usize,
520 pub table_cell_padding: usize,
521 pub column_colors: bool,
523 #[serde(default)]
525 pub sidebar_width: Option<u16>,
526}
527
528const DISPLAY_COMMENTS: &[(&str, &str)] = &[
530 (
531 "pages_lookahead",
532 "Number of pages to buffer ahead of visible area\nLarger values = smoother scrolling but more memory",
533 ),
534 (
535 "pages_lookback",
536 "Number of pages to buffer behind visible area\nLarger values = smoother scrolling but more memory",
537 ),
538 (
539 "max_buffered_rows",
540 "Maximum rows in scroll buffer (0 = no limit)\nPrevents unbounded memory use when scrolling",
541 ),
542 (
543 "max_buffered_mb",
544 "Maximum buffer size in MB (0 = no limit)\nUses estimated memory; helps with very wide tables",
545 ),
546 ("row_numbers", "Display row numbers on the left side of the table"),
547 ("row_start_index", "Starting index for row numbers (0 or 1)"),
548 (
549 "table_cell_padding",
550 "Number of spaces between columns in the main data table (>= 0)\nDefault 2",
551 ),
552 (
553 "column_colors",
554 "Colorize main table cells by column type (string, int, float, bool, date/datetime)\nSet to false to use default text color for all cells",
555 ),
556 (
557 "sidebar_width",
558 "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",
559 ),
560];
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
563#[serde(default)]
564pub struct PerformanceConfig {
565 pub sampling_threshold: Option<usize>,
567 pub event_poll_interval_ms: u64,
568 pub polars_streaming: bool,
570}
571
572const PERFORMANCE_COMMENTS: &[(&str, &str)] = &[
574 (
575 "sampling_threshold",
576 "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",
577 ),
578 (
579 "event_poll_interval_ms",
580 "Event polling interval in milliseconds\nLower values = more responsive but higher CPU usage",
581 ),
582 (
583 "polars_streaming",
584 "Use Polars streaming engine for LazyFrame collect when available (default: true). Reduces memory and can improve performance on large or partitioned data.",
585 ),
586];
587
588pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
590pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594#[serde(default)]
595pub struct ChartConfig {
596 pub row_limit: Option<usize>,
598}
599
600const CHART_COMMENTS: &[(&str, &str)] = &[
602 (
603 "row_limit",
604 "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",
605 ),
606];
607
608impl Default for ChartConfig {
609 fn default() -> Self {
610 Self {
611 row_limit: Some(DEFAULT_CHART_ROW_LIMIT),
612 }
613 }
614}
615
616impl ChartConfig {
617 pub fn merge(&mut self, other: Self) {
618 if other.row_limit.is_some() {
619 self.row_limit = other.row_limit;
620 }
621 }
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, Default)]
625#[serde(default)]
626pub struct ThemeConfig {
627 pub colors: ColorConfig,
628}
629
630const THEME_COMMENTS: &[(&str, &str)] = &[];
632
633fn default_row_numbers_color() -> String {
634 "dark_gray".to_string()
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
638#[serde(default)]
639pub struct ColorConfig {
687 pub keybind_hints: String,
688 pub keybind_labels: String,
689 pub throbber: String,
690 pub primary_chart_series_color: String,
691 pub secondary_chart_series_color: String,
692 pub success: String,
693 pub error: String,
694 pub warning: String,
695 pub dimmed: String,
696 pub background: String,
697 pub surface: String,
698 pub controls_bg: String,
699 pub text_primary: String,
700 pub text_secondary: String,
701 pub text_inverse: String,
702 pub table_header: String,
703 pub table_header_bg: String,
704 #[serde(default = "default_row_numbers_color")]
706 pub row_numbers: String,
707 pub column_separator: String,
708 pub table_selected: String,
709 pub sidebar_border: String,
710 pub modal_border_active: String,
711 pub modal_border_error: String,
712 pub distribution_normal: String,
713 pub distribution_skewed: String,
714 pub distribution_other: String,
715 pub outlier_marker: String,
716 pub cursor_focused: String,
717 pub cursor_dimmed: String,
718 pub alternate_row_color: String,
720 pub str_col: String,
722 pub int_col: String,
723 pub float_col: String,
724 pub bool_col: String,
725 pub temporal_col: String,
726 pub chart_series_color_1: String,
728 pub chart_series_color_2: String,
729 pub chart_series_color_3: String,
730 pub chart_series_color_4: String,
731 pub chart_series_color_5: String,
732 pub chart_series_color_6: String,
733 pub chart_series_color_7: String,
734}
735
736const COLOR_COMMENTS: &[(&str, &str)] = &[
738 (
739 "keybind_hints",
740 "Keybind hints (modals, breadcrumb, correlation matrix)",
741 ),
742 ("keybind_labels", "Action labels in controls bar"),
743 ("throbber", "Busy indicator (spinner) in control bar"),
744 (
745 "primary_chart_series_color",
746 "Chart data (histogram bars, Q-Q plot data points)",
747 ),
748 (
749 "secondary_chart_series_color",
750 "Chart theory (histogram overlays, Q-Q plot reference line)",
751 ),
752 ("success", "Success indicators, normal distributions"),
753 ("error", "Error messages, outliers"),
754 ("warning", "Warnings, skewed distributions"),
755 ("dimmed", "Dimmed elements, axis lines"),
756 ("background", "Main background"),
757 ("surface", "Modal/surface backgrounds"),
758 ("controls_bg", "Controls bar background"),
759 ("text_primary", "Primary text"),
760 ("text_secondary", "Secondary text"),
761 ("text_inverse", "Text on light backgrounds"),
762 ("table_header", "Table column header text"),
763 ("table_header_bg", "Table column header background"),
764 ("row_numbers", "Row numbers column text; use \"default\" for terminal default"),
765 ("column_separator", "Vertical line between columns"),
766 ("table_selected", "Selected row style"),
767 ("sidebar_border", "Sidebar borders"),
768 ("modal_border_active", "Active modal elements"),
769 ("modal_border_error", "Error modal borders"),
770 ("distribution_normal", "Normal distribution indicator"),
771 ("distribution_skewed", "Skewed distribution indicator"),
772 ("distribution_other", "Other distribution types"),
773 ("outlier_marker", "Outlier indicators"),
774 (
775 "cursor_focused",
776 "Cursor color when text input is focused\nText under cursor uses reverse of this color",
777 ),
778 (
779 "cursor_dimmed",
780 "Cursor color when text input is unfocused (currently unused - unfocused inputs hide cursor)",
781 ),
782 (
783 "alternate_row_color",
784 "Background color for every other row in the main data table\nSet to \"default\" to disable alternate row coloring",
785 ),
786 ("str_col", "Main table: string column text color"),
787 ("int_col", "Main table: integer column text color"),
788 ("float_col", "Main table: float column text color"),
789 ("bool_col", "Main table: boolean column text color"),
790 ("temporal_col", "Main table: date/datetime/time column text color"),
791 ("chart_series_color_1", "Chart view: first series color"),
792 ("chart_series_color_2", "Chart view: second series color"),
793 ("chart_series_color_3", "Chart view: third series color"),
794 ("chart_series_color_4", "Chart view: fourth series color"),
795 ("chart_series_color_5", "Chart view: fifth series color"),
796 ("chart_series_color_6", "Chart view: sixth series color"),
797 ("chart_series_color_7", "Chart view: seventh series color"),
798];
799
800#[derive(Debug, Clone, Serialize, Deserialize, Default)]
801#[serde(default)]
802pub struct UiConfig {
803 pub controls: ControlsConfig,
804}
805
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[serde(default)]
808pub struct ControlsConfig {
809 pub custom_controls: Option<Vec<(String, String)>>,
810 pub row_count_width: usize,
811}
812
813const CONTROLS_COMMENTS: &[(&str, &str)] = &[
815 (
816 "custom_controls",
817 "Custom control keybindings (optional)\nFormat: [[\"key\", \"label\"], [\"key\", \"label\"], ...]\nIf not specified, uses default controls",
818 ),
819 ("row_count_width", "Row count display width in characters"),
820];
821
822#[derive(Debug, Clone, Serialize, Deserialize)]
823#[serde(default)]
824pub struct QueryConfig {
825 pub history_limit: usize,
826 pub enable_history: bool,
827}
828
829const QUERY_COMMENTS: &[(&str, &str)] = &[
831 (
832 "history_limit",
833 "Maximum number of queries to keep in history",
834 ),
835 ("enable_history", "Enable query history caching"),
836];
837
838#[derive(Debug, Clone, Serialize, Deserialize, Default)]
839#[serde(default)]
840pub struct TemplateConfig {
841 pub auto_apply: bool,
842}
843
844const TEMPLATE_COMMENTS: &[(&str, &str)] = &[(
846 "auto_apply",
847 "Auto-apply most relevant template on file open",
848)];
849
850#[derive(Debug, Clone, Serialize, Deserialize)]
851#[serde(default)]
852pub struct DebugConfig {
853 pub enabled: bool,
854 pub show_performance: bool,
855 pub show_query: bool,
856 pub show_transformations: bool,
857}
858
859const DEBUG_COMMENTS: &[(&str, &str)] = &[
861 ("enabled", "Enable debug overlay by default"),
862 (
863 "show_performance",
864 "Show performance metrics in debug overlay",
865 ),
866 ("show_query", "Show LazyFrame query in debug overlay"),
867 (
868 "show_transformations",
869 "Show transformation state in debug overlay",
870 ),
871];
872
873impl Default for AppConfig {
875 fn default() -> Self {
876 Self {
877 version: "0.2".to_string(),
878 cloud: CloudConfig::default(),
879 file_loading: FileLoadingConfig::default(),
880 display: DisplayConfig::default(),
881 performance: PerformanceConfig::default(),
882 chart: ChartConfig::default(),
883 theme: ThemeConfig::default(),
884 ui: UiConfig::default(),
885 query: QueryConfig::default(),
886 templates: TemplateConfig::default(),
887 debug: DebugConfig::default(),
888 }
889 }
890}
891
892impl Default for DisplayConfig {
893 fn default() -> Self {
894 Self {
895 pages_lookahead: 3,
896 pages_lookback: 3,
897 max_buffered_rows: 100_000,
898 max_buffered_mb: 512,
899 row_numbers: false,
900 row_start_index: 1,
901 table_cell_padding: 2,
902 column_colors: true,
903 sidebar_width: None,
904 }
905 }
906}
907
908impl Default for PerformanceConfig {
909 fn default() -> Self {
910 Self {
911 sampling_threshold: None,
912 event_poll_interval_ms: 25,
913 polars_streaming: true,
914 }
915 }
916}
917
918impl Default for ColorConfig {
919 fn default() -> Self {
920 Self {
921 keybind_hints: "cyan".to_string(),
922 keybind_labels: "indexed(252)".to_string(),
923 throbber: "cyan".to_string(),
924 primary_chart_series_color: "cyan".to_string(),
925 secondary_chart_series_color: "indexed(245)".to_string(),
926 success: "green".to_string(),
927 error: "red".to_string(),
928 warning: "yellow".to_string(),
929 dimmed: "dark_gray".to_string(),
930 background: "default".to_string(),
931 surface: "default".to_string(),
932 controls_bg: "indexed(235)".to_string(),
933 text_primary: "default".to_string(),
934 text_secondary: "indexed(240)".to_string(),
935 text_inverse: "black".to_string(),
936 table_header: "white".to_string(),
937 table_header_bg: "indexed(235)".to_string(),
938 row_numbers: "dark_gray".to_string(),
939 column_separator: "cyan".to_string(),
940 table_selected: "reversed".to_string(),
941 sidebar_border: "indexed(235)".to_string(),
942 modal_border_active: "yellow".to_string(),
943 modal_border_error: "red".to_string(),
944 distribution_normal: "green".to_string(),
945 distribution_skewed: "yellow".to_string(),
946 distribution_other: "white".to_string(),
947 outlier_marker: "red".to_string(),
948 cursor_focused: "default".to_string(),
949 cursor_dimmed: "default".to_string(),
950 alternate_row_color: "indexed(235)".to_string(),
951 str_col: "green".to_string(),
952 int_col: "cyan".to_string(),
953 float_col: "blue".to_string(),
954 bool_col: "yellow".to_string(),
955 temporal_col: "magenta".to_string(),
956 chart_series_color_1: "cyan".to_string(),
957 chart_series_color_2: "magenta".to_string(),
958 chart_series_color_3: "green".to_string(),
959 chart_series_color_4: "yellow".to_string(),
960 chart_series_color_5: "blue".to_string(),
961 chart_series_color_6: "red".to_string(),
962 chart_series_color_7: "bright_cyan".to_string(),
963 }
964 }
965}
966
967impl Default for ControlsConfig {
968 fn default() -> Self {
969 Self {
970 custom_controls: None,
971 row_count_width: 20,
972 }
973 }
974}
975
976impl Default for QueryConfig {
977 fn default() -> Self {
978 Self {
979 history_limit: 1000,
980 enable_history: true,
981 }
982 }
983}
984
985impl Default for DebugConfig {
986 fn default() -> Self {
987 Self {
988 enabled: false,
989 show_performance: true,
990 show_query: true,
991 show_transformations: true,
992 }
993 }
994}
995
996impl AppConfig {
998 pub fn load(app_name: &str) -> Result<Self> {
1000 let mut config = AppConfig::default();
1001
1002 let config_path = ConfigManager::new(app_name)
1004 .ok()
1005 .map(|m| m.config_path("config.toml"));
1006 if let Ok(user_config) = Self::load_user_config(app_name) {
1007 config.merge(user_config);
1008 }
1009
1010 config.validate().map_err(|e| {
1012 let path_hint = config_path
1013 .as_ref()
1014 .map(|p| format!(" in {}", p.display()))
1015 .unwrap_or_default();
1016 eyre!("Invalid configuration{}: {}", path_hint, e)
1017 })?;
1018
1019 Ok(config)
1020 }
1021
1022 fn load_user_config(app_name: &str) -> Result<AppConfig> {
1024 let config_manager = ConfigManager::new(app_name)?;
1025 let config_path = config_manager.config_path("config.toml");
1026
1027 if !config_path.exists() {
1028 return Ok(AppConfig::default());
1029 }
1030
1031 let content = std::fs::read_to_string(&config_path).map_err(|e| {
1032 eyre!(
1033 "Failed to read config file at {}: {}",
1034 config_path.display(),
1035 e
1036 )
1037 })?;
1038
1039 toml::from_str(&content).map_err(|e| {
1040 eyre!(
1041 "Failed to parse config file at {}: {}",
1042 config_path.display(),
1043 e
1044 )
1045 })
1046 }
1047
1048 pub fn merge(&mut self, other: AppConfig) {
1050 if other.version != AppConfig::default().version {
1052 self.version = other.version;
1053 }
1054
1055 self.cloud.merge(other.cloud);
1057 self.file_loading.merge(other.file_loading);
1058 self.display.merge(other.display);
1059 self.performance.merge(other.performance);
1060 self.chart.merge(other.chart);
1061 self.theme.merge(other.theme);
1062 self.ui.merge(other.ui);
1063 self.query.merge(other.query);
1064 self.templates.merge(other.templates);
1065 self.debug.merge(other.debug);
1066 }
1067
1068 pub fn validate(&self) -> Result<()> {
1070 if !self.version.starts_with("0.2") {
1072 return Err(eyre!(
1073 "Unsupported config version: {}. Expected 0.2.x",
1074 self.version
1075 ));
1076 }
1077
1078 if let Some(t) = self.performance.sampling_threshold {
1080 if t == 0 {
1081 return Err(eyre!("sampling_threshold must be greater than 0 when set"));
1082 }
1083 }
1084
1085 if self.performance.event_poll_interval_ms == 0 {
1086 return Err(eyre!("event_poll_interval_ms must be greater than 0"));
1087 }
1088
1089 if let Some(n) = self.chart.row_limit {
1090 if n == 0 || n > MAX_CHART_ROW_LIMIT {
1091 return Err(eyre!(
1092 "chart.row_limit must be between 1 and {} when set, got {}",
1093 MAX_CHART_ROW_LIMIT,
1094 n
1095 ));
1096 }
1097 }
1098
1099 let parser = ColorParser::new();
1101 self.theme.colors.validate(&parser)?;
1102
1103 Ok(())
1104 }
1105}
1106
1107impl FileLoadingConfig {
1109 pub fn merge(&mut self, other: Self) {
1110 if other.delimiter.is_some() {
1111 self.delimiter = other.delimiter;
1112 }
1113 if other.has_header.is_some() {
1114 self.has_header = other.has_header;
1115 }
1116 if other.skip_lines.is_some() {
1117 self.skip_lines = other.skip_lines;
1118 }
1119 if other.skip_rows.is_some() {
1120 self.skip_rows = other.skip_rows;
1121 }
1122 if other.parse_dates.is_some() {
1123 self.parse_dates = other.parse_dates;
1124 }
1125 if other.decompress_in_memory.is_some() {
1126 self.decompress_in_memory = other.decompress_in_memory;
1127 }
1128 if other.temp_dir.is_some() {
1129 self.temp_dir = other.temp_dir.clone();
1130 }
1131 if other.single_spine_schema.is_some() {
1132 self.single_spine_schema = other.single_spine_schema;
1133 }
1134 if other.null_values.is_some() {
1135 self.null_values = other.null_values.clone();
1136 }
1137 }
1138}
1139
1140impl DisplayConfig {
1141 pub fn merge(&mut self, other: Self) {
1142 let default = DisplayConfig::default();
1143 if other.pages_lookahead != default.pages_lookahead {
1144 self.pages_lookahead = other.pages_lookahead;
1145 }
1146 if other.pages_lookback != default.pages_lookback {
1147 self.pages_lookback = other.pages_lookback;
1148 }
1149 if other.max_buffered_rows != default.max_buffered_rows {
1150 self.max_buffered_rows = other.max_buffered_rows;
1151 }
1152 if other.max_buffered_mb != default.max_buffered_mb {
1153 self.max_buffered_mb = other.max_buffered_mb;
1154 }
1155 if other.row_numbers != default.row_numbers {
1156 self.row_numbers = other.row_numbers;
1157 }
1158 if other.row_start_index != default.row_start_index {
1159 self.row_start_index = other.row_start_index;
1160 }
1161 if other.table_cell_padding != default.table_cell_padding {
1162 self.table_cell_padding = other.table_cell_padding;
1163 }
1164 if other.column_colors != default.column_colors {
1165 self.column_colors = other.column_colors;
1166 }
1167 if other.sidebar_width != default.sidebar_width {
1168 self.sidebar_width = other.sidebar_width;
1169 }
1170 }
1171}
1172
1173impl PerformanceConfig {
1174 pub fn merge(&mut self, other: Self) {
1175 let default = PerformanceConfig::default();
1176 if other.sampling_threshold != default.sampling_threshold {
1177 self.sampling_threshold = other.sampling_threshold;
1178 }
1179 if other.event_poll_interval_ms != default.event_poll_interval_ms {
1180 self.event_poll_interval_ms = other.event_poll_interval_ms;
1181 }
1182 if other.polars_streaming != default.polars_streaming {
1183 self.polars_streaming = other.polars_streaming;
1184 }
1185 }
1186}
1187
1188impl ThemeConfig {
1189 pub fn merge(&mut self, other: Self) {
1190 self.colors.merge(other.colors);
1191 }
1192}
1193
1194impl ColorConfig {
1195 fn validate(&self, parser: &ColorParser) -> Result<()> {
1197 macro_rules! validate_color {
1199 ($field:expr, $name:expr) => {
1200 parser.parse($field).map_err(|e| {
1201 eyre!(
1202 "theme.colors.{}: {}. Use a valid color name (e.g. red, cyan, bright_red), \
1203 hex (#rrggbb), or indexed(0-255)",
1204 $name,
1205 e
1206 )
1207 })?;
1208 };
1209 }
1210
1211 validate_color!(&self.keybind_hints, "keybind_hints");
1212 validate_color!(&self.keybind_labels, "keybind_labels");
1213 validate_color!(&self.throbber, "throbber");
1214 validate_color!(
1215 &self.primary_chart_series_color,
1216 "primary_chart_series_color"
1217 );
1218 validate_color!(
1219 &self.secondary_chart_series_color,
1220 "secondary_chart_series_color"
1221 );
1222 validate_color!(&self.success, "success");
1223 validate_color!(&self.error, "error");
1224 validate_color!(&self.warning, "warning");
1225 validate_color!(&self.dimmed, "dimmed");
1226 validate_color!(&self.background, "background");
1227 validate_color!(&self.surface, "surface");
1228 validate_color!(&self.controls_bg, "controls_bg");
1229 validate_color!(&self.text_primary, "text_primary");
1230 validate_color!(&self.text_secondary, "text_secondary");
1231 validate_color!(&self.text_inverse, "text_inverse");
1232 validate_color!(&self.table_header, "table_header");
1233 validate_color!(&self.table_header_bg, "table_header_bg");
1234 validate_color!(&self.row_numbers, "row_numbers");
1235 validate_color!(&self.column_separator, "column_separator");
1236 validate_color!(&self.table_selected, "table_selected");
1237 validate_color!(&self.sidebar_border, "sidebar_border");
1238 validate_color!(&self.modal_border_active, "modal_border_active");
1239 validate_color!(&self.modal_border_error, "modal_border_error");
1240 validate_color!(&self.distribution_normal, "distribution_normal");
1241 validate_color!(&self.distribution_skewed, "distribution_skewed");
1242 validate_color!(&self.distribution_other, "distribution_other");
1243 validate_color!(&self.outlier_marker, "outlier_marker");
1244 validate_color!(&self.cursor_focused, "cursor_focused");
1245 validate_color!(&self.cursor_dimmed, "cursor_dimmed");
1246 if self.alternate_row_color != "default" {
1247 validate_color!(&self.alternate_row_color, "alternate_row_color");
1248 }
1249 validate_color!(&self.str_col, "str_col");
1250 validate_color!(&self.int_col, "int_col");
1251 validate_color!(&self.float_col, "float_col");
1252 validate_color!(&self.bool_col, "bool_col");
1253 validate_color!(&self.temporal_col, "temporal_col");
1254 validate_color!(&self.chart_series_color_1, "chart_series_color_1");
1255 validate_color!(&self.chart_series_color_2, "chart_series_color_2");
1256 validate_color!(&self.chart_series_color_3, "chart_series_color_3");
1257 validate_color!(&self.chart_series_color_4, "chart_series_color_4");
1258 validate_color!(&self.chart_series_color_5, "chart_series_color_5");
1259 validate_color!(&self.chart_series_color_6, "chart_series_color_6");
1260 validate_color!(&self.chart_series_color_7, "chart_series_color_7");
1261
1262 Ok(())
1263 }
1264
1265 pub fn merge(&mut self, other: Self) {
1266 let default = ColorConfig::default();
1267
1268 if other.keybind_hints != default.keybind_hints {
1270 self.keybind_hints = other.keybind_hints;
1271 }
1272 if other.keybind_labels != default.keybind_labels {
1273 self.keybind_labels = other.keybind_labels;
1274 }
1275 if other.throbber != default.throbber {
1276 self.throbber = other.throbber;
1277 }
1278 if other.primary_chart_series_color != default.primary_chart_series_color {
1279 self.primary_chart_series_color = other.primary_chart_series_color;
1280 }
1281 if other.secondary_chart_series_color != default.secondary_chart_series_color {
1282 self.secondary_chart_series_color = other.secondary_chart_series_color;
1283 }
1284 if other.success != default.success {
1285 self.success = other.success;
1286 }
1287 if other.error != default.error {
1288 self.error = other.error;
1289 }
1290 if other.warning != default.warning {
1291 self.warning = other.warning;
1292 }
1293 if other.dimmed != default.dimmed {
1294 self.dimmed = other.dimmed;
1295 }
1296 if other.background != default.background {
1297 self.background = other.background;
1298 }
1299 if other.surface != default.surface {
1300 self.surface = other.surface;
1301 }
1302 if other.controls_bg != default.controls_bg {
1303 self.controls_bg = other.controls_bg;
1304 }
1305 if other.text_primary != default.text_primary {
1306 self.text_primary = other.text_primary;
1307 }
1308 if other.text_secondary != default.text_secondary {
1309 self.text_secondary = other.text_secondary;
1310 }
1311 if other.text_inverse != default.text_inverse {
1312 self.text_inverse = other.text_inverse;
1313 }
1314 if other.table_header != default.table_header {
1315 self.table_header = other.table_header;
1316 }
1317 if other.table_header_bg != default.table_header_bg {
1318 self.table_header_bg = other.table_header_bg;
1319 }
1320 if other.row_numbers != default.row_numbers {
1321 self.row_numbers = other.row_numbers;
1322 }
1323 if other.column_separator != default.column_separator {
1324 self.column_separator = other.column_separator;
1325 }
1326 if other.table_selected != default.table_selected {
1327 self.table_selected = other.table_selected;
1328 }
1329 if other.sidebar_border != default.sidebar_border {
1330 self.sidebar_border = other.sidebar_border;
1331 }
1332 if other.modal_border_active != default.modal_border_active {
1333 self.modal_border_active = other.modal_border_active;
1334 }
1335 if other.modal_border_error != default.modal_border_error {
1336 self.modal_border_error = other.modal_border_error;
1337 }
1338 if other.distribution_normal != default.distribution_normal {
1339 self.distribution_normal = other.distribution_normal;
1340 }
1341 if other.distribution_skewed != default.distribution_skewed {
1342 self.distribution_skewed = other.distribution_skewed;
1343 }
1344 if other.distribution_other != default.distribution_other {
1345 self.distribution_other = other.distribution_other;
1346 }
1347 if other.outlier_marker != default.outlier_marker {
1348 self.outlier_marker = other.outlier_marker;
1349 }
1350 if other.cursor_focused != default.cursor_focused {
1351 self.cursor_focused = other.cursor_focused;
1352 }
1353 if other.cursor_dimmed != default.cursor_dimmed {
1354 self.cursor_dimmed = other.cursor_dimmed;
1355 }
1356 if other.alternate_row_color != default.alternate_row_color {
1357 self.alternate_row_color = other.alternate_row_color;
1358 }
1359 if other.str_col != default.str_col {
1360 self.str_col = other.str_col;
1361 }
1362 if other.int_col != default.int_col {
1363 self.int_col = other.int_col;
1364 }
1365 if other.float_col != default.float_col {
1366 self.float_col = other.float_col;
1367 }
1368 if other.bool_col != default.bool_col {
1369 self.bool_col = other.bool_col;
1370 }
1371 if other.temporal_col != default.temporal_col {
1372 self.temporal_col = other.temporal_col;
1373 }
1374 if other.chart_series_color_1 != default.chart_series_color_1 {
1375 self.chart_series_color_1 = other.chart_series_color_1;
1376 }
1377 if other.chart_series_color_2 != default.chart_series_color_2 {
1378 self.chart_series_color_2 = other.chart_series_color_2;
1379 }
1380 if other.chart_series_color_3 != default.chart_series_color_3 {
1381 self.chart_series_color_3 = other.chart_series_color_3;
1382 }
1383 if other.chart_series_color_4 != default.chart_series_color_4 {
1384 self.chart_series_color_4 = other.chart_series_color_4;
1385 }
1386 if other.chart_series_color_5 != default.chart_series_color_5 {
1387 self.chart_series_color_5 = other.chart_series_color_5;
1388 }
1389 if other.chart_series_color_6 != default.chart_series_color_6 {
1390 self.chart_series_color_6 = other.chart_series_color_6;
1391 }
1392 if other.chart_series_color_7 != default.chart_series_color_7 {
1393 self.chart_series_color_7 = other.chart_series_color_7;
1394 }
1395 }
1396}
1397
1398impl UiConfig {
1399 pub fn merge(&mut self, other: Self) {
1400 self.controls.merge(other.controls);
1401 }
1402}
1403
1404impl ControlsConfig {
1405 pub fn merge(&mut self, other: Self) {
1406 if other.custom_controls.is_some() {
1407 self.custom_controls = other.custom_controls;
1408 }
1409 let default = ControlsConfig::default();
1410 if other.row_count_width != default.row_count_width {
1411 self.row_count_width = other.row_count_width;
1412 }
1413 }
1414}
1415
1416impl QueryConfig {
1417 pub fn merge(&mut self, other: Self) {
1418 let default = QueryConfig::default();
1419 if other.history_limit != default.history_limit {
1420 self.history_limit = other.history_limit;
1421 }
1422 if other.enable_history != default.enable_history {
1423 self.enable_history = other.enable_history;
1424 }
1425 }
1426}
1427
1428impl TemplateConfig {
1429 pub fn merge(&mut self, other: Self) {
1430 let default = TemplateConfig::default();
1431 if other.auto_apply != default.auto_apply {
1432 self.auto_apply = other.auto_apply;
1433 }
1434 }
1435}
1436
1437impl DebugConfig {
1438 pub fn merge(&mut self, other: Self) {
1439 let default = DebugConfig::default();
1440 if other.enabled != default.enabled {
1441 self.enabled = other.enabled;
1442 }
1443 if other.show_performance != default.show_performance {
1444 self.show_performance = other.show_performance;
1445 }
1446 if other.show_query != default.show_query {
1447 self.show_query = other.show_query;
1448 }
1449 if other.show_transformations != default.show_transformations {
1450 self.show_transformations = other.show_transformations;
1451 }
1452 }
1453}
1454
1455pub struct ColorParser {
1457 supports_true_color: bool,
1458 supports_256: bool,
1459 no_color: bool,
1460}
1461
1462impl ColorParser {
1463 pub fn new() -> Self {
1465 let no_color = std::env::var("NO_COLOR").is_ok();
1466 let support = supports_color::on(Stream::Stdout);
1467
1468 Self {
1469 supports_true_color: support.as_ref().map(|s| s.has_16m).unwrap_or(false),
1470 supports_256: support.as_ref().map(|s| s.has_256).unwrap_or(false),
1471 no_color,
1472 }
1473 }
1474
1475 pub fn parse(&self, s: &str) -> Result<Color> {
1477 if self.no_color {
1478 return Ok(Color::Reset);
1479 }
1480
1481 let trimmed = s.trim();
1482
1483 if trimmed.starts_with('#') && trimmed.len() == 7 {
1485 let (r, g, b) = parse_hex(trimmed)?;
1486 return Ok(self.convert_rgb_to_terminal_color(r, g, b));
1487 }
1488
1489 if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
1491 let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
1493 eyre!(
1494 "Invalid indexed color: '{}'. Expected format: indexed(0-255)",
1495 trimmed
1496 )
1497 })?;
1498 return Ok(Color::Indexed(num));
1499 }
1500
1501 let lower = trimmed.to_lowercase();
1503 match lower.as_str() {
1504 "black" => Ok(Color::Black),
1506 "red" => Ok(Color::Red),
1507 "green" => Ok(Color::Green),
1508 "yellow" => Ok(Color::Yellow),
1509 "blue" => Ok(Color::Blue),
1510 "magenta" => Ok(Color::Magenta),
1511 "cyan" => Ok(Color::Cyan),
1512 "white" => Ok(Color::White),
1513
1514 "bright_black" | "bright black" => Ok(Color::Indexed(8)),
1516 "bright_red" | "bright red" => Ok(Color::Indexed(9)),
1517 "bright_green" | "bright green" => Ok(Color::Indexed(10)),
1518 "bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
1519 "bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
1520 "bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
1521 "bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
1522 "bright_white" | "bright white" => Ok(Color::Indexed(15)),
1523
1524 "gray" | "grey" => Ok(Color::Indexed(8)),
1526 "dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
1527 "light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
1528
1529 "reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
1531
1532 _ => Err(eyre!(
1533 "Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
1534 bright variants (bright_red, etc.), or hex colors (#ff0000)",
1535 trimmed
1536 )),
1537 }
1538 }
1539
1540 fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
1542 if self.supports_true_color {
1543 Color::Rgb(r, g, b)
1544 } else if self.supports_256 {
1545 Color::Indexed(rgb_to_256_color(r, g, b))
1546 } else {
1547 rgb_to_basic_ansi(r, g, b)
1548 }
1549 }
1550}
1551
1552impl Default for ColorParser {
1553 fn default() -> Self {
1554 Self::new()
1555 }
1556}
1557
1558fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
1560 if !s.starts_with('#') || s.len() != 7 {
1561 return Err(eyre!(
1562 "Invalid hex color format: '{}'. Expected format: #rrggbb",
1563 s
1564 ));
1565 }
1566
1567 let r = u8::from_str_radix(&s[1..3], 16)
1568 .map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
1569 let g = u8::from_str_radix(&s[3..5], 16)
1570 .map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
1571 let b = u8::from_str_radix(&s[5..7], 16)
1572 .map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
1573
1574 Ok((r, g, b))
1575}
1576
1577pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
1580 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1582 if max_diff < 10 {
1583 let gray = (r as u16 + g as u16 + b as u16) / 3;
1585 if gray < 8 {
1586 return 16; } else if gray > 247 {
1588 return 231; } else {
1590 return 232 + ((gray - 8) * 24 / 240) as u8;
1591 }
1592 }
1593
1594 let r_idx = (r as u16 * 5 / 255) as u8;
1596 let g_idx = (g as u16 * 5 / 255) as u8;
1597 let b_idx = (b as u16 * 5 / 255) as u8;
1598
1599 16 + 36 * r_idx + 6 * g_idx + b_idx
1600}
1601
1602pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
1604 let r_bright = r > 128;
1606 let g_bright = g > 128;
1607 let b_bright = b > 128;
1608
1609 let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
1611 if max_diff < 30 {
1612 let avg = (r as u16 + g as u16 + b as u16) / 3;
1613 return if avg < 64 { Color::Black } else { Color::White };
1614 }
1615
1616 match (r_bright, g_bright, b_bright) {
1618 (false, false, false) => Color::Black,
1619 (true, false, false) => Color::Red,
1620 (false, true, false) => Color::Green,
1621 (true, true, false) => Color::Yellow,
1622 (false, false, true) => Color::Blue,
1623 (true, false, true) => Color::Magenta,
1624 (false, true, true) => Color::Cyan,
1625 (true, true, true) => Color::White,
1626 }
1627}
1628
1629#[derive(Debug, Clone)]
1631pub struct Theme {
1632 pub colors: HashMap<String, Color>,
1633}
1634
1635impl Theme {
1636 pub fn from_config(config: &ThemeConfig) -> Result<Self> {
1638 let parser = ColorParser::new();
1639 let mut colors = HashMap::new();
1640
1641 colors.insert(
1643 "keybind_hints".to_string(),
1644 parser.parse(&config.colors.keybind_hints)?,
1645 );
1646 colors.insert(
1647 "keybind_labels".to_string(),
1648 parser.parse(&config.colors.keybind_labels)?,
1649 );
1650 colors.insert(
1651 "throbber".to_string(),
1652 parser.parse(&config.colors.throbber)?,
1653 );
1654 colors.insert(
1655 "primary_chart_series_color".to_string(),
1656 parser.parse(&config.colors.primary_chart_series_color)?,
1657 );
1658 colors.insert(
1659 "secondary_chart_series_color".to_string(),
1660 parser.parse(&config.colors.secondary_chart_series_color)?,
1661 );
1662 colors.insert("success".to_string(), parser.parse(&config.colors.success)?);
1663 colors.insert("error".to_string(), parser.parse(&config.colors.error)?);
1664 colors.insert("warning".to_string(), parser.parse(&config.colors.warning)?);
1665 colors.insert("dimmed".to_string(), parser.parse(&config.colors.dimmed)?);
1666 colors.insert(
1667 "background".to_string(),
1668 parser.parse(&config.colors.background)?,
1669 );
1670 colors.insert("surface".to_string(), parser.parse(&config.colors.surface)?);
1671 colors.insert(
1672 "controls_bg".to_string(),
1673 parser.parse(&config.colors.controls_bg)?,
1674 );
1675 colors.insert(
1676 "text_primary".to_string(),
1677 parser.parse(&config.colors.text_primary)?,
1678 );
1679 colors.insert(
1680 "text_secondary".to_string(),
1681 parser.parse(&config.colors.text_secondary)?,
1682 );
1683 colors.insert(
1684 "text_inverse".to_string(),
1685 parser.parse(&config.colors.text_inverse)?,
1686 );
1687 colors.insert(
1688 "table_header".to_string(),
1689 parser.parse(&config.colors.table_header)?,
1690 );
1691 colors.insert(
1692 "table_header_bg".to_string(),
1693 parser.parse(&config.colors.table_header_bg)?,
1694 );
1695 colors.insert(
1696 "row_numbers".to_string(),
1697 parser.parse(&config.colors.row_numbers)?,
1698 );
1699 colors.insert(
1700 "column_separator".to_string(),
1701 parser.parse(&config.colors.column_separator)?,
1702 );
1703 colors.insert(
1704 "table_selected".to_string(),
1705 parser.parse(&config.colors.table_selected)?,
1706 );
1707 colors.insert(
1708 "sidebar_border".to_string(),
1709 parser.parse(&config.colors.sidebar_border)?,
1710 );
1711 colors.insert(
1712 "modal_border_active".to_string(),
1713 parser.parse(&config.colors.modal_border_active)?,
1714 );
1715 colors.insert(
1716 "modal_border_error".to_string(),
1717 parser.parse(&config.colors.modal_border_error)?,
1718 );
1719 colors.insert(
1720 "distribution_normal".to_string(),
1721 parser.parse(&config.colors.distribution_normal)?,
1722 );
1723 colors.insert(
1724 "distribution_skewed".to_string(),
1725 parser.parse(&config.colors.distribution_skewed)?,
1726 );
1727 colors.insert(
1728 "distribution_other".to_string(),
1729 parser.parse(&config.colors.distribution_other)?,
1730 );
1731 colors.insert(
1732 "outlier_marker".to_string(),
1733 parser.parse(&config.colors.outlier_marker)?,
1734 );
1735 colors.insert(
1736 "cursor_focused".to_string(),
1737 parser.parse(&config.colors.cursor_focused)?,
1738 );
1739 colors.insert(
1740 "cursor_dimmed".to_string(),
1741 parser.parse(&config.colors.cursor_dimmed)?,
1742 );
1743 if config.colors.alternate_row_color != "default" {
1744 colors.insert(
1745 "alternate_row_color".to_string(),
1746 parser.parse(&config.colors.alternate_row_color)?,
1747 );
1748 }
1749 colors.insert("str_col".to_string(), parser.parse(&config.colors.str_col)?);
1750 colors.insert("int_col".to_string(), parser.parse(&config.colors.int_col)?);
1751 colors.insert(
1752 "float_col".to_string(),
1753 parser.parse(&config.colors.float_col)?,
1754 );
1755 colors.insert(
1756 "bool_col".to_string(),
1757 parser.parse(&config.colors.bool_col)?,
1758 );
1759 colors.insert(
1760 "temporal_col".to_string(),
1761 parser.parse(&config.colors.temporal_col)?,
1762 );
1763 colors.insert(
1764 "chart_series_color_1".to_string(),
1765 parser.parse(&config.colors.chart_series_color_1)?,
1766 );
1767 colors.insert(
1768 "chart_series_color_2".to_string(),
1769 parser.parse(&config.colors.chart_series_color_2)?,
1770 );
1771 colors.insert(
1772 "chart_series_color_3".to_string(),
1773 parser.parse(&config.colors.chart_series_color_3)?,
1774 );
1775 colors.insert(
1776 "chart_series_color_4".to_string(),
1777 parser.parse(&config.colors.chart_series_color_4)?,
1778 );
1779 colors.insert(
1780 "chart_series_color_5".to_string(),
1781 parser.parse(&config.colors.chart_series_color_5)?,
1782 );
1783 colors.insert(
1784 "chart_series_color_6".to_string(),
1785 parser.parse(&config.colors.chart_series_color_6)?,
1786 );
1787 colors.insert(
1788 "chart_series_color_7".to_string(),
1789 parser.parse(&config.colors.chart_series_color_7)?,
1790 );
1791
1792 Ok(Self { colors })
1793 }
1794
1795 pub fn get(&self, name: &str) -> Color {
1797 self.colors.get(name).copied().unwrap_or(Color::Reset)
1798 }
1799
1800 pub fn get_optional(&self, name: &str) -> Option<Color> {
1802 self.colors.get(name).copied()
1803 }
1804}