Skip to content
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Custom color themes via `[[theme.custom]]` in config.toml with `#RRGGBB` hex and named color support, `inherits` for theme inheritance, and `foreground`/`background` broad-brush aliases ([#42](https://github.com/bgreenwell/xleak/issues/42))
- `--theme <NAME>` CLI flag to select a theme at launch, overriding the configured default
- Truecolor warning on stderr when launching the interactive TUI with a custom theme that uses RGB colors and `COLORTERM` doesn't advertise truecolor support. Built-in themes are not checked; graceful fallback for all themes is tracked in [#48](https://github.com/bgreenwell/xleak/issues/48)
- OSC 52 clipboard support: `c`/`C` now copy via OSC 52 (works over SSH) in addition to the system clipboard
- CSV/TSV support: read and interactively view `.csv`/`.tsv` files as a single sheet (behind the default-on `csv` feature)
- `--csv-delimiter` option to override the inferred CSV/TSV field delimiter
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ xleak --config /path/to/config.toml file.xlsx -i
default = "Dracula"
```

**Available themes:**
**Built-in themes:**
- `"Default"` - Clean light theme with subtle colors
- `"Dracula"` - Popular dark theme with purple accents
- `"Solarized Dark"` - Precision colors for machines and people
Expand All @@ -333,6 +333,28 @@ default = "Dracula"
- `"Nord"` - Arctic, north-bluish color palette

Press `t` in interactive mode to cycle through themes at runtime.
Use `--theme <NAME>` to select a theme from the command line.

#### Custom Themes

Define custom themes in `[[theme.custom]]` blocks. Colors can be `#RRGGBB` hex
or named colors (`red`, `cyan`, `light-yellow`, etc.).

```toml
[[theme.custom]]
name = "tokyonight"
inherits = "Dracula"
foreground = "#c0caf5"
background = "#1a1b26"
header_fg = "#7aa2f7"
border_fg = "#565f89"
```

- `inherits` copies another theme's palette before applying your overrides. Without it, a custom theme sharing a built-in's name inherits that built-in; a new name inherits Default.
- `foreground` / `background` are broad-brush aliases — specific fields like `string_fg` override them.
- Aliases exclude cursor, search, and row-highlight colors so they keep contrast from the parent theme.
- Custom themes appear after built-ins in the `t` cycle.
- `deny_unknown_fields` is on: a typo like `forground` will error rather than be silently ignored.

#### UI Settings

Expand Down Expand Up @@ -482,6 +504,20 @@ jump = "Ctrl+j"
default = "Nord"
```

**Custom theme:**
```toml
[theme]
default = "tokyonight"

[[theme.custom]]
name = "tokyonight"
inherits = "Dracula"
foreground = "#c0caf5"
background = "#1a1b26"
header_fg = "#7aa2f7"
border_fg = "#565f89"
```

**VIM user:**
```toml
[theme]
Expand Down
30 changes: 30 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@
# "Nord" - Arctic, north-bluish color palette
default = "Default"

# Custom themes
# =============
# Define custom themes with [[theme.custom]]. Colors can be "#RRGGBB" hex
# or named colors: black, red, green, yellow, blue, magenta, cyan, white,
# gray, dark-gray, light-red, light-green, light-yellow, light-blue,
# light-magenta, light-cyan.
#
# `inherits` copies another theme's palette before applying your overrides.
# Without it, a custom theme sharing a built-in's name inherits that built-in;
# a new name inherits Default.
#
# `foreground` and `background` are broad-brush aliases — specific fields
# override them. Cursor, search, and row-highlight colors are excluded from
# aliases so they keep contrast from the parent theme.
#
# [[theme.custom]]
# name = "tokyonight"
# inherits = "Dracula"
# foreground = "#c0caf5"
# background = "#1a1b26"
# header_fg = "#7aa2f7"
# border_fg = "#565f89"
#
# Per-field overrides (all optional):
# string_fg, number_fg, bool_fg, datetime_fg, error_fg, empty_fg,
# header_fg, header_bg, current_cell_fg, current_cell_bg,
# current_row_bg, current_col_fg, alternating_row_bg,
# search_match_fg, search_match_bg, current_search_fg, current_search_bg,
# border_fg, status_bar_fg, status_bar_bg

# =============================================================================
# UI SETTINGS
# =============================================================================
Expand Down
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ pub struct Cli {
#[arg(long)]
pub no_row_id: bool,

/// Color theme to use (e.g. Dracula, Nord, "Solarized Dark")
#[arg(long, value_name = "NAME")]
pub theme: Option<String>,

/// Disable colored output (useful for piping)
#[arg(long)]
pub no_color: bool,
Expand Down
237 changes: 235 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use anyhow::{Context, Result};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::style::Color;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
Expand All @@ -21,6 +22,97 @@ pub struct Config {
pub struct ThemeConfig {
/// Default theme to use on startup
pub default: String,
/// User-defined themes from `[[theme.custom]]`
#[serde(default, skip_serializing)]
pub custom: Vec<CustomTheme>,
}

/// A user-defined theme from `[[theme.custom]]` in config.toml.
///
/// Every color is optional: unset fields come from the theme named by
/// `inherits`, or from the built-in Default theme. `foreground` and `background`
/// are broad-brush aliases that the specific fields below override.
///
/// `deny_unknown_fields` catches typos like `forground` instead of silently
/// ignoring them. The tradeoff is that a config using a field added by a newer
/// xleak will fail on an older binary rather than degrade.
///
/// The color fields are generated by `color_field_table!` in `tui/theme.rs` —
/// the same table that drives `apply_custom_fields` and `uses_rgb`, so adding
/// a field in one place can't silently miss the others.
macro_rules! define_custom_theme {
( $( [$field:ident, $kind:ident, $alias:ident] )* ) => {
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CustomTheme {
pub name: String,
pub inherits: Option<String>,

#[serde(default, deserialize_with = "deserialize_opt_color")]
pub foreground: Option<Color>,
#[serde(default, deserialize_with = "deserialize_opt_color")]
pub background: Option<Color>,

$(
#[serde(default, deserialize_with = "deserialize_opt_color")]
pub $field: Option<Color>,
)*
}
};
}
crate::tui::color_field_table!(define_custom_theme);

/// Parse a config color: either `#RRGGBB` or one of the 16 ANSI color names.
///
/// 3-digit hex shorthand (`#fff`) is deliberately not supported — accepting it
/// would mean guessing whether `#123` means `#112233` or `#010203`.
fn parse_color(s: &str) -> Option<Color> {
let s = s.trim();

if let Some(hex) = s.strip_prefix('#') {
if hex.len() != 6 {
return None;
}
return Some(Color::Rgb(
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
));
}

match crate::utils::normalize_name(s).as_str() {
"black" => Some(Color::Black),
"red" => Some(Color::Red),
"green" => Some(Color::Green),
"yellow" => Some(Color::Yellow),
"blue" => Some(Color::Blue),
"magenta" => Some(Color::Magenta),
"cyan" => Some(Color::Cyan),
"gray" | "grey" => Some(Color::Gray),
"darkgray" | "darkgrey" => Some(Color::DarkGray),
"lightred" => Some(Color::LightRed),
"lightgreen" => Some(Color::LightGreen),
"lightyellow" => Some(Color::LightYellow),
"lightblue" => Some(Color::LightBlue),
"lightmagenta" => Some(Color::LightMagenta),
"lightcyan" => Some(Color::LightCyan),
"white" => Some(Color::White),
_ => None,
}
}

fn deserialize_opt_color<'de, D>(deserializer: D) -> std::result::Result<Option<Color>, D::Error>
where
D: serde::Deserializer<'de>,
{
match Option::<String>::deserialize(deserializer)? {
None => Ok(None),
Some(s) => parse_color(&s).map(Some).ok_or_else(|| {
serde::de::Error::custom(format!(
"invalid color '{s}': expected \"#RRGGBB\" or a named color such as \"cyan\""
))
}),
}
}

/// UI configuration
Expand Down Expand Up @@ -48,6 +140,7 @@ impl Default for ThemeConfig {
fn default() -> Self {
Self {
default: "Default".to_string(),
custom: Vec::new(),
}
}
}
Expand Down Expand Up @@ -384,8 +477,8 @@ mod tests {

#[test]
fn test_theme_name_case_insensitive() {
// Theme config parsing stores the string as-is
// TuiState::parse_theme_name handles case-insensitive matching
// Theme config parsing stores the string as-is;
// theme::ThemeSet handles case-insensitive matching
let config_str = "[theme]\ndefault = \"dracula\"";
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "dracula");
Expand Down Expand Up @@ -578,4 +671,144 @@ page_up = "Ctrl+b"
Some((KeyCode::Char('/'), KeyModifiers::empty()))
);
}

// =========================================================================
// Color Parsing Tests
// =========================================================================

#[test]
fn test_parse_color_hex() {
assert_eq!(parse_color("#ff0000"), Some(Color::Rgb(255, 0, 0)));
assert_eq!(parse_color("#00ff00"), Some(Color::Rgb(0, 255, 0)));
assert_eq!(parse_color("#0000ff"), Some(Color::Rgb(0, 0, 255)));
assert_eq!(parse_color("#1a1b26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(parse_color("#1A1B26"), Some(Color::Rgb(26, 27, 38)));
assert_eq!(parse_color(" #1a1b26 "), Some(Color::Rgb(26, 27, 38)));
}

#[test]
fn test_parse_color_named() {
assert_eq!(parse_color("red"), Some(Color::Red));
assert_eq!(parse_color("cyan"), Some(Color::Cyan));
assert_eq!(parse_color("White"), Some(Color::White));
assert_eq!(parse_color("dark_gray"), Some(Color::DarkGray));
assert_eq!(parse_color("DarkGray"), Some(Color::DarkGray));
assert_eq!(parse_color("light-yellow"), Some(Color::LightYellow));
assert_eq!(parse_color("grey"), Some(Color::Gray));
}

#[test]
fn test_parse_color_invalid() {
// 3-digit shorthand is deliberately unsupported.
assert_eq!(parse_color("#fff"), None);
assert_eq!(parse_color("#gggggg"), None);
assert_eq!(parse_color("#1a1b2"), None);
assert_eq!(parse_color("#1a1b267"), None);
assert_eq!(parse_color("notacolor"), None);
assert_eq!(parse_color(""), None);
}

// =========================================================================
// Custom Theme Config Tests
// =========================================================================

#[test]
fn test_custom_theme_parsing() {
// r## so the `"#` in the hex values doesn't terminate the raw string.
let config_str = r##"
[theme]
default = "tokyonight"

[[theme.custom]]
name = "tokyonight"
inherits = "Dracula"
foreground = "#c0caf5"
background = "#1a1b26"
header_fg = "#7aa2f7"
border_fg = "cyan"
"##;
let config: Config = toml::from_str(config_str).unwrap();
assert_eq!(config.theme.default, "tokyonight");
assert_eq!(config.theme.custom.len(), 1);

let t = &config.theme.custom[0];
assert_eq!(t.name, "tokyonight");
assert_eq!(t.inherits.as_deref(), Some("Dracula"));
assert_eq!(t.foreground, Some(Color::Rgb(192, 202, 245)));
assert_eq!(t.background, Some(Color::Rgb(26, 27, 38)));
assert_eq!(t.header_fg, Some(Color::Rgb(122, 162, 247)));
assert_eq!(t.border_fg, Some(Color::Cyan));
assert_eq!(t.number_fg, None, "unset fields stay None to be inherited");
}

#[test]
fn test_multiple_custom_themes_keep_config_order() {
let config_str = r#"
[theme]
default = "Default"

[[theme.custom]]
name = "first"

[[theme.custom]]
name = "second"
"#;
let config: Config = toml::from_str(config_str).unwrap();
let names: Vec<&str> = config
.theme
.custom
.iter()
.map(|t| t.name.as_str())
.collect();
assert_eq!(names, ["first", "second"]);
}

#[test]
fn test_no_custom_themes_defaults_to_empty() {
let config: Config = toml::from_str("[theme]\ndefault = \"Nord\"").unwrap();
assert!(config.theme.custom.is_empty());
}

#[test]
fn test_invalid_color_in_theme() {
let config_str = r#"
[theme]
default = "Default"

[[theme.custom]]
name = "bad"
foreground = "notacolor"
"#;
let err = toml::from_str::<Config>(config_str).unwrap_err();
assert!(
err.to_string().contains("notacolor"),
"error should name the bad value, got: {err}"
);
}

#[test]
fn test_unknown_field_rejected() {
// Typo protection: `forground` must not be silently ignored.
let config_str = r#"
[theme]
default = "Default"

[[theme.custom]]
name = "typo"
forground = "red"
"#;
assert!(toml::from_str::<Config>(config_str).is_err());
}

#[test]
fn test_custom_theme_requires_name() {
let config_str = r#"
[theme]
default = "Default"

[[theme.custom]]
foreground = "red"
"#;
assert!(toml::from_str::<Config>(config_str).is_err());
}
}
Loading