Add six bundled TUI themes and theme-aware accents (#46504)

## What changed

- Bundle `ada`, `babbage`, `curie`, `cushman`, `dali`, and `davinci` themes for configuration and the theme picker, preserving precedence and invalid-file warnings for custom themes with the same names.
- Use `codex.accent` for active and selected controls on truecolor and 256-color terminals, retaining existing fallbacks at lower color depths.
- Honor terminal-default diff backgrounds and clear matching gutter fills, allowing `dali` and `davinci` to show diffs without background fills.
- Restrict Windows native palette fallback to `ConsoleWindowClass` so a ConPTY palette is not mistaken for the renderer's colors when OSC probing fails.

## Testing

Add tests and snapshots for theme preview, selection, cancellation, custom-theme precedence, invalid-file warnings, accent color depth, and disabling individual diff fills.

GitOrigin-RevId: 72f3ef08f82626436dd2d80d40210cc77e1a5616
This commit is contained in:
Eric Traut
2026-09-18 23:16:04 +00:00
committed by copyberry
parent 547c9a1aad
commit 907b751eab
16 changed files with 737 additions and 71 deletions
+65 -49
View File
@@ -1,7 +1,7 @@
//! Syntax highlighting engine for the TUI.
//!
//! Wraps [syntect] with the [two_face] grammar and theme bundles to provide
//! ~250-language syntax highlighting and 32 bundled color themes. The module
//! ~250-language syntax highlighting and bundled color themes. The module
//! owns five process-global singletons:
//!
//! | Singleton | Type | Purpose |
@@ -24,6 +24,7 @@
//! prevent pathological CPU/memory usage. Callers must fall back to plain
//! unstyled text.
use super::model_themes;
use ratatui::style::Color as RtColor;
use ratatui::style::Modifier;
use ratatui::style::Style;
@@ -132,18 +133,14 @@ pub(crate) fn validate_theme_name(name: Option<&str>, codex_home: Option<&Path>)
let custom_theme_path_display = codex_home
.map(|home| custom_theme_path(name, home).display().to_string())
.unwrap_or_else(|| format!("$CODEX_HOME/themes/{name}.tmTheme"));
// Bundled themes always resolve.
if parse_theme_name(name).is_some() {
if resolve_theme_by_name(name, codex_home).is_some() {
return None;
}
// Custom themes must parse successfully; an unreadable/invalid file should
// still surface a startup warning so users can diagnose configuration issues.
if let Some(home) = codex_home {
let custom_path = custom_theme_path(name, home);
if custom_path.is_file() {
if load_custom_theme(name, home).is_some() {
return None;
}
if custom_path.try_exists().unwrap_or(true) {
return Some(format!(
"Custom theme \"{name}\" at {custom_theme_path_display} could not \
be loaded (invalid .tmTheme format). Falling back to the default theme."
@@ -232,14 +229,7 @@ fn resolve_theme_with_override(name: Option<&str>, codex_home: Option<&Path>) ->
// Honor user-configured theme if valid.
if let Some(name) = name {
// 1. Try bundled theme by kebab-case name.
if let Some(theme_name) = parse_theme_name(name) {
return ts.get(theme_name).clone();
}
// 2. Try loading {CODEX_HOME}/themes/{name}.tmTheme from disk.
if let Some(home) = codex_home
&& let Some(theme) = load_custom_theme(name, home)
{
if let Some(theme) = resolve_theme_by_name(name, codex_home) {
return theme;
}
tracing::debug!("Theme \"{name}\" not recognized; using default theme");
@@ -304,7 +294,14 @@ pub(crate) fn current_syntax_theme() -> Theme {
}
}
/// Raw RGB background colors extracted from syntax theme diff/markup scopes.
/// An explicit diff background, including the ANSI terminal-default marker.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DiffScopeBackground {
Rgb((u8, u8, u8)),
TerminalDefault,
}
/// Background colors extracted from syntax theme diff/markup scopes.
///
/// These are theme-provided colors, not yet adapted for any particular color
/// depth. [`diff_render`](crate::diff_render) converts them to ratatui
@@ -315,9 +312,9 @@ pub(crate) fn current_syntax_theme() -> Theme {
/// backgrounds, in which case the diff renderer falls back to its hardcoded
/// palette.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) struct DiffScopeBackgroundRgbs {
pub inserted: Option<(u8, u8, u8)>,
pub deleted: Option<(u8, u8, u8)>,
pub(crate) struct DiffScopeBackgrounds {
pub inserted: Option<DiffScopeBackground>,
pub deleted: Option<DiffScopeBackground>,
}
/// Query the active syntax theme for diff-scope background colors.
@@ -325,27 +322,34 @@ pub(crate) struct DiffScopeBackgroundRgbs {
/// Prefers `markup.inserted` / `markup.deleted` (the TextMate convention used
/// by most VS Code themes) and falls back to `diff.inserted` / `diff.deleted`
/// (used by some older `.tmTheme` files).
pub(crate) fn diff_scope_background_rgbs() -> DiffScopeBackgroundRgbs {
pub(crate) fn diff_scope_backgrounds() -> DiffScopeBackgrounds {
let theme = current_syntax_theme();
diff_scope_background_rgbs_for_theme(&theme)
diff_scope_backgrounds_for_theme(&theme)
}
/// Pure extraction helper, separated from the global theme singleton so tests
/// can pass arbitrary themes.
fn diff_scope_background_rgbs_for_theme(theme: &Theme) -> DiffScopeBackgroundRgbs {
fn diff_scope_backgrounds_for_theme(theme: &Theme) -> DiffScopeBackgrounds {
let highlighter = Highlighter::new(theme);
let inserted = scope_background_rgb(&highlighter, "markup.inserted")
.or_else(|| scope_background_rgb(&highlighter, "diff.inserted"));
let deleted = scope_background_rgb(&highlighter, "markup.deleted")
.or_else(|| scope_background_rgb(&highlighter, "diff.deleted"));
DiffScopeBackgroundRgbs { inserted, deleted }
let inserted = scope_background(&highlighter, "markup.inserted")
.or_else(|| scope_background(&highlighter, "diff.inserted"));
let deleted = scope_background(&highlighter, "markup.deleted")
.or_else(|| scope_background(&highlighter, "diff.deleted"));
DiffScopeBackgrounds { inserted, deleted }
}
/// Extract the background color for a single TextMate scope, if defined.
fn scope_background_rgb(highlighter: &Highlighter<'_>, scope_name: &str) -> Option<(u8, u8, u8)> {
fn scope_background(
highlighter: &Highlighter<'_>,
scope_name: &str,
) -> Option<DiffScopeBackground> {
let scope = Scope::new(scope_name).ok()?;
let bg = highlighter.style_mod_for_stack(&[scope]).background?;
Some((bg.r, bg.g, bg.b))
Some(if bg.a == ANSI_ALPHA_DEFAULT {
DiffScopeBackground::TerminalDefault
} else {
DiffScopeBackground::Rgb((bg.r, bg.g, bg.b))
})
}
/// Query the active syntax theme for the first foreground style provided by the
@@ -375,12 +379,8 @@ pub(crate) fn foreground_style_for_scopes_with_theme(
pub(crate) fn configured_theme_name() -> String {
// Explicit user override?
if let Some(Some(name)) = THEME_OVERRIDE.get() {
if parse_theme_name(name).is_some() {
return name.clone();
}
if let Some(Some(home)) = CODEX_HOME.get()
&& load_custom_theme(name, home).is_some()
{
let home = CODEX_HOME.get().and_then(|home| home.as_deref());
if resolve_theme_by_name(name, home).is_some() {
return name.clone();
}
}
@@ -397,11 +397,11 @@ pub(crate) fn resolve_theme_by_name(name: &str, codex_home: Option<&Path>) -> Op
}
// Custom .tmTheme file?
if let Some(home) = codex_home
&& let Some(theme) = load_custom_theme(name, home)
&& custom_theme_path(name, home).try_exists().unwrap_or(true)
{
return Some(theme);
return load_custom_theme(name, home);
}
None
model_themes::resolve(name)
}
/// A theme available in the picker, either bundled or loaded from a custom
@@ -447,6 +447,18 @@ pub(crate) fn list_available_themes(codex_home: Option<&Path>) -> Vec<ThemeEntry
}
}
// Existing custom files take precedence over the new bundled names.
for (name, _) in model_themes::THEMES {
if !entries.iter().any(|entry| entry.name == *name)
&& resolve_theme_by_name(name, codex_home).is_some()
{
entries.push(ThemeEntry {
name: (*name).to_string(),
is_custom: codex_home.is_some_and(|home| custom_theme_path(name, home).exists()),
});
}
}
// Keep picker ordering stable across platforms/filesystems while sorting
// custom and bundled themes together, case-insensitively.
entries.sort_by_cached_key(|entry| (entry.name.to_ascii_lowercase(), entry.name.clone()));
@@ -1316,12 +1328,12 @@ mod tests {
],
..Theme::default()
};
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
let rgbs = diff_scope_backgrounds_for_theme(&theme);
assert_eq!(
rgbs,
DiffScopeBackgroundRgbs {
inserted: Some((10, 20, 30)),
deleted: Some((40, 50, 60)),
DiffScopeBackgrounds {
inserted: Some(DiffScopeBackground::Rgb((10, 20, 30))),
deleted: Some(DiffScopeBackground::Rgb((40, 50, 60))),
}
);
}
@@ -1333,10 +1345,10 @@ mod tests {
scopes: vec![theme_item("constant.numeric", Some((1, 2, 3)))],
..Theme::default()
};
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
let rgbs = diff_scope_backgrounds_for_theme(&theme);
assert_eq!(
rgbs,
DiffScopeBackgroundRgbs {
DiffScopeBackgrounds {
inserted: None,
deleted: None,
}
@@ -1375,7 +1387,7 @@ mod tests {
fn bundled_theme_can_provide_diff_scope_backgrounds() {
let theme = resolve_theme_by_name("github", /*codex_home*/ None)
.expect("expected built-in GitHub theme to load");
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
let rgbs = diff_scope_backgrounds_for_theme(&theme);
assert!(
rgbs.inserted.is_some() && rgbs.deleted.is_some(),
"expected built-in theme to provide insert/delete backgrounds, got {rgbs:?}"
@@ -1397,12 +1409,12 @@ mod tests {
let theme = resolve_theme_by_name("custom-diff", Some(dir.path()))
.expect("expected custom theme to resolve");
let rgbs = diff_scope_background_rgbs_for_theme(&theme);
let rgbs = diff_scope_backgrounds_for_theme(&theme);
assert_eq!(
rgbs,
DiffScopeBackgroundRgbs {
inserted: Some((16, 32, 48)),
deleted: Some((64, 80, 96)),
DiffScopeBackgrounds {
inserted: Some(DiffScopeBackground::Rgb((16, 32, 48))),
deleted: Some(DiffScopeBackground::Rgb((64, 80, 96))),
}
);
}
@@ -1655,3 +1667,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "highlight_model_tests.rs"]
mod model_tests;
@@ -0,0 +1,52 @@
//! Compatibility coverage for bundled theme files and existing user overrides.
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn model_theme_files_preserve_custom_precedence_and_invalid_file_warnings() {
let home = tempfile::tempdir().unwrap();
std::fs::create_dir(home.path().join("themes")).unwrap();
let path = custom_theme_path("ada", home.path());
std::fs::write(&path, model_themes::THEMES[1].1).unwrap();
assert_eq!(
resolve_theme_by_name("ada", Some(home.path())),
resolve_theme_by_name("babbage", /*codex_home*/ None)
);
let entries = list_available_themes(Some(home.path()));
let entries = entries
.iter()
.filter(|entry| entry.name == "ada")
.collect::<Vec<_>>();
assert_eq!(entries.len(), 1);
assert!(entries[0].is_custom);
let uppercase_path = custom_theme_path("Ada", home.path());
std::fs::rename(&path, &uppercase_path).unwrap();
let entries = list_available_themes(Some(home.path()));
let lowercase = entries.iter().find(|entry| entry.name == "ada").unwrap();
assert_eq!(lowercase.is_custom, path.exists());
assert!(
entries
.iter()
.any(|entry| entry.name == "Ada" && entry.is_custom)
);
std::fs::rename(&uppercase_path, &path).unwrap();
std::fs::write(&path, "invalid theme").unwrap();
assert!(
validate_theme_name(Some("ada"), Some(home.path()))
.unwrap()
.contains("could not be loaded")
);
assert!(resolve_theme_by_name("ada", Some(home.path())).is_none());
assert_eq!(
resolve_theme_with_override(Some("ada"), Some(home.path())),
resolve_theme_with_override(/*name*/ None, Some(home.path()))
);
assert!(
!list_available_themes(Some(home.path()))
.iter()
.any(|entry| entry.name == "ada")
);
}
+1
View File
@@ -2,6 +2,7 @@ use ratatui::layout::Rect;
pub(crate) mod highlight;
pub(crate) mod line_utils;
mod model_themes;
pub(crate) mod renderable;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+28
View File
@@ -0,0 +1,28 @@
//! Fixed model-named theme assets loaded through the standard TextMate parser.
use std::io::Cursor;
use syntect::highlighting::Theme;
use syntect::highlighting::ThemeSet;
pub(super) const THEMES: &[(&str, &str)] = &[
("ada", include_str!("../../assets/themes/ada.tmTheme")),
(
"babbage",
include_str!("../../assets/themes/babbage.tmTheme"),
),
("curie", include_str!("../../assets/themes/curie.tmTheme")),
(
"cushman",
include_str!("../../assets/themes/cushman.tmTheme"),
),
("dali", include_str!("../../assets/themes/dali.tmTheme")),
(
"davinci",
include_str!("../../assets/themes/davinci.tmTheme"),
),
];
pub(super) fn resolve(name: &str) -> Option<Theme> {
let (_, source) = THEMES.iter().find(|(key, _)| *key == name)?;
ThemeSet::load_from_reader(&mut Cursor::new(source.as_bytes())).ok()
}