mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(tui/theme): add dedicated 16-color themes and palette mapping
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user