Compare commits

...

10 Commits

Author SHA1 Message Date
David Wiesen
3d3ca6475d assume true color for any Windows Terminal session. 2025-10-06 16:10:47 -07:00
David Wiesen
5d1e66f7b5 fix pointer comparison warning. 2025-10-06 14:23:27 -07:00
David Wiesen
c8ecce9e54 fix pointer comparison warning. 2025-10-06 14:17:57 -07:00
David Wiesen
494b97c87f fix windows compilation errors. 2025-10-06 14:03:12 -07:00
David Wiesen
b8e1580002 comments are too verbose. 2025-10-06 13:43:58 -07:00
David Wiesen
bff44cd091 avoid terminal background color when selecting basic user_message_bg 2025-10-06 13:28:22 -07:00
David Wiesen
d41ab7290e fix handle type. 2025-10-06 11:43:15 -07:00
David Wiesen
b28888f16a support Windows themes, which override the color table. 2025-10-06 11:29:16 -07:00
David Wiesen
8029ad9919 fix pointer type issue. 2025-10-06 10:51:24 -07:00
David Wiesen
5b6656101e initial implementation of user message background color for Windows. 2025-10-06 10:29:50 -07:00
5 changed files with 312 additions and 2 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1428,6 +1428,7 @@ dependencies = [
"unicode-width 0.2.1",
"url",
"vt100",
"windows-sys 0.59.0",
]
[[package]]

View File

@@ -188,6 +188,7 @@ walkdir = "2.5.0"
webbrowser = "1.0"
which = "6"
wildmatch = "2.5.0"
windows-sys = "0.59.0"
wiremock = "0.6"
zeroize = "1.8.1"

View File

@@ -92,6 +92,12 @@ libc = { workspace = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = { workspace = true }
[target.'cfg(windows)'.dependencies]
windows-sys = { workspace = true, features = [
"Win32_Foundation",
"Win32_System_Console",
] }
[dev-dependencies]
assert_matches = { workspace = true }

View File

@@ -1,6 +1,7 @@
use crate::color::blend;
use crate::color::is_light;
use crate::color::perceptual_distance;
use crate::terminal_palette::basic_palette;
use crate::terminal_palette::terminal_palette;
use ratatui::style::Color;
use ratatui::style::Style;
@@ -15,18 +16,29 @@ pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style {
#[allow(clippy::disallowed_methods)]
pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
// Determine a reference "top" color to blend toward. For dark backgrounds we lighten
// the mix with white, and for light backgrounds we darken it with black.
let top = if is_light(terminal_bg) {
(0, 0, 0)
} else {
(255, 255, 255)
};
let bottom = terminal_bg;
let Some(color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else {
let Some(mut color_level) = supports_color::on_cached(supports_color::Stream::Stdout) else {
return Color::default();
};
#[cfg(windows)]
// Windows Terminal has been the default shell application for Windows since October 2022
// and has supported truecolor even longer. However it usually does not set COLORTERM to indicate that.
// so this is a pretty safe heuristic.
if std::env::var_os("WT_SESSION").is_some() {
color_level.has_16m = true;
}
let target = blend(top, bottom, 0.1);
if color_level.has_16m {
// In truecolor terminals we can use the exact RGB value.
let (r, g, b) = target;
Color::Rgb(r, g, b)
} else if color_level.has_256
@@ -37,8 +49,94 @@ pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
.unwrap_or(std::cmp::Ordering::Equal)
})
{
// If we have a captured 256-color palette, pick whichever indexed color is
// perceptually closest to the blended target.
Color::Indexed(i as u8)
} else if color_level.has_basic {
if let Some(palette) = basic_palette() {
// On Windows terminals the palette is configurable, so evaluate the actual
// runtime color table to keep the blended shading aligned with custom themes.
closest_runtime_basic_color(target, terminal_bg, &palette)
} else {
// Finally, degrade to the well-known ANSI 16-color defaults using a perceptual
// distance match.
closest_basic_color(target, terminal_bg)
}
} else {
Color::default()
}
}
fn closest_runtime_basic_color(
target: (u8, u8, u8),
terminal_bg: (u8, u8, u8),
palette: &[(u8, u8, u8); 16],
) -> Color {
select_basic_palette_color(
target,
terminal_bg,
palette.iter().enumerate().filter_map(|(idx, rgb)| {
BASIC_TERMINAL_COLORS
.get(idx)
.map(|(color, _)| (*rgb, *color))
}),
)
}
fn closest_basic_color(target: (u8, u8, u8), terminal_bg: (u8, u8, u8)) -> Color {
// Iterate through the baked-in ANSI colors and return whichever one is closest to the
// desired RGB shade while maintaining contrast with the background.
select_basic_palette_color(
target,
terminal_bg,
BASIC_TERMINAL_COLORS
.iter()
.map(|(color, rgb)| (*rgb, *color)),
)
}
const MIN_PERCEPTUAL_DISTANCE: f32 = 6.0;
fn select_basic_palette_color(
target: (u8, u8, u8),
terminal_bg: (u8, u8, u8),
entries: impl Iterator<Item = ((u8, u8, u8), Color)>,
) -> Color {
let mut best = None;
let mut fallback = None;
for (rgb, color) in entries {
let dist = perceptual_distance(rgb, target);
if fallback.is_none_or(|(_, best_dist)| dist < best_dist) {
fallback = Some((color, dist));
}
if perceptual_distance(rgb, terminal_bg) > MIN_PERCEPTUAL_DISTANCE
&& best.is_none_or(|(_, best_dist)| dist < best_dist)
{
best = Some((color, dist));
}
}
best.or(fallback)
.map(|(color, _)| color)
.unwrap_or(Color::default())
}
// Mapping of ANSI color indices to approximate RGB tuples. These values mirror Windows'
// default console palette so the fallback path stays visually consistent across platforms.
const BASIC_TERMINAL_COLORS: [(Color, (u8, u8, u8)); 16] = [
(Color::Black, (0, 0, 0)),
(Color::Blue, (0, 0, 128)),
(Color::Green, (0, 128, 0)),
(Color::Cyan, (0, 128, 128)),
(Color::Red, (128, 0, 0)),
(Color::Magenta, (128, 0, 128)),
(Color::Yellow, (128, 128, 0)),
(Color::Gray, (192, 192, 192)),
(Color::DarkGray, (128, 128, 128)),
(Color::LightBlue, (0, 0, 255)),
(Color::LightGreen, (0, 255, 0)),
(Color::LightCyan, (0, 255, 255)),
(Color::LightRed, (255, 0, 0)),
(Color::LightMagenta, (255, 0, 255)),
(Color::LightYellow, (255, 255, 0)),
(Color::White, (255, 255, 255)),
];

View File

@@ -2,11 +2,21 @@ pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
imp::terminal_palette()
}
/// Returns the runtime palette for basic (0-15) color slots when the terminal reports one.
///
/// Windows exposes a mutable color table via the console API so callers can render subtle
/// shading that matches the configured theme. Unix terminals rarely offer similar hooks, so
/// this returns `None` there and consumers should fall back to well-known ANSI defaults.
pub fn basic_palette() -> Option<[(u8, u8, u8); 16]> {
imp::basic_palette()
}
pub fn requery_default_colors() {
imp::requery_default_colors();
}
#[derive(Clone, Copy)]
/// Snapshot of the terminal's default foreground and background RGB values.
pub struct DefaultColors {
#[allow(dead_code)]
fg: (u8, u8, u8),
@@ -77,6 +87,10 @@ mod imp {
})
}
pub(super) fn basic_palette() -> Option<[(u8, u8, u8); 16]> {
None
}
pub(super) fn default_colors() -> Option<DefaultColors> {
let cache = default_colors_cache();
let mut cache = cache.lock().ok()?;
@@ -109,6 +123,7 @@ mod imp {
Err(_) => return Ok(None),
};
// Request the RGB value for each extended color slot (OSC 4 ; index ; ? BEL).
for index in 0..256 {
write!(tty, "\x1b]4;{index};?\x07")?;
}
@@ -262,6 +277,8 @@ mod imp {
if Instant::now() >= idle_deadline {
break;
}
// Sleep briefly to avoid spinning while still allowing slow terminals
// to provide more data before the idle timeout expires.
std::thread::sleep(Duration::from_millis(5));
}
Err(err) if err.kind() == ErrorKind::Interrupted => continue,
@@ -429,7 +446,190 @@ mod imp {
}
}
#[cfg(not(all(unix, not(test))))]
#[cfg(all(windows, not(test)))]
mod imp {
use super::DefaultColors;
use std::mem::MaybeUninit;
use std::sync::Mutex;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFO;
use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFOEX;
use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfo;
use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfoEx;
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE;
/// Matches the historical Windows console palette where indices 0-15 are fixed.
const WINDOWS_COLORS: [(u8, u8, u8); 16] = [
(0, 0, 0),
(0, 0, 128),
(0, 128, 0),
(0, 128, 128),
(128, 0, 0),
(128, 0, 128),
(128, 128, 0),
(192, 192, 192),
(128, 128, 128),
(0, 0, 255),
(0, 255, 0),
(0, 255, 255),
(255, 0, 0),
(255, 0, 255),
(255, 255, 0),
(255, 255, 255),
];
/// Captures the console defaults along with the resolved 16-color palette so lookups can
/// serve both `default_colors()` and `basic_palette()` without re-querying Win32 APIs.
#[derive(Clone, Copy)]
struct ConsoleSnapshot {
defaults: DefaultColors,
palette: [(u8, u8, u8); 16],
}
struct Cache<T> {
attempted: bool,
value: Option<T>,
}
impl<T> Default for Cache<T> {
fn default() -> Self {
Self {
attempted: false,
value: None,
}
}
}
impl<T: Copy> Cache<T> {
fn get_or_init_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
if !self.attempted {
self.value = init();
self.attempted = true;
}
self.value
}
fn refresh_with(&mut self, mut init: impl FnMut() -> Option<T>) -> Option<T> {
self.value = init();
self.attempted = true;
self.value
}
}
fn console_snapshot_cache() -> &'static Mutex<Cache<ConsoleSnapshot>> {
static CACHE: OnceLock<Mutex<Cache<ConsoleSnapshot>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(Cache::default()))
}
pub(super) fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
// Legacy Windows terminals only support the 16 basic colors so there is no
// meaningful OSC palette to retrieve.
None
}
pub(super) fn basic_palette() -> Option<[(u8, u8, u8); 16]> {
let cache = console_snapshot_cache();
let mut cache = cache.lock().ok()?;
cache
.get_or_init_with(query_console_snapshot)
.map(|snapshot| snapshot.palette)
}
/// Uses the Win32 console APIs to look up the active color attribute indices and map
/// them into RGB triplets using the standard Windows palette table above.
pub(super) fn default_colors() -> Option<DefaultColors> {
let cache = console_snapshot_cache();
let mut cache = cache.lock().ok()?;
cache
.get_or_init_with(query_console_snapshot)
.map(|snapshot| snapshot.defaults)
}
pub(super) fn requery_default_colors() {
if let Ok(mut cache) = console_snapshot_cache().lock() {
cache.refresh_with(query_console_snapshot);
}
}
fn query_console_snapshot() -> Option<ConsoleSnapshot> {
unsafe {
let handle = GetStdHandle(STD_OUTPUT_HANDLE);
if handle.is_null() || std::ptr::eq(handle, INVALID_HANDLE_VALUE) {
return None;
}
// Prefer the extended console info call so that we can honor custom palettes.
if let Some(snapshot) = query_with_ex(handle) {
return Some(snapshot);
}
// Fall back to the legacy structure when the extended query is unavailable.
query_with_basic_info(handle)
}
}
unsafe fn query_with_ex(handle: HANDLE) -> Option<ConsoleSnapshot> {
let mut info = MaybeUninit::<CONSOLE_SCREEN_BUFFER_INFOEX>::zeroed();
let info_ptr = info.as_mut_ptr();
unsafe {
(*info_ptr).cbSize = std::mem::size_of::<CONSOLE_SCREEN_BUFFER_INFOEX>() as u32;
if GetConsoleScreenBufferInfoEx(handle, info_ptr) == 0 {
return None;
}
}
let info = unsafe { info.assume_init() };
let attrs = info.wAttributes as usize;
let fg_idx = attrs & 0x0f;
let bg_idx = (attrs >> 4) & 0x0f;
let mut palette = [(0u8, 0u8, 0u8); 16];
for (slot, colorref) in palette.iter_mut().zip(info.ColorTable.iter().copied()) {
*slot = unpack_colorref(colorref);
}
Some(ConsoleSnapshot {
defaults: DefaultColors {
fg: palette.get(fg_idx).copied().unwrap_or((255, 255, 255)),
bg: palette.get(bg_idx).copied().unwrap_or((0, 0, 0)),
},
palette,
})
}
unsafe fn query_with_basic_info(handle: HANDLE) -> Option<ConsoleSnapshot> {
let mut info = MaybeUninit::<CONSOLE_SCREEN_BUFFER_INFO>::uninit();
unsafe {
if GetConsoleScreenBufferInfo(handle, info.as_mut_ptr()) == 0 {
return None;
}
}
let info = unsafe { info.assume_init() };
let attrs = info.wAttributes as usize;
let fg_idx = attrs & 0x0f;
let bg_idx = (attrs >> 4) & 0x0f;
Some(ConsoleSnapshot {
defaults: DefaultColors {
fg: WINDOWS_COLORS
.get(fg_idx)
.copied()
.unwrap_or((255, 255, 255)),
bg: WINDOWS_COLORS.get(bg_idx).copied().unwrap_or((0, 0, 0)),
},
palette: WINDOWS_COLORS,
})
}
/// Unpacks a Windows COLORREF (0x00BBGGRR) triple into conventional RGB ordering.
fn unpack_colorref(colorref: u32) -> (u8, u8, u8) {
let r = (colorref & 0xff) as u8;
let g = ((colorref >> 8) & 0xff) as u8;
let b = ((colorref >> 16) & 0xff) as u8;
(r, g, b)
}
}
#[cfg(not(any(all(unix, not(test)), all(windows, not(test)))))]
mod imp {
use super::DefaultColors;
@@ -437,6 +637,10 @@ mod imp {
None
}
pub(super) fn basic_palette() -> Option<[(u8, u8, u8); 16]> {
None
}
pub(super) fn default_colors() -> Option<DefaultColors> {
None
}