Add consistent theme-based thread colors across the TUI (#44857)

## What changed

Assign thread colors deterministically from the full thread ID and the active syntax theme's accents. Use the same color for thread names and titles in the status line, status previews, agents overview, and resume/fork pickers, independent of renaming or list order.

Preserve thread colors when picker rows are selected and honor `tui.status_line_use_colors`. Load the configured theme before startup pickers render and refresh the cached palette when the theme changes. Fall back to the terminal's default color when no accents are available.

## Testing

Add unit and snapshot tests for palette extraction and refresh, default accent coverage, selected titles in both picker layouts, color suppression, and preview/footer consistency during automatic naming.

GitOrigin-RevId: 2b83a66bb3e80d4a1daea48ab9a86dbf2b525eef
This commit is contained in:
Eric Traut
2026-09-11 16:20:50 +00:00
committed by copyberry
parent 654b0a77d0
commit 0818b6550b
17 changed files with 398 additions and 19 deletions
+1
View File
@@ -321,6 +321,7 @@ impl App {
&self.app_server_target,
self.environment_manager.as_ref(),
),
self.local_settings.tui.status_line_use_colors,
self.app_event_tx.clone(),
self.keymap.clone(),
Arc::clone(&self.agents_overview.view_state),
@@ -1,5 +1,40 @@
use super::*;
#[tokio::test]
async fn overview_thread_colors_match_footer_and_respect_color_suppression() {
let mut app = make_test_app().await;
let id = ThreadId::from_u128(/*value*/ 42);
let mut snapshot = Vec::new();
for enabled in [true, false] {
app.local_settings.tui.status_line_use_colors = enabled;
let mut thread = overview_thread(
id,
/*parent_thread_id*/ None,
"Original prompt",
ThreadStatus::Idle,
);
thread.name = Some("Named task".into());
let view = app.agents_overview_view(vec![thread], Some(id));
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
.unwrap();
let buffer = terminal.backend().buffer();
for row in buffer.content.chunks(100) {
let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect();
if let Some(x) = text.find("Named task") {
let x = text[..x].chars().count();
snapshot.push(format!(
"{} | title style: {:?}",
text.trim_end(),
row[x].style()
));
}
}
}
insta::assert_snapshot!(snapshot.join("\n"));
}
#[tokio::test]
async fn older_server_notice_is_visible_in_agents_overview() {
let mut app = make_test_app().await;
@@ -1425,6 +1460,7 @@ async fn shared_overview_shows_only_root_sessions() {
view.rows.clone(),
Some(first_root),
/*worktrees_enabled*/ false,
/*use_theme_colors*/ true,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
Arc::clone(&app.agents_overview.view_state),
@@ -1666,6 +1702,7 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
.rows,
Some(first),
/*worktrees_enabled*/ false,
/*use_theme_colors*/ true,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
Arc::clone(&app.agents_overview.view_state),
+16 -2
View File
@@ -179,6 +179,7 @@ impl AgentsOverviewViewState {
}
pub(super) struct AgentsOverviewView {
use_theme_colors: bool,
pub(super) rows: Vec<AgentsOverviewRow>,
project_groups: Vec<AgentsOverviewProjectGroup>,
selected: usize,
@@ -195,6 +196,7 @@ impl AgentsOverviewView {
rows: Vec<AgentsOverviewRow>,
selected_thread_id: Option<ThreadId>,
worktrees_enabled: bool,
use_theme_colors: bool,
app_event_tx: AppEventSender,
keymap: RuntimeKeymap,
state: Arc<Mutex<AgentsOverviewViewState>>,
@@ -220,6 +222,7 @@ impl AgentsOverviewView {
.map(|row| AgentsOverviewProjectGroup::for_thread(&row.thread, worktrees_enabled))
.collect();
let mut view = Self {
use_theme_colors,
rows,
project_groups,
selected,
@@ -242,6 +245,14 @@ impl AgentsOverviewView {
self.rows.iter().map(|row| row.thread_id).collect()
}
fn title_style(&self, thread_id: ThreadId) -> Style {
if self.use_theme_colors {
Style::default().fg(crate::thread_color::thread_color(thread_id))
} else {
Style::default()
}
}
fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
@@ -439,7 +450,7 @@ impl AgentsOverviewView {
" ".into(),
dot,
" ".into(),
display_title(&row.thread).into(),
Span::styled(display_title(&row.thread), self.title_style(row.thread_id)),
current.dim(),
];
if project_grouping {
@@ -460,7 +471,10 @@ impl AgentsOverviewView {
Line::from("Task details".bold()),
Line::default(),
crate::line_truncation::truncate_line_with_ellipsis_if_overflow(
display_title(&row.thread).to_owned().bold().into(),
Line::from(Span::styled(
display_title(&row.thread).to_owned(),
self.title_style(row.thread_id).bold(),
)),
width,
),
Line::from(vec![dot, " ".into(), status.into()]),
@@ -0,0 +1,8 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "snapshot.join(\"\\n\")"
---
› ○ Named task Ready │ | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset)
│ Named task | title style: Style::new().fg(Color::Rgb(242, 205, 205)).bg(Color::Reset).underline_color(Color::Reset).bold()
› ○ Named task Ready │ | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset)
│ Named task | title style: Style::new().fg(Color::Reset).bg(Color::Reset).underline_color(Color::Reset).bold()
@@ -8,6 +8,8 @@ use ratatui::text::Span;
use super::status_line_setup::StatusLineItem;
use crate::render::highlight::foreground_style_for_scopes;
use crate::thread_color::thread_color;
use codex_protocol::ThreadId;
const STATUS_LINE_SEPARATOR: &str = " · ";
const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85;
@@ -87,11 +89,12 @@ impl StatusLineAccent {
pub(crate) fn status_line_from_segments<I>(
segments: I,
use_theme_colors: bool,
thread_id: Option<ThreadId>,
) -> Option<Line<'static>>
where
I: IntoIterator<Item = (StatusLineItem, String)>,
{
status_line_from_segments_with_resolver(segments, use_theme_colors, |accent| {
status_line_from_segments_with_resolver(segments, use_theme_colors, thread_id, |accent| {
foreground_style_for_scopes(accent.scopes())
})
}
@@ -99,6 +102,7 @@ where
fn status_line_from_segments_with_resolver<I, F>(
segments: I,
use_theme_colors: bool,
thread_id: Option<ThreadId>,
theme_style_for_accent: F,
) -> Option<Line<'static>>
where
@@ -110,7 +114,15 @@ where
if !spans.is_empty() {
spans.push(STATUS_LINE_SEPARATOR.dim());
}
let style = if use_theme_colors {
let style = if use_theme_colors
&& matches!(
item,
StatusLineItem::ThreadName | StatusLineItem::ThreadTitle
)
&& let Some(thread_id) = thread_id
{
Style::default().fg(thread_color(thread_id))
} else if use_theme_colors {
let accent = StatusLineAccent::for_item(item);
soften_status_line_style(
theme_style_for_accent(accent).unwrap_or_else(|| accent.fallback_style()),
@@ -204,6 +216,7 @@ mod tests {
(StatusLineItem::GitBranch, "main".to_string()),
],
/*use_theme_colors*/ true,
/*thread_id*/ None,
|_| None,
)
.expect("status line");
@@ -225,6 +238,7 @@ mod tests {
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
],
/*use_theme_colors*/ true,
/*thread_id*/ None,
|accent| match accent {
StatusLineAccent::Model => Some(Style::default().red()),
_ => None,
@@ -247,6 +261,7 @@ mod tests {
(StatusLineItem::EstimatedThreadCost, "~$0.21".to_string()),
],
/*use_theme_colors*/ true,
/*thread_id*/ None,
|_| None,
)
.expect("thread usage status line");
@@ -262,6 +277,7 @@ mod tests {
let line = status_line_from_segments_with_resolver(
[(StatusLineItem::ModelName, "gpt-5".to_string())],
/*use_theme_colors*/ true,
/*thread_id*/ None,
|_| Some(Style::default().fg(Color::Rgb(255, 0, 0))),
)
.expect("status line");
@@ -278,6 +294,7 @@ mod tests {
(StatusLineItem::ContextUsed, "Context 12% used".to_string()),
],
/*use_theme_colors*/ false,
/*thread_id*/ None,
|_| Some(Style::default().red()),
)
.expect("status line");
@@ -295,6 +312,7 @@ mod tests {
let line = status_line_from_segments_with_resolver(
[(StatusLineItem::PullRequestNumber, "PR #20252".to_string())],
/*use_theme_colors*/ false,
/*thread_id*/ None,
|_| None,
)
.expect("status line");
@@ -315,6 +333,7 @@ mod tests {
status_line_from_segments_with_resolver(
Vec::<(StatusLineItem, String)>::new(),
/*use_theme_colors*/ true,
/*thread_id*/ None,
|_| None,
),
None
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use codex_protocol::ThreadId;
use ratatui::text::Line;
use super::status_line_from_segments;
@@ -126,12 +127,14 @@ struct PreviewValue {
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct StatusSurfacePreviewData {
pub(crate) thread_id: Option<ThreadId>,
values: BTreeMap<StatusSurfacePreviewItem, PreviewValue>,
}
impl Default for StatusSurfacePreviewData {
fn default() -> Self {
let mut data = Self {
thread_id: None,
values: BTreeMap::new(),
};
for item in StatusSurfacePreviewItem::iter() {
@@ -242,7 +245,7 @@ impl StatusSurfacePreviewData {
self.value_for(item.preview_item())
.map(|value| (item, value.to_string()))
});
status_line_from_segments(segments, use_theme_colors)
status_line_from_segments(segments, use_theme_colors, self.thread_id)
}
}
@@ -364,6 +364,7 @@ impl ChatWidget {
.map(|value| (item, value))
}),
);
preview_data.thread_id = self.thread_id;
if self.rate_limit_snapshots_by_limit_id.contains_key("codex") {
for item in [
@@ -218,6 +218,7 @@ impl ChatWidget {
self.set_status_line(status_line_from_segments(
segments,
self.local_settings.tui.status_line_use_colors,
self.thread_id,
));
let hyperlink_url = selections
.status_line_items
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/status_surface_previews.rs
expression: "snapshot.join(\"\\n\")"
---
pending=false colors=true: Line::from_iter([Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" · ").dim(), Span::from("Named task").fg(Color::Rgb(242, 205, 205))])
pending=false colors=false: Line::from_iter([Span::from("Named task").dim(), Span::from(" · ").dim(), Span::from("Named task").dim()])
pending=true colors=true: Line::from_iter([Span::from("Named task ⠋").fg(Color::Rgb(242, 205, 205)), Span::from(" · ").dim(), Span::from("Named task ⠋").fg(Color::Rgb(242, 205, 205))])
pending=true colors=false: Line::from_iter([Span::from("Named task ⠋").dim(), Span::from(" · ").dim(), Span::from("Named task ⠋").dim()])
@@ -12,6 +12,35 @@ fn line_text(line: Line<'static>) -> String {
.collect()
}
#[tokio::test]
async fn thread_color_preview_matches_footer_while_auto_naming() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let id = ThreadId::from_u128(/*value*/ 42);
chat.thread_id = Some(id);
chat.local_settings.tui.animations = false;
chat.thread_name = Some("Named task".into());
let items = [StatusLineItem::ThreadName, StatusLineItem::ThreadTitle];
let mut snapshot = Vec::new();
for pending in [false, true] {
chat.set_thread_title_generation_pending(pending);
let preview = chat.status_surface_preview_data();
for use_colors in [true, false] {
let line = preview.status_line_for_items(items, use_colors).unwrap();
let footer = crate::bottom_pane::status_line_from_segments(
items
.into_iter()
.map(|item| (item, chat.status_line_value_for_item(item).unwrap())),
use_colors,
Some(id),
)
.unwrap();
assert_eq!(line, footer);
snapshot.push(format!("pending={pending} colors={use_colors}: {line:?}"));
}
}
insta::assert_snapshot!(snapshot.join("\n"));
}
fn status_preview_line_option(chat: &mut ChatWidget, items: &[StatusLineItem]) -> Option<String> {
let preview_data = chat.status_surface_preview_data();
preview_data
+11
View File
@@ -214,6 +214,7 @@ mod terminal_title;
mod terminal_visualization_instructions;
mod text_formatting;
mod theme_picker;
mod thread_color;
mod thread_transcript;
mod token_usage;
mod tooltips;
@@ -1327,6 +1328,16 @@ async fn run_ratatui_app(
})
};
// Startup pickers need the current theme before selection can reload config.
// Leave the one-time override initialization below to use the final config.
if (cli.resume_picker || cli.fork_picker)
&& let Some(name) = config.tui_theme.as_deref()
&& let Some(theme) =
crate::render::highlight::resolve_theme_by_name(name, Some(config.codex_home.as_path()))
{
crate::render::highlight::set_syntax_theme(theme);
}
let use_fork = cli.fork_picker || cli.fork_last || cli.fork_session_id.is_some();
let mut session_selection = if cli.agents_overview {
resume_picker::SessionSelection::AgentsOverview
+1 -1
View File
@@ -494,7 +494,7 @@ fn ansi_palette_color(index: u8) -> RtColor {
/// `clippy::disallowed_methods` is explicitly allowed here because this helper
/// intentionally constructs `ratatui::style::Color::Rgb`.
#[allow(clippy::disallowed_methods)]
fn convert_syntect_color(color: SyntectColor) -> Option<RtColor> {
pub(crate) fn convert_syntect_color(color: SyntectColor) -> Option<RtColor> {
match color.a {
// Bat-compatible encoding used by `ansi`, `base16`, and `base16-256`:
// alpha 0x00 means `r` stores an ANSI palette index, not RGB red.
+40 -13
View File
@@ -78,6 +78,10 @@ use uuid::Uuid;
mod archive;
mod page_loading;
#[cfg(test)]
#[path = "resume_picker_color_tests.rs"]
mod color_tests;
use page_loading::PageCwdFilter;
use page_loading::PageLoadMode;
use page_loading::PaginationState;
@@ -333,6 +337,7 @@ struct SessionPickerViewPersistence {
}
struct SessionPickerRunOptions {
use_theme_colors: bool,
show_all: bool,
filter_cwd: Option<PathBuf>,
local_filter_cwd: Option<PathBuf>,
@@ -444,6 +449,7 @@ async fn run_resume_picker_with_launch_context(
let provider_filter = picker_provider_filter(config, uses_remote_workspace);
let runtime_keymap = picker_runtime_keymap(local_settings)?;
let options = SessionPickerRunOptions {
use_theme_colors: local_settings.tui.status_line_use_colors,
show_all,
filter_cwd: cwd_filter,
local_filter_cwd,
@@ -504,6 +510,7 @@ pub async fn run_fork_picker_with_app_server(
let provider_filter = picker_provider_filter(config, uses_remote_workspace);
let runtime_keymap = picker_runtime_keymap(local_settings)?;
let options = SessionPickerRunOptions {
use_theme_colors: local_settings.tui.status_line_use_colors,
show_all,
filter_cwd: cwd_filter,
local_filter_cwd,
@@ -559,6 +566,7 @@ async fn run_session_picker_with_loader(
options.action,
);
state.local_filter_cwd = options.local_filter_cwd;
state.use_theme_colors = options.use_theme_colors;
state.worktrees_enabled = options.worktrees_enabled;
state.density = options.initial_density;
state.view_persistence = options.view_persistence;
@@ -828,6 +836,7 @@ impl Drop for AltScreenGuard<'_> {
}
struct PickerState {
use_theme_colors: bool,
// Resolve local filesystem membership once per cwd for each page-loading cycle.
local_cwd_matches: HashMap<PathBuf, bool>,
requester: FrameRequester,
@@ -1028,6 +1037,7 @@ impl PickerState {
action: SessionPickerAction,
) -> Self {
Self {
use_theme_colors: true,
requester,
relative_time_reference: None,
pagination: PaginationState::new(),
@@ -2836,11 +2846,7 @@ fn render_comfortable_session_lines(
) -> Vec<Line<'static>> {
let marker = selection_marker(is_selected, is_expanded);
let title = truncate_text(row.display_preview(), width.saturating_sub(2) as usize);
let title = if is_selected {
selected_session_title_span(title)
} else {
title.into()
};
let title = session_title_span(title, row.thread_id, state.use_theme_colors, is_selected);
let title_line = Line::from(vec![marker, title]);
let mut lines = vec![title_line];
let row_style = if is_selected {
@@ -2908,7 +2914,8 @@ fn apply_line_background(mut line: Line<'static>, style: Style, width: u16) -> L
}
line.style = line.style.patch(style);
for span in &mut line.spans {
span.style = span.style.patch(style);
// Keep identity accents while inheriting the selected row's background.
span.style = style.patch(span.style);
}
line
}
@@ -2935,6 +2942,8 @@ fn render_dense_session_lines(
marker,
date: &date,
title: row.display_preview(),
thread_id: row.thread_id,
use_theme_colors: state.use_theme_colors,
is_selected,
is_zebra,
width,
@@ -2946,6 +2955,8 @@ fn render_dense_session_lines(
}
struct DenseSummaryInput<'a> {
thread_id: Option<ThreadId>,
use_theme_colors: bool,
marker: Span<'static>,
date: &'a str,
title: &'a str,
@@ -2958,11 +2969,12 @@ fn dense_summary_line(input: DenseSummaryInput<'_>) -> Line<'static> {
let marker_width = input.marker.width();
let available = (input.width as usize).saturating_sub(marker_width);
let columns = dense_columns(available);
let title = if input.is_selected {
selected_session_title_span(dense_column_text(input.title, columns.title_width))
} else {
dense_column_text(input.title, columns.title_width).into()
};
let title = session_title_span(
dense_column_text(input.title, columns.title_width),
input.thread_id,
input.use_theme_colors,
input.is_selected,
);
let spans = vec![
input.marker,
@@ -3043,8 +3055,19 @@ fn selected_session_style() -> Style {
}
}
fn selected_session_title_span(title: String) -> Span<'static> {
title.set_style(selected_session_style())
fn session_title_span(
title: String,
thread_id: Option<ThreadId>,
use_theme_colors: bool,
is_selected: bool,
) -> Span<'static> {
if use_theme_colors && let Some(thread_id) = thread_id {
title.fg(crate::thread_color::thread_color(thread_id))
} else if is_selected {
title.set_style(selected_session_style())
} else {
title.into()
}
}
fn render_footer_lines(
@@ -5595,6 +5618,8 @@ session_picker_view = "dense"
marker: selection_marker(/*is_selected*/ true, /*is_expanded*/ false),
date: "15m ago",
title: "Selected dense row",
thread_id: None,
use_theme_colors: true,
is_selected: true,
is_zebra: false,
width: 80,
@@ -5611,6 +5636,8 @@ session_picker_view = "dense"
marker: selection_marker(/*is_selected*/ false, /*is_expanded*/ false),
date: "15m ago",
title: "Zebra dense row",
thread_id: None,
use_theme_colors: true,
is_selected: false,
is_zebra: true,
width: 80,
@@ -0,0 +1,42 @@
use super::*;
#[test]
fn thread_colors_survive_selection_in_both_picker_layouts() {
let id = ThreadId::from_u128(/*value*/ 42);
let row = Row {
path: None,
thread_id: Some(id),
thread_name: Some("Named task".into()),
preview: "Original prompt".into(),
created_at: None,
updated_at: None,
cwd: None,
git_branch: None,
};
let mut state = PickerState::new(
FrameRequester::test_dummy(),
Arc::new(|_| {}),
ProviderFilter::Any,
/*show_all*/ true,
/*filter_cwd*/ None,
SessionPickerAction::Resume,
);
let mut snapshot = Vec::new();
for density in [SessionListDensity::Comfortable, SessionListDensity::Dense] {
state.density = density;
for use_colors in [true, false] {
state.use_theme_colors = use_colors;
for selected in [true, false] {
let lines = render_session_lines(
&row, &state, selected, /*is_expanded*/ false, /*is_zebra*/ false,
/*width*/ 32,
);
snapshot.push(format!(
"{density:?} colors={use_colors} selected={selected}: {:?}",
lines[0]
));
}
}
}
insta::assert_snapshot!(snapshot.join("\n"));
}
@@ -0,0 +1,12 @@
---
source: tui/src/resume_picker_color_tests.rs
expression: "snapshot.join(\"\\n\")"
---
Comfortable colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").fg(Color::Rgb(242, 205, 205)), Span::from(" ").yellow()]).yellow()
Comfortable colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("Named task").fg(Color::Rgb(242, 205, 205))])
Comfortable colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("Named task").yellow(), Span::from(" ").yellow()]).yellow()
Comfortable colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("Named task")])
Dense colors=true selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))]).yellow()
Dense colors=true selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ").fg(Color::Rgb(242, 205, 205))])
Dense colors=false selected=true: Line::from_iter([Span::from("❯ ").yellow().bold(), Span::from("- ").dim(), Span::from("Named task ").yellow()]).yellow()
Dense colors=false selected=false: Line::from_iter([Span::from(" "), Span::from("- ").dim(), Span::from("Named task ")])
+83
View File
@@ -0,0 +1,83 @@
//! Stateless thread identity colors drawn from the active syntax theme.
//!
//! Only the theme palette is cached. Assignments depend on the full thread ID,
//! never on names, list order, or other threads; palette collisions are allowed.
use std::cell::RefCell;
use codex_protocol::ThreadId;
use ratatui::style::Color;
use syntect::highlighting::Highlighter;
use syntect::highlighting::Theme;
use syntect::parsing::Scope;
use crate::render::highlight::convert_syntect_color;
use crate::render::highlight::current_syntax_theme;
use crate::render::highlight::syntax_theme_revision;
thread_local! {
static PALETTE: RefCell<Option<(u64, Vec<Color>)>> = const { RefCell::new(None) };
}
pub(crate) fn thread_color(thread_id: ThreadId) -> Color {
PALETTE.with_borrow_mut(|cached| {
let revision = syntax_theme_revision();
let (cached_revision, colors) =
cached.get_or_insert_with(|| (revision, theme_accents(&current_syntax_theme())));
if *cached_revision != revision {
*colors = theme_accents(&current_syntax_theme());
*cached_revision = revision;
}
color_for_id(thread_id, colors)
})
}
fn theme_accents(theme: &Theme) -> Vec<Color> {
// Syntect stores scope rules, not a named palette. Scan every rule so rare
// accents remain available, excluding base text and generic comment/punctuation colors.
// A foreground paired with a special background is not a standalone accent.
let highlighter = Highlighter::new(theme);
let mut excluded = vec![theme.settings.foreground, theme.settings.background];
for scope in ["comment", "punctuation"] {
if let Ok(scope) = Scope::new(scope) {
excluded.push(highlighter.style_mod_for_stack(&[scope]).foreground);
}
}
let mut colors = Vec::new();
for color in theme
.scopes
.iter()
.filter(|rule| {
rule.style.background.is_none() || rule.style.background == theme.settings.background
})
.filter_map(|rule| rule.style.foreground)
.chain(theme.settings.accent)
{
if !excluded.contains(&Some(color))
&& let Some(color) = convert_syntect_color(color)
&& !colors.contains(&color)
{
colors.push(color);
}
}
colors
}
fn color_for_id(thread_id: ThreadId, colors: &[Color]) -> Color {
// Explicit FNV-1a, rather than a process-random or Rust-version-specific hasher.
let hash = thread_id
.to_string()
.bytes()
.fold(0xcbf29ce484222325_u64, |hash, byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x100000001b3)
});
colors
.get((hash % colors.len().max(1) as u64) as usize)
.copied()
.unwrap_or(Color::Reset)
}
#[cfg(test)]
#[path = "thread_color_tests.rs"]
mod tests;
+83
View File
@@ -0,0 +1,83 @@
use super::*;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use syntect::highlighting::Color as ThemeColor;
use syntect::highlighting::StyleModifier;
use syntect::highlighting::ThemeItem;
use two_face::theme::EmbeddedThemeName;
#[test]
fn assignments_reach_all_fourteen_default_accents() {
let themes = two_face::theme::extra();
for name in [
EmbeddedThemeName::CatppuccinLatte,
EmbeddedThemeName::CatppuccinMocha,
] {
let colors = theme_accents(themes.get(name));
let assigned: HashSet<_> = (0..512)
.map(|id| color_for_id(ThreadId::from_u128(id), &colors))
.collect();
assert_eq!(assigned.len(), 14);
assert_eq!(assigned, colors.iter().copied().collect());
}
}
#[test]
fn theme_colors_are_preserved_without_a_replacement_palette() {
let foreground = ThemeColor {
r: 120,
g: 120,
b: 120,
a: 255,
};
let accent = ThemeColor {
r: 121,
..foreground
};
let mut theme = Theme::default();
theme.settings.foreground = Some(foreground);
theme.scopes = [foreground, accent, accent]
.into_iter()
.map(|color| ThemeItem {
scope: "keyword".parse().unwrap(),
style: StyleModifier {
foreground: Some(color),
..StyleModifier::default()
},
})
.collect();
theme.scopes.push(ThemeItem {
scope: "invalid".parse().unwrap(),
style: StyleModifier {
foreground: Some(ThemeColor {
r: 122,
..foreground
}),
background: Some(accent),
..StyleModifier::default()
},
});
let colors = theme_accents(&theme);
assert_eq!(colors, vec![convert_syntect_color(accent).unwrap()]);
assert_eq!(
color_for_id(ThreadId::from_u128(/*value*/ 1), &colors),
colors[0]
);
theme.scopes.clear();
assert_eq!(
color_for_id(ThreadId::from_u128(/*value*/ 1), &theme_accents(&theme)),
Color::Reset
);
}
#[test]
fn theme_revision_refreshes_the_cached_palette() {
let id = ThreadId::from_u128(/*value*/ 1);
let revision = syntax_theme_revision();
PALETTE.set(Some((revision.wrapping_sub(1), Vec::new())));
assert_eq!(
thread_color(id),
color_for_id(id, &theme_accents(&current_syntax_theme()))
);
PALETTE.with_borrow(|cached| assert_eq!(cached.as_ref().unwrap().0, revision));
}