feat(tui): use two-face for extended syntax highlighting and adaptive theme

Replace syntect's default ~40 language set with two-face's bat-sourced
~250 language set, adding support for TypeScript, TSX, Kotlin, Swift,
and Zig. Auto-detect terminal background to pick CatppuccinMocha (dark)
or CatppuccinLatte (light). Suppress italic modifier by default since
many terminals render it poorly.
This commit is contained in:
Felipe Coury
2026-02-08 21:44:48 -03:00
parent d97bd689dd
commit 5f92cb4e58
4 changed files with 36 additions and 27 deletions

12
codex-rs/Cargo.lock generated
View File

@@ -2317,6 +2317,7 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"two-face",
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
@@ -9759,6 +9760,17 @@ dependencies = [
"utf-8",
]
[[package]]
name = "two-face"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b285c51f8a6ade109ed4566d33ac4fb289fb5d6cf87ed70908a5eaf65e948e34"
dependencies = [
"serde",
"serde_derive",
"syntect",
]
[[package]]
name = "type-map"
version = "0.5.1"

View File

@@ -93,7 +93,8 @@ toml = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
syntect = { workspace = true }
syntect = "5"
two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] }
unicode-segmentation = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }

View File

@@ -27,8 +27,8 @@ Buffer {
x: 73, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 7, fg: Rgb(150, 181, 180), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 7, fg: Rgb(192, 197, 206), bg: Reset, underline: Reset, modifier: NONE,
x: 4, y: 7, fg: Rgb(137, 180, 250), bg: Reset, underline: Reset, modifier: NONE,
x: 8, y: 7, fg: Rgb(205, 214, 244), bg: Reset, underline: Reset, modifier: NONE,
x: 20, y: 7, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,

View File

@@ -8,10 +8,10 @@ use syntect::easy::HighlightLines;
use syntect::highlighting::FontStyle;
use syntect::highlighting::Style as SyntectStyle;
use syntect::highlighting::Theme;
use syntect::highlighting::ThemeSet;
use syntect::parsing::SyntaxReference;
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
use two_face::theme::EmbeddedThemeName;
// -- Global singletons -------------------------------------------------------
@@ -19,13 +19,18 @@ static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
static THEME: OnceLock<Theme> = OnceLock::new();
fn syntax_set() -> &'static SyntaxSet {
SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
SYNTAX_SET.get_or_init(two_face::syntax::extra_newlines)
}
fn theme() -> &'static Theme {
THEME.get_or_init(|| {
let ts = ThemeSet::load_defaults();
ts.themes["base16-ocean.dark"].clone()
let ts = two_face::theme::extra();
// Pick light or dark theme based on terminal background color.
let name = match crate::terminal_palette::default_bg() {
Some(bg) if crate::color::is_light(bg) => EmbeddedThemeName::CatppuccinLatte,
_ => EmbeddedThemeName::CatppuccinMocha,
};
ts.get(name).clone()
})
}
@@ -77,9 +82,7 @@ fn convert_style(syn_style: SyntectStyle) -> Style {
if syn_style.font_style.contains(FontStyle::BOLD) {
rt_style.add_modifier |= Modifier::BOLD;
}
if syn_style.font_style.contains(FontStyle::ITALIC) {
rt_style.add_modifier |= Modifier::ITALIC;
}
// Intentionally skip italic — many terminals render it poorly or not at all.
if syn_style.font_style.contains(FontStyle::UNDERLINE) {
rt_style.add_modifier |= Modifier::UNDERLINED;
}
@@ -313,7 +316,8 @@ mod tests {
// Background is intentionally skipped.
assert_eq!(rt.bg, None);
assert!(rt.add_modifier.contains(Modifier::BOLD));
assert!(rt.add_modifier.contains(Modifier::ITALIC));
// Italic is intentionally suppressed.
assert!(!rt.add_modifier.contains(Modifier::ITALIC));
assert!(!rt.add_modifier.contains(Modifier::UNDERLINED));
}
@@ -385,12 +389,10 @@ mod tests {
#[test]
fn find_syntax_resolves_all_canonical_languages() {
// Every canonical name that normalize_lang produces AND that syntect's
// default syntax set supports must resolve. Note: syntect's defaults
// do NOT include TypeScript, TSX, Kotlin, Swift, or Zig, so those are
// intentionally omitted here (they gracefully fall back to plain text).
let canonical = [
"javascript",
"typescript",
"tsx",
"python",
"ruby",
"rust",
@@ -399,31 +401,25 @@ mod tests {
"cpp",
"yaml",
"bash",
"kotlin",
"markdown",
"sql",
"lua",
"zig",
"swift",
"java",
];
for lang in canonical {
assert!(
find_syntax(lang).is_some(),
"find_syntax({lang:?}) returned None — syntect cannot resolve this canonical name"
"find_syntax({lang:?}) returned None"
);
}
// Also verify common raw extensions resolve.
let extensions = ["rs", "py", "js", "rb", "go", "sh", "md", "yml"];
let extensions = ["rs", "py", "js", "ts", "rb", "go", "sh", "md", "yml"];
for ext in extensions {
assert!(
find_syntax(ext).is_some(),
"find_syntax({ext:?}) returned None — extension lookup failed"
);
}
// Unsupported languages should return None (graceful fallback).
let unsupported = ["typescript", "tsx", "kotlin", "swift", "zig"];
for lang in unsupported {
assert!(
find_syntax(lang).is_none(),
"find_syntax({lang:?}) unexpectedly returned Some — update test if syntect added support"
"find_syntax({ext:?}) returned None"
);
}
}