Use syntax theme colors for inline code and file paths (#46069)

## Why

Inline code and local file paths in the TUI used a fixed cyan foreground regardless of the active syntax theme.

## What changed

Use the active theme's Markdown raw-text foreground for inline code and local file paths, with cyan as the fallback when no matching color is available. Isolate the active theme and its revision per test thread to prevent parallel tests from affecting each other's rendering.

## Testing

Add rendering snapshots for dark, light, ANSI, and fallback themes, plus a regression test for theme and revision isolation between test threads.

GitOrigin-RevId: 40d84131d0abc9f177019d9b0b3e19536d5cd553
This commit is contained in:
Eric Traut
2026-09-17 01:28:01 +00:00
committed by copyberry
parent e22e6523eb
commit 172f8a2901
8 changed files with 247 additions and 48 deletions
+17 -1
View File
@@ -35,10 +35,13 @@
//! unusably short chunks, expansive cells form tall narrow strips across enough
//! body rows, or even 3-char-wide columns cannot fit, body rows render as
//! key/value records.
//!
//! Inline code and local file paths share the active syntax theme's raw-markup foreground.
use crate::markdown_text_merge::DecodedTextMerge;
use crate::render::highlight::current_syntax_theme;
use crate::render::highlight::foreground_style_for_scopes;
use crate::render::highlight::foreground_style_for_scopes_with_theme;
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::style::table_separator_style;
@@ -109,6 +112,12 @@ struct MarkdownStyles {
impl Default for MarkdownStyles {
fn default() -> Self {
Self::for_theme(&crate::render::highlight::current_syntax_theme())
}
}
impl MarkdownStyles {
fn for_theme(theme: &syntect::highlighting::Theme) -> Self {
Self {
h1: Style::new().bold().underlined(),
h2: Style::new().bold(),
@@ -116,7 +125,14 @@ impl Default for MarkdownStyles {
h4: Style::new().italic(),
h5: Style::new().italic(),
h6: Style::new().italic(),
code: Style::new().cyan(),
code: foreground_style_for_scopes_with_theme(
theme,
&[
"markup.inline.raw.string.markdown",
"markup.raw.inline.markdown",
],
)
.unwrap_or_else(|| Style::new().cyan()),
emphasis: Style::new().italic(),
strong: Style::new().bold(),
strikethrough: Style::new().crossed_out(),
@@ -8,7 +8,7 @@ expression: presentations
Span::from("plain "),
Span::from("plain").cyan().underlined(),
Span::from(" "),
Span::from("code").cyan(),
Span::from("code").fg(Color::Rgb(166, 227, 161)),
Span::from(" "),
Span::from("code").cyan().underlined(),
]),
@@ -31,7 +31,7 @@ expression: presentations
Span::from("https://example.com").cyan().underlined(),
Span::from(")"),
Span::from(" "),
Span::from("code").cyan(),
Span::from("code").fg(Color::Rgb(166, 227, 161)),
]),
Line::from_iter([
Span::from("code").cyan().underlined(),
+103 -32
View File
@@ -1,3 +1,4 @@
use super::MarkdownStyles;
use pretty_assertions::assert_eq;
use ratatui::style::Modifier;
use ratatui::style::Stylize;
@@ -711,10 +712,60 @@ fn ordered_item_with_indented_continuation_is_tight() {
#[test]
fn inline_code() {
let text = render_markdown_text("Example of `Inline code`");
let expected = Line::from_iter(["Example of ".into(), "Inline code".cyan()]).into();
let expected = Line::from_iter([
"Example of ".into(),
Span::styled("Inline code", MarkdownStyles::default().code),
])
.into();
assert_eq!(text, expected);
}
#[test]
fn inline_code_and_file_paths_follow_syntax_theme() {
let markdown = "Use `src/main.rs` and [src/lib.rs](./src/lib.rs).";
let mut rendered = Vec::new();
for name in [
"catppuccin-mocha",
"catppuccin-latte",
"ansi",
"no-markup-color",
] {
let theme = if name == "no-markup-color" {
syntect::highlighting::Theme::default()
} else {
crate::render::highlight::resolve_theme_by_name(name, /*codex_home*/ None)
.expect("bundled theme")
};
let styles = MarkdownStyles::for_theme(&theme);
let code_style = styles.code;
let mut writer = super::Writer::new(
markdown,
pulldown_cmark::Parser::new(markdown).into_offset_iter(),
/*wrap_width*/ Some(24),
/*cwd*/ None,
&|_| false,
);
writer.styles = styles;
writer.run();
let lines = crate::terminal_hyperlinks::visible_lines(writer.text);
let paths = lines
.iter()
.flat_map(|line| &line.spans)
.filter(|span| matches!(span.content.as_ref(), "src/main.rs" | "src/lib.rs"))
.cloned()
.collect::<Vec<_>>();
assert_eq!(
paths,
vec![
Span::styled("src/main.rs", code_style),
Span::styled("src/lib.rs", code_style),
],
);
rendered.push((name, lines));
}
assert_debug_snapshot!(rendered);
}
#[test]
fn strong() {
assert_eq!(
@@ -779,7 +830,7 @@ fn web_link_labels_use_link_style_and_preserve_inline_formatting() {
"https://example.com".cyan().underlined(),
")".into(),
" after ".into(),
"code".cyan(),
Span::styled("code", MarkdownStyles::default().code),
]));
assert_eq!(text, expected, "label: {label}");
}
@@ -828,7 +879,10 @@ fn file_link_hides_destination() {
"[/Users/example/code/codex/codex-rs/tui/src/My%20File.rs](/Users/example/code/codex/codex-rs/tui/src/My%20File.rs)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter(["codex-rs/tui/src/My File.rs".cyan()]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/My File.rs",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -840,11 +894,14 @@ fn file_link_keeps_descriptive_label_and_target() {
);
let expected = Text::from(Line::from_iter([
"Your ".into(),
"codex".cyan(),
Span::styled("codex", MarkdownStyles::default().code),
" launcher ".into(),
"automatically adds those overrides".into(),
" (".into(),
"project/dotslash-gen/bin/codex:1105".cyan(),
Span::styled(
"project/dotslash-gen/bin/codex:1105",
MarkdownStyles::default().code,
),
")".into(),
", even though you did not specify any.".into(),
]));
@@ -921,7 +978,10 @@ fn file_link_decodes_percent_encoded_bare_path_destination() {
let expected = Text::from(Line::from_iter([
"open Example Folder/Résumé/report.md".into(),
" (".into(),
"Example Folder/Résumé/report.md".cyan(),
Span::styled(
"Example Folder/Résumé/report.md",
MarkdownStyles::default().code,
),
")".into(),
]));
assert_eq!(text, expected);
@@ -952,7 +1012,7 @@ fn file_link_keeps_unrelated_relative_label_with_matching_suffix() {
let expected = Text::from(Line::from_iter([
"other/src/lib.rs".into(),
" (".into(),
"src/lib.rs".cyan(),
Span::styled("src/lib.rs", MarkdownStyles::default().code),
")".into(),
]));
assert_eq!(text, expected);
@@ -965,9 +1025,10 @@ fn file_link_appends_line_number_when_label_lacks_it() {
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74".cyan()
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -977,9 +1038,10 @@ fn file_link_keeps_absolute_paths_outside_cwd() {
"[README.md:74](/Users/example/code/codex/README.md:74)",
Path::new("/Users/example/code/codex/codex-rs/tui"),
);
let expected = Text::from(Line::from_iter([
"/Users/example/code/codex/README.md:74".cyan()
]));
let expected = Text::from(Line::from_iter([Span::styled(
"/Users/example/code/codex/README.md:74",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1010,9 +1072,10 @@ fn file_link_appends_hash_anchor_when_label_lacks_it() {
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1022,9 +1085,10 @@ fn file_link_uses_target_path_for_hash_anchor() {
"[markdown_render.rs#L74C3](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1034,9 +1098,10 @@ fn file_link_appends_range_when_label_lacks_it() {
"[markdown_render.rs](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3-76:9",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1046,9 +1111,10 @@ fn file_link_uses_target_path_for_range() {
"[markdown_render.rs:74:3-76:9](/Users/example/code/codex/codex-rs/tui/src/markdown_render.rs:74:3-76:9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3-76:9",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1058,9 +1124,10 @@ fn file_link_appends_hash_range_when_label_lacks_it() {
"[markdown_render.rs](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3-76:9",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
@@ -1077,7 +1144,10 @@ fn multiline_file_link_label_after_styled_prefix_does_not_panic() {
" ".into(),
"bar".into(),
" (".into(),
"codex-rs/tui/src/markdown_render.rs:74:3".cyan(),
Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3",
MarkdownStyles::default().code,
),
")".into(),
]));
assert_eq!(text, expected);
@@ -1089,9 +1159,10 @@ fn file_link_uses_target_path_for_hash_range() {
"[markdown_render.rs#L74C3-L76C9](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3-L76C9)",
Path::new("/Users/example/code/codex"),
);
let expected = Text::from(Line::from_iter([
"codex-rs/tui/src/markdown_render.rs:74:3-76:9".cyan(),
]));
let expected = Text::from(Line::from_iter([Span::styled(
"codex-rs/tui/src/markdown_render.rs:74:3-76:9",
MarkdownStyles::default().code,
)]));
assert_eq!(text, expected);
}
+52 -8
View File
@@ -16,7 +16,8 @@
//! config is resolved) to persist the user preference and seed the `THEME`
//! lock. After that, [`set_syntax_theme`] and [`current_syntax_theme`] can
//! swap/snapshot the theme for live preview. All highlighting functions read
//! the theme via `theme_lock()`.
//! the theme via `theme_lock()`. Unit tests isolate the active theme and its
//! revision per thread so parallel tests cannot change each other’s rendering.
//!
//! **Guardrails:** inputs exceeding 512 KB or 10 000 lines, or containing an
//! individual line longer than 4 KiB, are rejected early (returns `None`) to
@@ -32,7 +33,9 @@ use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
use std::sync::RwLock;
#[cfg(not(test))]
use std::sync::atomic::AtomicU64;
#[cfg(not(test))]
use std::sync::atomic::Ordering;
use syntect::easy::HighlightLines;
use syntect::highlighting::Color as SyntectColor;
@@ -55,11 +58,19 @@ pub(crate) use streaming::StreamingCodeHighlighter;
// -- Global singletons -------------------------------------------------------
static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
#[cfg(not(test))]
static THEME: OnceLock<RwLock<Theme>> = OnceLock::new();
#[cfg(not(test))]
static THEME_REVISION: AtomicU64 = AtomicU64::new(0);
static THEME_OVERRIDE: OnceLock<Option<String>> = OnceLock::new();
static CODEX_HOME: OnceLock<Option<PathBuf>> = OnceLock::new();
#[cfg(test)]
thread_local! {
static TEST_THEME: std::cell::OnceCell<std::sync::Arc<RwLock<Theme>>> = const { std::cell::OnceCell::new() };
static TEST_THEME_REVISION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
// Syntect/bat encode ANSI palette semantics in alpha:
// `a=0` => indexed ANSI palette via RGB payload, `a=1` => terminal default.
const ANSI_ALPHA_INDEX: u8 = 0x00;
@@ -95,7 +106,11 @@ pub(crate) fn set_theme_override(
let warning = validate_theme_name(name.as_deref(), codex_home.as_deref());
let override_set_ok = THEME_OVERRIDE.set(name.clone()).is_ok();
let codex_home_set_ok = CODEX_HOME.set(codex_home.clone()).is_ok();
if THEME.get().is_some() {
#[cfg(not(test))]
let initialized = THEME.get().is_some();
#[cfg(test)]
let initialized = TEST_THEME.with(|theme| theme.get().is_some());
if initialized {
set_syntax_theme(resolve_theme_with_override(
name.as_deref(),
codex_home.as_deref(),
@@ -243,28 +258,47 @@ fn build_default_theme() -> Theme {
resolve_theme_with_override(name, codex_home)
}
fn theme_lock() -> &'static RwLock<Theme> {
THEME.get_or_init(|| RwLock::new(build_default_theme()))
fn theme_lock() -> impl std::ops::Deref<Target = RwLock<Theme>> {
#[cfg(not(test))]
{
THEME.get_or_init(|| RwLock::new(build_default_theme()))
}
#[cfg(test)]
TEST_THEME.with(|theme| {
theme
.get_or_init(|| std::sync::Arc::new(RwLock::new(build_default_theme())))
.clone()
})
}
/// Swap the active syntax theme at runtime and invalidate rendered-content caches.
pub(crate) fn set_syntax_theme(theme: Theme) {
let mut guard = match theme_lock().write() {
let active_theme = theme_lock();
let mut guard = match active_theme.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
*guard = theme;
#[cfg(not(test))]
THEME_REVISION.fetch_add(1, Ordering::Release);
#[cfg(test)]
TEST_THEME_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
/// Return the revision of the active syntax theme for rendered-content caches.
pub(crate) fn syntax_theme_revision() -> u64 {
THEME_REVISION.load(Ordering::Acquire)
#[cfg(not(test))]
{
THEME_REVISION.load(Ordering::Acquire)
}
#[cfg(test)]
TEST_THEME_REVISION.with(std::cell::Cell::get)
}
/// Clone the current syntax theme (e.g. to save for cancel-restore).
pub(crate) fn current_syntax_theme() -> Theme {
match theme_lock().read() {
let active_theme = theme_lock();
match active_theme.read() {
Ok(theme) => theme.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
@@ -666,7 +700,8 @@ fn highlighted_line_spans(ranges: Vec<(SyntectStyle, &str)>) -> Vec<Span<'static
/// Each inner Vec represents one source line. Returns None when the language
/// is not recognized or the input exceeds safety limits.
fn highlight_to_line_spans(code: &str, lang: &str) -> Option<Vec<Vec<Span<'static>>>> {
let theme_guard = match theme_lock().read() {
let active_theme = theme_lock();
let theme_guard = match active_theme.read() {
Ok(theme_guard) => theme_guard,
Err(poisoned) => poisoned.into_inner(),
};
@@ -732,6 +767,15 @@ mod tests {
use syntect::highlighting::ThemeItem;
use syntect::highlighting::ThemeSettings;
#[test]
fn active_theme_and_revision_are_isolated_between_test_threads() {
let original = (current_syntax_theme(), syntax_theme_revision());
std::thread::spawn(|| set_syntax_theme(Theme::default()))
.join()
.unwrap();
assert_eq!((current_syntax_theme(), syntax_theme_revision()), original);
}
fn write_minimal_tmtheme(path: &Path) {
// Minimal valid .tmTheme plist (enough for syntect to parse).
std::fs::write(
@@ -34,7 +34,8 @@ impl StreamingCodeHighlighter {
/// Unknown languages and oversized input retain a permanent plain-text fallback. A changed
/// theme or failed replay returns `None`, requiring the caller to render the whole fence again.
pub(crate) fn new(code: &str, lang: &str, theme_revision: u64) -> Option<Self> {
let theme = match theme_lock().read() {
let active_theme = theme_lock();
let theme = match active_theme.read() {
Ok(theme) => theme,
Err(poisoned) => poisoned.into_inner(),
};
@@ -90,7 +91,8 @@ impl StreamingCodeHighlighter {
{
return None;
}
let theme = match theme_lock().read() {
let active_theme = theme_lock();
let theme = match active_theme.read() {
Ok(theme) => theme,
Err(poisoned) => poisoned.into_inner(),
};
@@ -0,0 +1,66 @@
---
source: tui/src/markdown_render_tests.rs
expression: rendered
---
[
(
"catppuccin-mocha",
[
Line::from_iter([
Span::from("Use "),
Span::from("src/main.rs").fg(Color::Rgb(166, 227, 161)),
Span::from(" and "),
Span::from("./").fg(Color::Rgb(166, 227, 161)),
]),
Line::from_iter([
Span::from("src/lib.rs").fg(Color::Rgb(166, 227, 161)),
Span::from("."),
]),
],
),
(
"catppuccin-latte",
[
Line::from_iter([
Span::from("Use "),
Span::from("src/main.rs").fg(Color::Rgb(64, 160, 43)),
Span::from(" and "),
Span::from("./").fg(Color::Rgb(64, 160, 43)),
]),
Line::from_iter([
Span::from("src/lib.rs").fg(Color::Rgb(64, 160, 43)),
Span::from("."),
]),
],
),
(
"ansi",
[
Line::from_iter([
Span::from("Use "),
Span::from("src/main.rs").green(),
Span::from(" and "),
Span::from("./").green(),
]),
Line::from_iter([
Span::from("src/lib.rs").green(),
Span::from("."),
]),
],
),
(
"no-markup-color",
[
Line::from_iter([
Span::from("Use "),
Span::from("src/main.rs").cyan(),
Span::from(" and "),
Span::from("./").cyan(),
]),
Line::from_iter([
Span::from("src/lib.rs").cyan(),
Span::from("."),
]),
],
),
]
@@ -2,4 +2,4 @@
source: tui/src/markdown_render_tests.rs
expression: text
---
Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").cyan(), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")]))
Text::from(Line::from_iter([Span::from("plain "), Span::from("plain").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")"), Span::from(" "), Span::from("code").fg(Color::Rgb(166, 227, 161)), Span::from(" "), Span::from("code").cyan().underlined(), Span::from(" ("), Span::from("https://example.com").cyan().underlined(), Span::from(")")]))
@@ -18,7 +18,7 @@ expression: rendered_cases
HyperlinkLine {
line: Line::from_iter([
Span::from("- "),
Span::from("Quarterly Report.xlsx").cyan(),
Span::from("Quarterly Report.xlsx").fg(Color::Rgb(166, 227, 161)),
]),
hyperlinks: [],
},
@@ -46,7 +46,7 @@ expression: rendered_cases
HyperlinkLine {
line: Line::from_iter([
Span::from("- "),
Span::from("Résumé *final* ✨.xlsx").cyan(),
Span::from("Résumé *final* ✨.xlsx").fg(Color::Rgb(166, 227, 161)),
]),
hyperlinks: [],
},