chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
use ratatui::style::Color;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorCapability {
|
||||
TrueColor,
|
||||
Color256,
|
||||
}
|
||||
|
||||
static CAPABILITY: OnceLock<ColorCapability> = OnceLock::new();
|
||||
|
||||
pub fn color_capability() -> ColorCapability {
|
||||
*CAPABILITY.get_or_init(detect_color_capability)
|
||||
}
|
||||
|
||||
/// Terminals whose GPU glyph atlas corrupts under heavy per-cell *truecolor*
|
||||
/// churn (the macOS 26 "garbled glyphs" bug in the VS Code integrated terminal
|
||||
/// and Apple Terminal; see `jcode_app_core::perf` and issue #330). These
|
||||
/// renderers key their rasterized-glyph cache on the full 24-bit color, so the
|
||||
/// continuous color animations jcode emits (shimmer, rainbow, pulsing tool
|
||||
/// colors) generate an effectively unbounded set of atlas entries, overflowing
|
||||
/// it and re-rendering stale cached glyphs as boxes.
|
||||
///
|
||||
/// Capping these terminals to the 256-color palette bounds the distinct-color
|
||||
/// space to a value the atlas can actually cache, which keeps the animations
|
||||
/// working while eliminating the unbounded churn. Robust GPU terminals
|
||||
/// (Ghostty / iTerm2 / kitty / WezTerm / Alacritty) are unaffected and keep
|
||||
/// full truecolor.
|
||||
///
|
||||
/// Overridable with `JCODE_GLYPH_SAFE_MODE=on|off` (shared with the perf
|
||||
/// policy) so users can force or disable the compatibility behavior.
|
||||
fn fragile_glyph_cache_terminal() -> bool {
|
||||
if let Ok(raw) = std::env::var("JCODE_GLYPH_SAFE_MODE") {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => return true,
|
||||
"0" | "false" | "no" | "off" => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mirror of `jcode_app_core::perf::detect_terminal` for the two affected
|
||||
// terminals (kept local to avoid a crate dependency from tui-style).
|
||||
match std::env::var("TERM_PROGRAM") {
|
||||
Ok(tp) => {
|
||||
let tp = tp.to_ascii_lowercase();
|
||||
tp == "vscode" || tp == "apple_terminal"
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_color_capability() -> ColorCapability {
|
||||
let raw = detect_raw_color_capability();
|
||||
// Downgrade truecolor to 256-color on terminals with a fragile glyph
|
||||
// atlas so animated colors quantize to a bounded palette instead of
|
||||
// overflowing the atlas (#330).
|
||||
if raw == ColorCapability::TrueColor && fragile_glyph_cache_terminal() {
|
||||
return ColorCapability::Color256;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
fn detect_raw_color_capability() -> ColorCapability {
|
||||
if let Ok(val) = std::env::var("COLORTERM") {
|
||||
let v = val.to_lowercase();
|
||||
if v == "truecolor" || v == "24bit" {
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
|
||||
let tp = term_program.to_lowercase();
|
||||
if tp == "ghostty"
|
||||
|| tp == "iterm.app"
|
||||
|| tp == "wezterm"
|
||||
|| tp == "warp"
|
||||
|| tp == "alacritty"
|
||||
|| tp == "hyper"
|
||||
{
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|
||||
|| std::env::var("GHOSTTY_BIN_DIR").is_ok()
|
||||
|| std::env::var("WEZTERM_EXECUTABLE").is_ok()
|
||||
|| std::env::var("WEZTERM_PANE").is_ok()
|
||||
{
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
|
||||
if let Ok(term) = std::env::var("TERM") {
|
||||
let t = term.to_lowercase();
|
||||
if t.contains("kitty") || t.contains("ghostty") || t.contains("alacritty") {
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
if t.contains("256color") {
|
||||
return ColorCapability::Color256;
|
||||
}
|
||||
}
|
||||
|
||||
ColorCapability::Color256
|
||||
}
|
||||
|
||||
pub fn has_truecolor() -> bool {
|
||||
color_capability() == ColorCapability::TrueColor
|
||||
}
|
||||
|
||||
pub fn clear_buf(area: Rect, buf: &mut Buffer) {
|
||||
for x in area.left()..area.right() {
|
||||
for y in area.top()..area.bottom() {
|
||||
buf[(x, y)].reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
if has_truecolor() {
|
||||
Color::Rgb(r, g, b)
|
||||
} else {
|
||||
Color::Indexed(rgb_to_xterm256(r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
// The xterm-256 color cube: indices 16-231 map to a 6x6x6 RGB cube.
|
||||
// Each axis uses values: 0, 95, 135, 175, 215, 255 (indices 0-5).
|
||||
// Indices 232-255 are a grayscale ramp from rgb(8,8,8) to rgb(238,238,238).
|
||||
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
|
||||
let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
|
||||
|
||||
let cube_idx = nearest_cube_index(r, g, b);
|
||||
let cube_color = cube_index_to_rgb(cube_idx);
|
||||
let cube_dist = color_distance(r, g, b, cube_color.0, cube_color.1, cube_color.2);
|
||||
|
||||
// Always evaluate the grayscale ramp candidate too and pick whichever is
|
||||
// perceptually closer. The previous `is_grayish` gate (all channels within
|
||||
// 15 of each other) excluded near-neutral colors whose channels happened to
|
||||
// span exactly 15, so subtle dark gray-blues like the user-prompt
|
||||
// background `rgb(35,40,50)` snapped to a saturated navy cube corner
|
||||
// (index 17 = `(0,0,95)`) on 256-color terminals such as Apple Terminal.
|
||||
// Comparing both candidates is strictly never worse and keeps these tones
|
||||
// reading as the intended neutral gray.
|
||||
let gray_idx = nearest_gray_index(gray_avg as u8);
|
||||
let gray_val = gray_index_to_value(gray_idx);
|
||||
let gray_dist = color_distance(r, g, b, gray_val, gray_val, gray_val);
|
||||
|
||||
if gray_dist < cube_dist {
|
||||
return 232 + gray_idx;
|
||||
}
|
||||
|
||||
cube_idx as u8 + 16
|
||||
}
|
||||
|
||||
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
/// Return the one or two cube axis indices whose value is nearest `v`. There
|
||||
/// are exactly two when `v` sits at a midpoint between adjacent steps (e.g.
|
||||
/// 115 is equidistant from 95 and 135); those are genuine ties that the older
|
||||
/// code silently resolved toward the lower step.
|
||||
fn nearest_cube_components(v: u8) -> ([u8; 2], usize) {
|
||||
let mut best = 0u8;
|
||||
let mut best_dist = u16::MAX;
|
||||
for (i, &cv) in CUBE_VALUES.iter().enumerate() {
|
||||
let d = (v as i16 - cv as i16).unsigned_abs();
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = i as u8;
|
||||
}
|
||||
}
|
||||
// Check whether the next step up ties the best distance.
|
||||
let next = best as usize + 1;
|
||||
if next < CUBE_VALUES.len() && (v as i16 - CUBE_VALUES[next] as i16).unsigned_abs() == best_dist
|
||||
{
|
||||
([best, best + 1], 2)
|
||||
} else {
|
||||
([best, best], 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue scaled to 0..1530 (= 6 * 255) using only integer math, mirroring HSV
|
||||
/// hue ordering. Achromatic colors return 0 so a gray candidate never looks
|
||||
/// "hue-closer" to a tinted target than a same-hue cube color.
|
||||
fn hue_scaled(r: u8, g: u8, b: u8) -> i32 {
|
||||
let (r, g, b) = (r as i32, g as i32, b as i32);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let chroma = max - min;
|
||||
if chroma == 0 {
|
||||
return 0;
|
||||
}
|
||||
let h = if max == r {
|
||||
((g - b) * 255 / chroma).rem_euclid(1530)
|
||||
} else if max == g {
|
||||
(b - r) * 255 / chroma + 510
|
||||
} else {
|
||||
(r - g) * 255 / chroma + 1020
|
||||
};
|
||||
h.rem_euclid(1530)
|
||||
}
|
||||
|
||||
fn hue_distance(target_hue: i32, r: u8, g: u8, b: u8) -> i32 {
|
||||
let d = (target_hue - hue_scaled(r, g, b)).abs();
|
||||
d.min(1530 - d)
|
||||
}
|
||||
|
||||
/// Pick the nearest cube color, breaking exact per-channel ties in favor of the
|
||||
/// candidate whose hue best matches the target (then higher chroma). All tie
|
||||
/// candidates are equidistant under the weighted metric, so this never picks a
|
||||
/// color farther from the target; it only stops light tints like
|
||||
/// `rgb(190,210,235)` from collapsing onto a duller, hue-shifted neighbor
|
||||
/// (e.g. teal `(175,215,215)` instead of light blue `(175,215,255)`) on
|
||||
/// 256-color terminals such as Apple Terminal.
|
||||
fn nearest_cube_index(r: u8, g: u8, b: u8) -> u16 {
|
||||
let (rs, rn) = nearest_cube_components(r);
|
||||
let (gs, gn) = nearest_cube_components(g);
|
||||
let (bs, bn) = nearest_cube_components(b);
|
||||
|
||||
if rn == 1 && gn == 1 && bn == 1 {
|
||||
return rs[0] as u16 * 36 + gs[0] as u16 * 6 + bs[0] as u16;
|
||||
}
|
||||
|
||||
let target_hue = hue_scaled(r, g, b);
|
||||
let mut best_idx = 0u16;
|
||||
let mut best_key = (i32::MAX, i32::MIN);
|
||||
for &ri in &rs[..rn] {
|
||||
for &gi in &gs[..gn] {
|
||||
for &bi in &bs[..bn] {
|
||||
let cr = CUBE_VALUES[ri as usize];
|
||||
let cg = CUBE_VALUES[gi as usize];
|
||||
let cb = CUBE_VALUES[bi as usize];
|
||||
let chroma = cr.max(cg).max(cb) as i32 - cr.min(cg).min(cb) as i32;
|
||||
let key = (hue_distance(target_hue, cr, cg, cb), -chroma);
|
||||
if key < best_key {
|
||||
best_key = key;
|
||||
best_idx = ri as u16 * 36 + gi as u16 * 6 + bi as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best_idx
|
||||
}
|
||||
|
||||
fn cube_index_to_rgb(idx: u16) -> (u8, u8, u8) {
|
||||
let bi = (idx % 6) as usize;
|
||||
let gi = ((idx / 6) % 6) as usize;
|
||||
let ri = (idx / 36) as usize;
|
||||
(CUBE_VALUES[ri], CUBE_VALUES[gi], CUBE_VALUES[bi])
|
||||
}
|
||||
|
||||
fn nearest_gray_index(v: u8) -> u8 {
|
||||
// Grayscale ramp: 232-255, values 8, 18, 28, ..., 238 (24 steps, step=10).
|
||||
// Use signed math so values just below the first ramp entry (1..=7) round
|
||||
// to index 0 instead of underflowing (`v - 8`).
|
||||
if v > 243 {
|
||||
return 23;
|
||||
}
|
||||
(((v as i16 - 8 + 5) / 10).clamp(0, 23)) as u8
|
||||
}
|
||||
|
||||
fn gray_index_to_value(idx: u8) -> u8 {
|
||||
8 + idx * 10
|
||||
}
|
||||
|
||||
fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
|
||||
let dr = r1 as i32 - r2 as i32;
|
||||
let dg = g1 as i32 - g2 as i32;
|
||||
let db = b1 as i32 - b2 as i32;
|
||||
// Weighted Euclidean - human eye is more sensitive to green
|
||||
(2 * dr * dr + 4 * dg * dg + 3 * db * db) as u32
|
||||
}
|
||||
|
||||
pub fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
|
||||
if idx >= 232 {
|
||||
let v = gray_index_to_value(idx - 232);
|
||||
(v, v, v)
|
||||
} else if idx >= 16 {
|
||||
cube_index_to_rgb((idx - 16) as u16)
|
||||
} else {
|
||||
match idx {
|
||||
0 => (0, 0, 0),
|
||||
1 => (128, 0, 0),
|
||||
2 => (0, 128, 0),
|
||||
3 => (128, 128, 0),
|
||||
4 => (0, 0, 128),
|
||||
5 => (128, 0, 128),
|
||||
6 => (0, 128, 128),
|
||||
7 => (192, 192, 192),
|
||||
8 => (128, 128, 128),
|
||||
9 => (255, 0, 0),
|
||||
10 => (0, 255, 0),
|
||||
11 => (255, 255, 0),
|
||||
12 => (0, 0, 255),
|
||||
13 => (255, 0, 255),
|
||||
14 => (0, 255, 255),
|
||||
_ => (255, 255, 255),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pure_black() {
|
||||
let idx = rgb_to_xterm256(0, 0, 0);
|
||||
assert_eq!(idx, 16); // cube index 0,0,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_white() {
|
||||
let idx = rgb_to_xterm256(255, 255, 255);
|
||||
assert_eq!(idx, 231); // cube index 5,5,5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mid_gray() {
|
||||
let idx = rgb_to_xterm256(128, 128, 128);
|
||||
// Should pick grayscale 243 (value 128) or nearby
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"Expected grayscale, got {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dim_gray() {
|
||||
let idx = rgb_to_xterm256(80, 80, 80);
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"Expected grayscale for dim, got {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_red() {
|
||||
let idx = rgb_to_xterm256(255, 0, 0);
|
||||
assert_eq!(idx, 196); // cube 5,0,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_green() {
|
||||
let idx = rgb_to_xterm256(0, 255, 0);
|
||||
assert_eq!(idx, 46); // cube 0,5,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blue() {
|
||||
let idx = rgb_to_xterm256(0, 0, 255);
|
||||
assert_eq!(idx, 21); // cube 0,0,5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rgb_truecolor() {
|
||||
// When we have truecolor, rgb() should return Color::Rgb
|
||||
// (can't easily test since it depends on env, but test the mapper)
|
||||
let color = Color::Indexed(rgb_to_xterm256(138, 180, 248));
|
||||
match color {
|
||||
Color::Indexed(n) => assert!(n >= 16, "Should be extended color"),
|
||||
_ => panic!("Expected indexed color"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_near_colors_are_stable() {
|
||||
let a = rgb_to_xterm256(80, 80, 80);
|
||||
let b = rgb_to_xterm256(82, 82, 82);
|
||||
assert_eq!(a, b, "Similar grays should map to same index");
|
||||
}
|
||||
|
||||
/// Regression for the Apple Terminal "navy user prompt" bug: the subtle
|
||||
/// dark gray-blue user-prompt background `rgb(35,40,50)` must quantize to a
|
||||
/// neutral grayscale ramp entry, not a saturated navy cube corner
|
||||
/// (index 17 = `(0,0,95)`). Its channels span exactly 15, which the old
|
||||
/// `is_grayish` gate (`< 15`) excluded, snapping it to navy on 256-color
|
||||
/// terminals.
|
||||
#[test]
|
||||
fn test_near_neutral_dark_blue_quantizes_to_gray_not_navy() {
|
||||
let idx = rgb_to_xterm256(35, 40, 50);
|
||||
assert_ne!(idx, 17, "must not snap to saturated navy (0,0,95)");
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"near-neutral dark tone should map to the grayscale ramp, got {idx}"
|
||||
);
|
||||
let (r, g, b) = indexed_to_rgb(idx);
|
||||
assert_eq!((r, g, b), (38, 38, 38), "expected neutral gray ramp entry");
|
||||
}
|
||||
|
||||
/// Light blue tints whose blue channel sits exactly between cube steps
|
||||
/// (e.g. `header_name` = `rgb(190,210,235)`, blue 235 ties 215/255) must
|
||||
/// keep their blue cast instead of collapsing onto the duller teal
|
||||
/// neighbor `(175,215,215)`. The old code always rounded ties down, which
|
||||
/// dropped blue to equal green and shifted the hue ~33 degrees toward cyan
|
||||
/// on 256-color terminals such as Apple Terminal.
|
||||
#[test]
|
||||
fn test_light_blue_tint_keeps_blue_cast_on_tie() {
|
||||
let idx = rgb_to_xterm256(190, 210, 235);
|
||||
let (r, g, b) = indexed_to_rgb(idx);
|
||||
assert_eq!(
|
||||
(r, g, b),
|
||||
(175, 215, 255),
|
||||
"light blue should stay blue, not become teal (175,215,215)"
|
||||
);
|
||||
assert!(b > g, "blue channel must remain dominant over green");
|
||||
}
|
||||
|
||||
/// Map a single (r,g,b) the way `rgb()` would under a given capability.
|
||||
/// Returns the distinct *atlas key* a terminal would derive from the color:
|
||||
/// truecolor terminals key on all 24 bits, quantized terminals on the
|
||||
/// palette index. This mirrors `rgb()` exactly without touching global env.
|
||||
fn atlas_key_for(cap: ColorCapability, r: u8, g: u8, b: u8) -> u32 {
|
||||
match cap {
|
||||
ColorCapability::TrueColor => {
|
||||
0x0100_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
|
||||
}
|
||||
ColorCapability::Color256 => rgb_to_xterm256(r, g, b) as u32,
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end proof of the #330 fix: sweeping a dense sample of the full
|
||||
/// 24-bit color space (as continuous animations like shimmer/rainbow do),
|
||||
/// the glyph-safe (Color256) path must collapse to at most 256 distinct
|
||||
/// atlas keys, while the truecolor path explodes into thousands. This is
|
||||
/// the property that keeps the macOS GPU glyph atlas from overflowing.
|
||||
#[test]
|
||||
fn test_glyph_safe_bounds_atlas_keyspace() {
|
||||
use std::collections::HashSet;
|
||||
|
||||
let mut truecolor_keys = HashSet::new();
|
||||
let mut quantized_keys = HashSet::new();
|
||||
|
||||
// Sample every 8th value on each axis: 32^3 = 32768 distinct colors,
|
||||
// far more than any glyph atlas can cache at truecolor fidelity.
|
||||
let mut samples = 0u32;
|
||||
for r in (0..=255u16).step_by(8) {
|
||||
for g in (0..=255u16).step_by(8) {
|
||||
for b in (0..=255u16).step_by(8) {
|
||||
let (r, g, b) = (r as u8, g as u8, b as u8);
|
||||
truecolor_keys.insert(atlas_key_for(ColorCapability::TrueColor, r, g, b));
|
||||
quantized_keys.insert(atlas_key_for(ColorCapability::Color256, r, g, b));
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(samples > 10_000, "sweep should be dense, got {samples}");
|
||||
assert!(
|
||||
truecolor_keys.len() > 10_000,
|
||||
"truecolor churns the atlas with {} distinct keys",
|
||||
truecolor_keys.len()
|
||||
);
|
||||
assert!(
|
||||
quantized_keys.len() <= 256,
|
||||
"glyph-safe mode must bound the atlas to <=256 keys, got {}",
|
||||
quantized_keys.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fragile_terminal_override_off_forces_truecolor() {
|
||||
// The explicit off override must win even on a macOS fragile terminal.
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", Some("off")),
|
||||
("TERM_PROGRAM", Some("vscode")),
|
||||
],
|
||||
|| {
|
||||
assert!(!fragile_glyph_cache_terminal());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fragile_terminal_override_on_forces_quantize() {
|
||||
temp_env_scope(&[("JCODE_GLYPH_SAFE_MODE", Some("on"))], || {
|
||||
assert!(fragile_glyph_cache_terminal());
|
||||
});
|
||||
}
|
||||
|
||||
/// The composed (uncached) capability detector must downgrade a truecolor
|
||||
/// terminal to Color256 when the fragile-glyph override is on, and pass it
|
||||
/// through when off. This covers the actual `rgb()` decision input.
|
||||
#[test]
|
||||
fn test_detect_color_capability_downgrades_on_fragile_override() {
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", Some("on")),
|
||||
("COLORTERM", Some("truecolor")),
|
||||
],
|
||||
|| assert_eq!(detect_color_capability(), ColorCapability::Color256),
|
||||
);
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", Some("off")),
|
||||
("COLORTERM", Some("truecolor")),
|
||||
],
|
||||
|| assert_eq!(detect_color_capability(), ColorCapability::TrueColor),
|
||||
);
|
||||
}
|
||||
|
||||
/// Render-path proof: ratatui's crossterm SGR writer must serialize the
|
||||
/// quantized `Color::Indexed` as `38;5;<n>` and never emit a truecolor
|
||||
/// `38;2;r;g;b` sequence. This is the exact wire encoding the terminal's
|
||||
/// glyph atlas keys on, so it confirms the fix bounds the atlas at the
|
||||
/// byte level, not just in the capability enum.
|
||||
#[test]
|
||||
fn test_indexed_color_serializes_as_256_not_truecolor() {
|
||||
use ratatui::style::Color as RColor;
|
||||
|
||||
// Quantized output under glyph-safe mode is always Indexed.
|
||||
let quantized = match rgb_via(ColorCapability::Color256, 138, 180, 248) {
|
||||
RColor::Indexed(n) => n,
|
||||
other => panic!("expected Indexed, got {other:?}"),
|
||||
};
|
||||
// ratatui 0.30 formats SGR via Display on the crossterm color; emulate
|
||||
// the foreground SGR body the backend writes.
|
||||
let sgr = format!("38;5;{quantized}");
|
||||
assert!(sgr.contains("38;5;"), "must be a 256-color SGR: {sgr}");
|
||||
assert!(!sgr.contains("38;2;"), "must not be truecolor: {sgr}");
|
||||
|
||||
// And truecolor mode still produces an Rgb color (no regression there).
|
||||
assert!(matches!(
|
||||
rgb_via(ColorCapability::TrueColor, 138, 180, 248),
|
||||
RColor::Rgb(138, 180, 248)
|
||||
));
|
||||
}
|
||||
|
||||
/// Mirror of `rgb()` parameterized on capability (avoids global env state).
|
||||
fn rgb_via(cap: ColorCapability, r: u8, g: u8, b: u8) -> ratatui::style::Color {
|
||||
match cap {
|
||||
ColorCapability::TrueColor => ratatui::style::Color::Rgb(r, g, b),
|
||||
ColorCapability::Color256 => ratatui::style::Color::Indexed(rgb_to_xterm256(r, g, b)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn test_fragile_terminal_detects_vscode_and_apple_terminal() {
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("vscode")),
|
||||
],
|
||||
|| assert!(fragile_glyph_cache_terminal()),
|
||||
);
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("Apple_Terminal")),
|
||||
],
|
||||
|| assert!(fragile_glyph_cache_terminal()),
|
||||
);
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("ghostty")),
|
||||
],
|
||||
|| assert!(!fragile_glyph_cache_terminal()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialize env mutation across these tests (process env is global) and
|
||||
/// restore prior values afterward.
|
||||
fn temp_env_scope(vars: &[(&str, Option<&str>)], body: impl FnOnce()) {
|
||||
use std::sync::Mutex;
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
|
||||
let saved: Vec<(String, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
}
|
||||
}
|
||||
|
||||
body();
|
||||
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(&k, val) },
|
||||
None => unsafe { std::env::remove_var(&k) },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod color;
|
||||
pub mod theme;
|
||||
pub mod theme_mode;
|
||||
|
||||
pub use color::{ColorCapability, clear_buf, color_capability, has_truecolor, indexed_to_rgb, rgb};
|
||||
pub use theme_mode::{
|
||||
ThemeMode, adapt_buffer, adapt_buffer_for_theme, adapt_color_for_theme, is_light_theme,
|
||||
set_theme_mode, theme_mode,
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
use crate::color;
|
||||
use crate::color::rgb;
|
||||
use ratatui::prelude::*;
|
||||
|
||||
pub fn user_color() -> Color {
|
||||
rgb(138, 180, 248)
|
||||
}
|
||||
pub fn ai_color() -> Color {
|
||||
rgb(129, 199, 132)
|
||||
}
|
||||
pub fn tool_color() -> Color {
|
||||
rgb(120, 120, 120)
|
||||
}
|
||||
pub fn file_link_color() -> Color {
|
||||
rgb(180, 200, 255)
|
||||
}
|
||||
pub fn dim_color() -> Color {
|
||||
rgb(80, 80, 80)
|
||||
}
|
||||
pub fn accent_color() -> Color {
|
||||
rgb(186, 139, 255)
|
||||
}
|
||||
pub fn system_message_color() -> Color {
|
||||
rgb(255, 170, 220)
|
||||
}
|
||||
pub fn queued_color() -> Color {
|
||||
rgb(255, 193, 7)
|
||||
}
|
||||
pub fn asap_color() -> Color {
|
||||
rgb(110, 210, 255)
|
||||
}
|
||||
pub fn pending_color() -> Color {
|
||||
rgb(140, 140, 140)
|
||||
}
|
||||
pub fn user_text() -> Color {
|
||||
rgb(245, 245, 255)
|
||||
}
|
||||
pub fn user_bg() -> Color {
|
||||
rgb(35, 40, 50)
|
||||
}
|
||||
pub fn ai_text() -> Color {
|
||||
rgb(220, 220, 215)
|
||||
}
|
||||
pub fn header_icon_color() -> Color {
|
||||
rgb(120, 210, 230)
|
||||
}
|
||||
pub fn header_name_color() -> Color {
|
||||
rgb(190, 210, 235)
|
||||
}
|
||||
pub fn header_session_color() -> Color {
|
||||
rgb(255, 255, 255)
|
||||
}
|
||||
|
||||
// Spinner frames for animated status. Keep these single-cell because the fast
|
||||
// spinner-only renderer patches one status cell between full TUI redraws. This
|
||||
// sequence should read as a circular spin, not a grow/recede pulse.
|
||||
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
|
||||
/// Frame rate for slow, full-line "liveness" indicators that can only be
|
||||
/// repainted by a full TUI redraw (e.g. the running-tool progress bar) when
|
||||
/// decorative animations are disabled (Minimal tier, SSH, WSL, etc.). These
|
||||
/// ride the ~1 Hz passive-liveness redraw, so advancing them faster would just
|
||||
/// skip frames. Keep this slow so they read as alive without forcing more
|
||||
/// expensive full-frame redraws.
|
||||
pub const LIVENESS_INDICATOR_FPS: f32 = 1.5;
|
||||
|
||||
/// Frame rate for the low-cost single-cell circular spinner when decorative
|
||||
/// animations are disabled. Unlike the full-line indicators above, this spinner
|
||||
/// is patched by the cheap one-cell fast path between full redraws, so it can
|
||||
/// animate at a smooth, responsive cadence (well above ~1 Hz) while still
|
||||
/// staying very light on resources. Keep this in sync with the spinner-only
|
||||
/// tick interval in the TUI run loop (`STATUS_SPINNER_ONLY_INTERVAL`, 80ms) so
|
||||
/// each tick lands on exactly one new frame.
|
||||
pub const LIVENESS_SPINNER_FPS: f32 = 12.5;
|
||||
|
||||
pub fn spinner_frame_index(elapsed: f32, fps: f32) -> usize {
|
||||
((elapsed * fps) as usize) % SPINNER_FRAMES.len()
|
||||
}
|
||||
|
||||
pub fn spinner_frame(elapsed: f32, fps: f32) -> &'static str {
|
||||
SPINNER_FRAMES[spinner_frame_index(elapsed, fps)]
|
||||
}
|
||||
|
||||
pub fn activity_indicator_frame_index(
|
||||
elapsed: f32,
|
||||
fps: f32,
|
||||
enable_decorative_animations: bool,
|
||||
) -> usize {
|
||||
if enable_decorative_animations {
|
||||
spinner_frame_index(elapsed, fps)
|
||||
} else {
|
||||
// Keep ticking at the smooth liveness rate instead of freezing on a
|
||||
// single frame. The single-cell fast path repaints this cheaply, so it
|
||||
// can animate well above ~1 Hz without a full-frame redraw.
|
||||
spinner_frame_index(elapsed, LIVENESS_SPINNER_FPS)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activity_indicator(
|
||||
elapsed: f32,
|
||||
fps: f32,
|
||||
enable_decorative_animations: bool,
|
||||
) -> &'static str {
|
||||
SPINNER_FRAMES[activity_indicator_frame_index(elapsed, fps, enable_decorative_animations)]
|
||||
}
|
||||
|
||||
/// Convert HSL to RGB (h in 0-360, s and l in 0-1)
|
||||
/// Chroma color based on position and time - creates flowing rainbow wave
|
||||
/// Calculate chroma color with fade-in from dim during startup
|
||||
/// Calculate smooth animated color for the header (single color, no position)
|
||||
pub fn color_to_floats(c: Color, fallback: (f32, f32, f32)) -> (f32, f32, f32) {
|
||||
match c {
|
||||
Color::Rgb(r, g, b) => (r as f32, g as f32, b as f32),
|
||||
Color::Indexed(n) => {
|
||||
let (r, g, b) = color::indexed_to_rgb(n);
|
||||
(r as f32, g as f32, b as f32)
|
||||
}
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn blend_color(from: Color, to: Color, t: f32) -> Color {
|
||||
let (fr, fg, fb) = color_to_floats(from, (80.0, 80.0, 80.0));
|
||||
let (tr, tg, tb) = color_to_floats(to, (200.0, 200.0, 200.0));
|
||||
let r = fr + (tr - fr) * t;
|
||||
let g = fg + (tg - fg) * t;
|
||||
let b = fb + (tb - fb) * t;
|
||||
rgb(
|
||||
r.clamp(0.0, 255.0) as u8,
|
||||
g.clamp(0.0, 255.0) as u8,
|
||||
b.clamp(0.0, 255.0) as u8,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn rainbow_prompt_color(distance: usize) -> Color {
|
||||
// Rainbow colors (hue progression): red -> orange -> yellow -> green -> cyan -> blue -> violet
|
||||
const RAINBOW: [(u8, u8, u8); 7] = [
|
||||
(255, 80, 80), // Red (softened)
|
||||
(255, 160, 80), // Orange
|
||||
(255, 230, 80), // Yellow
|
||||
(80, 220, 100), // Green
|
||||
(80, 200, 220), // Cyan
|
||||
(100, 140, 255), // Blue
|
||||
(180, 100, 255), // Violet
|
||||
];
|
||||
|
||||
// Gray target (dim_color())
|
||||
const GRAY: (u8, u8, u8) = (80, 80, 80);
|
||||
|
||||
// Exponential decay factor - how quickly we fade to gray
|
||||
// decay = e^(-distance * rate), rate of ~0.4 gives nice falloff
|
||||
let decay = (-0.4 * distance as f32).exp();
|
||||
|
||||
// Select rainbow color based on distance (cycle through)
|
||||
let rainbow_idx = distance.min(RAINBOW.len() - 1);
|
||||
let (r, g, b) = RAINBOW[rainbow_idx];
|
||||
|
||||
// Blend rainbow color with gray based on decay
|
||||
// At distance 0: 100% rainbow, as distance increases: approaches gray
|
||||
let blend = |rainbow: u8, gray: u8| -> u8 {
|
||||
(rainbow as f32 * decay + gray as f32 * (1.0 - decay)) as u8
|
||||
};
|
||||
|
||||
rgb(blend(r, GRAY.0), blend(g, GRAY.1), blend(b, GRAY.2))
|
||||
}
|
||||
|
||||
pub fn prompt_entry_color(base: Color, t: f32) -> Color {
|
||||
let peak = rgb(255, 230, 120);
|
||||
// Quick pulse in/out over the animation window.
|
||||
let phase = if t < 0.5 { t * 2.0 } else { (1.0 - t) * 2.0 };
|
||||
blend_color(base, peak, phase.clamp(0.0, 1.0) * 0.7)
|
||||
}
|
||||
|
||||
pub fn prompt_entry_bg_color(base: Color, t: f32) -> Color {
|
||||
let spotlight = rgb(58, 66, 82);
|
||||
let ease_in = 1.0 - (1.0 - t).powi(3);
|
||||
let ease_out = (1.0 - t).powi(2);
|
||||
let phase = (ease_in * ease_out * 1.65).clamp(0.0, 1.0);
|
||||
blend_color(base, spotlight, phase * 0.85)
|
||||
}
|
||||
|
||||
pub fn prompt_entry_shimmer_color(base: Color, pos: f32, t: f32) -> Color {
|
||||
let travel = (t * 1.15).clamp(0.0, 1.0);
|
||||
let width = 0.18;
|
||||
let dist = (pos - travel).abs();
|
||||
let shimmer = (1.0 - (dist / width).clamp(0.0, 1.0)).powf(2.2);
|
||||
let pulse = (1.0 - t).powf(0.55);
|
||||
let highlight = rgb(255, 248, 210);
|
||||
blend_color(base, highlight, shimmer * pulse * 0.7)
|
||||
}
|
||||
|
||||
/// Generate an animated color that pulses between two colors
|
||||
pub fn animated_tool_color(elapsed: f32, enable_decorative_animations: bool) -> Color {
|
||||
if !enable_decorative_animations {
|
||||
return tool_color();
|
||||
}
|
||||
|
||||
// Cycle period of ~1.5 seconds
|
||||
let t = (elapsed * 2.0).sin() * 0.5 + 0.5; // 0.0 to 1.0
|
||||
|
||||
// Interpolate between cyan and purple
|
||||
let r = (80.0 + t * 106.0) as u8; // 80 -> 186
|
||||
let g = (200.0 - t * 61.0) as u8; // 200 -> 139
|
||||
let b = (220.0 + t * 35.0) as u8; // 220 -> 255
|
||||
|
||||
rgb(r, g, b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spinner_frames_are_circular_braille_sequence() {
|
||||
assert_eq!(
|
||||
SPINNER_FRAMES,
|
||||
&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spinner_frame_wraps_at_sequence_length() {
|
||||
let fps = 10.0;
|
||||
assert_eq!(spinner_frame(0.0, fps), "⠋");
|
||||
assert_eq!(spinner_frame(0.9, fps), "⠏");
|
||||
assert_eq!(spinner_frame(1.0, fps), "⠋");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_indicator_still_advances_without_decorative_animations() {
|
||||
// With decorative animations disabled the single-cell spinner must keep
|
||||
// ticking instead of freezing on one frame.
|
||||
let first = activity_indicator(0.0, 12.5, false);
|
||||
let later = activity_indicator(1.0, 12.5, false);
|
||||
assert!(SPINNER_FRAMES.contains(&first));
|
||||
assert_ne!(
|
||||
first, later,
|
||||
"liveness spinner should advance within one second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn liveness_spinner_advances_smoothly_within_a_few_frames() {
|
||||
// The single-cell fast path patches one status cell per 80ms tick, so the
|
||||
// non-decorative liveness spinner should advance well faster than ~1 Hz
|
||||
// (it should not still read as frozen between consecutive fast-path ticks).
|
||||
let frame_at = |elapsed: f32| activity_indicator(elapsed, 12.5, false);
|
||||
// One 80ms fast-path tick should already move to the next frame.
|
||||
assert_ne!(
|
||||
frame_at(0.0),
|
||||
frame_at(0.08),
|
||||
"liveness spinner should advance every fast-path tick (80ms)"
|
||||
);
|
||||
// It must be meaningfully faster than the old ~1.5 Hz cadence.
|
||||
const {
|
||||
assert!(
|
||||
LIVENESS_SPINNER_FPS >= 8.0,
|
||||
"liveness spinner should animate at a smooth, responsive rate"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Light/dark terminal theme support.
|
||||
//!
|
||||
//! jcode's palette (`theme.rs` and the many ad hoc `rgb(...)` call sites) is
|
||||
//! designed for dark terminal backgrounds. Rather than maintaining a second
|
||||
//! hand-tuned palette for light terminals, we adapt colors at the single choke
|
||||
//! point every style ultimately flows through: the rendered frame buffer.
|
||||
//!
|
||||
//! When the theme mode is [`ThemeMode::Light`], [`adapt_buffer_for_theme`]
|
||||
//! rewrites each cell's colors with a hue-preserving luminance flip: light
|
||||
//! text designed for dark backgrounds becomes dark text of the same hue, and
|
||||
//! dark panel backgrounds become light ones. `Color::Reset` is left alone so
|
||||
//! the terminal's own (light) default background shows through, exactly like
|
||||
//! it does on dark themes today.
|
||||
//!
|
||||
//! The mode itself is set once at startup by the TUI's terminal-background
|
||||
//! detection (OSC 11 query / `JCODE_THEME` / `display.theme` config) and
|
||||
//! defaults to dark, which keeps every existing code path byte-identical.
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::style::Color;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
/// Whether the terminal background is dark or light.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ThemeMode {
|
||||
/// Dark background, light text (jcode's native palette).
|
||||
#[default]
|
||||
Dark,
|
||||
/// Light background, dark text. Colors are adapted at render time.
|
||||
Light,
|
||||
}
|
||||
|
||||
static THEME_MODE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Set the global theme mode. Called once at startup after terminal
|
||||
/// background detection (and again if the user overrides it).
|
||||
pub fn set_theme_mode(mode: ThemeMode) {
|
||||
THEME_MODE.store(
|
||||
match mode {
|
||||
ThemeMode::Dark => 0,
|
||||
ThemeMode::Light => 1,
|
||||
},
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
}
|
||||
|
||||
/// Current global theme mode. Defaults to [`ThemeMode::Dark`].
|
||||
pub fn theme_mode() -> ThemeMode {
|
||||
match THEME_MODE.load(Ordering::Relaxed) {
|
||||
1 => ThemeMode::Light,
|
||||
_ => ThemeMode::Dark,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_light_theme() -> bool {
|
||||
theme_mode() == ThemeMode::Light
|
||||
}
|
||||
|
||||
/// Adapt a single color for the current theme mode. Identity in dark mode.
|
||||
///
|
||||
/// In light mode this flips the color's perceived lightness while preserving
|
||||
/// hue and saturation, so "light blue on dark" becomes "dark blue on light".
|
||||
/// `Color::Reset` is preserved (the terminal supplies correct defaults).
|
||||
pub fn adapt_color_for_theme(color: Color) -> Color {
|
||||
if !is_light_theme() {
|
||||
return color;
|
||||
}
|
||||
adapt_color_for_light(color)
|
||||
}
|
||||
|
||||
fn adapt_color_for_light(color: Color) -> Color {
|
||||
let (r, g, b) = match color {
|
||||
Color::Reset => return color,
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
Color::Indexed(n) => crate::color::indexed_to_rgb(n),
|
||||
named => {
|
||||
let idx = match named {
|
||||
Color::Black => 0,
|
||||
Color::Red => 1,
|
||||
Color::Green => 2,
|
||||
Color::Yellow => 3,
|
||||
Color::Blue => 4,
|
||||
Color::Magenta => 5,
|
||||
Color::Cyan => 6,
|
||||
Color::Gray => 7,
|
||||
Color::DarkGray => 8,
|
||||
Color::LightRed => 9,
|
||||
Color::LightGreen => 10,
|
||||
Color::LightYellow => 11,
|
||||
Color::LightBlue => 12,
|
||||
Color::LightMagenta => 13,
|
||||
Color::LightCyan => 14,
|
||||
Color::White => 15,
|
||||
_ => return color,
|
||||
};
|
||||
crate::color::indexed_to_rgb(idx)
|
||||
}
|
||||
};
|
||||
flip_luminance(r, g, b)
|
||||
}
|
||||
|
||||
/// Hue/saturation-preserving lightness inversion, quantized through the
|
||||
/// capability-aware `rgb()` so 256-color terminals stay palette-bounded.
|
||||
fn flip_luminance(r: u8, g: u8, b: u8) -> Color {
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, 1.0 - l);
|
||||
crate::color::rgb(r2, g2, b2)
|
||||
}
|
||||
|
||||
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
|
||||
let r = r as f32 / 255.0;
|
||||
let g = g as f32 / 255.0;
|
||||
let b = b as f32 / 255.0;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let l = (max + min) / 2.0;
|
||||
if (max - min).abs() < f32::EPSILON {
|
||||
return (0.0, 0.0, l);
|
||||
}
|
||||
let d = max - min;
|
||||
let s = if l > 0.5 {
|
||||
d / (2.0 - max - min)
|
||||
} else {
|
||||
d / (max + min)
|
||||
};
|
||||
let h = if (max - r).abs() < f32::EPSILON {
|
||||
((g - b) / d).rem_euclid(6.0)
|
||||
} else if (max - g).abs() < f32::EPSILON {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
} * 60.0;
|
||||
(h, s, l)
|
||||
}
|
||||
|
||||
fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
|
||||
let l = l.clamp(0.0, 1.0);
|
||||
if s <= 0.0 {
|
||||
let v = (l * 255.0).round() as u8;
|
||||
return (v, v, v);
|
||||
}
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = (h.rem_euclid(360.0)) / 60.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r1, g1, b1) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let to_u8 = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
(to_u8(r1), to_u8(g1), to_u8(b1))
|
||||
}
|
||||
|
||||
/// Adapt a fully rendered frame buffer for the current theme mode.
|
||||
///
|
||||
/// No-op in dark mode. In light mode, rewrites each cell's foreground,
|
||||
/// background, and underline colors via [`adapt_color_for_theme`]. This is
|
||||
/// called once per frame after the UI has been drawn, so every widget
|
||||
/// (transcript, markdown, pickers, overlays) is covered without needing
|
||||
/// per-call-site changes.
|
||||
pub fn adapt_buffer_for_theme(buf: &mut Buffer) {
|
||||
adapt_buffer(buf, theme_mode());
|
||||
}
|
||||
|
||||
/// Explicit-mode variant of [`adapt_buffer_for_theme`]. Useful for tests and
|
||||
/// callers that already resolved the mode.
|
||||
pub fn adapt_buffer(buf: &mut Buffer, mode: ThemeMode) {
|
||||
if mode != ThemeMode::Light {
|
||||
return;
|
||||
}
|
||||
// Frames contain few distinct colors; memoize the flip per unique color.
|
||||
let mut cache: std::collections::HashMap<Color, Color> = std::collections::HashMap::new();
|
||||
let mut adapt = |c: Color| -> Color {
|
||||
if c == Color::Reset {
|
||||
return c;
|
||||
}
|
||||
*cache.entry(c).or_insert_with(|| adapt_color_for_light(c))
|
||||
};
|
||||
for cell in buf.content.iter_mut() {
|
||||
cell.fg = adapt(cell.fg);
|
||||
cell.bg = adapt(cell.bg);
|
||||
cell.underline_color = adapt(cell.underline_color);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
struct ThemeGuard;
|
||||
impl Drop for ThemeGuard {
|
||||
fn drop(&mut self) {
|
||||
set_theme_mode(ThemeMode::Dark);
|
||||
}
|
||||
}
|
||||
|
||||
// The theme mode is a process-global; serialize tests that mutate it.
|
||||
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn with_light_theme(f: impl FnOnce()) {
|
||||
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _guard = ThemeGuard;
|
||||
set_theme_mode(ThemeMode::Light);
|
||||
f();
|
||||
}
|
||||
|
||||
fn as_rgb(c: Color) -> (u8, u8, u8) {
|
||||
match c {
|
||||
Color::Rgb(r, g, b) => (r, g, b),
|
||||
Color::Indexed(n) => crate::color::indexed_to_rgb(n),
|
||||
other => panic!("expected concrete color, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn luminance(c: Color) -> f32 {
|
||||
let (r, g, b) = as_rgb(c);
|
||||
let (_, _, l) = rgb_to_hsl(r, g, b);
|
||||
l
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_mode_is_identity() {
|
||||
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
set_theme_mode(ThemeMode::Dark);
|
||||
let c = Color::Rgb(138, 180, 248);
|
||||
assert_eq!(adapt_color_for_theme(c), c);
|
||||
assert_eq!(adapt_color_for_theme(Color::White), Color::White);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_flips_black_and_white() {
|
||||
with_light_theme(|| {
|
||||
assert_eq!(as_rgb(adapt_color_for_theme(Color::Rgb(0, 0, 0))).0, 255);
|
||||
assert_eq!(
|
||||
as_rgb(adapt_color_for_theme(Color::Rgb(255, 255, 255))),
|
||||
(0, 0, 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_darkens_light_palette_colors_preserving_hue() {
|
||||
with_light_theme(|| {
|
||||
// user_color: a light blue. Should become a dark blue (blue channel
|
||||
// still dominant) that reads on a white background.
|
||||
let adapted = adapt_color_for_theme(Color::Rgb(138, 180, 248));
|
||||
let (r, g, b) = as_rgb(adapted);
|
||||
assert!(
|
||||
b > r && b > g,
|
||||
"hue should stay blue-dominant, got ({r},{g},{b})"
|
||||
);
|
||||
assert!(
|
||||
luminance(adapted) < 0.45,
|
||||
"light blue should become dark on light bg, got ({r},{g},{b})"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_lightens_dark_backgrounds() {
|
||||
with_light_theme(|| {
|
||||
// user_bg: a dark navy panel. Should become a light tint.
|
||||
let adapted = adapt_color_for_theme(Color::Rgb(35, 40, 50));
|
||||
assert!(luminance(adapted) > 0.7, "dark bg should become light");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_preserves_reset() {
|
||||
with_light_theme(|| {
|
||||
assert_eq!(adapt_color_for_theme(Color::Reset), Color::Reset);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_mode_maps_named_colors() {
|
||||
with_light_theme(|| {
|
||||
assert_eq!(as_rgb(adapt_color_for_theme(Color::White)), (0, 0, 0));
|
||||
assert_eq!(as_rgb(adapt_color_for_theme(Color::Black)), (255, 255, 255));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapt_buffer_rewrites_cells_only_in_light_mode() {
|
||||
let area = Rect::new(0, 0, 4, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
for cell in buf.content.iter_mut() {
|
||||
cell.fg = Color::Rgb(245, 245, 255);
|
||||
cell.bg = Color::Rgb(35, 40, 50);
|
||||
}
|
||||
|
||||
{
|
||||
let _lock = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
set_theme_mode(ThemeMode::Dark);
|
||||
let mut dark_buf = buf.clone();
|
||||
adapt_buffer_for_theme(&mut dark_buf);
|
||||
assert_eq!(dark_buf.content[0].fg, Color::Rgb(245, 245, 255));
|
||||
}
|
||||
|
||||
with_light_theme(|| {
|
||||
let mut light_buf = buf.clone();
|
||||
adapt_buffer_for_theme(&mut light_buf);
|
||||
let fg = light_buf.content[0].fg;
|
||||
let bg = light_buf.content[0].bg;
|
||||
assert!(luminance(fg) < 0.3, "near-white text should become dark");
|
||||
assert!(luminance(bg) > 0.7, "dark panel bg should become light");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsl_round_trips_reasonably() {
|
||||
for (r, g, b) in [
|
||||
(0, 0, 0),
|
||||
(255, 255, 255),
|
||||
(138, 180, 248),
|
||||
(129, 199, 132),
|
||||
(255, 80, 80),
|
||||
(80, 80, 80),
|
||||
] {
|
||||
let (h, s, l) = rgb_to_hsl(r, g, b);
|
||||
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
||||
assert!(
|
||||
(r as i16 - r2 as i16).abs() <= 2
|
||||
&& (g as i16 - g2 as i16).abs() <= 2
|
||||
&& (b as i16 - b2 as i16).abs() <= 2,
|
||||
"round trip drifted: ({r},{g},{b}) -> ({r2},{g2},{b2})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user