use crate::numfmt::{self, Glob, Grouping, NumberFormat, NumberFormatSettings};
use color_eyre::eyre::eyre;
use color_eyre::Result;
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use supports_color::Stream;
#[derive(Clone)]
pub struct ConfigManager {
pub(crate) config_dir: PathBuf,
}
impl ConfigManager {
pub fn with_dir(config_dir: PathBuf) -> Self {
Self { config_dir }
}
pub fn new(app_name: &str) -> Result<Self> {
let config_dir = dirs::config_dir()
.ok_or_else(|| eyre!("Could not determine config directory"))?
.join(app_name);
Ok(Self { config_dir })
}
pub fn config_dir(&self) -> &Path {
&self.config_dir
}
pub fn config_path(&self, path: &str) -> PathBuf {
self.config_dir.join(path)
}
pub fn ensure_config_dir(&self) -> Result<()> {
if !self.config_dir.exists() {
std::fs::create_dir_all(&self.config_dir)?;
}
Ok(())
}
pub fn ensure_subdir(&self, subdir: &str) -> Result<PathBuf> {
let subdir_path = self.config_dir.join(subdir);
if !subdir_path.exists() {
std::fs::create_dir_all(&subdir_path)?;
}
Ok(subdir_path)
}
pub fn generate_default_config(&self) -> String {
let config = AppConfig::default();
let toml_str = toml::to_string_pretty(&config)
.unwrap_or_else(|e| panic!("Failed to serialize default config: {}", e));
let comments = Self::collect_all_comments();
Self::comment_all_fields(toml_str, comments)
}
fn collect_all_comments() -> std::collections::HashMap<String, String> {
let mut comments = std::collections::HashMap::new();
for (field, comment) in APP_COMMENTS {
comments.insert(field.to_string(), comment.to_string());
}
for (field, comment) in CLOUD_COMMENTS {
comments.insert(format!("cloud.{}", field), comment.to_string());
}
comments.insert(
"display.unicode".to_string(),
DISPLAY_UNICODE_COMMENT.to_string(),
);
for (field, comment) in DATA_COMMENTS {
comments.insert(format!("data.{}", field), comment.to_string());
}
for (field, comment) in DATA_SEARCH_COMMENTS {
comments.insert(format!("data.search.{}", field), comment.to_string());
}
for (field, comment) in FILE_LOADING_COMMENTS {
comments.insert(format!("file_loading.{}", field), comment.to_string());
}
for (field, comment) in DISPLAY_COMMENTS {
comments.insert(format!("display.{}", field), comment.to_string());
}
for (field, comment) in PERFORMANCE_COMMENTS {
comments.insert(format!("performance.{}", field), comment.to_string());
}
for (field, comment) in CHART_COMMENTS {
comments.insert(format!("chart.{}", field), comment.to_string());
}
for (field, comment) in THEME_COMMENTS {
comments.insert(format!("theme.{}", field), comment.to_string());
}
for (field, comment) in COLOR_COMMENTS {
comments.insert(format!("theme.colors.{}", field), comment.to_string());
}
for (field, comment) in CONTROLS_COMMENTS {
comments.insert(format!("ui.controls.{}", field), comment.to_string());
}
for (field, comment) in QUERY_COMMENTS {
comments.insert(format!("query.{}", field), comment.to_string());
}
for (field, comment) in TEMPLATE_COMMENTS {
comments.insert(format!("templates.{}", field), comment.to_string());
}
for (field, comment) in DEBUG_COMMENTS {
comments.insert(format!("debug.{}", field), comment.to_string());
}
comments
}
fn comment_all_fields(
toml: String,
comments: std::collections::HashMap<String, String>,
) -> String {
let mut result = String::new();
result.push_str("# datui configuration file\n");
result
.push_str("# This file uses TOML format. See https://toml.io/ for syntax reference.\n");
result.push('\n');
let lines: Vec<&str> = toml.lines().collect();
let mut i = 0;
let mut current_section = String::new();
let mut seen_fields: std::collections::HashSet<String> = std::collections::HashSet::new();
while i < lines.len() {
let line = lines[i];
if let Some(section) = Self::extract_section_name(line) {
current_section = section.clone();
if let Some(header) = SECTION_HEADERS.iter().find(|(s, _)| s == §ion) {
result.push_str(header.1);
result.push('\n');
}
result.push_str("# ");
result.push_str(line);
result.push('\n');
i += 1;
continue;
}
if let Some(field_path) = Self::extract_field_path_simple(line, ¤t_section) {
seen_fields.insert(field_path.clone());
if let Some(comment) = comments.get(&field_path) {
for comment_line in comment.lines() {
if comment_line.is_empty() {
result.push_str("#\n");
continue;
}
result.push_str("# ");
result.push_str(comment_line);
result.push('\n');
}
}
result.push_str("# ");
result.push_str(line);
result.push('\n');
let mut depth = bracket_depth(line);
while depth > 0 && i + 1 < lines.len() {
i += 1;
result.push_str("# ");
result.push_str(lines[i]);
result.push('\n');
depth += bracket_depth(lines[i]);
}
} else {
result.push_str(line);
result.push('\n');
}
i += 1;
}
result = Self::add_missing_option_fields(result, &comments, &seen_fields);
result
}
fn add_missing_option_fields(
mut result: String,
comments: &std::collections::HashMap<String, String>,
seen_fields: &std::collections::HashSet<String>,
) -> String {
let option_fields = [
"cloud.s3_endpoint_url",
"cloud.s3_access_key_id",
"cloud.s3_secret_access_key",
"cloud.s3_region",
"file_loading.delimiter",
"file_loading.has_header",
"file_loading.skip_lines",
"file_loading.skip_rows",
"file_loading.single_spine_schema",
"chart.row_limit",
"ui.controls.custom_controls",
];
let mut missing_by_section: std::collections::HashMap<String, Vec<&str>> =
std::collections::HashMap::new();
for field_path in &option_fields {
if !seen_fields.contains(*field_path) && comments.contains_key(*field_path) {
if let Some(dot_pos) = field_path.find('.') {
let section = &field_path[..dot_pos];
missing_by_section
.entry(section.to_string())
.or_default()
.push(field_path);
}
}
}
for (section, fields) in &missing_by_section {
let section_header = format!("[{}]", section);
if let Some(section_pos) = result.find(§ion_header) {
let after_header_start = section_pos + section_header.len();
let after_header = &result[after_header_start..];
let newline_pos = after_header.find('\n').unwrap_or(0);
let insert_pos = after_header_start + newline_pos + 1;
let mut new_content = String::new();
for field_path in fields {
if let Some(comment) = comments.get(*field_path) {
for comment_line in comment.lines() {
new_content.push_str("# ");
new_content.push_str(comment_line);
new_content.push('\n');
}
}
let field_name = field_path.rsplit('.').next().unwrap_or(field_path);
new_content.push_str(&format!("# {} = null\n", field_name));
new_content.push('\n');
}
result.insert_str(insert_pos, &new_content);
}
}
result
}
fn extract_section_name(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
Some(trimmed[1..trimmed.len() - 1].to_string())
} else {
None
}
}
fn extract_field_path_simple(line: &str, current_section: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('[') {
return None;
}
if let Some(eq_pos) = trimmed.find('=') {
let field_name = trimmed[..eq_pos].trim();
if current_section.is_empty() {
Some(field_name.to_string())
} else {
Some(format!("{}.{}", current_section, field_name))
}
} else {
None
}
}
pub fn write_default_config(&self, force: bool) -> Result<PathBuf> {
let config_path = self.config_path("config.toml");
if config_path.exists() && !force {
return Err(eyre!(
"Config file already exists at {}. Use --force to overwrite.",
config_path.display()
));
}
self.ensure_config_dir()?;
let template = self.generate_default_config();
std::fs::write(&config_path, template)?;
Ok(config_path)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AppConfig {
pub import: Vec<String>,
pub version: String,
pub cloud: CloudConfig,
pub file_loading: FileLoadingConfig,
pub display: DisplayConfig,
pub performance: PerformanceConfig,
pub chart: ChartConfig,
pub theme: ThemeConfig,
pub data: DataConfig,
pub ui: UiConfig,
pub query: QueryConfig,
pub templates: TemplateConfig,
pub debug: DebugConfig,
}
const APP_COMMENTS: &[(&str, &str)] = &[
(
"import",
"Config files to merge in before this file's own values.\n\
Precedence, lowest first: datui defaults -> each import in order -> this file.\n\
So an imported theme restyles datui, but anything you set here still wins.\n\
Paths may be absolute, relative to this file, or use ~ and $VAR.\n\
An import that does not exist is skipped with a warning on stderr.\n\
To follow the active Omarchy theme:\n\
import = [\"~/.local/state/omarchy/current/theme/datui.toml\"]",
),
(
"version",
"Configuration format version (for future compatibility)",
),
];
const SECTION_HEADERS: &[(&str, &str)] = &[
(
"cloud",
"# ============================================================================\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.",
),
(
"file_loading",
"# ============================================================================\n# File Loading Defaults\n# ============================================================================",
),
(
"display",
"# ============================================================================\n# Display Settings\n# ============================================================================",
),
(
"performance",
"# ============================================================================\n# Performance Settings\n# ============================================================================",
),
(
"chart",
"# ============================================================================\n# Chart View\n# ============================================================================",
),
(
"theme",
"# ============================================================================\n# Color Theme\n# ============================================================================",
),
(
"theme.colors",
"# 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",
),
(
"ui",
"# ============================================================================\n# UI Layout\n# ============================================================================",
),
("ui.controls", "# Control bar settings"),
(
"query",
"# ============================================================================\n# Query System\n# ============================================================================",
),
(
"templates",
"# ============================================================================\n# Template Settings\n# ============================================================================",
),
(
"debug",
"# ============================================================================\n# Debug Settings\n# ============================================================================",
),
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct CloudConfig {
pub s3_endpoint_url: Option<String>,
pub s3_access_key_id: Option<String>,
pub s3_secret_access_key: Option<String>,
pub s3_region: Option<String>,
}
const CLOUD_COMMENTS: &[(&str, &str)] = &[
(
"s3_endpoint_url",
"Custom endpoint for S3-compatible storage (MinIO, etc.). Example: \"http://localhost:9000\". Unset = AWS.",
),
(
"s3_access_key_id",
"Access key when using custom endpoint (or set AWS_ACCESS_KEY_ID).",
),
(
"s3_secret_access_key",
"Secret key when using custom endpoint (or set AWS_SECRET_ACCESS_KEY).",
),
(
"s3_region",
"Region (e.g. us-east-1). Required for custom endpoints; MinIO often uses us-east-1.",
),
];
impl CloudConfig {
pub fn merge(&mut self, other: Self) {
if other.s3_endpoint_url.is_some() {
self.s3_endpoint_url = other.s3_endpoint_url;
}
if other.s3_access_key_id.is_some() {
self.s3_access_key_id = other.s3_access_key_id;
}
if other.s3_secret_access_key.is_some() {
self.s3_secret_access_key = other.s3_secret_access_key;
}
if other.s3_region.is_some() {
self.s3_region = other.s3_region;
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct FileLoadingConfig {
pub delimiter: Option<u8>,
pub has_header: Option<bool>,
pub skip_lines: Option<usize>,
pub skip_rows: Option<usize>,
pub skip_tail_rows: Option<usize>,
pub parse_dates: Option<bool>,
pub decompress_in_memory: Option<bool>,
pub temp_dir: Option<String>,
pub single_spine_schema: Option<bool>,
pub null_values: Option<Vec<String>>,
pub parse_strings: Option<bool>,
pub parse_strings_sample_rows: Option<usize>,
pub infer_schema_length: Option<usize>,
pub ignore_errors: Option<bool>,
}
const FILE_LOADING_COMMENTS: &[(&str, &str)] = &[
(
"delimiter",
"Default delimiter for CSV files (as ASCII value, e.g., 44 for comma)\nIf not specified, auto-detection is used",
),
(
"has_header",
"Whether files have headers by default\nnull = auto-detect, true = has header, false = no header",
),
("skip_lines", "Number of lines to skip at the start of files"),
("skip_rows", "Number of rows to skip when reading files"),
(
"skip_tail_rows",
"Number of rows to skip at the end of the file (e.g. vendor footer or trailing garbage). CSV only.",
),
(
"parse_dates",
"When true (default), CSV reader tries to parse string columns as dates (e.g. YYYY-MM-DD, ISO datetime)",
),
(
"decompress_in_memory",
"When true, decompress compressed CSV into memory (eager). When false (default), decompress to a temp file and use lazy scan",
),
(
"temp_dir",
"Directory for decompression temp files. null = system default (e.g. TMPDIR)",
),
(
"single_spine_schema",
"When true (default), infer Hive/partitioned Parquet schema from one file for faster load. When false, use full schema scan (Polars collect_schema).",
),
(
"null_values",
"CSV: values to treat as null. Plain string = all columns; \"COL=VAL\" = column COL only. Example: [\"NA\", \"amount=\"]",
),
(
"parse_strings",
"When false, disable parse-strings. When true or unset, parse all CSV string columns (default). Use CLI --parse-strings=COL or --no-parse-strings.",
),
(
"parse_strings_sample_rows",
"Rows to sample for parse_strings type inference (default 1000).",
),
(
"infer_schema_length",
"Number of rows to use when inferring CSV schema (default 1000). Larger values reduce risk of wrong type (e.g. int then N/A).",
),
(
"ignore_errors",
"When true, CSV reader ignores parse errors and continues with the next batch (default false).",
),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DisplayConfig {
pub unicode: crate::glyphs::UnicodeMode,
pub pages_lookahead: usize,
pub pages_lookback: usize,
pub max_buffered_rows: usize,
pub max_buffered_mb: usize,
pub row_numbers: bool,
pub row_start_index: usize,
pub table_cell_padding: usize,
pub column_colors: bool,
#[serde(default)]
pub sidebar_width: Option<u16>,
pub align_numeric_right: bool,
#[serde(default)]
pub number_format: NumberFormatConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum NumberFormatConfig {
Preset(String),
Custom(Box<NumberFormatTable>),
}
impl Default for NumberFormatConfig {
fn default() -> Self {
NumberFormatConfig::Preset("none".to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(default)]
pub struct NumberFormatTable {
pub grouping: Option<String>,
pub group_separator: Option<String>,
pub decimal_separator: Option<String>,
pub floats: Option<bool>,
pub float_precision: Option<u8>,
pub exclude_columns: Vec<String>,
#[serde(flatten)]
pub unknown: std::collections::BTreeMap<String, toml::Value>,
}
const NUMBER_FORMAT_KEYS: &str =
"grouping, group_separator, decimal_separator, floats, float_precision, exclude_columns";
impl NumberFormatConfig {
pub fn resolve(&self, align_numeric_right: bool) -> Result<NumberFormatSettings> {
let (format, exclude) = match self {
NumberFormatConfig::Preset(name) => (Self::lookup_preset(name)?, Vec::new()),
NumberFormatConfig::Custom(table) => {
if !table.unknown.is_empty() {
let keys: Vec<&str> = table.unknown.keys().map(String::as_str).collect();
return Err(eyre!(
"display.number_format: unknown key{} {}. Expected one of: {}",
if keys.len() > 1 { "s" } else { "" },
keys.iter()
.map(|k| format!("'{}'", k))
.collect::<Vec<_>>()
.join(", "),
NUMBER_FORMAT_KEYS
));
}
let base = match table.grouping.as_deref() {
Some(name) => Self::lookup_preset(name)?,
None => NumberFormat::PLAIN,
};
let mut fmt = base;
if let Some(sep) = table.group_separator.as_deref() {
fmt.group_sep = Self::single_char(sep, "group_separator")?;
}
if let Some(sep) = table.decimal_separator.as_deref() {
fmt.decimal_sep = Self::single_char(sep, "decimal_separator")?;
}
if let Some(v) = table.floats {
fmt.floats = v;
}
if table.float_precision.is_some() {
fmt.float_precision = table.float_precision;
}
(fmt, table.exclude_columns.iter().map(Glob::new).collect())
}
};
if format.grouping != Grouping::None && format.group_sep == format.decimal_sep {
return Err(eyre!(
"display.number_format: group_separator and decimal_separator are both '{}'; \
they must differ or numbers become ambiguous",
format.group_sep
));
}
let enabled = !format.is_noop();
let format = if enabled {
format
} else {
NumberFormat {
grouping: Grouping::Thousands,
..format
}
};
Ok(NumberFormatSettings {
format,
enabled,
exclude,
align_numeric_right,
})
}
pub fn with_grouping_override(&self, name: &str) -> Self {
match self {
NumberFormatConfig::Preset(_) => NumberFormatConfig::Preset(name.to_string()),
NumberFormatConfig::Custom(table) => {
let mut table = table.clone();
table.grouping = Some(name.to_string());
NumberFormatConfig::Custom(table)
}
}
}
fn lookup_preset(name: &str) -> Result<NumberFormat> {
let name = if name == "system" {
match numfmt::system_locale_tag() {
Some(tag) => numfmt::preset_for_locale_tag(&tag),
None => "thousands",
}
} else {
name
};
NumberFormat::preset(name).ok_or_else(|| {
eyre!(
"display.number_format: unknown value '{}'. Expected one of: {}, system",
name,
NumberFormat::PRESET_NAMES.join(", ")
)
})
}
fn single_char(s: &str, field: &str) -> Result<char> {
let mut chars = s.chars();
match (chars.next(), chars.next()) {
(Some(c), None) => Ok(c),
_ => Err(eyre!(
"display.number_format.{}: expected a single character, got {:?}",
field,
s
)),
}
}
}
const DISPLAY_COMMENTS: &[(&str, &str)] = &[
(
"pages_lookahead",
"Number of pages to buffer ahead of visible area\nLarger values = smoother scrolling but more memory",
),
(
"pages_lookback",
"Number of pages to buffer behind visible area\nLarger values = smoother scrolling but more memory",
),
(
"max_buffered_rows",
"Maximum rows in scroll buffer (0 = no limit)\nPrevents unbounded memory use when scrolling",
),
(
"max_buffered_mb",
"Maximum buffer size in MB (0 = no limit)\nUses estimated memory; helps with very wide tables",
),
("row_numbers", "Display row numbers on the left side of the table"),
("row_start_index", "Starting index for row numbers (0 or 1)"),
(
"table_cell_padding",
"Number of spaces between columns in the main data table (>= 0)\nDefault 2",
),
(
"column_colors",
"Colorize main table cells by column type (string, int, float, bool, date/datetime)\nSet to false to use default text color for all cells",
),
(
"sidebar_width",
"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",
),
(
"align_numeric_right",
"Right-align numeric columns and their headers in the data table (default: true)\nSet to false to left-align everything as in datui 0.2.55 and earlier",
),
(
"number_format",
"How numbers are displayed in the data table. Press F to toggle on/off while running.\n\
Shorthand — one of:\n\
\x20 none 1234567 (default: renders exactly as the file stores it)\n\
\x20 thousands 1,234,567\n\
\x20 european 1.234.567,89\n\
\x20 si 1 234 567.89 (narrow no-break space, ISO 31-0)\n\
\x20 swiss 1'234'567.89\n\
\x20 indian 12,34,567.89 (lakh / crore)\n\
\x20 underscore 1_234_567\n\
\n\
For finer control, replace the line below with a table:\n\
\x20 [display.number_format]\n\
\x20 grouping = \"thousands\" # none | thousands | indian | system | any preset above\n\
\x20 group_separator = \",\"\n\
\x20 decimal_separator = \".\"\n\
\x20 floats = true # group float columns too\n\
\x20 float_precision = 2 # omit to keep the file's own decimal rendering\n\
\x20 exclude_columns = [\"*_id\", \"year\"] # never format these (globs: * and ?)\n\
\n\
Every value in a formatted column is grouped. Use exclude_columns for columns that hold\n\
identifiers rather than quantities -- years, sample IDs, ZIP codes, accession numbers.\n\
\n\
grouping = \"system\" is opt-in: it reads LC_ALL / LC_NUMERIC / LANG and picks a matching\n\
preset. Formatting is otherwise never taken from the environment, because a data file has\n\
no locale and the same file should render identically on every machine.\n\
\n\
Formatting is display-only. Exports, queries, filters and templates always use raw values.",
),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PerformanceConfig {
pub sampling_threshold: Option<usize>,
pub event_poll_interval_ms: u64,
pub polars_streaming: bool,
}
const PERFORMANCE_COMMENTS: &[(&str, &str)] = &[
(
"sampling_threshold",
"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",
),
(
"event_poll_interval_ms",
"Event polling interval in milliseconds\nLower values = more responsive but higher CPU usage",
),
(
"polars_streaming",
"Use Polars streaming engine for LazyFrame collect when available (default: true). Reduces memory and can improve performance on large or partitioned data.",
),
];
pub const DEFAULT_CHART_ROW_LIMIT: usize = 10_000;
pub const MAX_CHART_ROW_LIMIT: usize = u32::MAX as usize;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ChartConfig {
pub row_limit: Option<usize>,
}
const CHART_COMMENTS: &[(&str, &str)] = &[
(
"row_limit",
"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",
),
];
impl Default for ChartConfig {
fn default() -> Self {
Self {
row_limit: Some(DEFAULT_CHART_ROW_LIMIT),
}
}
}
impl ChartConfig {
pub fn merge(&mut self, other: Self) {
if other.row_limit.is_some() {
self.row_limit = other.row_limit;
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
#[default]
Auto,
Dark,
Light,
}
impl ThemeMode {
pub fn resolve(self) -> Self {
match self {
Self::Auto => detect_terminal_mode(),
other => other,
}
}
}
fn detect_terminal_mode() -> ThemeMode {
let Ok(raw) = std::env::var("COLORFGBG") else {
return ThemeMode::Dark;
};
match raw
.rsplit(';')
.next()
.and_then(|b| b.trim().parse::<u8>().ok())
{
Some(7) | Some(15) => ThemeMode::Light,
_ => ThemeMode::Dark,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DataConfig {
pub directories: Vec<String>,
pub use_desktop_recents: bool,
pub search: SearchConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SearchConfig {
pub enabled: bool,
pub max_depth: usize,
pub max_results: usize,
pub time_budget_ms: u64,
pub cross_filesystems: bool,
pub follow_gitignore: bool,
pub skip: Vec<String>,
pub skip_extra: Vec<String>,
pub extensions: Vec<String>,
}
pub const DEFAULT_SEARCH_SKIP: &[&str] = &[
"node_modules",
"target",
"build",
"dist",
"vendor",
"site-packages",
"__pycache__",
"venv",
"env",
];
impl Default for SearchConfig {
fn default() -> Self {
Self {
enabled: true,
max_depth: 8,
max_results: 20_000,
time_budget_ms: 1_500,
cross_filesystems: false,
follow_gitignore: false,
skip: DEFAULT_SEARCH_SKIP.iter().map(|s| s.to_string()).collect(),
skip_extra: Vec::new(),
extensions: Vec::new(),
}
}
}
impl SearchConfig {
pub fn merge(&mut self, other: Self) {
let d = SearchConfig::default();
if other.enabled != d.enabled {
self.enabled = other.enabled;
}
if other.max_depth != d.max_depth {
self.max_depth = other.max_depth;
}
if other.max_results != d.max_results {
self.max_results = other.max_results;
}
if other.time_budget_ms != d.time_budget_ms {
self.time_budget_ms = other.time_budget_ms;
}
if other.cross_filesystems != d.cross_filesystems {
self.cross_filesystems = other.cross_filesystems;
}
if other.follow_gitignore != d.follow_gitignore {
self.follow_gitignore = other.follow_gitignore;
}
if other.skip != d.skip {
self.skip = other.skip;
}
if !other.skip_extra.is_empty() {
self.skip_extra = other.skip_extra;
}
if !other.extensions.is_empty() {
self.extensions = other.extensions;
}
}
pub fn skipped_dirs(&self) -> Vec<String> {
let mut out = self.skip.clone();
out.extend(self.skip_extra.iter().cloned());
out
}
}
impl Default for DataConfig {
fn default() -> Self {
Self {
directories: Vec::new(),
use_desktop_recents: true,
search: SearchConfig::default(),
}
}
}
impl DataConfig {
pub fn merge(&mut self, other: Self) {
if !other.directories.is_empty() {
self.directories = other.directories;
}
if other.use_desktop_recents != DataConfig::default().use_desktop_recents {
self.use_desktop_recents = other.use_desktop_recents;
}
self.search.merge(other.search);
}
pub fn resolved_directories(&self) -> Vec<PathBuf> {
self.directories.iter().map(|d| expand_path(d)).collect()
}
}
const DISPLAY_UNICODE_COMMENT: &str =
"Draw box-drawing and arrow characters: \"auto\" (default), \"always\", or \"never\".\n\
\"auto\" uses them when the locale is UTF-8. Set \"never\" on a terminal that shows\n\
replacement boxes instead — datui falls back to plain ASCII throughout.";
const DATA_COMMENTS: &[(&str, &str)] = &[
(
"directories",
"Directories to offer as roots on the datui home screen (opened with no arguments).\n\
Think of this like PATH: a list of places, not a catalogue. datui stores nothing\n\
about what it finds. Supports ~ and $VAR.\n\
Directories of datasets you opened recently are offered automatically, so this is\n\
only needed for places you have not visited yet.\n\
Example: directories = [\"/mnt/data\", \"~/datasets\"]",
),
(
"use_desktop_recents",
"Also offer directories your desktop records you opening data files from\n\
(freedesktop's recently-used list, written by file managers and GTK apps).\n\
Only the DIRECTORIES are used, never the file names: that list often holds\n\
things you would not want on a screen you are sharing.\n\
Set false to ignore it entirely.",
),
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ThemeConfig {
pub mode: Option<ThemeMode>,
pub colors: ColorConfig,
}
const THEME_COMMENTS: &[(&str, &str)] = &[(
"mode",
"Which built-in colour set to start from: \"auto\" (default), \"dark\" or \"light\".\n\
datui's stock chrome (header fills, row striping, borders, dim text) uses fixed\n\
shades, and a set tuned for a dark terminal is unreadable on a light one.\n\
\"auto\" reads COLORFGBG and falls back to dark; Alacritty, Kitty and Ghostty do\n\
not set it, so on a light background in those terminals set this to \"light\".\n\
Individual colours below always override whichever set is chosen.",
)];
fn default_row_numbers_color() -> String {
"dark_gray".to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ColorConfig {
pub keybind_hints: String,
pub keybind_labels: String,
pub throbber: String,
pub primary_chart_series_color: String,
pub secondary_chart_series_color: String,
pub success: String,
pub error: String,
pub warning: String,
pub dimmed: String,
pub background: String,
pub surface: String,
pub controls_bg: String,
pub text_primary: String,
pub text_secondary: String,
pub text_inverse: String,
pub table_header: String,
pub table_header_bg: String,
#[serde(default = "default_row_numbers_color")]
pub row_numbers: String,
pub column_separator: String,
pub table_selected: String,
pub sidebar_border: String,
pub modal_border_active: String,
pub modal_border_error: String,
pub distribution_normal: String,
pub distribution_skewed: String,
pub distribution_other: String,
pub outlier_marker: String,
pub cursor_focused: String,
pub cursor_dimmed: String,
pub alternate_row_color: String,
pub str_col: String,
pub int_col: String,
pub float_col: String,
pub bool_col: String,
pub temporal_col: String,
pub binary_col: String,
pub chart_series_color_1: String,
pub chart_series_color_2: String,
pub chart_series_color_3: String,
pub chart_series_color_4: String,
pub chart_series_color_5: String,
pub chart_series_color_6: String,
pub chart_series_color_7: String,
}
const COLOR_COMMENTS: &[(&str, &str)] = &[
(
"keybind_hints",
"Keybind hints (modals, breadcrumb, correlation matrix)",
),
("keybind_labels", "Action labels in controls bar"),
("throbber", "Busy indicator (spinner) in control bar"),
(
"primary_chart_series_color",
"Chart data (histogram bars, Q-Q plot data points)",
),
(
"secondary_chart_series_color",
"Chart theory (histogram overlays, Q-Q plot reference line)",
),
("success", "Success indicators, normal distributions"),
("error", "Error messages, outliers"),
("warning", "Warnings, skewed distributions"),
("dimmed", "Dimmed elements, axis lines"),
("background", "Main background"),
("surface", "Modal/surface backgrounds"),
("controls_bg", "Controls bar background"),
("text_primary", "Primary text"),
("text_secondary", "Secondary text"),
("text_inverse", "Text on light backgrounds"),
("table_header", "Table column header text"),
("table_header_bg", "Table column header background"),
("row_numbers", "Row numbers column text; use \"default\" for terminal default"),
("column_separator", "Vertical line between columns"),
("table_selected", "Selected row style"),
("sidebar_border", "Sidebar borders"),
("modal_border_active", "Active modal elements"),
("modal_border_error", "Error modal borders"),
("distribution_normal", "Normal distribution indicator"),
("distribution_skewed", "Skewed distribution indicator"),
("distribution_other", "Other distribution types"),
("outlier_marker", "Outlier indicators"),
(
"cursor_focused",
"Cursor color when text input is focused\nText under cursor uses reverse of this color",
),
(
"cursor_dimmed",
"Cursor color when text input is unfocused (currently unused - unfocused inputs hide cursor)",
),
(
"alternate_row_color",
"Background color for every other row in the main data table\nSet to \"default\" to disable alternate row coloring",
),
("str_col", "Main table: string column text color"),
("int_col", "Main table: integer column text color"),
("float_col", "Main table: float column text color"),
("bool_col", "Main table: boolean column text color"),
("temporal_col", "Main table: date/datetime/time column text color"),
("binary_col", "Main table: binary column placeholder color"),
("chart_series_color_1", "Chart view: first series color"),
("chart_series_color_2", "Chart view: second series color"),
("chart_series_color_3", "Chart view: third series color"),
("chart_series_color_4", "Chart view: fourth series color"),
("chart_series_color_5", "Chart view: fifth series color"),
("chart_series_color_6", "Chart view: sixth series color"),
("chart_series_color_7", "Chart view: seventh series color"),
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct UiConfig {
pub controls: ControlsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ControlsConfig {
pub custom_controls: Option<Vec<(String, String)>>,
pub row_count_width: usize,
}
const CONTROLS_COMMENTS: &[(&str, &str)] = &[
(
"custom_controls",
"Custom control keybindings (optional)\nFormat: [[\"key\", \"label\"], [\"key\", \"label\"], ...]\nIf not specified, uses default controls",
),
("row_count_width", "Row count display width in characters"),
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct QueryConfig {
pub history_limit: usize,
pub enable_history: bool,
}
const QUERY_COMMENTS: &[(&str, &str)] = &[
(
"history_limit",
"Maximum number of queries to keep in history",
),
("enable_history", "Enable query history caching"),
];
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct TemplateConfig {
pub auto_apply: bool,
}
const TEMPLATE_COMMENTS: &[(&str, &str)] = &[(
"auto_apply",
"Auto-apply most relevant template on file open",
)];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DebugConfig {
pub enabled: bool,
pub show_performance: bool,
pub show_query: bool,
pub show_transformations: bool,
}
const DEBUG_COMMENTS: &[(&str, &str)] = &[
("enabled", "Enable debug overlay by default"),
(
"show_performance",
"Show performance metrics in debug overlay",
),
("show_query", "Show LazyFrame query in debug overlay"),
(
"show_transformations",
"Show transformation state in debug overlay",
),
];
impl Default for AppConfig {
fn default() -> Self {
Self {
import: Vec::new(),
version: "0.2".to_string(),
cloud: CloudConfig::default(),
file_loading: FileLoadingConfig::default(),
display: DisplayConfig::default(),
performance: PerformanceConfig::default(),
chart: ChartConfig::default(),
theme: ThemeConfig::default(),
data: DataConfig::default(),
ui: UiConfig::default(),
query: QueryConfig::default(),
templates: TemplateConfig::default(),
debug: DebugConfig::default(),
}
}
}
impl Default for DisplayConfig {
fn default() -> Self {
Self {
unicode: crate::glyphs::UnicodeMode::default(),
pages_lookahead: 3,
pages_lookback: 3,
max_buffered_rows: 100_000,
max_buffered_mb: 512,
row_numbers: false,
row_start_index: 1,
table_cell_padding: 2,
column_colors: true,
sidebar_width: None,
align_numeric_right: true,
number_format: NumberFormatConfig::default(),
}
}
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
sampling_threshold: None,
event_poll_interval_ms: 25,
polars_streaming: true,
}
}
}
impl Default for ColorConfig {
fn default() -> Self {
Self::dark()
}
}
impl ColorConfig {
pub fn for_mode(mode: ThemeMode) -> Self {
match mode.resolve() {
ThemeMode::Light => Self::light(),
_ => Self::dark(),
}
}
pub fn dark() -> Self {
Self {
keybind_hints: "cyan".to_string(),
keybind_labels: "indexed(252)".to_string(),
throbber: "cyan".to_string(),
primary_chart_series_color: "cyan".to_string(),
secondary_chart_series_color: "indexed(245)".to_string(),
success: "green".to_string(),
error: "red".to_string(),
warning: "yellow".to_string(),
dimmed: "dark_gray".to_string(),
background: "default".to_string(),
surface: "default".to_string(),
controls_bg: "indexed(235)".to_string(),
text_primary: "default".to_string(),
text_secondary: "indexed(240)".to_string(),
text_inverse: "black".to_string(),
table_header: "white".to_string(),
table_header_bg: "indexed(235)".to_string(),
row_numbers: "dark_gray".to_string(),
column_separator: "cyan".to_string(),
table_selected: "reversed".to_string(),
sidebar_border: "indexed(235)".to_string(),
modal_border_active: "yellow".to_string(),
modal_border_error: "red".to_string(),
distribution_normal: "green".to_string(),
distribution_skewed: "yellow".to_string(),
distribution_other: "white".to_string(),
outlier_marker: "red".to_string(),
cursor_focused: "default".to_string(),
cursor_dimmed: "default".to_string(),
alternate_row_color: "indexed(235)".to_string(),
str_col: "green".to_string(),
int_col: "cyan".to_string(),
float_col: "blue".to_string(),
bool_col: "yellow".to_string(),
temporal_col: "magenta".to_string(),
binary_col: "dark_gray".to_string(),
chart_series_color_1: "cyan".to_string(),
chart_series_color_2: "magenta".to_string(),
chart_series_color_3: "green".to_string(),
chart_series_color_4: "yellow".to_string(),
chart_series_color_5: "blue".to_string(),
chart_series_color_6: "red".to_string(),
chart_series_color_7: "bright_cyan".to_string(),
}
}
pub fn light() -> Self {
Self {
keybind_hints: "blue".to_string(),
keybind_labels: "indexed(238)".to_string(),
throbber: "blue".to_string(),
primary_chart_series_color: "blue".to_string(),
secondary_chart_series_color: "indexed(244)".to_string(),
success: "green".to_string(),
error: "red".to_string(),
warning: "indexed(94)".to_string(),
dimmed: "indexed(243)".to_string(),
background: "default".to_string(),
surface: "default".to_string(),
controls_bg: "indexed(254)".to_string(),
text_primary: "default".to_string(),
text_secondary: "indexed(240)".to_string(),
text_inverse: "white".to_string(),
table_header: "black".to_string(),
table_header_bg: "indexed(253)".to_string(),
row_numbers: "indexed(243)".to_string(),
column_separator: "indexed(250)".to_string(),
table_selected: "reversed".to_string(),
sidebar_border: "indexed(250)".to_string(),
modal_border_active: "blue".to_string(),
modal_border_error: "red".to_string(),
distribution_normal: "green".to_string(),
distribution_skewed: "indexed(94)".to_string(),
distribution_other: "black".to_string(),
outlier_marker: "red".to_string(),
cursor_focused: "default".to_string(),
cursor_dimmed: "default".to_string(),
alternate_row_color: "indexed(254)".to_string(),
str_col: "green".to_string(),
int_col: "indexed(23)".to_string(),
float_col: "blue".to_string(),
bool_col: "indexed(94)".to_string(),
temporal_col: "magenta".to_string(),
binary_col: "indexed(243)".to_string(),
chart_series_color_1: "blue".to_string(),
chart_series_color_2: "green".to_string(),
chart_series_color_3: "magenta".to_string(),
chart_series_color_4: "indexed(94)".to_string(),
chart_series_color_5: "indexed(23)".to_string(),
chart_series_color_6: "red".to_string(),
chart_series_color_7: "indexed(54)".to_string(),
}
}
}
impl Default for ControlsConfig {
fn default() -> Self {
Self {
custom_controls: None,
row_count_width: 20,
}
}
}
impl Default for QueryConfig {
fn default() -> Self {
Self {
history_limit: 1000,
enable_history: true,
}
}
}
impl Default for DebugConfig {
fn default() -> Self {
Self {
enabled: false,
show_performance: true,
show_query: true,
show_transformations: true,
}
}
}
const MAX_IMPORT_DEPTH: usize = 8;
pub fn expand_config_path(raw: &str) -> PathBuf {
expand_path(raw)
}
fn expand_path(raw: &str) -> PathBuf {
let mut expanded = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c != '$' {
expanded.push(c);
continue;
}
let braced = chars.peek() == Some(&'{');
if braced {
chars.next();
}
let mut name = String::new();
while let Some(&next) = chars.peek() {
if braced && next == '}' {
chars.next();
break;
}
if !next.is_ascii_alphanumeric() && next != '_' {
break;
}
name.push(next);
chars.next();
}
if name.is_empty() {
expanded.push('$');
} else if let Ok(value) = std::env::var(&name) {
expanded.push_str(&value);
}
}
if expanded == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
} else if let Some(rest) = expanded.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
PathBuf::from(expanded)
}
impl AppConfig {
pub fn load(app_name: &str) -> Result<Self> {
match ConfigManager::new(app_name) {
Ok(manager) => Self::load_from_file(&manager.config_path("config.toml")),
Err(_) => {
let config = AppConfig::default();
config
.validate()
.map_err(|e| eyre!("Invalid configuration: {}", e))?;
Ok(config)
}
}
}
pub fn load_from_file(config_path: &Path) -> Result<Self> {
let mut layers: Vec<AppConfig> = Vec::new();
let mut imports: Vec<String> = Vec::new();
if config_path.exists() {
if let Ok(layer) = Self::read_layer(config_path) {
let root = config_path
.canonicalize()
.unwrap_or_else(|_| config_path.to_path_buf());
let mut stack = vec![root];
imports = layer.import.clone();
Self::collect_imports(&imports, config_path, &mut stack, &mut layers)?;
layers.push(layer);
}
}
let declared = layers
.iter()
.rev()
.find_map(|l| l.theme.mode)
.unwrap_or_default();
let resolved = declared.resolve();
let mut config = AppConfig::default();
config.theme.colors = ColorConfig::for_mode(resolved);
for layer in layers {
config.merge(layer);
}
config.import = imports;
config.theme.mode = Some(resolved);
config
.validate()
.map_err(|e| eyre!("Invalid configuration in {}: {}", config_path.display(), e))?;
Ok(config)
}
fn collect_imports(
imports: &[String],
origin: &Path,
stack: &mut Vec<PathBuf>,
out: &mut Vec<AppConfig>,
) -> Result<()> {
if imports.is_empty() {
return Ok(());
}
if stack.len() >= MAX_IMPORT_DEPTH {
return Err(eyre!(
"config import chain is more than {} files deep (at {}); \
flatten the chain or remove the extra levels",
MAX_IMPORT_DEPTH,
origin.display()
));
}
let origin_dir = origin.parent().unwrap_or_else(|| Path::new("."));
for entry in imports {
let expanded = expand_path(entry);
let path = if expanded.is_absolute() {
expanded
} else {
origin_dir.join(expanded)
};
if !path.exists() {
eprintln!(
"datui: warning: config import not found, skipping: {} (imported by {})",
path.display(),
origin.display()
);
continue;
}
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
if stack.contains(&canonical) {
return Err(eyre!(
"circular config import: {} is already being loaded (imported by {})",
canonical.display(),
origin.display()
));
}
let layer = Self::read_layer(&path)
.map_err(|e| eyre!("{} (imported by {})", e, origin.display()))?;
let nested = layer.import.clone();
stack.push(canonical);
Self::collect_imports(&nested, &path, stack, out)?;
stack.pop();
out.push(layer);
}
Ok(())
}
fn read_layer(path: &Path) -> Result<AppConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| eyre!("Failed to read config file at {}: {}", path.display(), e))?;
toml::from_str(&content)
.map_err(|e| eyre!("Failed to parse config file at {}: {}", path.display(), e))
}
pub fn merge(&mut self, other: AppConfig) {
if other.version != AppConfig::default().version {
self.version = other.version;
}
self.cloud.merge(other.cloud);
self.file_loading.merge(other.file_loading);
self.display.merge(other.display);
self.performance.merge(other.performance);
self.chart.merge(other.chart);
self.theme.merge(other.theme);
self.data.merge(other.data);
self.ui.merge(other.ui);
self.query.merge(other.query);
self.templates.merge(other.templates);
self.debug.merge(other.debug);
}
pub fn validate(&self) -> Result<()> {
if !self.version.starts_with("0.2") {
return Err(eyre!(
"Unsupported config version: {}. Expected 0.2.x",
self.version
));
}
if let Some(t) = self.performance.sampling_threshold {
if t == 0 {
return Err(eyre!("sampling_threshold must be greater than 0 when set"));
}
}
if self.performance.event_poll_interval_ms == 0 {
return Err(eyre!("event_poll_interval_ms must be greater than 0"));
}
if let Some(n) = self.chart.row_limit {
if n == 0 || n > MAX_CHART_ROW_LIMIT {
return Err(eyre!(
"chart.row_limit must be between 1 and {} when set, got {}",
MAX_CHART_ROW_LIMIT,
n
));
}
}
self.display
.number_format
.resolve(self.display.align_numeric_right)?;
let parser = ColorParser::new();
self.theme.colors.validate(&parser)?;
Ok(())
}
}
impl FileLoadingConfig {
pub fn merge(&mut self, other: Self) {
if other.delimiter.is_some() {
self.delimiter = other.delimiter;
}
if other.has_header.is_some() {
self.has_header = other.has_header;
}
if other.skip_lines.is_some() {
self.skip_lines = other.skip_lines;
}
if other.skip_rows.is_some() {
self.skip_rows = other.skip_rows;
}
if other.skip_tail_rows.is_some() {
self.skip_tail_rows = other.skip_tail_rows;
}
if other.parse_dates.is_some() {
self.parse_dates = other.parse_dates;
}
if other.decompress_in_memory.is_some() {
self.decompress_in_memory = other.decompress_in_memory;
}
if other.temp_dir.is_some() {
self.temp_dir = other.temp_dir.clone();
}
if other.single_spine_schema.is_some() {
self.single_spine_schema = other.single_spine_schema;
}
if other.null_values.is_some() {
self.null_values = other.null_values.clone();
}
if other.parse_strings.is_some() {
self.parse_strings = other.parse_strings;
}
if other.parse_strings_sample_rows.is_some() {
self.parse_strings_sample_rows = other.parse_strings_sample_rows;
}
if other.infer_schema_length.is_some() {
self.infer_schema_length = other.infer_schema_length;
}
if other.ignore_errors.is_some() {
self.ignore_errors = other.ignore_errors;
}
}
}
impl DisplayConfig {
pub fn merge(&mut self, other: Self) {
if other.unicode != crate::glyphs::UnicodeMode::default() {
self.unicode = other.unicode;
}
let default = DisplayConfig::default();
if other.pages_lookahead != default.pages_lookahead {
self.pages_lookahead = other.pages_lookahead;
}
if other.pages_lookback != default.pages_lookback {
self.pages_lookback = other.pages_lookback;
}
if other.max_buffered_rows != default.max_buffered_rows {
self.max_buffered_rows = other.max_buffered_rows;
}
if other.max_buffered_mb != default.max_buffered_mb {
self.max_buffered_mb = other.max_buffered_mb;
}
if other.row_numbers != default.row_numbers {
self.row_numbers = other.row_numbers;
}
if other.row_start_index != default.row_start_index {
self.row_start_index = other.row_start_index;
}
if other.table_cell_padding != default.table_cell_padding {
self.table_cell_padding = other.table_cell_padding;
}
if other.column_colors != default.column_colors {
self.column_colors = other.column_colors;
}
if other.sidebar_width != default.sidebar_width {
self.sidebar_width = other.sidebar_width;
}
if other.align_numeric_right != default.align_numeric_right {
self.align_numeric_right = other.align_numeric_right;
}
if other.number_format != default.number_format {
self.number_format = other.number_format;
}
}
}
impl PerformanceConfig {
pub fn merge(&mut self, other: Self) {
let default = PerformanceConfig::default();
if other.sampling_threshold != default.sampling_threshold {
self.sampling_threshold = other.sampling_threshold;
}
if other.event_poll_interval_ms != default.event_poll_interval_ms {
self.event_poll_interval_ms = other.event_poll_interval_ms;
}
if other.polars_streaming != default.polars_streaming {
self.polars_streaming = other.polars_streaming;
}
}
}
impl ThemeConfig {
pub fn merge(&mut self, other: Self) {
if other.mode.is_some() {
self.mode = other.mode;
}
self.colors.merge(other.colors);
}
}
impl ColorConfig {
fn validate(&self, parser: &ColorParser) -> Result<()> {
macro_rules! validate_color {
($field:expr, $name:expr) => {
parser.parse($field).map_err(|e| {
eyre!(
"theme.colors.{}: {}. Use a valid color name (e.g. red, cyan, bright_red), \
hex (#rrggbb), or indexed(0-255)",
$name,
e
)
})?;
};
}
validate_color!(&self.keybind_hints, "keybind_hints");
validate_color!(&self.keybind_labels, "keybind_labels");
validate_color!(&self.throbber, "throbber");
validate_color!(
&self.primary_chart_series_color,
"primary_chart_series_color"
);
validate_color!(
&self.secondary_chart_series_color,
"secondary_chart_series_color"
);
validate_color!(&self.success, "success");
validate_color!(&self.error, "error");
validate_color!(&self.warning, "warning");
validate_color!(&self.dimmed, "dimmed");
validate_color!(&self.background, "background");
validate_color!(&self.surface, "surface");
validate_color!(&self.controls_bg, "controls_bg");
validate_color!(&self.text_primary, "text_primary");
validate_color!(&self.text_secondary, "text_secondary");
validate_color!(&self.text_inverse, "text_inverse");
validate_color!(&self.table_header, "table_header");
validate_color!(&self.table_header_bg, "table_header_bg");
validate_color!(&self.row_numbers, "row_numbers");
validate_color!(&self.column_separator, "column_separator");
validate_color!(&self.table_selected, "table_selected");
validate_color!(&self.sidebar_border, "sidebar_border");
validate_color!(&self.modal_border_active, "modal_border_active");
validate_color!(&self.modal_border_error, "modal_border_error");
validate_color!(&self.distribution_normal, "distribution_normal");
validate_color!(&self.distribution_skewed, "distribution_skewed");
validate_color!(&self.distribution_other, "distribution_other");
validate_color!(&self.outlier_marker, "outlier_marker");
validate_color!(&self.cursor_focused, "cursor_focused");
validate_color!(&self.cursor_dimmed, "cursor_dimmed");
if self.alternate_row_color != "default" {
validate_color!(&self.alternate_row_color, "alternate_row_color");
}
validate_color!(&self.str_col, "str_col");
validate_color!(&self.int_col, "int_col");
validate_color!(&self.float_col, "float_col");
validate_color!(&self.bool_col, "bool_col");
validate_color!(&self.temporal_col, "temporal_col");
validate_color!(&self.chart_series_color_1, "chart_series_color_1");
validate_color!(&self.chart_series_color_2, "chart_series_color_2");
validate_color!(&self.chart_series_color_3, "chart_series_color_3");
validate_color!(&self.chart_series_color_4, "chart_series_color_4");
validate_color!(&self.chart_series_color_5, "chart_series_color_5");
validate_color!(&self.chart_series_color_6, "chart_series_color_6");
validate_color!(&self.chart_series_color_7, "chart_series_color_7");
Ok(())
}
pub fn merge(&mut self, other: Self) {
let default = ColorConfig::default();
if other.keybind_hints != default.keybind_hints {
self.keybind_hints = other.keybind_hints;
}
if other.keybind_labels != default.keybind_labels {
self.keybind_labels = other.keybind_labels;
}
if other.throbber != default.throbber {
self.throbber = other.throbber;
}
if other.primary_chart_series_color != default.primary_chart_series_color {
self.primary_chart_series_color = other.primary_chart_series_color;
}
if other.secondary_chart_series_color != default.secondary_chart_series_color {
self.secondary_chart_series_color = other.secondary_chart_series_color;
}
if other.success != default.success {
self.success = other.success;
}
if other.error != default.error {
self.error = other.error;
}
if other.warning != default.warning {
self.warning = other.warning;
}
if other.dimmed != default.dimmed {
self.dimmed = other.dimmed;
}
if other.background != default.background {
self.background = other.background;
}
if other.surface != default.surface {
self.surface = other.surface;
}
if other.controls_bg != default.controls_bg {
self.controls_bg = other.controls_bg;
}
if other.text_primary != default.text_primary {
self.text_primary = other.text_primary;
}
if other.text_secondary != default.text_secondary {
self.text_secondary = other.text_secondary;
}
if other.text_inverse != default.text_inverse {
self.text_inverse = other.text_inverse;
}
if other.table_header != default.table_header {
self.table_header = other.table_header;
}
if other.table_header_bg != default.table_header_bg {
self.table_header_bg = other.table_header_bg;
}
if other.row_numbers != default.row_numbers {
self.row_numbers = other.row_numbers;
}
if other.column_separator != default.column_separator {
self.column_separator = other.column_separator;
}
if other.table_selected != default.table_selected {
self.table_selected = other.table_selected;
}
if other.sidebar_border != default.sidebar_border {
self.sidebar_border = other.sidebar_border;
}
if other.modal_border_active != default.modal_border_active {
self.modal_border_active = other.modal_border_active;
}
if other.modal_border_error != default.modal_border_error {
self.modal_border_error = other.modal_border_error;
}
if other.distribution_normal != default.distribution_normal {
self.distribution_normal = other.distribution_normal;
}
if other.distribution_skewed != default.distribution_skewed {
self.distribution_skewed = other.distribution_skewed;
}
if other.distribution_other != default.distribution_other {
self.distribution_other = other.distribution_other;
}
if other.outlier_marker != default.outlier_marker {
self.outlier_marker = other.outlier_marker;
}
if other.cursor_focused != default.cursor_focused {
self.cursor_focused = other.cursor_focused;
}
if other.cursor_dimmed != default.cursor_dimmed {
self.cursor_dimmed = other.cursor_dimmed;
}
if other.alternate_row_color != default.alternate_row_color {
self.alternate_row_color = other.alternate_row_color;
}
if other.str_col != default.str_col {
self.str_col = other.str_col;
}
if other.int_col != default.int_col {
self.int_col = other.int_col;
}
if other.float_col != default.float_col {
self.float_col = other.float_col;
}
if other.bool_col != default.bool_col {
self.bool_col = other.bool_col;
}
if other.temporal_col != default.temporal_col {
self.temporal_col = other.temporal_col;
}
if other.binary_col != default.binary_col {
self.binary_col = other.binary_col;
}
if other.chart_series_color_1 != default.chart_series_color_1 {
self.chart_series_color_1 = other.chart_series_color_1;
}
if other.chart_series_color_2 != default.chart_series_color_2 {
self.chart_series_color_2 = other.chart_series_color_2;
}
if other.chart_series_color_3 != default.chart_series_color_3 {
self.chart_series_color_3 = other.chart_series_color_3;
}
if other.chart_series_color_4 != default.chart_series_color_4 {
self.chart_series_color_4 = other.chart_series_color_4;
}
if other.chart_series_color_5 != default.chart_series_color_5 {
self.chart_series_color_5 = other.chart_series_color_5;
}
if other.chart_series_color_6 != default.chart_series_color_6 {
self.chart_series_color_6 = other.chart_series_color_6;
}
if other.chart_series_color_7 != default.chart_series_color_7 {
self.chart_series_color_7 = other.chart_series_color_7;
}
}
}
impl UiConfig {
pub fn merge(&mut self, other: Self) {
self.controls.merge(other.controls);
}
}
impl ControlsConfig {
pub fn merge(&mut self, other: Self) {
if other.custom_controls.is_some() {
self.custom_controls = other.custom_controls;
}
let default = ControlsConfig::default();
if other.row_count_width != default.row_count_width {
self.row_count_width = other.row_count_width;
}
}
}
impl QueryConfig {
pub fn merge(&mut self, other: Self) {
let default = QueryConfig::default();
if other.history_limit != default.history_limit {
self.history_limit = other.history_limit;
}
if other.enable_history != default.enable_history {
self.enable_history = other.enable_history;
}
}
}
impl TemplateConfig {
pub fn merge(&mut self, other: Self) {
let default = TemplateConfig::default();
if other.auto_apply != default.auto_apply {
self.auto_apply = other.auto_apply;
}
}
}
impl DebugConfig {
pub fn merge(&mut self, other: Self) {
let default = DebugConfig::default();
if other.enabled != default.enabled {
self.enabled = other.enabled;
}
if other.show_performance != default.show_performance {
self.show_performance = other.show_performance;
}
if other.show_query != default.show_query {
self.show_query = other.show_query;
}
if other.show_transformations != default.show_transformations {
self.show_transformations = other.show_transformations;
}
}
}
pub struct ColorParser {
supports_true_color: bool,
supports_256: bool,
no_color: bool,
}
impl ColorParser {
pub fn new() -> Self {
let no_color = std::env::var("NO_COLOR").is_ok();
let support = supports_color::on(Stream::Stdout);
Self {
supports_true_color: support.as_ref().map(|s| s.has_16m).unwrap_or(false),
supports_256: support.as_ref().map(|s| s.has_256).unwrap_or(false),
no_color,
}
}
pub fn parse(&self, s: &str) -> Result<Color> {
if self.no_color {
return Ok(Color::Reset);
}
let trimmed = s.trim();
if trimmed.starts_with('#') && trimmed.len() == 7 {
let (r, g, b) = parse_hex(trimmed)?;
return Ok(self.convert_rgb_to_terminal_color(r, g, b));
}
if trimmed.to_lowercase().starts_with("indexed(") && trimmed.ends_with(')') {
let num_str = &trimmed[8..trimmed.len() - 1]; let num = num_str.parse::<u8>().map_err(|_| {
eyre!(
"Invalid indexed color: '{}'. Expected format: indexed(0-255)",
trimmed
)
})?;
return Ok(Color::Indexed(num));
}
let lower = trimmed.to_lowercase();
match lower.as_str() {
"black" => Ok(Color::Black),
"red" => Ok(Color::Red),
"green" => Ok(Color::Green),
"yellow" => Ok(Color::Yellow),
"blue" => Ok(Color::Blue),
"magenta" => Ok(Color::Magenta),
"cyan" => Ok(Color::Cyan),
"white" => Ok(Color::White),
"bright_black" | "bright black" => Ok(Color::Indexed(8)),
"bright_red" | "bright red" => Ok(Color::Indexed(9)),
"bright_green" | "bright green" => Ok(Color::Indexed(10)),
"bright_yellow" | "bright yellow" => Ok(Color::Indexed(11)),
"bright_blue" | "bright blue" => Ok(Color::Indexed(12)),
"bright_magenta" | "bright magenta" => Ok(Color::Indexed(13)),
"bright_cyan" | "bright cyan" => Ok(Color::Indexed(14)),
"bright_white" | "bright white" => Ok(Color::Indexed(15)),
"gray" | "grey" => Ok(Color::Indexed(8)),
"dark_gray" | "dark gray" | "dark_grey" | "dark grey" => Ok(Color::Indexed(8)),
"light_gray" | "light gray" | "light_grey" | "light grey" => Ok(Color::Indexed(7)),
"reset" | "default" | "none" | "reversed" => Ok(Color::Reset),
_ => Err(eyre!(
"Unknown color name: '{}'. Supported: basic ANSI colors (red, blue, etc.), \
bright variants (bright_red, etc.), or hex colors (#ff0000)",
trimmed
)),
}
}
fn convert_rgb_to_terminal_color(&self, r: u8, g: u8, b: u8) -> Color {
if self.supports_true_color {
Color::Rgb(r, g, b)
} else if self.supports_256 {
Color::Indexed(rgb_to_256_color(r, g, b))
} else {
rgb_to_basic_ansi(r, g, b)
}
}
}
impl Default for ColorParser {
fn default() -> Self {
Self::new()
}
}
fn parse_hex(s: &str) -> Result<(u8, u8, u8)> {
if !s.starts_with('#') || s.len() != 7 {
return Err(eyre!(
"Invalid hex color format: '{}'. Expected format: #rrggbb",
s
));
}
let r = u8::from_str_radix(&s[1..3], 16)
.map_err(|_| eyre!("Invalid red component in hex color: {}", s))?;
let g = u8::from_str_radix(&s[3..5], 16)
.map_err(|_| eyre!("Invalid green component in hex color: {}", s))?;
let b = u8::from_str_radix(&s[5..7], 16)
.map_err(|_| eyre!("Invalid blue component in hex color: {}", s))?;
Ok((r, g, b))
}
pub fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
if max_diff < 10 {
let gray = (r as u16 + g as u16 + b as u16) / 3;
if gray < 8 {
return 16; } else if gray > 247 {
return 231; } else {
return 232 + ((gray - 8) * 24 / 240) as u8;
}
}
let r_idx = (r as u16 * 5 / 255) as u8;
let g_idx = (g as u16 * 5 / 255) as u8;
let b_idx = (b as u16 * 5 / 255) as u8;
16 + 36 * r_idx + 6 * g_idx + b_idx
}
pub fn rgb_to_basic_ansi(r: u8, g: u8, b: u8) -> Color {
let r_bright = r > 128;
let g_bright = g > 128;
let b_bright = b > 128;
let max_diff = r.max(g).max(b) as i16 - r.min(g).min(b) as i16;
if max_diff < 30 {
let avg = (r as u16 + g as u16 + b as u16) / 3;
return if avg < 64 { Color::Black } else { Color::White };
}
match (r_bright, g_bright, b_bright) {
(false, false, false) => Color::Black,
(true, false, false) => Color::Red,
(false, true, false) => Color::Green,
(true, true, false) => Color::Yellow,
(false, false, true) => Color::Blue,
(true, false, true) => Color::Magenta,
(false, true, true) => Color::Cyan,
(true, true, true) => Color::White,
}
}
#[derive(Debug, Clone)]
pub struct Theme {
pub colors: HashMap<String, Color>,
}
impl Theme {
pub fn from_config(config: &ThemeConfig) -> Result<Self> {
let parser = ColorParser::new();
let mut colors = HashMap::new();
colors.insert(
"keybind_hints".to_string(),
parser.parse(&config.colors.keybind_hints)?,
);
colors.insert(
"keybind_labels".to_string(),
parser.parse(&config.colors.keybind_labels)?,
);
colors.insert(
"throbber".to_string(),
parser.parse(&config.colors.throbber)?,
);
colors.insert(
"primary_chart_series_color".to_string(),
parser.parse(&config.colors.primary_chart_series_color)?,
);
colors.insert(
"secondary_chart_series_color".to_string(),
parser.parse(&config.colors.secondary_chart_series_color)?,
);
colors.insert("success".to_string(), parser.parse(&config.colors.success)?);
colors.insert("error".to_string(), parser.parse(&config.colors.error)?);
colors.insert("warning".to_string(), parser.parse(&config.colors.warning)?);
colors.insert("dimmed".to_string(), parser.parse(&config.colors.dimmed)?);
colors.insert(
"background".to_string(),
parser.parse(&config.colors.background)?,
);
colors.insert("surface".to_string(), parser.parse(&config.colors.surface)?);
colors.insert(
"controls_bg".to_string(),
parser.parse(&config.colors.controls_bg)?,
);
colors.insert(
"text_primary".to_string(),
parser.parse(&config.colors.text_primary)?,
);
colors.insert(
"text_secondary".to_string(),
parser.parse(&config.colors.text_secondary)?,
);
colors.insert(
"text_inverse".to_string(),
parser.parse(&config.colors.text_inverse)?,
);
colors.insert(
"table_header".to_string(),
parser.parse(&config.colors.table_header)?,
);
colors.insert(
"table_header_bg".to_string(),
parser.parse(&config.colors.table_header_bg)?,
);
colors.insert(
"row_numbers".to_string(),
parser.parse(&config.colors.row_numbers)?,
);
colors.insert(
"column_separator".to_string(),
parser.parse(&config.colors.column_separator)?,
);
colors.insert(
"table_selected".to_string(),
parser.parse(&config.colors.table_selected)?,
);
colors.insert(
"sidebar_border".to_string(),
parser.parse(&config.colors.sidebar_border)?,
);
colors.insert(
"modal_border_active".to_string(),
parser.parse(&config.colors.modal_border_active)?,
);
colors.insert(
"modal_border_error".to_string(),
parser.parse(&config.colors.modal_border_error)?,
);
colors.insert(
"distribution_normal".to_string(),
parser.parse(&config.colors.distribution_normal)?,
);
colors.insert(
"distribution_skewed".to_string(),
parser.parse(&config.colors.distribution_skewed)?,
);
colors.insert(
"distribution_other".to_string(),
parser.parse(&config.colors.distribution_other)?,
);
colors.insert(
"outlier_marker".to_string(),
parser.parse(&config.colors.outlier_marker)?,
);
colors.insert(
"cursor_focused".to_string(),
parser.parse(&config.colors.cursor_focused)?,
);
colors.insert(
"cursor_dimmed".to_string(),
parser.parse(&config.colors.cursor_dimmed)?,
);
if config.colors.alternate_row_color != "default" {
colors.insert(
"alternate_row_color".to_string(),
parser.parse(&config.colors.alternate_row_color)?,
);
}
colors.insert("str_col".to_string(), parser.parse(&config.colors.str_col)?);
colors.insert("int_col".to_string(), parser.parse(&config.colors.int_col)?);
colors.insert(
"float_col".to_string(),
parser.parse(&config.colors.float_col)?,
);
colors.insert(
"bool_col".to_string(),
parser.parse(&config.colors.bool_col)?,
);
colors.insert(
"temporal_col".to_string(),
parser.parse(&config.colors.temporal_col)?,
);
colors.insert(
"binary_col".to_string(),
parser.parse(&config.colors.binary_col)?,
);
colors.insert(
"chart_series_color_1".to_string(),
parser.parse(&config.colors.chart_series_color_1)?,
);
colors.insert(
"chart_series_color_2".to_string(),
parser.parse(&config.colors.chart_series_color_2)?,
);
colors.insert(
"chart_series_color_3".to_string(),
parser.parse(&config.colors.chart_series_color_3)?,
);
colors.insert(
"chart_series_color_4".to_string(),
parser.parse(&config.colors.chart_series_color_4)?,
);
colors.insert(
"chart_series_color_5".to_string(),
parser.parse(&config.colors.chart_series_color_5)?,
);
colors.insert(
"chart_series_color_6".to_string(),
parser.parse(&config.colors.chart_series_color_6)?,
);
colors.insert(
"chart_series_color_7".to_string(),
parser.parse(&config.colors.chart_series_color_7)?,
);
Ok(Self { colors })
}
pub fn get(&self, name: &str) -> Color {
self.colors.get(name).copied().unwrap_or(Color::Reset)
}
pub fn get_optional(&self, name: &str) -> Option<Color> {
self.colors.get(name).copied()
}
}
const DATA_SEARCH_COMMENTS: &[(&str, &str)] = &[
(
"enabled",
"Search below the working directory when you type on the home screen.\n\
The walk runs once, in the background, the first time you type; every\n\
keystroke after that filters the result in memory. Set false to list only\n\
the directories themselves.",
),
(
"max_depth",
"How deep to descend. Data is rarely twelve directories down, and every\n\
extra level costs a listing on every branch.",
),
(
"max_results",
"Stop after this many datasets. The list is a way to find something, not an\n\
inventory. Hitting the limit is reported on screen, never silent.",
),
(
"time_budget_ms",
"Give up walking after this long and keep whatever was found. A cold or\n\
enormous tree must degrade to partial results, never to a wait.",
),
(
"cross_filesystems",
"Descend into directories on a different filesystem than the one you started\n\
in. Off by default, and the most important limit here: it is what keeps a\n\
search from wandering onto a network share, and on autofs, from MOUNTING one\n\
merely by looking at it. Turn it on only if your data lives on a mount\n\
beneath your working directory and you know that mount is fast.",
),
(
"follow_gitignore",
"Obey .gitignore. Off by default, and deliberately: people gitignore data\n\
directories precisely because the data is too big to commit, which is the\n\
same reason they want to open it in datui. In datui's own repository,\n\
honouring it hides 38 real test datasets while hiding 69 files of virtualenv\n\
noise -- wrong in both directions. Use skip/skip_extra for the noise.",
),
(
"skip",
"Directory names never descended into. Setting this REPLACES the defaults:\n\
node_modules, target, build, dist, vendor, site-packages, __pycache__,\n\
venv, env. Hidden directories (.git, .venv, the caches) are always skipped.\n\
To add to the defaults rather than replace them, use skip_extra.",
),
(
"skip_extra",
"Directory names to skip in addition to the defaults, so adding one does not\n\
mean restating the whole list. Example: skip_extra = [\"archive\", \"raw\"]",
),
(
"extensions",
"File extensions to search for. Empty (default) means every format datui can\n\
open -- which includes json and txt, noisy in a source tree. Narrow it if\n\
that bothers you. Example: extensions = [\"parquet\", \"csv\"]",
),
];
fn bracket_depth(line: &str) -> i32 {
line.chars().fold(0, |acc, c| match c {
'[' => acc + 1,
']' => acc - 1,
_ => acc,
})
}