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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+599
View File
@@ -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) },
}
}
}
}
+9
View File
@@ -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,
};
+262
View File
@@ -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"
);
}
}
}
+335
View File
@@ -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})"
);
}
}
}