返回 CodeWhale
adapt.rs
根目录 / crates / tui / src / palette / adapt.rs
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
858 lines RUST