fix(tui/theme): add dedicated 16-color themes and palette mapping

This commit is contained in:
James Peter
2025-09-29 21:34:05 +10:00
parent cabc8ad6b0
commit 5efc9e0a78
8 changed files with 666 additions and 132 deletions
+2
View File
@@ -620,12 +620,14 @@ pub fn set_tui_theme_name(codex_home: &Path, theme: ThemeName) -> anyhow::Result
// Map enum to kebab-case string used in config
let theme_str = match theme {
ThemeName::LightPhoton => "light-photon",
ThemeName::LightPhotonAnsi16 => "light-photon-ansi16",
ThemeName::LightPrismRainbow => "light-prism-rainbow",
ThemeName::LightVividTriad => "light-vivid-triad",
ThemeName::LightPorcelain => "light-porcelain",
ThemeName::LightSandbar => "light-sandbar",
ThemeName::LightGlacier => "light-glacier",
ThemeName::DarkCarbonNight => "dark-carbon-night",
ThemeName::DarkCarbonAnsi16 => "dark-carbon-ansi16",
ThemeName::DarkShinobiDusk => "dark-shinobi-dusk",
ThemeName::DarkOledBlackPro => "dark-oled-black-pro",
ThemeName::DarkAmberTerminal => "dark-amber-terminal",
+2
View File
@@ -821,6 +821,7 @@ pub enum ThemeName {
// Light themes (at top)
#[default]
LightPhoton,
LightPhotonAnsi16,
LightPrismRainbow,
LightVividTriad,
LightPorcelain,
@@ -828,6 +829,7 @@ pub enum ThemeName {
LightGlacier,
// Dark themes (below)
DarkCarbonNight,
DarkCarbonAnsi16,
DarkShinobiDusk,
DarkOledBlackPro,
DarkAmberTerminal,
+5 -2
View File
@@ -574,8 +574,11 @@ impl BottomPane<'_> {
}
/// Show the theme selection UI
pub fn show_theme_selection(&mut self, current_theme: ThemeName) {
let view = ThemeSelectionView::new(current_theme, self.app_event_tx.clone());
pub fn show_theme_selection(&mut self, _current_theme: ThemeName) {
let view = ThemeSelectionView::new(
crate::theme::current_theme_name(),
self.app_event_tx.clone(),
);
self.active_view = Some(Box::new(view));
self.active_view_kind = ActiveViewKind::Other;
// Status shown in composer title now
@@ -18,6 +18,7 @@ use ratatui::widgets::Widget;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::theme::{custom_theme_is_dark, map_theme_for_palette, palette_mode, PaletteMode};
use super::BottomPane;
use super::bottom_pane_view::BottomPaneView;
@@ -46,6 +47,7 @@ pub(crate) struct ThemeSelectionView {
impl ThemeSelectionView {
pub fn new(current_theme: ThemeName, app_event_tx: AppEventSender) -> Self {
let current_theme = map_theme_for_palette(current_theme, custom_theme_is_dark());
let themes = Self::get_theme_options();
let selected_theme_index = themes
.iter()
@@ -79,6 +81,21 @@ impl ThemeSelectionView {
}
fn get_theme_options() -> Vec<(ThemeName, &'static str, &'static str)> {
if matches!(palette_mode(), PaletteMode::Ansi16) {
return vec![
(
ThemeName::LightPhotonAnsi16,
"Light (16-color)",
"High-contrast light palette for limited terminals",
),
(
ThemeName::DarkCarbonAnsi16,
"Dark (16-color)",
"High-contrast dark palette for limited terminals",
),
];
}
let mut v = vec![
// Light themes (at top)
(
@@ -155,6 +172,9 @@ impl ThemeSelectionView {
];
// Append custom theme if available (use saved label and light/dark prefix)
if let Some(label0) = crate::theme::custom_theme_label() {
if matches!(palette_mode(), PaletteMode::Ansi16) {
return v;
}
// Sanitize any leading Light/Dark prefix the model may have included
let mut label = label0.trim().to_string();
for pref in ["Light - ", "Dark - ", "Light ", "Dark "] {
@@ -177,6 +197,10 @@ impl ThemeSelectionView {
v
}
fn allow_custom_theme_generation() -> bool {
!matches!(palette_mode(), PaletteMode::Ansi16)
}
fn move_selection_up(&mut self) {
if matches!(self.mode, Mode::Themes) {
let options = Self::get_theme_options();
@@ -202,8 +226,13 @@ impl ThemeSelectionView {
fn move_selection_down(&mut self) {
if matches!(self.mode, Mode::Themes) {
let options = Self::get_theme_options();
// Allow moving onto the extra pseudo-row
if self.selected_theme_index + 1 <= options.len() {
let allow_extra_row = Self::allow_custom_theme_generation();
let limit = if allow_extra_row {
options.len()
} else {
options.len().saturating_sub(1)
};
if self.selected_theme_index + 1 <= limit {
self.selected_theme_index += 1;
if self.selected_theme_index < options.len() {
self.current_theme = options[self.selected_theme_index].0;
@@ -918,7 +947,8 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Mode::Overview => 8,
// Detail lists: fixed 9 visible rows (max), shrink if fewer
Mode::Themes => {
let n = (Self::get_theme_options().len() as u16) + 1; // +1 for "Generate your own…"
let extra = if Self::allow_custom_theme_generation() { 1 } else { 0 };
let n = (Self::get_theme_options().len() as u16) + extra;
// Border(2) + padding(2) + title(1)+space(1) + list
6 + n.min(9)
}
@@ -1150,7 +1180,9 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Mode::Themes => {
// If tail row selected (Generate your own…), open create form
let count = Self::get_theme_options().len();
if self.selected_theme_index >= count {
if Self::allow_custom_theme_generation()
&& self.selected_theme_index >= count
{
// Revert preview to the theme before entering Themes list for better legibility
self.app_event_tx
.send(AppEvent::PreviewTheme(self.revert_theme_on_back));
@@ -1692,7 +1724,8 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
.add_modifier(Modifier::BOLD),
)));
// Compute anchored window: top until middle, then center; bottom shows end
let count = options.len() + 1; // include pseudo-row for Generate your own…
let allow_custom = Self::allow_custom_theme_generation();
let count = options.len() + if allow_custom { 1 } else { 0 };
let visible = available_height.saturating_sub(1).min(9).max(1);
let (start, _vis, _mid) = crate::util::list_window::anchored_window(
self.selected_theme_index,
@@ -1702,7 +1735,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
let end = (start + visible).min(count);
for i in start..end {
let is_selected = i == self.selected_theme_index;
if i >= options.len() {
if allow_custom && i >= options.len() {
// Pseudo-row: Generate your own…
let mut spans = vec![Span::raw(" ")];
if is_selected {
+74 -8
View File
@@ -242,7 +242,7 @@ use crate::history::state::{
RateLimitsRecord,
TextTone,
};
use crate::slash_command::SlashCommand;
use crate::slash_command::{ProcessedCommand, SlashCommand};
use crate::live_wrap::RowBuilder;
use crate::streaming::StreamKind;
use crate::streaming::controller::AppEventHistorySink;
@@ -2628,7 +2628,7 @@ impl ChatWidget<'_> {
self.stream_order_seq.get(&(kind, id.to_string())).copied()
}
pub(crate) fn new(
config: Config,
mut config: Config,
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
@@ -2637,6 +2637,12 @@ impl ChatWidget<'_> {
show_order_overlay: bool,
latest_upgrade_version: Option<String>,
) -> Self {
let mapped_theme = crate::theme::map_theme_for_palette(
config.tui.theme.name,
config.tui.theme.is_dark,
);
config.tui.theme.name = mapped_theme;
let (codex_op_tx, codex_op_rx) = unbounded_channel::<Op>();
let auth_manager = AuthManager::shared(
@@ -5506,6 +5512,26 @@ impl ChatWidget<'_> {
return;
}
let original_text = message.display_text.clone();
let only_text_items = message
.ordered_items
.iter()
.all(|item| matches!(item, InputItem::Text { .. }));
if only_text_items {
if let Some((command_line, rest_text)) =
Self::split_leading_slash_command(&original_text)
{
let preview = crate::slash_command::process_slash_command_message(
command_line.as_str(),
);
if !matches!(preview, ProcessedCommand::NotCommand(_)) {
self.submit_user_message(command_line.clone().into());
if !rest_text.trim().is_empty() {
self.submit_user_message(rest_text.into());
}
return;
}
}
}
// Build a combined string view of the text-only parts to process slash commands
let mut text_only = String::new();
for it in &message.ordered_items {
@@ -5880,6 +5906,26 @@ impl ChatWidget<'_> {
// (debug watchdog removed)
}
fn split_leading_slash_command(text: &str) -> Option<(String, String)> {
if !text.starts_with('/') {
return None;
}
let mut parts = text.splitn(2, '\n');
let first_line = parts.next().unwrap_or_default();
let rest = parts.next().unwrap_or("");
if rest.is_empty() {
return None;
}
let command = first_line.trim_end_matches('\r');
if command.is_empty() {
return None;
}
if rest.trim().is_empty() {
return None;
}
Some((command.to_string(), rest.to_string()))
}
fn capture_ghost_snapshot(&mut self, summary: Option<String>) -> GhostSnapshotJobHandle {
if self.ghost_snapshots_disabled {
return GhostSnapshotJobHandle::Skipped;
@@ -11847,7 +11893,7 @@ impl ChatWidget<'_> {
pub(crate) fn show_theme_selection(&mut self) {
self.bottom_pane
.show_theme_selection(self.config.tui.theme.name);
.show_theme_selection(crate::theme::current_theme_name());
}
// Ctrl+Y syntax cycling disabled intentionally.
@@ -11890,19 +11936,38 @@ impl ChatWidget<'_> {
}
pub(crate) fn set_theme(&mut self, new_theme: codex_core::config_types::ThemeName) {
let custom_hint = if matches!(new_theme, codex_core::config_types::ThemeName::Custom) {
self.config
.tui
.theme
.is_dark
.or_else(|| crate::theme::custom_theme_is_dark())
} else {
None
};
let mapped_theme = crate::theme::map_theme_for_palette(new_theme, custom_hint);
// Update the config
self.config.tui.theme.name = new_theme;
self.config.tui.theme.name = mapped_theme;
if matches!(new_theme, codex_core::config_types::ThemeName::Custom) {
self.config.tui.theme.is_dark = custom_hint;
} else {
self.config.tui.theme.is_dark = None;
}
// Save the theme to config file
self.save_theme_to_config(new_theme);
self.save_theme_to_config(mapped_theme);
// Retint pre-rendered history cell lines to the new palette
self.restyle_history_after_theme_change();
// Add confirmation message to history (replaceable system notice)
let theme_name = match new_theme {
let theme_name = match mapped_theme {
// Light themes
codex_core::config_types::ThemeName::LightPhoton => "Light - Photon".to_string(),
codex_core::config_types::ThemeName::LightPhotonAnsi16 => {
"Light - Photon (16-color)".to_string()
}
codex_core::config_types::ThemeName::LightPrismRainbow => {
"Light - Prism Rainbow".to_string()
}
@@ -11916,6 +11981,9 @@ impl ChatWidget<'_> {
codex_core::config_types::ThemeName::DarkCarbonNight => {
"Dark - Carbon Night".to_string()
}
codex_core::config_types::ThemeName::DarkCarbonAnsi16 => {
"Dark - Carbon (16-color)".to_string()
}
codex_core::config_types::ThemeName::DarkShinobiDusk => {
"Dark - Shinobi Dusk".to_string()
}
@@ -11934,10 +12002,8 @@ impl ChatWidget<'_> {
"Dark - Paper Light Pro".to_string()
}
codex_core::config_types::ThemeName::Custom => {
// Use saved custom name and is_dark to show a friendly label
let mut label =
crate::theme::custom_theme_label().unwrap_or_else(|| "Custom".to_string());
// Sanitize leading Light/Dark if present
for pref in ["Light - ", "Dark - ", "Light ", "Dark "] {
if label.starts_with(pref) {
label = label[pref.len()..].trim().to_string();
+56
View File
@@ -8,6 +8,7 @@ use crate::slash_command::SlashCommand;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use codex_core::config_types::ReasoningEffort;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
@@ -33,6 +34,8 @@ use codex_core::protocol::RateLimitSnapshotEvent;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::OrderMeta;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::StreamErrorEvent;
use codex_core::protocol::TaskCompleteEvent;
use crossterm::event::KeyCode;
@@ -1121,6 +1124,59 @@ fn slash_undo_shows_no_snapshot_state() {
);
}
#[test]
fn slash_command_prefix_processes_followup_message() {
let runtime = tokio::runtime::Runtime::new().expect("runtime");
let _guard = runtime.enter();
let (mut chat, rx, mut op_rx) = make_chatwidget_manual();
let text = "/reasoning high\nContinue with the next step.";
let message = crate::chatwidget::message::UserMessage {
display_text: text.to_string(),
ordered_items: vec![InputItem::Text {
text: text.to_string(),
}],
};
chat.submit_user_message(message);
assert_eq!(
chat.config.model_reasoning_effort,
ReasoningEffort::High,
"slash command should update reasoning effort"
);
let op = op_rx
.try_recv()
.expect("follow-up message sent to agent");
match op {
Op::UserInput { items } => {
assert_eq!(
items,
vec![InputItem::Text {
text: "Continue with the next step.".to_string()
}],
"expected follow-up text to be forwarded without the slash command"
);
}
other => panic!("expected user input op, got {other:?}"),
}
let history_cells = drain_insert_history(&rx);
let mut saw_follow_up = false;
for lines in &history_cells {
let combined = lines_to_single_string(lines);
if combined.contains("Continue with the next step.") {
saw_follow_up = true;
break;
}
}
assert!(
saw_follow_up,
"expected follow-up text to appear in the queued history entry"
);
}
#[test]
fn undo_options_view_shows_toggles() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
+79 -35
View File
@@ -1,4 +1,4 @@
use crate::theme::current_theme;
use crate::theme::{current_theme, palette_mode, quantize_color_for_palette, PaletteMode};
use ratatui::style::Color;
// Legacy color constants - now redirect to theme
@@ -40,16 +40,31 @@ pub(crate) fn border() -> Color {
/// Blends the theme border toward the background by 30% to reduce contrast
/// while preserving the original hue relationship.
pub(crate) fn border_dim() -> Color {
let b = current_theme().border;
let bg = current_theme().background;
let (br, bg_g, bb) = color_to_rgb(b);
let (rr, rg, rb) = color_to_rgb(bg);
let t: f32 = 0.30; // 30% toward background
let mix = |a: u8, b: u8| -> u8 { ((a as f32) * (1.0 - t) + (b as f32) * t).round() as u8 };
let r = mix(br, rr);
let g = mix(bg_g, rg);
let bl = mix(bb, rb);
Color::Rgb(r, g, bl)
match palette_mode() {
PaletteMode::Ansi16 => {
if is_dark_background(current_theme().background) {
Color::Indexed(8)
} else {
Color::Indexed(8)
}
}
PaletteMode::Ansi256 => {
let b = current_theme().border;
let bg = current_theme().background;
let (br, bg_g, bb) = color_to_rgb(b);
let (rr, rg, rb) = color_to_rgb(bg);
let t: f32 = 0.30; // 30% toward background
let mix = |a: u8, b: u8| -> u8 { ((a as f32) * (1.0 - t) + (b as f32) * t).round() as u8 };
let r = mix(br, rr);
let g = mix(bg_g, rg);
let bl = mix(bb, rb);
quantize_color_for_palette(Color::Rgb(r, g, bl))
}
}
}
fn is_dark_background(color: Color) -> bool {
matches!(color, Color::Indexed(0) | Color::Black)
}
#[allow(dead_code)]
@@ -75,9 +90,20 @@ pub(crate) fn spinner() -> Color {
/// Midpoint color between `text` and `text_dim` for secondary list levels.
pub(crate) fn text_mid() -> Color {
let a = current_theme().text;
let b = current_theme().text_dim;
mix_toward(a, b, 0.5)
match palette_mode() {
PaletteMode::Ansi16 => {
if is_dark_background(current_theme().background) {
Color::Indexed(7)
} else {
Color::Indexed(8)
}
}
PaletteMode::Ansi256 => {
let a = current_theme().text;
let b = current_theme().text_dim;
mix_toward(a, b, 0.5)
}
}
}
pub(crate) fn info() -> Color {
@@ -195,7 +221,7 @@ pub(crate) fn mix_toward(from: Color, to: Color, t: f32) -> Color {
let a = color_to_rgb(from);
let b = color_to_rgb(to);
let (r, g, b) = blend_rgb(a, b, t.clamp(0.0, 1.0));
Color::Rgb(r, g, b)
quantize_color_for_palette(Color::Rgb(r, g, b))
}
fn blend_with_black(rgb: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
@@ -222,14 +248,25 @@ pub(crate) fn overlay_scrim() -> Color {
// For light themes, use a slightly stronger darkening; for dark themes, a gentler one.
let alpha = if is_light(rgb) { 0.18 } else { 0.10 };
let (r, g, b) = blend_with_black(rgb, alpha);
Color::Rgb(r, g, b)
quantize_color_for_palette(Color::Rgb(r, g, b))
}
/// Background for assistant messages: theme background moved 5% toward theme info.
pub(crate) fn assistant_bg() -> Color {
let bg = current_theme().background;
let info = current_theme().info;
mix_toward(bg, info, 0.05)
match palette_mode() {
PaletteMode::Ansi16 => {
if is_dark_background(current_theme().background) {
Color::Indexed(4)
} else {
Color::Indexed(7)
}
}
PaletteMode::Ansi256 => {
let bg = current_theme().background;
let info = current_theme().info;
mix_toward(bg, info, 0.05)
}
}
}
/// Background for multiline code blocks rendered in assistant markdown.
@@ -245,21 +282,28 @@ pub(crate) fn code_block_bg() -> Color {
/// Defined as halfway from the theme background toward the assistant background tint.
/// This makes the rule more pronounced than the cell background while staying subtle.
pub(crate) fn assistant_hr() -> Color {
let bg = current_theme().background;
let info = current_theme().info;
let cell = assistant_bg();
// Start with a stronger mix toward the theme's info color than the cell bg.
// Cell is bg→info at 5%; pick ~15% as a baseline so it is darker than cell on light themes.
let candidate = mix_toward(bg, info, 0.15);
let cand_l = relative_luminance(color_to_rgb(candidate));
let cell_l = relative_luminance(color_to_rgb(cell));
if cand_l < cell_l {
// Already darker than the cell background.
candidate
} else {
// Ensure the rule is darker than the cell even on dark themes where info is brighter.
// Darken the cell bg by ~12% for a subtle, theme-respecting separator.
let (r, g, b) = blend_with_black(color_to_rgb(cell), 0.12);
Color::Rgb(r, g, b)
match palette_mode() {
PaletteMode::Ansi16 => {
if is_dark_background(current_theme().background) {
Color::Indexed(8)
} else {
Color::Indexed(7)
}
}
PaletteMode::Ansi256 => {
let bg = current_theme().background;
let info = current_theme().info;
let cell = assistant_bg();
let candidate = mix_toward(bg, info, 0.15);
let cand_l = relative_luminance(color_to_rgb(candidate));
let cell_l = relative_luminance(color_to_rgb(cell));
let result = if cand_l < cell_l {
candidate
} else {
let (r, g, b) = blend_with_black(color_to_rgb(cell), 0.12);
Color::Rgb(r, g, b)
};
quantize_color_for_palette(result)
}
}
}
+409 -81
View File
@@ -3,10 +3,12 @@ use codex_core::config_types::ThemeConfig;
use codex_core::config_types::ThemeName;
use lazy_static::lazy_static;
use ratatui::style::Color;
use std::cmp::Ordering;
use std::sync::RwLock;
lazy_static! {
static ref CURRENT_THEME: RwLock<Theme> = RwLock::new(Theme::default());
static ref CURRENT_THEME_NAME: RwLock<ThemeName> = RwLock::new(ThemeName::LightPhoton);
static ref CUSTOM_THEME_LABEL: RwLock<Option<String>> = RwLock::new(None);
static ref CUSTOM_THEME_COLORS: RwLock<Option<codex_core::config_types::ThemeColors>> = RwLock::new(None);
static ref CUSTOM_THEME_IS_DARK: RwLock<Option<bool>> = RwLock::new(None);
@@ -57,11 +59,12 @@ impl Default for Theme {
/// Initialize the global theme from configuration
pub fn init_theme(config: &ThemeConfig) {
let mut theme = get_predefined_theme(config.name);
let mapped_name = map_theme_for_palette(config.name, config.is_dark);
let mut theme = get_predefined_theme(mapped_name);
// Important: Only apply color overrides for the Custom theme.
// Built-in themes should render exactly as defined so that switching away
// from Custom does not keep stale custom overrides from config.
if matches!(config.name, ThemeName::Custom) {
if matches!(config.name, ThemeName::Custom) && matches!(palette_mode(), PaletteMode::Ansi256) {
apply_custom_colors(&mut theme, &config.colors);
}
@@ -73,7 +76,8 @@ pub fn init_theme(config: &ThemeConfig) {
}
let mut current = CURRENT_THEME.write().unwrap();
*current = theme;
*current = theme.clone();
*CURRENT_THEME_NAME.write().unwrap() = mapped_name;
// Track custom theme label for UI display
if matches!(config.name, ThemeName::Custom) {
*CUSTOM_THEME_LABEL.write().unwrap() = config.label.clone();
@@ -87,6 +91,10 @@ pub fn current_theme() -> Theme {
CURRENT_THEME.read().unwrap().clone()
}
pub(crate) fn current_theme_name() -> ThemeName {
*CURRENT_THEME_NAME.read().unwrap()
}
/// Get the custom theme's display label, if any
pub fn custom_theme_label() -> Option<String> {
CUSTOM_THEME_LABEL.read().unwrap().clone()
@@ -117,13 +125,14 @@ pub fn custom_theme_is_dark() -> Option<bool> {
/// Switch to a different predefined theme
pub fn switch_theme(theme_name: ThemeName) {
let theme = get_predefined_theme(theme_name);
let mut current = CURRENT_THEME.write().unwrap();
let mut theme = theme;
let mapped_name = map_theme_for_palette(theme_name, custom_theme_is_dark());
let mut theme = get_predefined_theme(mapped_name);
if needs_ansi256_fallback() {
quantize_theme_to_ansi256(&mut theme);
}
*current = theme;
let mut current = CURRENT_THEME.write().unwrap();
*current = theme.clone();
*CURRENT_THEME_NAME.write().unwrap() = mapped_name;
}
/// Parse a color string (hex or named color)
@@ -324,8 +333,90 @@ pub(crate) fn has_truecolor_terminal() -> bool {
/// Quantize all theme colors to the ANSI-256 palette so backends that do not
/// render truecolor reliably still get consistent colors.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaletteMode {
Ansi16,
Ansi256,
}
const ANSI16_COLORS: [(u8, u8, u8); 16] = [
(0, 0, 0), // 0 black
(205, 0, 0), // 1 red
(0, 205, 0), // 2 green
(205, 205, 0), // 3 yellow
(0, 0, 205), // 4 blue
(205, 0, 205), // 5 magenta
(0, 205, 205), // 6 cyan
(229, 229, 229), // 7 light gray
(127, 127, 127), // 8 dark gray
(255, 102, 102), // 9 light red
(102, 255, 178), // 10 light green
(255, 255, 102), // 11 light yellow
(102, 153, 255), // 12 light blue
(255, 102, 255), // 13 light magenta
(102, 255, 255), // 14 light cyan
(255, 255, 255), // 15 white
];
pub(crate) fn palette_mode() -> PaletteMode {
if let Some(level) = supports_color::on_cached(supports_color::Stream::Stdout) {
if level.has_16m {
return PaletteMode::Ansi256;
}
}
PaletteMode::Ansi16
}
fn is_light_theme_name(name: ThemeName) -> bool {
matches!(
name,
ThemeName::LightPhoton
| ThemeName::LightPhotonAnsi16
| ThemeName::LightPrismRainbow
| ThemeName::LightVividTriad
| ThemeName::LightPorcelain
| ThemeName::LightSandbar
| ThemeName::LightGlacier
| ThemeName::DarkPaperLightPro
)
}
pub(crate) fn map_theme_for_palette(
name: ThemeName,
custom_is_dark_hint: Option<bool>,
) -> ThemeName {
match palette_mode() {
PaletteMode::Ansi16 => match name {
ThemeName::LightPhotonAnsi16 | ThemeName::DarkCarbonAnsi16 => name,
ThemeName::Custom => {
let is_dark = custom_is_dark_hint
.or_else(|| custom_theme_is_dark())
.unwrap_or(false);
if is_dark {
ThemeName::DarkCarbonAnsi16
} else {
ThemeName::LightPhotonAnsi16
}
}
other => {
if is_light_theme_name(other) {
ThemeName::LightPhotonAnsi16
} else {
ThemeName::DarkCarbonAnsi16
}
}
},
PaletteMode::Ansi256 => match name {
ThemeName::LightPhotonAnsi16 => ThemeName::LightPhoton,
ThemeName::DarkCarbonAnsi16 => ThemeName::DarkCarbonNight,
other => other,
},
}
}
fn quantize_theme_to_ansi256(theme: &mut Theme) {
let original = theme.clone();
let palette = palette_mode();
// Preserve exact white backgrounds as truecolor to avoid terminals whose
// ANSI palette's "white" (15) is a light gray. This specifically fixes
// macOS Terminal.app where bright white can appear gray.
@@ -333,16 +424,16 @@ fn quantize_theme_to_ansi256(theme: &mut Theme) {
if !for_background { return None; }
if let Color::Rgb(r, g, b) = c {
if r >= 245 && g >= 245 && b >= 245 {
// On limited-color terminals we want a strong white for light themes.
// Use ANSI bright white (index 15), which maps to the profile's
// bright white and is reliably high‑contrast (unlike grayscale 231).
return Some(Color::Indexed(15));
}
}
None
}
let q = quantize_color_to_ansi256;
let q = match palette {
PaletteMode::Ansi256 => quantize_color_to_ansi256,
PaletteMode::Ansi16 => quantize_color_to_ansi16,
};
theme.primary = q(theme.primary);
theme.secondary = q(theme.secondary);
theme.background = preserve_true_white(theme.background, true).unwrap_or_else(|| q(theme.background));
@@ -365,7 +456,10 @@ fn quantize_theme_to_ansi256(theme: &mut Theme) {
theme.spinner = q(theme.spinner);
theme.progress = q(theme.progress);
enforce_light_theme_contrast(&original, theme);
enforce_theme_contrast(&original, theme, palette);
if matches!(palette, PaletteMode::Ansi16) {
apply_ansi16_profile(theme, &original);
}
}
fn quantize_color_to_ansi256(c: Color) -> Color {
@@ -376,6 +470,34 @@ fn quantize_color_to_ansi256(c: Color) -> Color {
}
}
fn quantize_color_to_ansi16(c: Color) -> Color {
match c {
Color::Rgb(r, g, b) => {
let mut best = 0;
let mut best_dist = i32::MAX;
for (i, &(cr, cg, cb)) in ANSI16_COLORS.iter().enumerate() {
let dist = color_distance((r, g, b), (cr, cg, cb));
if dist < best_dist {
best_dist = dist;
best = i as u8;
}
}
Color::Indexed(best)
}
other => other,
}
}
pub(crate) fn quantize_color_for_palette(c: Color) -> Color {
match c {
Color::Indexed(_) | Color::Reset => c,
_ => match palette_mode() {
PaletteMode::Ansi16 => quantize_color_to_ansi16(c),
PaletteMode::Ansi256 => quantize_color_to_ansi256(c),
},
}
}
// Map an RGB color to the closest xterm-256 color index using the standard
// 6x6x6 color cube + grayscale ramp.
fn rgb_to_ansi256_index(r: u8, g: u8, b: u8) -> u8 {
@@ -417,25 +539,6 @@ fn rgb_to_ansi256_index(r: u8, g: u8, b: u8) -> u8 {
// Candidate 3: 16-color ANSI (0..15), includes true white (15) which the
// grayscale ramp does not reach. This fixes near-white mapping to gray.
const ANSI16: [(u8, u8, u8); 16] = [
(0, 0, 0), // 0 black
(205, 0, 0), // 1 red
(0, 205, 0), // 2 green
(205, 205, 0), // 3 yellow
(0, 0, 205), // 4 blue
(205, 0, 205), // 5 magenta
(0, 205, 205), // 6 cyan
(229, 229, 229), // 7 gray
(127, 127, 127), // 8 dark gray
(255, 102, 102), // 9 light red
(102, 255, 178), // 10 light green
(255, 255, 102), // 11 light yellow
(102, 153, 255), // 12 light blue
(255, 102, 255), // 13 light magenta
(102, 255, 255), // 14 light cyan
(255, 255, 255), // 15 white
];
let rgb = (r, g, b);
let cube_rgb = (cube_r, cube_g, cube_b);
let gray_rgb = (gray_value, gray_value, gray_value);
@@ -449,7 +552,7 @@ fn rgb_to_ansi256_index(r: u8, g: u8, b: u8) -> u8 {
best_index = gray_index;
}
for (i, &(ar, ag, ab)) in ANSI16.iter().enumerate() {
for (i, &(ar, ag, ab)) in ANSI16_COLORS.iter().enumerate() {
let d = dist2(rgb, (ar, ag, ab));
if d < best_dist {
best_dist = d;
@@ -460,39 +563,87 @@ fn rgb_to_ansi256_index(r: u8, g: u8, b: u8) -> u8 {
best_index
}
fn enforce_light_theme_contrast(original: &Theme, quantized: &mut Theme) {
if !is_light_color(original.background) {
return;
}
if !is_light_color(quantized.background) {
return;
}
fn enforce_theme_contrast(original: &Theme, quantized: &mut Theme, palette: PaletteMode) {
align_background_classification(original, quantized, palette);
quantized.text = ensure_contrast(original.text, quantized.text, quantized.background, 7.0);
quantized.text_dim = ensure_contrast(original.text_dim, quantized.text_dim, quantized.background, 3.0);
quantized.text_bright = ensure_contrast(original.text_bright, quantized.text_bright, quantized.background, 4.5);
quantized.border = ensure_contrast(original.border, quantized.border, quantized.background, 1.4);
quantized.border_focused = ensure_contrast(original.border_focused, quantized.border_focused, quantized.background, 1.8);
quantized.comment = ensure_contrast(original.comment, quantized.comment, quantized.background, 2.0);
quantized.foreground = ensure_contrast(original.foreground, quantized.foreground, quantized.background, 7.0, palette);
quantized.text = ensure_contrast(original.text, quantized.text, quantized.background, 7.0, palette);
quantized.text_dim = ensure_contrast(original.text_dim, quantized.text_dim, quantized.background, 3.0, palette);
quantized.text_bright = ensure_contrast(original.text_bright, quantized.text_bright, quantized.background, 4.5, palette);
let border_min_ratio = if is_light_color(quantized.background) { 2.8 } else { 2.6 };
quantized.border = ensure_contrast(
original.border,
quantized.border,
quantized.background,
border_min_ratio,
palette,
);
quantized.border_focused = ensure_contrast(
original.border_focused,
quantized.border_focused,
quantized.background,
border_min_ratio + 0.8,
palette,
);
quantized.comment = ensure_contrast(original.comment, quantized.comment, quantized.background, 2.0, palette);
}
fn ensure_contrast(original: Color, current: Color, background: Color, min_ratio: f32) -> Color {
fn ensure_contrast(
original: Color,
current: Color,
background: Color,
min_ratio: f32,
palette: PaletteMode,
) -> Color {
if contrast_ratio(current, background) >= min_ratio {
return current;
}
let target = color_to_rgb(original);
let prefer_grayscale = is_low_saturation(target);
if let Some(candidate) =
find_palette_match_with_contrast(target, background, min_ratio, prefer_grayscale)
{
let background_lum = relative_luminance_color(background);
let original_lum = relative_luminance_color(original);
let luminance_tolerance: f32 = 0.02;
let desired_relation = if (original_lum - background_lum).abs() <= luminance_tolerance {
None
} else if original_lum > background_lum {
Some(Ordering::Greater)
} else {
Some(Ordering::Less)
};
if let Some(candidate) = find_palette_match_with_contrast(
target,
background,
min_ratio,
prefer_grayscale,
desired_relation,
palette,
) {
return candidate;
}
if let Some(candidate) = find_palette_match_with_contrast(
target,
background,
min_ratio,
prefer_grayscale,
None,
palette,
) {
return candidate;
}
if is_light_color(background) {
Color::Indexed(16)
match palette {
PaletteMode::Ansi16 => Color::Indexed(0),
PaletteMode::Ansi256 => Color::Indexed(16),
}
} else {
Color::Indexed(231)
match palette {
PaletteMode::Ansi16 => Color::Indexed(15),
PaletteMode::Ansi256 => Color::Indexed(15),
}
}
}
@@ -501,13 +652,28 @@ fn find_palette_match_with_contrast(
background: Color,
min_ratio: f32,
prefer_grayscale: bool,
desired_relation: Option<Ordering>,
palette: PaletteMode,
) -> Option<Color> {
let mut best: Option<(i32, Color)> = None;
let background_lum = relative_luminance_color(background);
let luminance_epsilon: f32 = 0.02;
let consider_candidate = |candidate: Color, best: &mut Option<(i32, Color)>| {
if contrast_ratio(candidate, background) < min_ratio {
return;
}
if let Some(relation) = desired_relation {
let candidate_lum = relative_luminance_color(candidate);
let matches = match relation {
Ordering::Less => candidate_lum + luminance_epsilon < background_lum,
Ordering::Equal => (candidate_lum - background_lum).abs() <= luminance_epsilon,
Ordering::Greater => candidate_lum > background_lum + luminance_epsilon,
};
if !matches {
return;
}
}
let rgb = color_to_rgb(candidate);
let dist = color_distance(rgb, target);
match best {
@@ -518,24 +684,156 @@ fn find_palette_match_with_contrast(
};
if prefer_grayscale {
const GRAY_INDICES: [u8; 29] = [
0, 8, 7, 15, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245,
246, 247, 248, 249, 250, 251, 252, 253, 254, 255,
];
for &idx in &GRAY_INDICES {
consider_candidate(Color::Indexed(idx), &mut best);
match palette {
PaletteMode::Ansi16 => {
const GRAYS: [u8; 4] = [0, 8, 7, 15];
for &idx in &GRAYS {
consider_candidate(Color::Indexed(idx), &mut best);
}
}
PaletteMode::Ansi256 => {
const GRAY_INDICES: [u8; 29] = [
0, 8, 7, 15, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243,
244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255,
];
for &idx in &GRAY_INDICES {
consider_candidate(Color::Indexed(idx), &mut best);
}
}
}
if best.is_some() {
return best.map(|(_, color)| color);
}
}
for idx in 0u16..=255 {
consider_candidate(Color::Indexed(idx as u8), &mut best);
match palette {
PaletteMode::Ansi16 => {
for idx in 0u8..=15 {
consider_candidate(Color::Indexed(idx), &mut best);
}
}
PaletteMode::Ansi256 => {
for idx in 0u16..=255 {
consider_candidate(Color::Indexed(idx as u8), &mut best);
}
}
}
best.map(|(_, color)| color)
}
fn align_background_classification(original: &Theme, quantized: &mut Theme, palette: PaletteMode) {
let should_be_light = is_light_color(original.background);
if is_light_color(quantized.background) == should_be_light {
return;
}
if let Some(candidate) = find_background_candidate(original.background, should_be_light, palette) {
quantized.background = candidate;
}
}
fn find_background_candidate(target: Color, require_light: bool, palette: PaletteMode) -> Option<Color> {
let mut best: Option<(i32, Color)> = None;
let target_rgb = color_to_rgb(target);
match palette {
PaletteMode::Ansi16 => {
for idx in 0u8..=15 {
let candidate = Color::Indexed(idx);
if is_light_color(candidate) != require_light {
continue;
}
let candidate_rgb = color_to_rgb(candidate);
let dist = color_distance(candidate_rgb, target_rgb);
match best {
None => best = Some((dist, candidate)),
Some((best_dist, _)) if dist < best_dist => best = Some((dist, candidate)),
_ => {}
}
}
}
PaletteMode::Ansi256 => {
for idx in 0u16..=255 {
let candidate = Color::Indexed(idx as u8);
if is_light_color(candidate) != require_light {
continue;
}
let candidate_rgb = color_to_rgb(candidate);
let dist = color_distance(candidate_rgb, target_rgb);
match best {
None => best = Some((dist, candidate)),
Some((best_dist, _)) if dist < best_dist => best = Some((dist, candidate)),
_ => {}
}
}
}
}
best.map(|(_, color)| color)
}
fn apply_ansi16_profile(theme: &mut Theme, original: &Theme) {
let is_light = is_light_color(original.background);
if is_light {
// Light profile
theme.background = Color::Indexed(15);
theme.foreground = Color::Indexed(0);
theme.text = Color::Indexed(0);
theme.text_dim = Color::Indexed(8);
theme.text_bright = Color::Indexed(0);
theme.border = Color::Indexed(8);
theme.border_focused = Color::Indexed(0);
theme.cursor = Color::Indexed(0);
theme.primary = Color::Indexed(12);
theme.secondary = Color::Indexed(13);
theme.selection = Color::Indexed(12);
theme.success = Color::Indexed(2);
theme.warning = Color::Indexed(3);
theme.error = Color::Indexed(1);
theme.info = Color::Indexed(12);
theme.keyword = Color::Indexed(12);
theme.string = Color::Indexed(10);
theme.comment = Color::Indexed(8);
theme.function = Color::Indexed(10);
theme.spinner = Color::Indexed(4);
theme.progress = Color::Indexed(12);
} else {
// Dark profile
theme.background = Color::Indexed(0);
theme.foreground = Color::Indexed(15);
theme.text = Color::Indexed(15);
theme.text_dim = Color::Indexed(7);
theme.text_bright = Color::Indexed(15);
theme.border = Color::Indexed(7);
theme.border_focused = Color::Indexed(15);
theme.cursor = Color::Indexed(15);
theme.primary = Color::Indexed(12);
theme.secondary = Color::Indexed(13);
theme.selection = Color::Indexed(4);
theme.success = Color::Indexed(2);
theme.warning = Color::Indexed(3);
theme.error = Color::Indexed(1);
theme.info = Color::Indexed(14);
theme.keyword = Color::Indexed(12);
theme.string = Color::Indexed(10);
theme.comment = Color::Indexed(7);
theme.function = Color::Indexed(10);
theme.spinner = Color::Indexed(7);
theme.progress = Color::Indexed(12);
}
// Ensure accent text retains readable contrast relative to the new palette.
if contrast_ratio(theme.keyword, theme.background) < 2.5 {
theme.keyword = if is_light { Color::Indexed(12) } else { Color::Indexed(13) };
}
if contrast_ratio(theme.string, theme.background) < 2.5 {
theme.string = if is_light { Color::Indexed(10) } else { Color::Indexed(10) };
}
if contrast_ratio(theme.comment, theme.background) < 2.0 {
theme.comment = if is_light { Color::Indexed(8) } else { Color::Indexed(8) };
}
}
fn color_distance(a: (u8, u8, u8), b: (u8, u8, u8)) -> i32 {
let dr = a.0 as i32 - b.0 as i32;
let dg = a.1 as i32 - b.1 as i32;
@@ -605,27 +903,8 @@ fn color_to_rgb(color: Color) -> (u8, u8, u8) {
}
fn ansi256_to_rgb(idx: u8) -> (u8, u8, u8) {
const ANSI16: [(u8, u8, u8); 16] = [
(0, 0, 0),
(205, 0, 0),
(0, 205, 0),
(205, 205, 0),
(0, 0, 205),
(205, 0, 205),
(0, 205, 205),
(229, 229, 229),
(127, 127, 127),
(255, 102, 102),
(102, 255, 178),
(255, 255, 102),
(102, 153, 255),
(255, 102, 255),
(102, 255, 255),
(255, 255, 255),
];
if idx < 16 {
return ANSI16[idx as usize];
return ANSI16_COLORS[idx as usize];
}
if (16..=231).contains(&idx) {
let offset = idx - 16;
@@ -651,9 +930,12 @@ mod tests {
assert!(contrast_ratio(theme.text, theme.background) >= 7.0);
assert!(contrast_ratio(theme.text_dim, theme.background) >= 3.0);
assert!(contrast_ratio(theme.border, theme.background) >= 1.4);
assert!(contrast_ratio(theme.border_focused, theme.background) >= 1.8);
assert!(contrast_ratio(theme.border, theme.background) >= 2.8);
assert!(contrast_ratio(theme.border_focused, theme.background) >= 3.6);
assert!(contrast_ratio(theme.comment, theme.background) >= 2.0);
assert_ne!(theme.border, theme.background, "border should differ from background");
assert_ne!(theme.text, theme.background, "text should differ from background");
assert_ne!(theme.text_dim, theme.background, "text_dim should differ from background");
let (r_text_dim, g_text_dim, b_text_dim) = color_to_rgb(theme.text_dim);
assert_eq!(r_text_dim, g_text_dim, "text_dim should remain neutral grayscale");
@@ -687,6 +969,29 @@ fn get_predefined_theme(name: ThemeName) -> Theme {
spinner: Color::Rgb(59, 67, 79), // #3B434F
progress: Color::Rgb(37, 194, 255), // #25C2FF
},
ThemeName::DarkCarbonAnsi16 => Theme {
primary: Color::Indexed(12),
secondary: Color::Indexed(13),
background: Color::Indexed(0),
foreground: Color::Indexed(15),
border: Color::Indexed(7),
border_focused: Color::Indexed(15),
selection: Color::Indexed(4),
cursor: Color::Indexed(15),
success: Color::Indexed(2),
warning: Color::Indexed(3),
error: Color::Indexed(1),
info: Color::Indexed(14),
text: Color::Indexed(15),
text_dim: Color::Indexed(7),
text_bright: Color::Indexed(15),
keyword: Color::Indexed(12),
string: Color::Indexed(10),
comment: Color::Indexed(7),
function: Color::Indexed(10),
spinner: Color::Indexed(7),
progress: Color::Indexed(12),
},
ThemeName::LightPhoton => Theme {
// Light default - clean professional light theme
@@ -712,6 +1017,29 @@ fn get_predefined_theme(name: ThemeName) -> Theme {
spinner: Color::Rgb(156, 163, 175), // #9CA3AF
progress: Color::Rgb(0, 95, 204), // #005FCC
},
ThemeName::LightPhotonAnsi16 => Theme {
primary: Color::Indexed(12),
secondary: Color::Indexed(13),
background: Color::Indexed(15),
foreground: Color::Indexed(0),
border: Color::Indexed(8),
border_focused: Color::Indexed(0),
selection: Color::Indexed(12),
cursor: Color::Indexed(0),
success: Color::Indexed(2),
warning: Color::Indexed(3),
error: Color::Indexed(1),
info: Color::Indexed(12),
text: Color::Indexed(0),
text_dim: Color::Indexed(8),
text_bright: Color::Indexed(0),
keyword: Color::Indexed(12),
string: Color::Indexed(10),
comment: Color::Indexed(8),
function: Color::Indexed(10),
spinner: Color::Indexed(4),
progress: Color::Indexed(12),
},
ThemeName::LightPrismRainbow => Theme {
// Light - Prism Rainbow