initial implementation of user message background color for Windows.

This commit is contained in:
David Wiesen
2025-10-06 10:29:50 -07:00
parent 721003c552
commit 5b6656101e
5 changed files with 168 additions and 1 deletions
+1
View File
@@ -1428,6 +1428,7 @@ dependencies = [
"unicode-width 0.2.1",
"url",
"vt100",
"windows-sys 0.59.0",
]
[[package]]
+1
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"
+6
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 }
+54
View File
@@ -14,7 +14,15 @@ pub fn user_message_style(terminal_bg: Option<(u8, u8, u8)>) -> Style {
}
#[allow(clippy::disallowed_methods)]
/// Derives a background color for the user input field that contrasts with the terminal.
///
/// The goal is to blend the user's default terminal background with either black or
/// white (depending on whether the background is light/dark) so the composer area feels
/// consistent with the host theme. The function progressively falls back to palettes of
/// decreasing fidelity depending on what the runtime reports about color support.
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 {
@@ -25,8 +33,11 @@ pub fn user_message_bg(terminal_bg: (u8, u8, u8)) -> Color {
return Color::default();
};
// Blend 10% toward the contrasting top color to create a subtle shading effect that
// keeps the composer distinct without overwhelming the terminal theme.
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 +48,51 @@ 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 {
// Finally, degrade to the basic 16 ANSI colors using a perceptual distance match.
closest_basic_color(target)
} else {
// If the runtime reports no color support at all, keep the default background to
// avoid rendering garbage escape sequences.
Color::default()
}
}
fn closest_basic_color(target: (u8, u8, u8)) -> Color {
// Iterate through the baked-in ANSI colors and return whichever one is closest to the
// desired RGB shade. This mirrors the logic used for the 256-color lookup but avoids
// allocating an array for each call.
BASIC_TERMINAL_COLORS
.iter()
.min_by(|(_, a), (_, b)| {
perceptual_distance(*a, target)
.partial_cmp(&perceptual_distance(*b, target))
.unwrap_or(std::cmp::Ordering::Equal)
})
.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)),
];
+106 -1
View File
@@ -1,27 +1,37 @@
/// Returns the cached 256-color palette reported by the active terminal, when available.
///
/// Unix terminals often respond to OSC 4 palette dumps while Windows does not, so callers
/// should always be prepared for this to return `None`.
pub fn terminal_palette() -> Option<[(u8, u8, u8); 256]> {
imp::terminal_palette()
}
/// Forces the palette cache to re-run the default color probe for the current terminal.
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),
bg: (u8, u8, u8),
}
/// Returns the default colors (if known), priming the cache by probing the terminal when
/// necessary.
pub fn default_colors() -> Option<DefaultColors> {
imp::default_colors()
}
#[allow(dead_code)]
/// Convenience accessor for only the cached default foreground.
pub fn default_fg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.fg)
}
/// Convenience accessor for only the cached default background.
pub fn default_bg() -> Option<(u8, u8, u8)> {
default_colors().map(|c| c.bg)
}
@@ -34,6 +44,8 @@ mod imp {
use std::sync::Mutex;
use std::sync::OnceLock;
/// Lightweight memoization helper storing the last probe result and whether a probe
/// has already been attempted.
struct Cache<T> {
attempted: bool,
value: Option<T>,
@@ -66,6 +78,8 @@ mod imp {
fn default_colors_cache() -> &'static Mutex<Cache<DefaultColors>> {
static CACHE: OnceLock<Mutex<Cache<DefaultColors>>> = OnceLock::new();
// Lazily construct a mutex-protected cache so multiple threads can reuse the
// expensive OSC probe without data races.
CACHE.get_or_init(|| Mutex::new(Cache::default()))
}
@@ -85,6 +99,8 @@ mod imp {
pub(super) fn requery_default_colors() {
if let Ok(mut cache) = default_colors_cache().lock() {
// Force the expensive OSC query to run again so palette adjustments in the
// user's terminal are picked up immediately.
cache.refresh_with(|| query_default_colors().unwrap_or_default());
}
}
@@ -109,6 +125,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")?;
}
@@ -147,6 +164,8 @@ mod imp {
}
}
// Attempt to parse any trailing bytes that are already buffered, then pull in any
// remaining responses until the terminal has been idle briefly.
remaining = remaining.saturating_sub(apply_palette_responses(&mut buffer, &mut palette));
remaining = remaining.saturating_sub(drain_remaining(&mut tty, &mut buffer, &mut palette));
@@ -181,6 +200,8 @@ mod imp {
if !stdout_handle.is_terminal() {
return Ok(None);
}
// Ask for both the default foreground (OSC 10) and background (OSC 11) in a single
// write so that we minimize flush latency.
stdout_handle.write_all(b"\x1b]10;?\x07\x1b]11;?\x07")?;
stdout_handle.flush()?;
@@ -209,6 +230,8 @@ mod imp {
Ok(n) => {
buffer.extend_from_slice(&chunk[..n]);
if fg.is_none() {
// OSC replies can be interleaved; keep checking the buffered text
// until we observe the requested color.
fg = parse_osc_color(&buffer, 10);
}
if bg.is_none() {
@@ -247,6 +270,8 @@ mod imp {
use std::time::Instant;
let mut chunk = [0u8; 512];
// Continue draining until we observe no new data for a short period; this guards
// against leaving responses unread when terminals stream slowly.
let mut idle_deadline = Instant::now() + Duration::from_millis(50);
let mut newly_filled = 0usize;
@@ -262,6 +287,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,
@@ -309,6 +336,8 @@ mod imp {
) -> usize {
let mut newly_filled = 0;
// Search for OSC introducers (ESC ]) inside the rolling buffer and peel off
// responses as soon as we have a full payload.
while let Some(start) = buffer.windows(2).position(|window| window == [0x1b, b']']) {
if start > 0 {
buffer.drain(..start);
@@ -320,10 +349,12 @@ mod imp {
while index < buffer.len() {
match buffer[index] {
0x07 => {
// BEL terminated response.
terminator_len = Some(1);
break;
}
0x1b if index + 1 < buffer.len() && buffer[index + 1] == b'\\' => {
// ESC \ (String Terminator) variant.
terminator_len = Some(2);
break;
}
@@ -345,6 +376,8 @@ mod imp {
if let Some((slot, color)) = parsed
&& palette[slot].is_none()
{
// Record the color only if the slot hasn't been filled yet to avoid
// clobbering data when terminals echo duplicate responses.
palette[slot] = Some(color);
newly_filled += 1;
}
@@ -367,6 +400,8 @@ mod imp {
if model != "rgb" && model != "rgba" {
return None;
}
// The OSC payload encodes each color component as hexadecimal strings separated by
// '/', so decode them in order.
let mut components = values.split('/');
let r = parse_component(components.next()?)?;
let g = parse_component(components.next()?)?;
@@ -374,6 +409,7 @@ mod imp {
Some((index, (r, g, b)))
}
/// Normalizes a hexadecimal OSC color component (which can be 4-64 bits) to 0-255.
fn parse_component(component: &str) -> Option<u8> {
let trimmed = component.trim();
if trimmed.is_empty() {
@@ -399,6 +435,8 @@ mod imp {
11 => "\u{1b}]11;",
_ => return None,
};
// Locate the most recent reply for the requested OSC code in case multiple
// sequences are buffered together.
let start = text.rfind(prefix)?;
let after_prefix = &text[start + prefix.len()..];
let end_bel = after_prefix.find('\u{7}');
@@ -425,11 +463,78 @@ mod imp {
let r = parse_component(parts.next()?)?;
let g = parse_component(parts.next()?)?;
let b = parse_component(parts.next()?)?;
// Some terminals append an alpha component for rgba; ignore it if present since the
// OSC format represents the alpha channel as the next slash-separated part.
Some((r, g, b))
}
}
#[cfg(not(all(unix, not(test))))]
#[cfg(all(windows, not(test)))]
mod imp {
use super::DefaultColors;
use std::mem::MaybeUninit;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::CONSOLE_SCREEN_BUFFER_INFO;
use windows_sys::Win32::System::Console::GetConsoleScreenBufferInfo;
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),
];
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
}
/// 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> {
unsafe {
let handle = GetStdHandle(STD_OUTPUT_HANDLE);
if handle == 0 || handle == INVALID_HANDLE_VALUE {
return None;
}
let mut info = MaybeUninit::<CONSOLE_SCREEN_BUFFER_INFO>::uninit();
if GetConsoleScreenBufferInfo(handle, info.as_mut_ptr()) == 0 {
return None;
}
let info = info.assume_init();
let attrs = info.wAttributes as usize;
let fg_idx = attrs & 0x0f;
let bg_idx = (attrs >> 4) & 0x0f;
Some(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)),
})
}
}
pub(super) fn requery_default_colors() {}
}
#[cfg(not(any(all(unix, not(test)), all(windows, not(test)))))]
mod imp {
use super::DefaultColors;