feat(themes): allow users to add theme overrides using their config file

Fixes #560

### Add theme color overrides in configuration file

Link to #560

### Problem

Currently, customizing television's appearance to match personal style
requires users to rebuild from source code to modify theme colors. This
creates a significant barrier for users who want to personalize their
experience without dealing with compilation and development setup.
Users who want to adjust colors for better readability, match their
terminal theme, or create custom color schemes must either:
- Modify theme files in the source code
- Rebuild the entire application
- Maintain separate theme files
This workflow is not user-friendly and prevents quick experimentation
with different color combinations.

### Solution
Added theme color overrides directly in the configuration file, allowing
users to customize any theme color without rebuilding from source. The
themes/*.toml files now serve as defaults, while the configuration file
provides overrides that take precedence.

### Key features:
- No rebuild required: Customize colors instantly through config
- Easy testing: Try different color combinations without compilation
- Theme flexibility: Mix and match colors from different themes
- Backward compatible: Existing configs continue to work unchanged
- User-friendly: Simple TOML syntax for color customization

### Usage Example

Find your config file location:
```
 Default locations:
   # Unix: ~/.config/television/config.toml
   # Windows: %APPDATA%\television\config.toml
```

Add theme overrides to your config:

```
[ui]
   theme = "catppuccin"  # or any other theme
   
   [ui.theme_overrides]
   background = "#000000"      # Pure black background for maximum contrast
   text_fg = "#00ff00"         # Bright green for main text (high visibility)
   selection_bg = "#ff00ff"    # Bright magenta for selected items (eye-catching)
   selection_fg = "#ffffff"    # Pure white text on selected items (high contrast)
   match_fg = "#ffff00"        # Bright yellow for search matches (stands out)
   border_fg = "#00ffff"       # Bright cyan for borders (neon effect)
   result_name_fg = "#ff8800"  # Bright orange for file names (warm highlight)
   result_value_fg = "#ff0080" # Bright pink for values (vibrant accent)
```


Test your changes:

```
# Run with your config
./target/release/tv --config-file ~/.config/television/config.toml
```

<img width="1420" alt="Screenshot 2025-07-08 at 08 14 42"
src="https://github.com/user-attachments/assets/90563f86-74aa-460a-a7c2-82080be3dc0b"
/>

---------

Co-authored-by: Mohamed-Amine Bousahih <mohamed-aminebousahih@pc-66.home>
Co-authored-by: Mohamed-Amine Bousahih <mohamed-aminebousahih@mohamedminesair.home>
This commit is contained in:
MohMine 2025-07-11 00:06:46 +02:00 committed by GitHub
parent 23cf584f67
commit 4e90a357e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 272 additions and 4 deletions

View File

@ -63,6 +63,20 @@ orientation = "landscape"
# repository. You may also create your own theme by creating a new file in a `themes`
# directory in your configuration directory (see the `config.toml` location above).
theme = "default"
# Theme color overrides
# ---------------------
# You can override specific colors from the selected theme by adding them here.
# This allows you to customize the appearance without creating a full theme file.
# Colors can be specified as ANSI color names (e.g., "red", "bright-blue") or
# as hex values (e.g., "#ff0000", "#1e1e2e").
#
# Example overrides:
# [ui.theme_overrides]
# background = "#000000"
# text_fg = "#ffffff"
# selection_bg = "#444444"
# match_fg = "#ff0000"
# The default size of the preview panel (in percentage of the screen)
preview_size = 50

View File

@ -26,4 +26,4 @@ jobs:
working-directory: website
- name: Build website
run: pnpm run build
working-directory: website
working-directory: website

View File

@ -6,6 +6,8 @@ Builtin themes are available in the [themes](https://github.com/alexpasmantier/t
| :-----------------------------------------------------------------------------------: | :---------------------------------------------------------: |
| ![solarized-dark](../../assets/solarized-dark.png "gruvbox-light") **solarized-dark** | ![nord](../../assets/nord.png "nord") **nord** |
## Custom Themes
You may create your own custom themes by adding them to the `themes` directory in your configuration folder and then referring to them by file name (without the extension) in the configuration file.
```
@ -43,3 +45,30 @@ remote_control_mode_bg = '#a6e3a1'
send_to_channel_mode_fg = '#89dceb'
```
## Theme Color Overrides
Override specific colors from any theme directly in your configuration:
```toml
[ui]
theme = "gruvbox-dark"
[ui.theme_overrides]
background = "#000000"
text_fg = "#ffffff"
selection_bg = "#444444"
match_fg = "#ff0000"
```
### Properties
**General:** `background`, `border_fg`, `text_fg`, `dimmed_text_fg`
**Input:** `input_text_fg`, `result_count_fg`
**Results:** `result_name_fg`, `result_line_number_fg`, `result_value_fg`, `selection_bg`, `selection_fg`, `match_fg`
**Preview:** `preview_title_fg`
**Modes:** `channel_mode_fg`, `channel_mode_bg`, `remote_control_mode_fg`, `remote_control_mode_bg`
### Colors
ANSI: `"red"`, `"bright-blue"`, `"white"`
Hex: `"#ff0000"`, `"#1e1e2e"`

View File

@ -144,6 +144,71 @@ impl Theme {
let theme: Theme = toml::from_str(&theme)?;
Ok(theme)
}
/// Merge this theme with color overrides from the configuration
pub fn merge_with_overrides(
&self,
overrides: &crate::config::ui::ThemeOverrides,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut merged_theme = self.clone();
macro_rules! apply_override {
($field:ident, $override_field:expr) => {
if let Some(ref color_str) = $override_field {
merged_theme.$field = Color::from_str(color_str)
.ok_or_else(|| {
format!(
"invalid {} color: {}",
stringify!($field),
color_str
)
})?;
}
};
(opt $field:ident, $override_field:expr) => {
if let Some(ref color_str) = $override_field {
merged_theme.$field =
Some(Color::from_str(color_str).ok_or_else(|| {
format!(
"invalid {} color: {}",
stringify!($field),
color_str
)
})?);
}
};
}
// Apply overrides using the macro
apply_override!(opt background, overrides.background);
apply_override!(border_fg, overrides.border_fg);
apply_override!(text_fg, overrides.text_fg);
apply_override!(dimmed_text_fg, overrides.dimmed_text_fg);
apply_override!(input_text_fg, overrides.input_text_fg);
apply_override!(result_count_fg, overrides.result_count_fg);
apply_override!(result_name_fg, overrides.result_name_fg);
apply_override!(
result_line_number_fg,
overrides.result_line_number_fg
);
apply_override!(result_value_fg, overrides.result_value_fg);
apply_override!(selection_bg, overrides.selection_bg);
apply_override!(selection_fg, overrides.selection_fg);
apply_override!(match_fg, overrides.match_fg);
apply_override!(preview_title_fg, overrides.preview_title_fg);
apply_override!(channel_mode_fg, overrides.channel_mode_fg);
apply_override!(channel_mode_bg, overrides.channel_mode_bg);
apply_override!(
remote_control_mode_fg,
overrides.remote_control_mode_fg
);
apply_override!(
remote_control_mode_bg,
overrides.remote_control_mode_bg
);
Ok(merged_theme)
}
}
pub const DEFAULT_THEME: &str = "default";
@ -461,6 +526,28 @@ impl Into<ModeColorscheme> for &Theme {
mod tests {
use super::*;
fn create_test_theme() -> Theme {
Theme {
background: Some(Color::Ansi(ANSIColor::Black)),
border_fg: Color::Ansi(ANSIColor::White),
text_fg: Color::Ansi(ANSIColor::BrightWhite),
dimmed_text_fg: Color::Ansi(ANSIColor::BrightBlack),
input_text_fg: Color::Ansi(ANSIColor::BrightWhite),
result_count_fg: Color::Ansi(ANSIColor::BrightWhite),
result_name_fg: Color::Ansi(ANSIColor::BrightWhite),
result_line_number_fg: Color::Ansi(ANSIColor::BrightWhite),
result_value_fg: Color::Ansi(ANSIColor::BrightWhite),
selection_bg: Color::Ansi(ANSIColor::BrightWhite),
selection_fg: Color::Ansi(ANSIColor::BrightWhite),
match_fg: Color::Ansi(ANSIColor::BrightWhite),
preview_title_fg: Color::Ansi(ANSIColor::BrightWhite),
channel_mode_fg: Color::Ansi(ANSIColor::BrightWhite),
channel_mode_bg: Color::Ansi(ANSIColor::BrightBlack),
remote_control_mode_fg: Color::Ansi(ANSIColor::BrightWhite),
remote_control_mode_bg: Color::Ansi(ANSIColor::BrightBlack),
}
}
#[test]
fn test_theme_deserialization() {
let theme_content = r##"
@ -562,6 +649,94 @@ mod tests {
);
}
#[test]
fn test_theme_merge_with_overrides() {
let base_theme = create_test_theme();
let overrides = crate::config::ui::ThemeOverrides {
background: Some("#ff0000".to_string()),
text_fg: Some("red".to_string()),
selection_bg: Some("#00ff00".to_string()),
..Default::default()
};
let merged_theme =
base_theme.merge_with_overrides(&overrides).unwrap();
// Check that overridden colors are changed
assert_eq!(
merged_theme.background,
Some(Color::Rgb(RGBColor::from_str("ff0000").unwrap()))
);
assert_eq!(merged_theme.text_fg, Color::Ansi(ANSIColor::Red));
assert_eq!(
merged_theme.selection_bg,
Color::Rgb(RGBColor::from_str("00ff00").unwrap())
);
// Check that non-overridden colors remain the same
assert_eq!(merged_theme.border_fg, Color::Ansi(ANSIColor::White));
assert_eq!(
merged_theme.input_text_fg,
Color::Ansi(ANSIColor::BrightWhite)
);
assert_eq!(merged_theme.match_fg, Color::Ansi(ANSIColor::BrightWhite));
}
#[test]
fn test_theme_merge_with_invalid_color() {
let base_theme = create_test_theme();
let overrides = crate::config::ui::ThemeOverrides {
text_fg: Some("invalid-color".to_string()),
..Default::default()
};
let result = base_theme.merge_with_overrides(&overrides);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid text_fg color")
);
}
#[test]
fn test_theme_merge_with_empty_overrides() {
let base_theme = create_test_theme();
let overrides = crate::config::ui::ThemeOverrides::default();
let merged_theme =
base_theme.merge_with_overrides(&overrides).unwrap();
// Check that all colors remain the same when no overrides are provided
assert_eq!(merged_theme.background, base_theme.background);
assert_eq!(merged_theme.border_fg, base_theme.border_fg);
assert_eq!(merged_theme.text_fg, base_theme.text_fg);
assert_eq!(merged_theme.dimmed_text_fg, base_theme.dimmed_text_fg);
assert_eq!(merged_theme.input_text_fg, base_theme.input_text_fg);
assert_eq!(merged_theme.result_count_fg, base_theme.result_count_fg);
assert_eq!(merged_theme.result_name_fg, base_theme.result_name_fg);
assert_eq!(
merged_theme.result_line_number_fg,
base_theme.result_line_number_fg
);
assert_eq!(merged_theme.result_value_fg, base_theme.result_value_fg);
assert_eq!(merged_theme.selection_bg, base_theme.selection_bg);
assert_eq!(merged_theme.selection_fg, base_theme.selection_fg);
assert_eq!(merged_theme.match_fg, base_theme.match_fg);
assert_eq!(merged_theme.preview_title_fg, base_theme.preview_title_fg);
assert_eq!(merged_theme.channel_mode_fg, base_theme.channel_mode_fg);
assert_eq!(merged_theme.channel_mode_bg, base_theme.channel_mode_bg);
assert_eq!(
merged_theme.remote_control_mode_fg,
base_theme.remote_control_mode_fg
);
assert_eq!(
merged_theme.remote_control_mode_bg,
base_theme.remote_control_mode_bg
);
}
#[test]
fn test_theme_deserialization_invalid_color() {
let theme_content = r##"

View File

@ -66,6 +66,39 @@ impl Default for RemoteControlConfig {
}
}
/// Theme color overrides that can be specified in the configuration file
/// to customize the appearance of the selected theme
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash, Default)]
#[serde(default)]
pub struct ThemeOverrides {
// General colors
pub background: Option<String>,
pub border_fg: Option<String>,
pub text_fg: Option<String>,
pub dimmed_text_fg: Option<String>,
// Input colors
pub input_text_fg: Option<String>,
pub result_count_fg: Option<String>,
// Result colors
pub result_name_fg: Option<String>,
pub result_line_number_fg: Option<String>,
pub result_value_fg: Option<String>,
pub selection_bg: Option<String>,
pub selection_fg: Option<String>,
pub match_fg: Option<String>,
// Preview colors
pub preview_title_fg: Option<String>,
// Mode colors
pub channel_mode_fg: Option<String>,
pub channel_mode_bg: Option<String>,
pub remote_control_mode_fg: Option<String>,
pub remote_control_mode_bg: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)]
#[serde(default)]
pub struct UiConfig {
@ -82,6 +115,10 @@ pub struct UiConfig {
pub preview_panel: PreviewPanelConfig,
pub help_panel: HelpPanelConfig,
pub remote_control: RemoteControlConfig,
// Theme color overrides
#[serde(default)]
pub theme_overrides: ThemeOverrides,
}
impl Default for UiConfig {
@ -98,6 +135,7 @@ impl Default for UiConfig {
preview_panel: PreviewPanelConfig::default(),
help_panel: HelpPanelConfig::default(),
remote_control: RemoteControlConfig::default(),
theme_overrides: ThemeOverrides::default(),
}
}
}

View File

@ -34,7 +34,7 @@ use std::fmt::Display;
use tokio::sync::mpsc::{
UnboundedReceiver, UnboundedSender, unbounded_channel,
};
use tracing::debug;
use tracing::{debug, error};
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
pub enum Mode {
@ -133,7 +133,14 @@ impl Television {
.to_string_lossy()
.to_string(),
);
let colorscheme = (&Theme::from_name(&config.ui.theme)).into();
let base_theme = Theme::from_name(&config.ui.theme);
let theme = base_theme
.merge_with_overrides(&config.ui.theme_overrides)
.unwrap_or_else(|e| {
error!("Failed to apply theme overrides: {}", e);
base_theme
});
let colorscheme = (&theme).into();
let patrnn = Television::preprocess_pattern(
matching_mode,

View File

@ -3,7 +3,12 @@
//! These tests verify Television's user interface customization capabilities,
//! ensuring users can adapt the layout and appearance to their preferences and needs.
use television::tui::TESTING_ENV_VAR;
use std::fs;
use television::{
config::{Config, ConfigEnv},
tui::TESTING_ENV_VAR,
};
use tempfile::tempdir;
use super::common::*;