| 1 | //! Color adaptation for palette mode, community themes, and terminal depth. |
| 2 | |
| 3 | use ratatui::style::Color; |
| 4 | |
| 5 | use super::detect::PaletteMode; |
| 6 | use super::themes::{ |
| 7 | GRAYSCALE_UI_THEME, LIGHT_UI_THEME, SOLARIZED_LIGHT_UI_THEME, ThemeId, UiTheme, |
| 8 | }; |
| 9 | use super::tokens::*; |
| 10 | |
| 11 | #[must_use] |
| 12 | pub fn adapt_fg_for_palette_mode(color: Color, _bg: Color, mode: PaletteMode) -> Color { |
| 13 | match mode { |
| 14 | PaletteMode::Dark => color, |
| 15 | PaletteMode::Light => adapt_fg_for_light_palette(color), |
| 16 | PaletteMode::Grayscale => adapt_fg_for_grayscale_palette(color), |
| 17 | PaletteMode::SolarizedLight => adapt_fg_for_solarized_light_palette(color), |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | #[must_use] |
| 22 | pub fn adapt_bg_for_palette_mode(color: Color, mode: PaletteMode) -> Color { |
| 23 | match mode { |
| 24 | PaletteMode::Dark => color, |
| 25 | PaletteMode::Light => adapt_bg_for_light_palette(color), |
| 26 | PaletteMode::Grayscale => adapt_bg_for_grayscale_palette(color), |
| 27 | PaletteMode::SolarizedLight => adapt_bg_for_solarized_light_palette(color), |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | fn adapt_fg_for_light_palette(color: Color) -> Color { |
| 32 | if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White { |
| 33 | LIGHT_TEXT_BODY |
| 34 | } else if color == TEXT_SECONDARY || color == TEXT_MUTED { |
| 35 | LIGHT_TEXT_MUTED |
| 36 | } else if color == TEXT_HINT || color == TEXT_DIM { |
| 37 | LIGHT_TEXT_HINT |
| 38 | } else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT { |
| 39 | LIGHT_TEXT_SOFT |
| 40 | } else if color == BORDER_COLOR { |
| 41 | LIGHT_BORDER |
| 42 | } else if color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE { |
| 43 | LIGHT_LIVE |
| 44 | } else if color == WHALE_INFO || color == WHALE_ACTION || color == WHALE_ACCENT_PRIMARY { |
| 45 | LIGHT_ACTION |
| 46 | } else if color == MODE_AGENT { |
| 47 | LIGHT_UI_THEME.mode_agent |
| 48 | } else if color == WHALE_HUMAN { |
| 49 | LIGHT_HUMAN |
| 50 | } else if color == MODE_PLAN { |
| 51 | LIGHT_UI_THEME.mode_plan |
| 52 | } else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE { |
| 53 | Color::Rgb(146, 64, 14) |
| 54 | } else if color == ACCENT_TOOL_ISSUE || color == WHALE_ERROR || color == STATUS_ERROR { |
| 55 | LIGHT_DANGER |
| 56 | } else if color == MODE_YOLO { |
| 57 | LIGHT_UI_THEME.mode_yolo |
| 58 | } else if color == STATUS_WARNING { |
| 59 | LIGHT_WARNING |
| 60 | } else if color == STATUS_SUCCESS { |
| 61 | LIGHT_SUCCESS_FG |
| 62 | } else if color == MODE_OPERATE { |
| 63 | LIGHT_OPERATE |
| 64 | } else if color == DIFF_ADDED { |
| 65 | Color::Rgb(22, 101, 52) |
| 66 | } else if color == USER_BODY { |
| 67 | LIGHT_USER_BODY |
| 68 | } else { |
| 69 | color |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | fn adapt_bg_for_light_palette(color: Color) -> Color { |
| 74 | if color == WHALE_BG || color == BACKGROUND_DARK { |
| 75 | LIGHT_SURFACE |
| 76 | } else if color == WHALE_PANEL |
| 77 | || color == COMPOSER_BG |
| 78 | || color == SURFACE_PANEL |
| 79 | || color == SURFACE_TOOL |
| 80 | { |
| 81 | LIGHT_PANEL |
| 82 | } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE { |
| 83 | LIGHT_ELEVATED |
| 84 | } else if color == SURFACE_REASONING |
| 85 | || color == SURFACE_REASONING_TINT |
| 86 | || color == SURFACE_REASONING_ACTIVE |
| 87 | { |
| 88 | LIGHT_REASONING |
| 89 | } else if color == SURFACE_SUCCESS { |
| 90 | LIGHT_SUCCESS |
| 91 | } else if color == SURFACE_ERROR { |
| 92 | LIGHT_ERROR |
| 93 | } else if color == DIFF_ADDED_BG { |
| 94 | LIGHT_SUCCESS |
| 95 | } else if color == DIFF_DELETED_BG { |
| 96 | LIGHT_ERROR |
| 97 | } else if color == SELECTION_BG { |
| 98 | LIGHT_SELECTION_BG |
| 99 | } else { |
| 100 | color |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | fn adapt_fg_for_solarized_light_palette(color: Color) -> Color { |
| 105 | if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White { |
| 106 | SOLARIZED_TEXT_BODY |
| 107 | } else if color == TEXT_SECONDARY || color == TEXT_MUTED { |
| 108 | SOLARIZED_TEXT_MUTED |
| 109 | } else if color == TEXT_HINT || color == TEXT_DIM { |
| 110 | SOLARIZED_TEXT_HINT |
| 111 | } else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT { |
| 112 | SOLARIZED_TEXT_SOFT |
| 113 | } else if color == BORDER_COLOR { |
| 114 | SOLARIZED_BORDER |
| 115 | } else if color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE { |
| 116 | SOLARIZED_CYAN |
| 117 | } else if color == WHALE_INFO || color == WHALE_ACTION || color == WHALE_ACCENT_PRIMARY { |
| 118 | SOLARIZED_BLUE |
| 119 | } else if color == MODE_AGENT { |
| 120 | SOLARIZED_LIGHT_UI_THEME.mode_agent |
| 121 | } else if color == WHALE_HUMAN { |
| 122 | SOLARIZED_ORANGE |
| 123 | } else if color == MODE_PLAN { |
| 124 | SOLARIZED_LIGHT_UI_THEME.mode_plan |
| 125 | } else if color == STATUS_WARNING || color == TEXT_REASONING || color == ACCENT_REASONING_LIVE { |
| 126 | SOLARIZED_ORANGE |
| 127 | } else if color == ACCENT_TOOL_ISSUE || color == WHALE_ERROR || color == STATUS_ERROR { |
| 128 | SOLARIZED_RED |
| 129 | } else if color == MODE_YOLO { |
| 130 | SOLARIZED_LIGHT_UI_THEME.mode_yolo |
| 131 | } else if color == DIFF_ADDED || color == USER_BODY || color == STATUS_SUCCESS { |
| 132 | SOLARIZED_GREEN |
| 133 | } else if color == MODE_OPERATE { |
| 134 | Color::Rgb(0x6C, 0x71, 0xC4) |
| 135 | } else { |
| 136 | color |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | fn adapt_bg_for_solarized_light_palette(color: Color) -> Color { |
| 141 | if color == WHALE_BG || color == BACKGROUND_DARK { |
| 142 | SOLARIZED_SURFACE |
| 143 | } else if color == WHALE_PANEL |
| 144 | || color == COMPOSER_BG |
| 145 | || color == SURFACE_PANEL |
| 146 | || color == SURFACE_TOOL |
| 147 | { |
| 148 | SOLARIZED_PANEL |
| 149 | } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE { |
| 150 | SOLARIZED_ELEVATED |
| 151 | } else if color == SURFACE_REASONING |
| 152 | || color == SURFACE_REASONING_TINT |
| 153 | || color == SURFACE_REASONING_ACTIVE |
| 154 | { |
| 155 | SOLARIZED_PANEL |
| 156 | } else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG { |
| 157 | SOLARIZED_DIFF_ADDED_BG |
| 158 | } else if color == SURFACE_ERROR { |
| 159 | SOLARIZED_ERROR_SURFACE |
| 160 | } else if color == DIFF_DELETED_BG { |
| 161 | SOLARIZED_DIFF_DELETED_BG |
| 162 | } else if color == SELECTION_BG { |
| 163 | SOLARIZED_SELECT_BG |
| 164 | } else { |
| 165 | color |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | // === Community-theme remap === |
| 170 | // |
| 171 | // The vast majority of render sites in this crate reach for `palette::TEXT_*`, |
| 172 | // `palette::WHALE_BG`, `palette::BORDER_COLOR`, etc. directly rather than |
| 173 | // looking up `app.ui_theme`. To make community theme presets (Catppuccin, |
| 174 | // Tokyo Night, …) actually move the needle visually we intercept colors at |
| 175 | // the backend layer (see `tui::color_compat::ColorCompatBackend`) and remap |
| 176 | // every well-known dark-palette constant to the equivalent UiTheme slot for |
| 177 | // the active preset. For `System`, `Whale`, and `WhaleLight` the remap is a |
| 178 | // no-op — the existing dark/light pipeline handles those. |
| 179 | |
| 180 | /// Per-preset green accent used for things that semantically *should* stay |
| 181 | /// green even after theming (diff "+" lines, user-input body). Now delegates |
| 182 | /// to the active UiTheme's diff_added_fg. |
| 183 | #[must_use] |
| 184 | const fn theme_green(ui: &UiTheme) -> Color { |
| 185 | ui.diff_added_fg |
| 186 | } |
| 187 | |
| 188 | /// Per-preset red accent, used for diff "−" line foreground when present. |
| 189 | #[must_use] |
| 190 | #[allow(dead_code)] |
| 191 | const fn theme_red(ui: &UiTheme) -> Color { |
| 192 | ui.diff_deleted_fg |
| 193 | } |
| 194 | |
| 195 | /// Per-preset dark-green diff-added background tint. |
| 196 | #[must_use] |
| 197 | const fn theme_diff_added_bg(ui: &UiTheme) -> Color { |
| 198 | ui.diff_added_bg |
| 199 | } |
| 200 | |
| 201 | /// Per-preset dark-red diff-deleted background tint. |
| 202 | #[must_use] |
| 203 | const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color { |
| 204 | ui.diff_deleted_bg |
| 205 | } |
| 206 | |
| 207 | /// Returns `true` if the preset participates in the cell-level remap. The |
| 208 | /// default Whale and System themes pass through unchanged so this whole |
| 209 | /// stage compiles down to a single load+compare on the hot path. |
| 210 | #[inline] |
| 211 | #[must_use] |
| 212 | pub const fn theme_remap_active(theme: ThemeId) -> bool { |
| 213 | matches!( |
| 214 | theme, |
| 215 | ThemeId::Terminal |
| 216 | | ThemeId::CatppuccinMocha |
| 217 | | ThemeId::TokyoNight |
| 218 | | ThemeId::Dracula |
| 219 | | ThemeId::GruvboxDark |
| 220 | | ThemeId::Claude |
| 221 | | ThemeId::Matrix |
| 222 | | ThemeId::SolarizedLight |
| 223 | ) |
| 224 | } |
| 225 | |
| 226 | /// Remap a foreground color for a community theme preset. Mirrors the |
| 227 | /// structure of [`adapt_fg_for_palette_mode`] — same source set, different |
| 228 | /// destinations sourced from the preset's [`UiTheme`]. |
| 229 | /// |
| 230 | /// The `ui` argument is the *active* UiTheme as carried on `App` — |
| 231 | /// `ThemeId.ui_theme()` with the user's `background_color` override |
| 232 | /// already applied. Passing it through (rather than re-resolving from |
| 233 | /// `theme` inside this function) preserves that override; otherwise a |
| 234 | /// user combining `background_color = "#..."` with a community theme |
| 235 | /// would see their override silently overwritten by the preset's |
| 236 | /// surface_bg on every cell remap. |
| 237 | #[must_use] |
| 238 | pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { |
| 239 | if !theme_remap_active(theme) { |
| 240 | return color; |
| 241 | } |
| 242 | |
| 243 | if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White { |
| 244 | ui.text_body |
| 245 | } else if color == TEXT_SECONDARY || color == TEXT_MUTED { |
| 246 | ui.text_muted |
| 247 | } else if color == TEXT_HINT || color == TEXT_DIM { |
| 248 | ui.text_hint |
| 249 | } else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT { |
| 250 | ui.text_soft |
| 251 | } else if color == BORDER_COLOR { |
| 252 | ui.border |
| 253 | } else if color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE { |
| 254 | ui.status_working |
| 255 | } else if color == WHALE_INFO || color == WHALE_ACTION || color == WHALE_ACCENT_PRIMARY { |
| 256 | ui.accent_primary |
| 257 | } else if color == MODE_AGENT { |
| 258 | ui.mode_agent |
| 259 | } else if color == WHALE_HUMAN { |
| 260 | ui.accent_action |
| 261 | } else if color == MODE_PLAN { |
| 262 | ui.mode_plan |
| 263 | } else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE { |
| 264 | if theme == ThemeId::Matrix { |
| 265 | Color::Rgb(0x00, 0x55, 0x00) // #005500 |
| 266 | } else { |
| 267 | ui.mode_plan |
| 268 | } |
| 269 | } else if color == ACCENT_TOOL_ISSUE || color == STATUS_ERROR || color == WHALE_ERROR { |
| 270 | ui.error_fg |
| 271 | } else if color == MODE_YOLO { |
| 272 | ui.mode_yolo |
| 273 | } else if color == STATUS_WARNING { |
| 274 | ui.warning |
| 275 | } else if color == STATUS_SUCCESS { |
| 276 | ui.success |
| 277 | } else if color == MODE_OPERATE { |
| 278 | ui.mode_operate |
| 279 | } else if color == DIFF_ADDED || color == USER_BODY { |
| 280 | theme_green(ui) |
| 281 | } else { |
| 282 | color |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Remap a background color for a community theme preset. See the |
| 287 | /// `ui` note on [`adapt_fg_for_theme`] — same contract here. |
| 288 | #[must_use] |
| 289 | pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color { |
| 290 | if !theme_remap_active(theme) { |
| 291 | return color; |
| 292 | } |
| 293 | |
| 294 | if color == WHALE_BG || color == BACKGROUND_DARK { |
| 295 | ui.surface_bg |
| 296 | } else if color == WHALE_PANEL |
| 297 | || color == COMPOSER_BG |
| 298 | || color == SURFACE_PANEL |
| 299 | || color == SURFACE_TOOL |
| 300 | { |
| 301 | ui.panel_bg |
| 302 | } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE { |
| 303 | ui.elevated_bg |
| 304 | } else if color == SURFACE_REASONING |
| 305 | || color == SURFACE_REASONING_TINT |
| 306 | || color == SURFACE_REASONING_ACTIVE |
| 307 | { |
| 308 | ui.panel_bg |
| 309 | } else if color == SURFACE_SUCCESS { |
| 310 | ui.diff_added_bg |
| 311 | } else if color == SURFACE_ERROR { |
| 312 | ui.error_surface |
| 313 | } else if color == SELECTION_BG { |
| 314 | ui.selection_bg |
| 315 | } else if color == DIFF_ADDED_BG { |
| 316 | theme_diff_added_bg(ui) |
| 317 | } else if color == DIFF_DELETED_BG { |
| 318 | theme_diff_deleted_bg(ui) |
| 319 | } else { |
| 320 | color |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | fn adapt_fg_for_grayscale_palette(color: Color) -> Color { |
| 325 | if color == Color::Reset { |
| 326 | return color; |
| 327 | } |
| 328 | // Resolved grayscale mode slots are already final palette colors. Keep |
| 329 | // this branch ahead of the luma buckets so a direct `UiTheme` call site is |
| 330 | // idempotent instead of being adapted a second time. |
| 331 | if color == GRAYSCALE_UI_THEME.mode_agent |
| 332 | || color == GRAYSCALE_UI_THEME.mode_plan |
| 333 | || color == GRAYSCALE_UI_THEME.mode_operate |
| 334 | || color == GRAYSCALE_UI_THEME.mode_yolo |
| 335 | { |
| 336 | color |
| 337 | } else if color == MODE_AGENT { |
| 338 | GRAYSCALE_UI_THEME.mode_agent |
| 339 | } else if color == MODE_PLAN { |
| 340 | GRAYSCALE_UI_THEME.mode_plan |
| 341 | } else if color == MODE_OPERATE { |
| 342 | GRAYSCALE_UI_THEME.mode_operate |
| 343 | } else if color == MODE_YOLO { |
| 344 | GRAYSCALE_UI_THEME.mode_yolo |
| 345 | } else if color == TEXT_BODY |
| 346 | || color == SELECTION_TEXT |
| 347 | || color == LIGHT_TEXT_BODY |
| 348 | || color == Color::White |
| 349 | || color == WHALE_ERROR |
| 350 | || color == STATUS_ERROR |
| 351 | { |
| 352 | GRAYSCALE_TEXT_BODY |
| 353 | } else if color == TEXT_SOFT |
| 354 | || color == TEXT_TOOL_OUTPUT |
| 355 | || color == LIGHT_TEXT_SOFT |
| 356 | || color == TEXT_ACCENT |
| 357 | || color == WHALE_INFO |
| 358 | || color == WHALE_ACCENT_PRIMARY |
| 359 | || color == WHALE_HUMAN |
| 360 | || color == ACCENT_TOOL_LIVE |
| 361 | || color == STATUS_SUCCESS |
| 362 | || color == STATUS_INFO |
| 363 | { |
| 364 | GRAYSCALE_TEXT_SOFT |
| 365 | } else if color == TEXT_SECONDARY |
| 366 | || color == TEXT_MUTED |
| 367 | || color == LIGHT_TEXT_MUTED |
| 368 | || color == TEXT_REASONING |
| 369 | || color == ACCENT_REASONING_LIVE |
| 370 | || color == STATUS_WARNING |
| 371 | || color == USER_BODY |
| 372 | || color == LIGHT_USER_BODY |
| 373 | || color == DIFF_ADDED |
| 374 | { |
| 375 | GRAYSCALE_TEXT_MUTED |
| 376 | } else if color == TEXT_HINT |
| 377 | || color == TEXT_DIM |
| 378 | || color == LIGHT_TEXT_HINT |
| 379 | || color == BORDER_COLOR |
| 380 | || color == LIGHT_BORDER |
| 381 | || color == ACCENT_TOOL_ISSUE |
| 382 | { |
| 383 | GRAYSCALE_TEXT_HINT |
| 384 | } else { |
| 385 | match color { |
| 386 | Color::Black => GRAYSCALE_TEXT_BODY, |
| 387 | Color::Gray | Color::DarkGray => GRAYSCALE_TEXT_HINT, |
| 388 | Color::Red |
| 389 | | Color::LightRed |
| 390 | | Color::Green |
| 391 | | Color::LightGreen |
| 392 | | Color::Yellow |
| 393 | | Color::LightYellow |
| 394 | | Color::Blue |
| 395 | | Color::LightBlue |
| 396 | | Color::Magenta |
| 397 | | Color::LightMagenta |
| 398 | | Color::Cyan |
| 399 | | Color::LightCyan => GRAYSCALE_TEXT_SOFT, |
| 400 | Color::Rgb(r, g, b) => grayscale_fg_from_luma(luma(r, g, b)), |
| 401 | Color::Indexed(_) => color, |
| 402 | _ => color, |
| 403 | } |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | fn adapt_bg_for_grayscale_palette(color: Color) -> Color { |
| 408 | if color == Color::Reset { |
| 409 | return color; |
| 410 | } |
| 411 | if color == WHALE_BG || color == BACKGROUND_DARK || color == LIGHT_SURFACE { |
| 412 | GRAYSCALE_SURFACE |
| 413 | } else if color == WHALE_PANEL |
| 414 | || color == COMPOSER_BG |
| 415 | || color == SURFACE_PANEL |
| 416 | || color == SURFACE_TOOL |
| 417 | || color == LIGHT_PANEL |
| 418 | { |
| 419 | GRAYSCALE_PANEL |
| 420 | } else if color == SURFACE_ELEVATED |
| 421 | || color == SURFACE_TOOL_ACTIVE |
| 422 | || color == LIGHT_ELEVATED |
| 423 | || color == SELECTION_BG |
| 424 | || color == LIGHT_SELECTION_BG |
| 425 | { |
| 426 | GRAYSCALE_ELEVATED |
| 427 | } else if color == SURFACE_REASONING |
| 428 | || color == SURFACE_REASONING_TINT |
| 429 | || color == SURFACE_REASONING_ACTIVE |
| 430 | || color == LIGHT_REASONING |
| 431 | { |
| 432 | GRAYSCALE_REASONING |
| 433 | } else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG || color == LIGHT_SUCCESS { |
| 434 | GRAYSCALE_SUCCESS |
| 435 | } else if color == SURFACE_ERROR || color == DIFF_DELETED_BG || color == LIGHT_ERROR { |
| 436 | GRAYSCALE_ERROR |
| 437 | } else { |
| 438 | match color { |
| 439 | Color::Black => GRAYSCALE_SURFACE, |
| 440 | Color::White | Color::Gray => GRAYSCALE_ELEVATED, |
| 441 | Color::DarkGray => GRAYSCALE_PANEL, |
| 442 | Color::Red |
| 443 | | Color::LightRed |
| 444 | | Color::Green |
| 445 | | Color::LightGreen |
| 446 | | Color::Yellow |
| 447 | | Color::LightYellow |
| 448 | | Color::Blue |
| 449 | | Color::LightBlue |
| 450 | | Color::Magenta |
| 451 | | Color::LightMagenta |
| 452 | | Color::Cyan |
| 453 | | Color::LightCyan => GRAYSCALE_ELEVATED, |
| 454 | Color::Rgb(r, g, b) => grayscale_bg_from_luma(luma(r, g, b)), |
| 455 | Color::Indexed(_) => color, |
| 456 | _ => color, |
| 457 | } |
| 458 | } |
| 459 | } |
| 460 | |
| 461 | fn grayscale_fg_from_luma(luma: u8) -> Color { |
| 462 | match luma { |
| 463 | 0..=95 => GRAYSCALE_TEXT_HINT, |
| 464 | 96..=155 => GRAYSCALE_TEXT_MUTED, |
| 465 | 156..=215 => GRAYSCALE_TEXT_SOFT, |
| 466 | _ => GRAYSCALE_TEXT_BODY, |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | fn grayscale_bg_from_luma(luma: u8) -> Color { |
| 471 | match luma { |
| 472 | 0..=28 => GRAYSCALE_SURFACE, |
| 473 | 29..=95 => GRAYSCALE_PANEL, |
| 474 | 96..=185 => GRAYSCALE_ELEVATED, |
| 475 | _ => GRAYSCALE_REASONING, |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | pub(crate) fn luma(r: u8, g: u8, b: u8) -> u8 { |
| 480 | ((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8 |
| 481 | } |
| 482 | // === Color depth + brightness helpers (v0.6.6 UI redesign) === |
| 483 | |
| 484 | /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg |
| 485 | /// tints) on terminals that can't render them faithfully. |
| 486 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 487 | pub enum ColorDepth { |
| 488 | /// 16-color terminals (macOS Terminal.app default, dumb tmux setups). |
| 489 | /// Background tints distort the named-palette mapping, so we drop them. |
| 490 | Ansi16, |
| 491 | /// 256-color terminals — RGB→256 fallback is faithful enough. |
| 492 | Ansi256, |
| 493 | /// True-color (24-bit) — render the palette verbatim. |
| 494 | TrueColor, |
| 495 | } |
| 496 | |
| 497 | /// Foreground roles that must remain distinct after the terminal reduces the |
| 498 | /// palette. RGB proximity is deliberately irrelevant here: action and Operate, |
| 499 | /// or a human ask and a warning, are different product states even when their |
| 500 | /// source hues happen to share a nearest ANSI color. |
| 501 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 502 | pub(crate) enum SemanticForegroundRole { |
| 503 | Action, |
| 504 | Live, |
| 505 | Human, |
| 506 | Warning, |
| 507 | Danger, |
| 508 | Success, |
| 509 | ModeAgent, |
| 510 | ModePlan, |
| 511 | ModeOperate, |
| 512 | ModeYolo, |
| 513 | } |
| 514 | |
| 515 | impl SemanticForegroundRole { |
| 516 | #[must_use] |
| 517 | const fn ansi16(self) -> Color { |
| 518 | match self { |
| 519 | Self::Action => Color::LightBlue, |
| 520 | Self::Live => Color::LightCyan, |
| 521 | Self::Human => Color::LightYellow, |
| 522 | Self::Warning => Color::Yellow, |
| 523 | Self::Danger => Color::LightRed, |
| 524 | Self::Success => Color::LightGreen, |
| 525 | Self::ModeAgent => Color::Blue, |
| 526 | Self::ModePlan => Color::Magenta, |
| 527 | Self::ModeOperate => Color::LightMagenta, |
| 528 | Self::ModeYolo => Color::Red, |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | fn raw_semantic_foreground_role(color: Color) -> Option<SemanticForegroundRole> { |
| 534 | if color == MODE_AGENT { |
| 535 | Some(SemanticForegroundRole::ModeAgent) |
| 536 | } else if color == MODE_PLAN { |
| 537 | Some(SemanticForegroundRole::ModePlan) |
| 538 | } else if color == MODE_OPERATE { |
| 539 | Some(SemanticForegroundRole::ModeOperate) |
| 540 | } else if color == MODE_YOLO { |
| 541 | Some(SemanticForegroundRole::ModeYolo) |
| 542 | } else if color == WHALE_ACTION |
| 543 | || color == WHALE_INFO |
| 544 | || color == STATUS_INFO |
| 545 | || color == WHALE_ACCENT_PRIMARY |
| 546 | { |
| 547 | Some(SemanticForegroundRole::Action) |
| 548 | } else if color == WHALE_LIVE || color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE { |
| 549 | Some(SemanticForegroundRole::Live) |
| 550 | } else if color == WHALE_HUMAN { |
| 551 | Some(SemanticForegroundRole::Human) |
| 552 | } else if color == STATUS_WARNING { |
| 553 | Some(SemanticForegroundRole::Warning) |
| 554 | } else if color == WHALE_ERROR || color == STATUS_ERROR || color == ACCENT_TOOL_ISSUE { |
| 555 | Some(SemanticForegroundRole::Danger) |
| 556 | } else if color == STATUS_SUCCESS || color == USER_BODY || color == DIFF_ADDED { |
| 557 | Some(SemanticForegroundRole::Success) |
| 558 | } else { |
| 559 | None |
| 560 | } |
| 561 | } |
| 562 | |
| 563 | fn theme_semantic_foreground_role(color: Color, ui: &UiTheme) -> Option<SemanticForegroundRole> { |
| 564 | // Mode slots come first. Shipped themes keep these source colors distinct |
| 565 | // from the general semantic lanes so direct `app.ui_theme.mode_*` call |
| 566 | // sites retain the same identity as raw `MODE_*` call sites. |
| 567 | if color == ui.mode_agent { |
| 568 | Some(SemanticForegroundRole::ModeAgent) |
| 569 | } else if color == ui.mode_plan { |
| 570 | Some(SemanticForegroundRole::ModePlan) |
| 571 | } else if color == ui.mode_operate { |
| 572 | Some(SemanticForegroundRole::ModeOperate) |
| 573 | } else if color == ui.mode_yolo { |
| 574 | Some(SemanticForegroundRole::ModeYolo) |
| 575 | } else if color == ui.accent_primary { |
| 576 | Some(SemanticForegroundRole::Action) |
| 577 | } else if color == ui.status_working |
| 578 | || color == ui.accent_secondary |
| 579 | || color == ui.tool_running |
| 580 | // `UiTheme::info` is the sky/worker lane used by ambient and live |
| 581 | // surfaces. Several shipped themes intentionally alias it to their |
| 582 | // working color, so it must not precede the live buckets. |
| 583 | || color == ui.info |
| 584 | { |
| 585 | Some(SemanticForegroundRole::Live) |
| 586 | } else if color == ui.accent_action { |
| 587 | Some(SemanticForegroundRole::Human) |
| 588 | } else if color == ui.warning || color == ui.status_warning { |
| 589 | Some(SemanticForegroundRole::Warning) |
| 590 | } else if color == ui.error_fg || color == ui.tool_failed || color == ui.diff_deleted_fg { |
| 591 | Some(SemanticForegroundRole::Danger) |
| 592 | } else if color == ui.success || color == ui.tool_success || color == ui.diff_added_fg { |
| 593 | Some(SemanticForegroundRole::Success) |
| 594 | } else { |
| 595 | None |
| 596 | } |
| 597 | } |
| 598 | |
| 599 | /// Adapt a resolved foreground to terminal depth while retaining the semantic |
| 600 | /// role carried by the original cell color. Truecolor and ANSI-256 preserve the |
| 601 | /// resolved theme value; ANSI-16 uses a fixed, injective role matrix instead of |
| 602 | /// an arbitrary nearest-color guess. |
| 603 | #[must_use] |
| 604 | pub(crate) fn adapt_fg_for_depth( |
| 605 | source: Color, |
| 606 | resolved: Color, |
| 607 | depth: ColorDepth, |
| 608 | ui: &UiTheme, |
| 609 | ) -> Color { |
| 610 | if depth == ColorDepth::Ansi16 |
| 611 | && let Some(role) = raw_semantic_foreground_role(source) |
| 612 | .or_else(|| theme_semantic_foreground_role(source, ui)) |
| 613 | { |
| 614 | role.ansi16() |
| 615 | } else { |
| 616 | adapt_color(resolved, depth) |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | impl ColorDepth { |
| 621 | /// Detect the active terminal's color depth. Honors `COLORTERM` |
| 622 | /// (truecolor / 24bit) first, then falls back to `TERM`. Defaults to |
| 623 | /// `TrueColor` because most modern terminals support it; the conservative |
| 624 | /// fallback is `Ansi16` so background tints disappear safely. |
| 625 | #[must_use] |
| 626 | pub fn detect() -> Self { |
| 627 | if let Ok(ct) = std::env::var("COLORTERM") { |
| 628 | let ct = ct.to_ascii_lowercase(); |
| 629 | if ct.contains("truecolor") || ct.contains("24bit") { |
| 630 | return Self::TrueColor; |
| 631 | } |
| 632 | } |
| 633 | if std::env::var_os("WT_SESSION").is_some() { |
| 634 | return Self::TrueColor; |
| 635 | } |
| 636 | if let Ok(term_program) = std::env::var("TERM_PROGRAM") { |
| 637 | let term_program = term_program.to_ascii_lowercase(); |
| 638 | if term_program.contains("iterm") |
| 639 | || term_program.contains("wezterm") |
| 640 | || term_program.contains("vscode") |
| 641 | || term_program.contains("warp") |
| 642 | { |
| 643 | return Self::TrueColor; |
| 644 | } |
| 645 | } |
| 646 | let term = std::env::var("TERM").unwrap_or_default(); |
| 647 | let term = term.to_ascii_lowercase(); |
| 648 | if term.contains("truecolor") || term.contains("24bit") { |
| 649 | Self::TrueColor |
| 650 | } else if term.contains("256") { |
| 651 | Self::Ansi256 |
| 652 | } else if term.is_empty() || term == "dumb" { |
| 653 | Self::Ansi16 |
| 654 | } else { |
| 655 | // Unknown TERM strings should not receive 24-bit SGR by default. |
| 656 | // Older macOS/remote terminals can render truecolor backgrounds as |
| 657 | // bright cyan blocks; 256-color output is the safer compromise. |
| 658 | Self::Ansi256 |
| 659 | } |
| 660 | } |
| 661 | } |
| 662 | |
| 663 | /// Adapt a foreground color to the terminal's color depth. |
| 664 | /// |
| 665 | /// On TrueColor, `color` passes through. ANSI-256 uses the stable extended |
| 666 | /// palette; ANSI-16 uses a generic nearest named color. Rendered semantic |
| 667 | /// foregrounds must go through [`adapt_fg_for_depth`] so role identity is not |
| 668 | /// inferred from RGB proximity. |
| 669 | #[allow(dead_code)] |
| 670 | #[must_use] |
| 671 | pub fn adapt_color(color: Color, depth: ColorDepth) -> Color { |
| 672 | match (color, depth) { |
| 673 | (_, ColorDepth::TrueColor) => color, |
| 674 | (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)), |
| 675 | (Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b), |
| 676 | _ => color, |
| 677 | } |
| 678 | } |
| 679 | |
| 680 | /// Adapt a background color. On Ansi16 terminals background tints are noisy, |
| 681 | /// so we drop them to `Color::Reset` rather than attempt a coarse named-color |
| 682 | /// match — a quiet background reads cleaner than a wrong one. |
| 683 | #[allow(dead_code)] |
| 684 | #[must_use] |
| 685 | pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color { |
| 686 | match (color, depth) { |
| 687 | (_, ColorDepth::TrueColor) => color, |
| 688 | (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)), |
| 689 | (_, ColorDepth::Ansi256) => color, |
| 690 | (_, ColorDepth::Ansi16) => Color::Reset, |
| 691 | } |
| 692 | } |
| 693 | |
| 694 | /// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not |
| 695 | /// RGB falls back to `fg` — there's no meaningful alpha blend on a named |
| 696 | /// palette entry. |
| 697 | #[allow(dead_code)] |
| 698 | #[must_use] |
| 699 | pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color { |
| 700 | let alpha = alpha.clamp(0.0, 1.0); |
| 701 | match (fg, bg) { |
| 702 | (Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => { |
| 703 | let mix = |a: u8, b: u8| -> u8 { |
| 704 | let a = f32::from(a); |
| 705 | let b = f32::from(b); |
| 706 | (b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8 |
| 707 | }; |
| 708 | Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb)) |
| 709 | } |
| 710 | _ => fg, |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | /// Return the dedicated reasoning surface tint for terminals that can render |
| 715 | /// background colors faithfully. ANSI-16 terminals disable the tint because |
| 716 | /// the nearest named background is too coarse for this subtle treatment. |
| 717 | #[must_use] |
| 718 | pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> { |
| 719 | match depth { |
| 720 | ColorDepth::Ansi16 => None, |
| 721 | _ => Some(adapt_bg(SURFACE_REASONING_TINT, depth)), |
| 722 | } |
| 723 | } |
| 724 | |
| 725 | /// Pulse `color` between 30% and 100% brightness on a 2s cycle keyed off |
| 726 | /// `now_ms` (epoch ms). The minimum keeps the glyph readable at trough; the |
| 727 | /// maximum is the source color verbatim. Linear interpolation between them |
| 728 | /// reads as a slow heartbeat. |
| 729 | #[must_use] |
| 730 | pub fn pulse_brightness(color: Color, now_ms: u64) -> Color { |
| 731 | // 2 s = 2000 ms full cycle; sin gives a smooth 0..1..0 swing. |
| 732 | let phase = (now_ms % 2000) as f32 / 2000.0; |
| 733 | let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; // 0..1 |
| 734 | let alpha = 0.30 + t * 0.70; // 30%..100% |
| 735 | match color { |
| 736 | Color::Rgb(r, g, b) => { |
| 737 | let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 }; |
| 738 | Color::Rgb(s(r), s(g), s(b)) |
| 739 | } |
| 740 | other => other, |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | /// Map an RGB triple to its closest ANSI-16 named color. Only used by |
| 745 | /// `adapt_color` on Ansi16 terminals; we lean on hue dominance + lightness so |
| 746 | /// brand colors land on the obviously-related named entry (sky → cyan, blue → |
| 747 | /// blue, red → red, etc.) rather than dithering around grey. |
| 748 | #[allow(dead_code)] |
| 749 | pub(crate) fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color { |
| 750 | let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3; |
| 751 | if lum < 24 { |
| 752 | return Color::Black; |
| 753 | } |
| 754 | if r > 220 && g > 220 && b > 220 { |
| 755 | return Color::White; |
| 756 | } |
| 757 | let bright = lum > 144; |
| 758 | let max = r.max(g).max(b); |
| 759 | let min = r.min(g).min(b); |
| 760 | if max.saturating_sub(min) < 16 { |
| 761 | return if bright { Color::Gray } else { Color::DarkGray }; |
| 762 | } |
| 763 | if r >= g && r >= b { |
| 764 | if g > b + 24 { |
| 765 | if bright { |
| 766 | Color::LightYellow |
| 767 | } else { |
| 768 | Color::Yellow |
| 769 | } |
| 770 | } else if b > r.saturating_sub(24) { |
| 771 | if bright { |
| 772 | Color::LightMagenta |
| 773 | } else { |
| 774 | Color::Magenta |
| 775 | } |
| 776 | } else if bright { |
| 777 | Color::LightRed |
| 778 | } else { |
| 779 | Color::Red |
| 780 | } |
| 781 | } else if g >= r && g >= b { |
| 782 | if b > r + 24 { |
| 783 | if bright { |
| 784 | Color::LightCyan |
| 785 | } else { |
| 786 | Color::Cyan |
| 787 | } |
| 788 | } else if bright { |
| 789 | Color::LightGreen |
| 790 | } else { |
| 791 | Color::Green |
| 792 | } |
| 793 | } else if r.saturating_add(48) >= b && r > g + 24 { |
| 794 | if bright { |
| 795 | Color::LightMagenta |
| 796 | } else { |
| 797 | Color::Magenta |
| 798 | } |
| 799 | } else if g.saturating_add(48) >= b && g > r + 24 { |
| 800 | if bright { |
| 801 | Color::LightCyan |
| 802 | } else { |
| 803 | Color::Cyan |
| 804 | } |
| 805 | } else if bright { |
| 806 | Color::LightBlue |
| 807 | } else { |
| 808 | Color::Blue |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | /// Map an RGB color to the nearest xterm 256-color palette index. We use only |
| 813 | /// the stable 6x6x6 cube and grayscale ramp (16..255), not the terminal's |
| 814 | /// user-configurable 0..15 colors. |
| 815 | #[allow(dead_code)] |
| 816 | pub(crate) fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { |
| 817 | const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255]; |
| 818 | |
| 819 | fn nearest_cube_level(channel: u8) -> usize { |
| 820 | CUBE_LEVELS |
| 821 | .iter() |
| 822 | .enumerate() |
| 823 | .min_by_key(|(_, level)| channel.abs_diff(**level)) |
| 824 | .map(|(idx, _)| idx) |
| 825 | .unwrap_or(0) |
| 826 | } |
| 827 | |
| 828 | fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 { |
| 829 | let dr = i32::from(a.0) - i32::from(b.0); |
| 830 | let dg = i32::from(a.1) - i32::from(b.1); |
| 831 | let db = i32::from(a.2) - i32::from(b.2); |
| 832 | (dr * dr + dg * dg + db * db) as u32 |
| 833 | } |
| 834 | |
| 835 | let ri = nearest_cube_level(r); |
| 836 | let gi = nearest_cube_level(g); |
| 837 | let bi = nearest_cube_level(b); |
| 838 | let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]); |
| 839 | let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8; |
| 840 | |
| 841 | let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8; |
| 842 | let gray_i = if avg <= 8 { |
| 843 | 0 |
| 844 | } else if avg >= 238 { |
| 845 | 23 |
| 846 | } else { |
| 847 | ((u16::from(avg) - 8 + 5) / 10).min(23) as u8 |
| 848 | }; |
| 849 | let gray = 8 + 10 * gray_i; |
| 850 | let gray_index = 232 + gray_i; |
| 851 | |
| 852 | if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) { |
| 853 | gray_index |
| 854 | } else { |
| 855 | cube_index |
| 856 | } |
| 857 | } |
| 858 |