| 1 | //! DeepSeek color palette and semantic roles. |
| 2 | |
| 3 | use ratatui::style::Color; |
| 4 | |
| 5 | pub const DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229); // #3578E5 |
| 6 | pub const DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242); |
| 7 | #[allow(dead_code)] |
| 8 | pub const DEEPSEEK_AQUA_RGB: (u8, u8, u8) = (54, 187, 212); |
| 9 | #[allow(dead_code)] |
| 10 | pub const DEEPSEEK_NAVY_RGB: (u8, u8, u8) = (24, 63, 138); |
| 11 | pub const DEEPSEEK_INK_RGB: (u8, u8, u8) = (11, 21, 38); |
| 12 | pub const DEEPSEEK_SLATE_RGB: (u8, u8, u8) = (18, 28, 46); |
| 13 | pub const DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96); |
| 14 | |
| 15 | // New semantic colors |
| 16 | pub const BORDER_COLOR_RGB: (u8, u8, u8) = (42, 74, 127); // #2A4A7F |
| 17 | |
| 18 | pub const DEEPSEEK_BLUE: Color = Color::Rgb( |
| 19 | DEEPSEEK_BLUE_RGB.0, |
| 20 | DEEPSEEK_BLUE_RGB.1, |
| 21 | DEEPSEEK_BLUE_RGB.2, |
| 22 | ); |
| 23 | pub const DEEPSEEK_SKY: Color = |
| 24 | Color::Rgb(DEEPSEEK_SKY_RGB.0, DEEPSEEK_SKY_RGB.1, DEEPSEEK_SKY_RGB.2); |
| 25 | #[allow(dead_code)] |
| 26 | pub const DEEPSEEK_AQUA: Color = Color::Rgb( |
| 27 | DEEPSEEK_AQUA_RGB.0, |
| 28 | DEEPSEEK_AQUA_RGB.1, |
| 29 | DEEPSEEK_AQUA_RGB.2, |
| 30 | ); |
| 31 | #[allow(dead_code)] |
| 32 | pub const DEEPSEEK_NAVY: Color = Color::Rgb( |
| 33 | DEEPSEEK_NAVY_RGB.0, |
| 34 | DEEPSEEK_NAVY_RGB.1, |
| 35 | DEEPSEEK_NAVY_RGB.2, |
| 36 | ); |
| 37 | pub const DEEPSEEK_INK: Color = |
| 38 | Color::Rgb(DEEPSEEK_INK_RGB.0, DEEPSEEK_INK_RGB.1, DEEPSEEK_INK_RGB.2); |
| 39 | pub const DEEPSEEK_SLATE: Color = Color::Rgb( |
| 40 | DEEPSEEK_SLATE_RGB.0, |
| 41 | DEEPSEEK_SLATE_RGB.1, |
| 42 | DEEPSEEK_SLATE_RGB.2, |
| 43 | ); |
| 44 | pub const DEEPSEEK_RED: Color = |
| 45 | Color::Rgb(DEEPSEEK_RED_RGB.0, DEEPSEEK_RED_RGB.1, DEEPSEEK_RED_RGB.2); |
| 46 | |
| 47 | pub const TEXT_BODY: Color = Color::White; |
| 48 | pub const TEXT_SECONDARY: Color = Color::Rgb(192, 192, 192); // #C0C0C0 |
| 49 | pub const TEXT_HINT: Color = Color::Rgb(160, 160, 160); // #A0A0A0 |
| 50 | pub const TEXT_ACCENT: Color = DEEPSEEK_SKY; |
| 51 | pub const SELECTION_TEXT: Color = Color::White; |
| 52 | pub const TEXT_SOFT: Color = Color::Rgb(214, 223, 235); // #D6DFEB |
| 53 | |
| 54 | // Compatibility aliases for existing call sites. |
| 55 | pub const TEXT_PRIMARY: Color = TEXT_BODY; |
| 56 | pub const TEXT_MUTED: Color = TEXT_SECONDARY; |
| 57 | pub const TEXT_DIM: Color = TEXT_HINT; |
| 58 | |
| 59 | // New semantic colors for UI theming |
| 60 | pub const BORDER_COLOR: Color = |
| 61 | Color::Rgb(BORDER_COLOR_RGB.0, BORDER_COLOR_RGB.1, BORDER_COLOR_RGB.2); |
| 62 | #[allow(dead_code)] |
| 63 | pub const ACCENT_PRIMARY: Color = DEEPSEEK_BLUE; // #3578E5 |
| 64 | #[allow(dead_code)] |
| 65 | pub const ACCENT_SECONDARY: Color = TEXT_ACCENT; // #6AAEF2 |
| 66 | #[allow(dead_code)] |
| 67 | pub const BACKGROUND_DARK: Color = Color::Rgb(13, 26, 48); // #0D1A30 |
| 68 | #[allow(dead_code)] |
| 69 | pub const STATUS_NEUTRAL: Color = Color::Rgb(160, 160, 160); // #A0A0A0 |
| 70 | #[allow(dead_code)] |
| 71 | pub const SURFACE_PANEL: Color = Color::Rgb(21, 33, 52); // #152134 |
| 72 | #[allow(dead_code)] |
| 73 | pub const SURFACE_ELEVATED: Color = Color::Rgb(28, 42, 64); // #1C2A40 |
| 74 | pub const SURFACE_REASONING: Color = Color::Rgb(54, 44, 26); // #362C1A |
| 75 | #[allow(dead_code)] |
| 76 | pub const SURFACE_REASONING_ACTIVE: Color = Color::Rgb(68, 53, 28); // #44351C |
| 77 | #[allow(dead_code)] |
| 78 | pub const SURFACE_TOOL: Color = Color::Rgb(24, 39, 60); // #18273C |
| 79 | #[allow(dead_code)] |
| 80 | pub const SURFACE_TOOL_ACTIVE: Color = Color::Rgb(29, 48, 73); // #1D3049 |
| 81 | #[allow(dead_code)] |
| 82 | pub const SURFACE_SUCCESS: Color = Color::Rgb(22, 56, 63); // #16383F |
| 83 | #[allow(dead_code)] |
| 84 | pub const SURFACE_ERROR: Color = Color::Rgb(63, 27, 36); // #3F1B24 |
| 85 | pub const DIFF_ADDED_BG: Color = Color::Rgb(18, 52, 38); // #123426 dark green tint |
| 86 | pub const DIFF_DELETED_BG: Color = Color::Rgb(52, 22, 28); // #34161C dark red tint |
| 87 | pub const DIFF_ADDED: Color = Color::Rgb(87, 199, 133); // #57C785 |
| 88 | pub const ACCENT_REASONING_LIVE: Color = Color::Rgb(146, 198, 248); // #92C6F8 |
| 89 | pub const ACCENT_TOOL_LIVE: Color = Color::Rgb(133, 184, 234); // #85B8EA |
| 90 | pub const ACCENT_TOOL_ISSUE: Color = Color::Rgb(192, 143, 153); // #C08F99 |
| 91 | pub const TEXT_TOOL_OUTPUT: Color = Color::Rgb(205, 216, 228); // #CDD8E4 |
| 92 | |
| 93 | // Legacy status colors - keep for backward compatibility |
| 94 | pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY; |
| 95 | pub const STATUS_WARNING: Color = Color::Rgb(255, 170, 60); // Amber |
| 96 | pub const STATUS_ERROR: Color = DEEPSEEK_RED; |
| 97 | #[allow(dead_code)] |
| 98 | pub const STATUS_INFO: Color = DEEPSEEK_BLUE; |
| 99 | |
| 100 | // Mode-specific accent colors for mode badges |
| 101 | pub const MODE_AGENT: Color = Color::Rgb(80, 150, 255); // Bright blue |
| 102 | pub const MODE_YOLO: Color = Color::Rgb(255, 100, 100); // Warning red |
| 103 | pub const MODE_PLAN: Color = Color::Rgb(255, 170, 60); // Orange |
| 104 | |
| 105 | pub const SELECTION_BG: Color = Color::Rgb(26, 44, 74); |
| 106 | #[allow(dead_code)] |
| 107 | pub const COMPOSER_BG: Color = DEEPSEEK_SLATE; |
| 108 | |
| 109 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 110 | pub struct UiTheme { |
| 111 | pub name: &'static str, |
| 112 | pub composer_bg: Color, |
| 113 | pub selection_bg: Color, |
| 114 | pub header_bg: Color, |
| 115 | pub footer_bg: Color, |
| 116 | /// Statusline mode colors (agent/yolo/plan) |
| 117 | pub mode_agent: Color, |
| 118 | pub mode_yolo: Color, |
| 119 | pub mode_plan: Color, |
| 120 | /// Statusline status colors |
| 121 | pub status_ready: Color, |
| 122 | pub status_working: Color, |
| 123 | pub status_warning: Color, |
| 124 | /// Statusline text colors |
| 125 | pub text_dim: Color, |
| 126 | pub text_hint: Color, |
| 127 | pub text_muted: Color, |
| 128 | } |
| 129 | |
| 130 | pub const UI_THEME: UiTheme = UiTheme { |
| 131 | name: "whale", |
| 132 | composer_bg: DEEPSEEK_SLATE, |
| 133 | selection_bg: SELECTION_BG, |
| 134 | header_bg: DEEPSEEK_INK, |
| 135 | footer_bg: DEEPSEEK_INK, |
| 136 | mode_agent: MODE_AGENT, |
| 137 | mode_yolo: MODE_YOLO, |
| 138 | mode_plan: MODE_PLAN, |
| 139 | status_ready: TEXT_MUTED, |
| 140 | status_working: DEEPSEEK_SKY, |
| 141 | status_warning: STATUS_WARNING, |
| 142 | text_dim: TEXT_DIM, |
| 143 | text_hint: TEXT_HINT, |
| 144 | text_muted: TEXT_MUTED, |
| 145 | }; |
| 146 | |
| 147 | // === Color depth + brightness helpers (v0.6.6 UI redesign) === |
| 148 | |
| 149 | /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg |
| 150 | /// tints) on terminals that can't render them faithfully. |
| 151 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 152 | pub enum ColorDepth { |
| 153 | /// 16-color terminals (macOS Terminal.app default, dumb tmux setups). |
| 154 | /// Background tints distort the named-palette mapping, so we drop them. |
| 155 | Ansi16, |
| 156 | /// 256-color terminals — RGB→256 fallback is faithful enough. |
| 157 | Ansi256, |
| 158 | /// True-color (24-bit) — render the palette verbatim. |
| 159 | TrueColor, |
| 160 | } |
| 161 | |
| 162 | impl ColorDepth { |
| 163 | /// Detect the active terminal's color depth. Honors `COLORTERM` |
| 164 | /// (truecolor / 24bit) first, then falls back to `TERM`. Defaults to |
| 165 | /// `TrueColor` because most modern terminals support it; the conservative |
| 166 | /// fallback is `Ansi16` so background tints disappear safely. |
| 167 | #[must_use] |
| 168 | pub fn detect() -> Self { |
| 169 | if let Ok(ct) = std::env::var("COLORTERM") { |
| 170 | let ct = ct.to_ascii_lowercase(); |
| 171 | if ct.contains("truecolor") || ct.contains("24bit") { |
| 172 | return Self::TrueColor; |
| 173 | } |
| 174 | } |
| 175 | if std::env::var_os("WT_SESSION").is_some() { |
| 176 | return Self::TrueColor; |
| 177 | } |
| 178 | if let Ok(term_program) = std::env::var("TERM_PROGRAM") { |
| 179 | let term_program = term_program.to_ascii_lowercase(); |
| 180 | if term_program.contains("iterm") |
| 181 | || term_program.contains("wezterm") |
| 182 | || term_program.contains("vscode") |
| 183 | || term_program.contains("warp") |
| 184 | { |
| 185 | return Self::TrueColor; |
| 186 | } |
| 187 | } |
| 188 | let term = std::env::var("TERM").unwrap_or_default(); |
| 189 | let term = term.to_ascii_lowercase(); |
| 190 | if term.contains("truecolor") || term.contains("24bit") { |
| 191 | Self::TrueColor |
| 192 | } else if term.contains("256") { |
| 193 | Self::Ansi256 |
| 194 | } else if term.is_empty() || term == "dumb" { |
| 195 | Self::Ansi16 |
| 196 | } else { |
| 197 | // Unknown TERM strings should not receive 24-bit SGR by default. |
| 198 | // Older macOS/remote terminals can render truecolor backgrounds as |
| 199 | // bright cyan blocks; 256-color output is the safer compromise. |
| 200 | Self::Ansi256 |
| 201 | } |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /// Adapt a foreground color to the terminal's color depth. |
| 206 | /// |
| 207 | /// On TrueColor, `color` passes through. On Ansi256 we let ratatui's renderer |
| 208 | /// down-convert (it does this already). On Ansi16 we strip RGB to a near |
| 209 | /// named color so semantic intent survives even on legacy terminals. |
| 210 | #[allow(dead_code)] |
| 211 | #[must_use] |
| 212 | pub fn adapt_color(color: Color, depth: ColorDepth) -> Color { |
| 213 | match (color, depth) { |
| 214 | (_, ColorDepth::TrueColor) => color, |
| 215 | (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)), |
| 216 | (Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b), |
| 217 | _ => color, |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /// Adapt a background color. On Ansi16 terminals background tints are noisy, |
| 222 | /// so we drop them to `Color::Reset` rather than attempt a coarse named-color |
| 223 | /// match — a quiet background reads cleaner than a wrong one. |
| 224 | #[allow(dead_code)] |
| 225 | #[must_use] |
| 226 | pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color { |
| 227 | match (color, depth) { |
| 228 | (_, ColorDepth::TrueColor) => color, |
| 229 | (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)), |
| 230 | (_, ColorDepth::Ansi256) => color, |
| 231 | (_, ColorDepth::Ansi16) => Color::Reset, |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not |
| 236 | /// RGB falls back to `fg` — there's no meaningful alpha blend on a named |
| 237 | /// palette entry. |
| 238 | #[must_use] |
| 239 | pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color { |
| 240 | let alpha = alpha.clamp(0.0, 1.0); |
| 241 | match (fg, bg) { |
| 242 | (Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => { |
| 243 | let mix = |a: u8, b: u8| -> u8 { |
| 244 | let a = f32::from(a); |
| 245 | let b = f32::from(b); |
| 246 | (b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8 |
| 247 | }; |
| 248 | Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb)) |
| 249 | } |
| 250 | _ => fg, |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /// Return the reasoning surface color tinted at 12% over the app background. |
| 255 | /// This is the headline reasoning treatment in v0.6.6; a 12% blend keeps the |
| 256 | /// warm bias subtle without competing with body text. Returns `None` when the |
| 257 | /// terminal can't render the bg faithfully. |
| 258 | #[must_use] |
| 259 | pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> { |
| 260 | match depth { |
| 261 | ColorDepth::Ansi16 => None, |
| 262 | _ => Some(adapt_bg( |
| 263 | blend(SURFACE_REASONING, DEEPSEEK_INK, 0.12), |
| 264 | depth, |
| 265 | )), |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | /// Pulse `color` between 30% and 100% brightness on a 2s cycle keyed off |
| 270 | /// `now_ms` (epoch ms). The minimum keeps the glyph readable at trough; the |
| 271 | /// maximum is the source color verbatim. Linear interpolation between them |
| 272 | /// reads as a slow heartbeat. |
| 273 | #[must_use] |
| 274 | pub fn pulse_brightness(color: Color, now_ms: u64) -> Color { |
| 275 | // 2 s = 2000 ms full cycle; sin gives a smooth 0..1..0 swing. |
| 276 | let phase = (now_ms % 2000) as f32 / 2000.0; |
| 277 | let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; // 0..1 |
| 278 | let alpha = 0.30 + t * 0.70; // 30%..100% |
| 279 | match color { |
| 280 | Color::Rgb(r, g, b) => { |
| 281 | let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 }; |
| 282 | Color::Rgb(s(r), s(g), s(b)) |
| 283 | } |
| 284 | other => other, |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | /// Map an RGB triple to its closest ANSI-16 named color. Only used by |
| 289 | /// `adapt_color` on Ansi16 terminals; we lean on hue dominance + lightness so |
| 290 | /// brand colors land on the obviously-related named entry (sky → cyan, blue → |
| 291 | /// blue, red → red, etc.) rather than dithering around grey. |
| 292 | #[allow(dead_code)] |
| 293 | fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color { |
| 294 | let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3; |
| 295 | if lum < 24 { |
| 296 | return Color::Black; |
| 297 | } |
| 298 | if r > 220 && g > 220 && b > 220 { |
| 299 | return Color::White; |
| 300 | } |
| 301 | let bright = lum > 144; |
| 302 | let max = r.max(g).max(b); |
| 303 | let min = r.min(g).min(b); |
| 304 | if max.saturating_sub(min) < 16 { |
| 305 | return if bright { Color::Gray } else { Color::DarkGray }; |
| 306 | } |
| 307 | if r >= g && r >= b { |
| 308 | if g > b + 24 { |
| 309 | if bright { |
| 310 | Color::LightYellow |
| 311 | } else { |
| 312 | Color::Yellow |
| 313 | } |
| 314 | } else if b > r.saturating_sub(24) { |
| 315 | if bright { |
| 316 | Color::LightMagenta |
| 317 | } else { |
| 318 | Color::Magenta |
| 319 | } |
| 320 | } else if bright { |
| 321 | Color::LightRed |
| 322 | } else { |
| 323 | Color::Red |
| 324 | } |
| 325 | } else if g >= r && g >= b { |
| 326 | if b > r + 24 { |
| 327 | if bright { |
| 328 | Color::LightCyan |
| 329 | } else { |
| 330 | Color::Cyan |
| 331 | } |
| 332 | } else if bright { |
| 333 | Color::LightGreen |
| 334 | } else { |
| 335 | Color::Green |
| 336 | } |
| 337 | } else if r.saturating_add(48) >= b && r > g + 24 { |
| 338 | if bright { |
| 339 | Color::LightMagenta |
| 340 | } else { |
| 341 | Color::Magenta |
| 342 | } |
| 343 | } else if g.saturating_add(48) >= b && g > r + 24 { |
| 344 | if bright { |
| 345 | Color::LightCyan |
| 346 | } else { |
| 347 | Color::Cyan |
| 348 | } |
| 349 | } else if bright { |
| 350 | Color::LightBlue |
| 351 | } else { |
| 352 | Color::Blue |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | /// Map an RGB color to the nearest xterm 256-color palette index. We use only |
| 357 | /// the stable 6x6x6 cube and grayscale ramp (16..255), not the terminal's |
| 358 | /// user-configurable 0..15 colors. |
| 359 | #[allow(dead_code)] |
| 360 | fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { |
| 361 | const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; |
| 362 | |
| 363 | fn nearest_cube_level(channel: u8) -> usize { |
| 364 | CUBE_LEVELS |
| 365 | .iter() |
| 366 | .enumerate() |
| 367 | .min_by_key(|(_, level)| channel.abs_diff(**level)) |
| 368 | .map(|(idx, _)| idx) |
| 369 | .unwrap_or(0) |
| 370 | } |
| 371 | |
| 372 | fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 { |
| 373 | let dr = i32::from(a.0) - i32::from(b.0); |
| 374 | let dg = i32::from(a.1) - i32::from(b.1); |
| 375 | let db = i32::from(a.2) - i32::from(b.2); |
| 376 | (dr * dr + dg * dg + db * db) as u32 |
| 377 | } |
| 378 | |
| 379 | let ri = nearest_cube_level(r); |
| 380 | let gi = nearest_cube_level(g); |
| 381 | let bi = nearest_cube_level(b); |
| 382 | let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]); |
| 383 | let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8; |
| 384 | |
| 385 | let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8; |
| 386 | let gray_i = if avg <= 8 { |
| 387 | 0 |
| 388 | } else if avg >= 238 { |
| 389 | 23 |
| 390 | } else { |
| 391 | ((u16::from(avg) - 8 + 5) / 10).min(23) as u8 |
| 392 | }; |
| 393 | let gray = 8 + 10 * gray_i; |
| 394 | let gray_index = 232 + gray_i; |
| 395 | |
| 396 | if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) { |
| 397 | gray_index |
| 398 | } else { |
| 399 | cube_index |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | #[cfg(test)] |
| 404 | mod tests { |
| 405 | use super::{ |
| 406 | ACCENT_REASONING_LIVE, ColorDepth, DEEPSEEK_INK, DEEPSEEK_RED, DEEPSEEK_SKY, |
| 407 | SURFACE_REASONING, adapt_bg, adapt_color, blend, nearest_ansi16, pulse_brightness, |
| 408 | reasoning_surface_tint, rgb_to_ansi256, |
| 409 | }; |
| 410 | use ratatui::style::Color; |
| 411 | |
| 412 | #[test] |
| 413 | fn adapt_color_passes_through_truecolor() { |
| 414 | let c = Color::Rgb(53, 120, 229); |
| 415 | assert_eq!(adapt_color(c, ColorDepth::TrueColor), c); |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn adapt_color_maps_rgb_to_indexed_on_ansi256() { |
| 420 | let c = Color::Rgb(53, 120, 229); |
| 421 | assert!(matches!( |
| 422 | adapt_color(c, ColorDepth::Ansi256), |
| 423 | Color::Indexed(_) |
| 424 | )); |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn adapt_bg_maps_rgb_to_indexed_on_ansi256() { |
| 429 | assert!(matches!( |
| 430 | adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256), |
| 431 | Color::Indexed(_) |
| 432 | )); |
| 433 | } |
| 434 | |
| 435 | #[test] |
| 436 | fn adapt_color_drops_to_named_on_ansi16() { |
| 437 | // Sky: blue-dominant and bright → LightBlue, not terminal cyan. |
| 438 | assert_eq!( |
| 439 | adapt_color(DEEPSEEK_SKY, ColorDepth::Ansi16), |
| 440 | Color::LightBlue |
| 441 | ); |
| 442 | // Red: red-dominant, mid lum → Red (not the bright variant). |
| 443 | assert_eq!(adapt_color(DEEPSEEK_RED, ColorDepth::Ansi16), Color::Red); |
| 444 | } |
| 445 | |
| 446 | #[test] |
| 447 | fn adapt_bg_disables_tints_on_ansi16() { |
| 448 | assert_eq!( |
| 449 | adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16), |
| 450 | Color::Reset |
| 451 | ); |
| 452 | assert_eq!( |
| 453 | adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor), |
| 454 | SURFACE_REASONING |
| 455 | ); |
| 456 | } |
| 457 | |
| 458 | #[test] |
| 459 | fn reasoning_tint_is_none_on_ansi16() { |
| 460 | assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none()); |
| 461 | assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some()); |
| 462 | assert!(matches!( |
| 463 | reasoning_surface_tint(ColorDepth::Ansi256), |
| 464 | Some(Color::Indexed(_)) |
| 465 | )); |
| 466 | } |
| 467 | |
| 468 | #[test] |
| 469 | fn blend_at_zero_returns_bg_at_one_returns_fg() { |
| 470 | let fg = Color::Rgb(200, 100, 50); |
| 471 | let bg = Color::Rgb(0, 0, 0); |
| 472 | assert_eq!(blend(fg, bg, 0.0), bg); |
| 473 | assert_eq!(blend(fg, bg, 1.0), fg); |
| 474 | } |
| 475 | |
| 476 | #[test] |
| 477 | fn blend_at_half_is_midpoint() { |
| 478 | let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5); |
| 479 | assert_eq!(mid, Color::Rgb(100, 50, 0)); |
| 480 | } |
| 481 | |
| 482 | #[test] |
| 483 | fn pulse_brightness_swings_within_envelope() { |
| 484 | // The pulse rides between 30%..100% — never below 30% of the source. |
| 485 | let src = ACCENT_REASONING_LIVE; |
| 486 | let mut min_r = u8::MAX; |
| 487 | let mut max_r = 0u8; |
| 488 | for ms in (0u64..2000).step_by(50) { |
| 489 | if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) { |
| 490 | min_r = min_r.min(r); |
| 491 | max_r = max_r.max(r); |
| 492 | } |
| 493 | } |
| 494 | let Color::Rgb(src_r, _, _) = src else { |
| 495 | panic!("expected RGB"); |
| 496 | }; |
| 497 | // Trough should land near 30% of source; crest near source itself. |
| 498 | let lower = (f32::from(src_r) * 0.30).round() as u8; |
| 499 | assert!(min_r <= lower + 2, "trough too high: {min_r}"); |
| 500 | assert!(max_r + 2 >= src_r, "crest too low: {max_r}"); |
| 501 | } |
| 502 | |
| 503 | #[test] |
| 504 | fn pulse_passes_named_colors_unchanged() { |
| 505 | // Named palette entries don't blend meaningfully — leave them alone. |
| 506 | assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset); |
| 507 | assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan); |
| 508 | } |
| 509 | |
| 510 | #[test] |
| 511 | fn nearest_ansi16_routes_known_brand_colors() { |
| 512 | // Blue-dominant brand colors should stay blue rather than collapsing |
| 513 | // to the user's terminal cyan, which is often much louder. |
| 514 | assert_eq!(nearest_ansi16(53, 120, 229), Color::Blue); |
| 515 | assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue); |
| 516 | assert_eq!(nearest_ansi16(42, 74, 127), Color::Blue); |
| 517 | assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); |
| 518 | assert_eq!(nearest_ansi16(226, 80, 96), Color::Red); |
| 519 | assert_eq!(nearest_ansi16(11, 21, 38), Color::Black); |
| 520 | } |
| 521 | |
| 522 | #[test] |
| 523 | fn rgb_to_ansi256_uses_stable_extended_palette() { |
| 524 | assert!(rgb_to_ansi256(53, 120, 229) >= 16); |
| 525 | assert!(rgb_to_ansi256(11, 21, 38) >= 16); |
| 526 | } |
| 527 | |
| 528 | #[test] |
| 529 | fn color_depth_detect_is_safe_without_env() { |
| 530 | // Don't try to pin the result — env may be anything in CI. Just |
| 531 | // exercise the path so a panic would surface. |
| 532 | let _ = ColorDepth::detect(); |
| 533 | let _ = adapt_color(DEEPSEEK_INK, ColorDepth::detect()); |
| 534 | } |
| 535 | } |
| 536 |