返回 CodeWhale
adapt.rs
根目录 / crates / palette / src / 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_ACTION {
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_ACTION {
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 dark-green diff-added background tint.
189 #[must_use]
190 const fn theme_diff_added_bg(ui: &UiTheme) -> Color {
191 ui.diff_added_bg
192 }
193
194 /// Per-preset dark-red diff-deleted background tint.
195 #[must_use]
196 const fn theme_diff_deleted_bg(ui: &UiTheme) -> Color {
197 ui.diff_deleted_bg
198 }
199
200 /// Returns `true` if the preset participates in the cell-level remap. The
201 /// default Whale and System themes pass through unchanged so this whole
202 /// stage compiles down to a single load+compare on the hot path. Shoreline is
203 /// listed because it is a full re-ink — warm charcoal instead of the navy the
204 /// direct terminal constants were tuned for — so every one of those call
205 /// sites has to land on the preset's slots.
206 #[inline]
207 #[must_use]
208 pub const fn theme_remap_active(theme: ThemeId) -> bool {
209 matches!(
210 theme,
211 ThemeId::Terminal
212 | ThemeId::Shoreline
213 | ThemeId::ShorelineLight
214 | ThemeId::CatppuccinMocha
215 | ThemeId::TokyoNight
216 | ThemeId::Dracula
217 | ThemeId::GruvboxDark
218 | ThemeId::Claude
219 | ThemeId::Matrix
220 | ThemeId::SolarizedLight
221 | ThemeId::Uwu
222 )
223 }
224
225 /// Remap a foreground color for a community theme preset. Mirrors the
226 /// structure of [`adapt_fg_for_palette_mode`] — same source set, different
227 /// destinations sourced from the preset's [`UiTheme`].
228 ///
229 /// The `ui` argument is the *active* UiTheme as carried on `App` —
230 /// `ThemeId.ui_theme()` with the user's `background_color` override
231 /// already applied. Passing it through (rather than re-resolving from
232 /// `theme` inside this function) preserves that override; otherwise a
233 /// user combining `background_color = "#..."` with a community theme
234 /// would see their override silently overwritten by the preset's
235 /// surface_bg on every cell remap.
236 #[must_use]
237 pub fn adapt_fg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
238 if !theme_remap_active(theme) {
239 return color;
240 }
241
242 if color == TEXT_BODY || color == SELECTION_TEXT || color == Color::White {
243 ui.text_body
244 } else if color == TEXT_SECONDARY || color == TEXT_MUTED {
245 ui.text_muted
246 } else if color == TEXT_HINT || color == TEXT_DIM {
247 ui.text_hint
248 } else if color == TEXT_SOFT || color == TEXT_TOOL_OUTPUT {
249 ui.text_soft
250 } else if color == BORDER_COLOR {
251 ui.border
252 } else if color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE {
253 ui.status_working
254 } else if color == WHALE_ACTION {
255 ui.accent_primary
256 } else if color == MODE_AGENT {
257 ui.mode_agent
258 } else if color == WHALE_HUMAN {
259 ui.accent_action
260 } else if color == MODE_PLAN {
261 ui.mode_plan
262 } else if color == TEXT_REASONING || color == ACCENT_REASONING_LIVE {
263 if theme == ThemeId::Matrix {
264 Color::Rgb(0x00, 0x55, 0x00) // #005500
265 } else {
266 ui.mode_plan
267 }
268 } else if color == ACCENT_TOOL_ISSUE || color == STATUS_ERROR || color == WHALE_ERROR {
269 ui.error_fg
270 } else if color == MODE_YOLO {
271 ui.mode_yolo
272 } else if color == STATUS_WARNING {
273 ui.warning
274 } else if color == STATUS_SUCCESS {
275 ui.success
276 } else if color == MODE_OPERATE {
277 ui.mode_operate
278 } else if color == DIFF_ADDED || color == USER_BODY {
279 theme_green(ui)
280 } else {
281 color
282 }
283 }
284
285 /// Remap a background color for a community theme preset. See the
286 /// `ui` note on [`adapt_fg_for_theme`] — same contract here.
287 #[must_use]
288 pub fn adapt_bg_for_theme(color: Color, theme: ThemeId, ui: &UiTheme) -> Color {
289 // The field follows the active shell for every preset, not only the
290 // remapped ones: the Whale pair leaves `surface_bg` at `Color::Reset` so
291 // the terminal owns the ground, and a direct `bg(WHALE_BG)` paint must
292 // not lay a navy patch over it. Deepsea repaints Reset cells through the
293 // ocean column afterwards; a user `background_color` override lands here
294 // too.
295 if color == WHALE_BG || color == BACKGROUND_DARK {
296 return ui.surface_bg;
297 }
298 if !theme_remap_active(theme) {
299 return color;
300 }
301
302 if color == WHALE_PANEL
303 || color == COMPOSER_BG
304 || color == SURFACE_PANEL
305 || color == SURFACE_TOOL
306 {
307 ui.panel_bg
308 } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE {
309 ui.elevated_bg
310 } else if color == SURFACE_REASONING
311 || color == SURFACE_REASONING_TINT
312 || color == SURFACE_REASONING_ACTIVE
313 {
314 ui.panel_bg
315 } else if color == SURFACE_SUCCESS {
316 ui.diff_added_bg
317 } else if color == SURFACE_ERROR {
318 ui.error_surface
319 } else if color == SELECTION_BG {
320 ui.selection_bg
321 } else if color == DIFF_ADDED_BG {
322 theme_diff_added_bg(ui)
323 } else if color == DIFF_DELETED_BG {
324 theme_diff_deleted_bg(ui)
325 } else {
326 color
327 }
328 }
329
330 fn adapt_fg_for_grayscale_palette(color: Color) -> Color {
331 if color == Color::Reset {
332 return color;
333 }
334 // Resolved grayscale mode slots are already final palette colors. Keep
335 // this branch ahead of the luma buckets so a direct `UiTheme` call site is
336 // idempotent instead of being adapted a second time.
337 if color == GRAYSCALE_UI_THEME.mode_agent
338 || color == GRAYSCALE_UI_THEME.mode_plan
339 || color == GRAYSCALE_UI_THEME.mode_operate
340 || color == GRAYSCALE_UI_THEME.mode_yolo
341 {
342 color
343 } else if color == MODE_AGENT {
344 GRAYSCALE_UI_THEME.mode_agent
345 } else if color == MODE_PLAN {
346 GRAYSCALE_UI_THEME.mode_plan
347 } else if color == MODE_OPERATE {
348 GRAYSCALE_UI_THEME.mode_operate
349 } else if color == MODE_YOLO {
350 GRAYSCALE_UI_THEME.mode_yolo
351 } else if color == TEXT_BODY
352 || color == SELECTION_TEXT
353 || color == LIGHT_TEXT_BODY
354 || color == Color::White
355 || color == WHALE_ERROR
356 || color == STATUS_ERROR
357 {
358 GRAYSCALE_TEXT_BODY
359 } else if color == TEXT_SOFT
360 || color == TEXT_TOOL_OUTPUT
361 || color == LIGHT_TEXT_SOFT
362 || color == TEXT_ACCENT
363 || color == WHALE_ACTION
364 || color == WHALE_HUMAN
365 || color == ACCENT_TOOL_LIVE
366 || color == STATUS_SUCCESS
367 {
368 GRAYSCALE_TEXT_SOFT
369 } else if color == TEXT_SECONDARY
370 || color == TEXT_MUTED
371 || color == LIGHT_TEXT_MUTED
372 || color == TEXT_REASONING
373 || color == ACCENT_REASONING_LIVE
374 || color == STATUS_WARNING
375 || color == USER_BODY
376 || color == LIGHT_USER_BODY
377 || color == DIFF_ADDED
378 {
379 GRAYSCALE_TEXT_MUTED
380 } else if color == TEXT_HINT
381 || color == TEXT_DIM
382 || color == LIGHT_TEXT_HINT
383 || color == BORDER_COLOR
384 || color == LIGHT_BORDER
385 || color == ACCENT_TOOL_ISSUE
386 {
387 GRAYSCALE_TEXT_HINT
388 } else {
389 match color {
390 Color::Black => GRAYSCALE_TEXT_BODY,
391 Color::Gray | Color::DarkGray => GRAYSCALE_TEXT_HINT,
392 Color::Red
393 | Color::LightRed
394 | Color::Green
395 | Color::LightGreen
396 | Color::Yellow
397 | Color::LightYellow
398 | Color::Blue
399 | Color::LightBlue
400 | Color::Magenta
401 | Color::LightMagenta
402 | Color::Cyan
403 | Color::LightCyan => GRAYSCALE_TEXT_SOFT,
404 Color::Rgb(r, g, b) => grayscale_fg_from_luma(luma(r, g, b)),
405 Color::Indexed(_) => color,
406 _ => color,
407 }
408 }
409 }
410
411 fn adapt_bg_for_grayscale_palette(color: Color) -> Color {
412 // Direct UiTheme paints have already resolved these slots. Bucketing
413 // their luminance again collapses selection into panel and raised
414 // surfaces into the field, so preserve the authored grayscale ladder.
415 if color == Color::Reset
416 || color == GRAYSCALE_SURFACE
417 || color == GRAYSCALE_PANEL
418 || color == GRAYSCALE_ELEVATED
419 || color == GRAYSCALE_REASONING
420 || color == GRAYSCALE_SELECTION_BG
421 || color == GRAYSCALE_SUCCESS
422 || color == GRAYSCALE_ERROR
423 {
424 return color;
425 }
426 if color == WHALE_BG || color == BACKGROUND_DARK || color == LIGHT_SURFACE {
427 GRAYSCALE_SURFACE
428 } else if color == WHALE_PANEL
429 || color == COMPOSER_BG
430 || color == SURFACE_PANEL
431 || color == SURFACE_TOOL
432 || color == LIGHT_PANEL
433 {
434 GRAYSCALE_PANEL
435 } else if color == SELECTION_BG || color == LIGHT_SELECTION_BG {
436 GRAYSCALE_SELECTION_BG
437 } else if color == SURFACE_ELEVATED || color == SURFACE_TOOL_ACTIVE || color == LIGHT_ELEVATED {
438 GRAYSCALE_ELEVATED
439 } else if color == SURFACE_REASONING
440 || color == SURFACE_REASONING_TINT
441 || color == SURFACE_REASONING_ACTIVE
442 || color == LIGHT_REASONING
443 {
444 GRAYSCALE_REASONING
445 } else if color == SURFACE_SUCCESS || color == DIFF_ADDED_BG || color == LIGHT_SUCCESS {
446 GRAYSCALE_SUCCESS
447 } else if color == SURFACE_ERROR || color == DIFF_DELETED_BG || color == LIGHT_ERROR {
448 GRAYSCALE_ERROR
449 } else {
450 match color {
451 Color::Black => GRAYSCALE_SURFACE,
452 Color::White | Color::Gray => GRAYSCALE_ELEVATED,
453 Color::DarkGray => GRAYSCALE_PANEL,
454 Color::Red
455 | Color::LightRed
456 | Color::Green
457 | Color::LightGreen
458 | Color::Yellow
459 | Color::LightYellow
460 | Color::Blue
461 | Color::LightBlue
462 | Color::Magenta
463 | Color::LightMagenta
464 | Color::Cyan
465 | Color::LightCyan => GRAYSCALE_ELEVATED,
466 Color::Rgb(r, g, b) => grayscale_bg_from_luma(luma(r, g, b)),
467 Color::Indexed(_) => color,
468 _ => color,
469 }
470 }
471 }
472
473 fn grayscale_fg_from_luma(luma: u8) -> Color {
474 match luma {
475 0..=95 => GRAYSCALE_TEXT_HINT,
476 96..=155 => GRAYSCALE_TEXT_MUTED,
477 156..=215 => GRAYSCALE_TEXT_SOFT,
478 _ => GRAYSCALE_TEXT_BODY,
479 }
480 }
481
482 fn grayscale_bg_from_luma(luma: u8) -> Color {
483 match luma {
484 0..=28 => GRAYSCALE_SURFACE,
485 29..=95 => GRAYSCALE_PANEL,
486 96..=185 => GRAYSCALE_ELEVATED,
487 _ => GRAYSCALE_REASONING,
488 }
489 }
490
491 pub(crate) fn luma(r: u8, g: u8, b: u8) -> u8 {
492 ((u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114 + 500) / 1000) as u8
493 }
494 // === Color depth + brightness helpers (v0.6.6 UI redesign) ===
495
496 /// Terminal color depth, used to gate truecolor surfaces (e.g. reasoning bg
497 /// tints) on terminals that can't render them faithfully.
498 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
499 pub enum ColorDepth {
500 /// Explicit NO_COLOR: terminal-owned foreground/background, with text
501 /// modifiers and semantic symbols retained.
502 Monochrome,
503 /// 16-color terminals (macOS Terminal.app default, dumb tmux setups).
504 /// Background tints distort the named-palette mapping, so we drop them.
505 Ansi16,
506 /// 256-color terminals — RGB→256 fallback is faithful enough.
507 Ansi256,
508 /// True-color (24-bit) — render the palette verbatim.
509 TrueColor,
510 }
511
512 /// Foreground roles that must remain distinct after the terminal reduces the
513 /// palette. RGB proximity is deliberately irrelevant here: action and Operate,
514 /// or a human ask and a warning, are different product states even when their
515 /// source hues happen to share a nearest ANSI color.
516 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
517 pub(crate) enum SemanticForegroundRole {
518 Action,
519 Live,
520 Human,
521 Warning,
522 Danger,
523 Success,
524 ModeAgent,
525 ModePlan,
526 ModeOperate,
527 ModeYolo,
528 }
529
530 impl SemanticForegroundRole {
531 #[must_use]
532 const fn ansi16(self) -> Color {
533 match self {
534 Self::Action => Color::LightBlue,
535 Self::Live => Color::LightCyan,
536 Self::Human => Color::LightYellow,
537 Self::Warning => Color::Yellow,
538 Self::Danger => Color::LightRed,
539 Self::Success => Color::LightGreen,
540 Self::ModeAgent => Color::Blue,
541 Self::ModePlan => Color::Magenta,
542 Self::ModeOperate => Color::LightMagenta,
543 Self::ModeYolo => Color::Red,
544 }
545 }
546 }
547
548 fn raw_semantic_foreground_role(color: Color) -> Option<SemanticForegroundRole> {
549 if color == MODE_AGENT {
550 Some(SemanticForegroundRole::ModeAgent)
551 } else if color == MODE_PLAN {
552 Some(SemanticForegroundRole::ModePlan)
553 } else if color == MODE_OPERATE {
554 Some(SemanticForegroundRole::ModeOperate)
555 } else if color == MODE_YOLO {
556 Some(SemanticForegroundRole::ModeYolo)
557 } else if color == WHALE_ACTION {
558 Some(SemanticForegroundRole::Action)
559 } else if color == WHALE_LIVE || color == TEXT_ACCENT || color == ACCENT_TOOL_LIVE {
560 Some(SemanticForegroundRole::Live)
561 } else if color == WHALE_HUMAN {
562 Some(SemanticForegroundRole::Human)
563 } else if color == STATUS_WARNING {
564 Some(SemanticForegroundRole::Warning)
565 } else if color == WHALE_ERROR || color == STATUS_ERROR || color == ACCENT_TOOL_ISSUE {
566 Some(SemanticForegroundRole::Danger)
567 } else if color == STATUS_SUCCESS || color == USER_BODY || color == DIFF_ADDED {
568 Some(SemanticForegroundRole::Success)
569 } else {
570 None
571 }
572 }
573
574 fn theme_semantic_foreground_role(color: Color, ui: &UiTheme) -> Option<SemanticForegroundRole> {
575 // Mode slots come first. Shipped themes keep these source colors distinct
576 // from the general semantic lanes so direct `app.ui_theme.mode_*` call
577 // sites retain the same identity as raw `MODE_*` call sites.
578 if color == ui.mode_agent {
579 Some(SemanticForegroundRole::ModeAgent)
580 } else if color == ui.mode_plan {
581 Some(SemanticForegroundRole::ModePlan)
582 } else if color == ui.mode_operate {
583 Some(SemanticForegroundRole::ModeOperate)
584 } else if color == ui.mode_yolo {
585 Some(SemanticForegroundRole::ModeYolo)
586 } else if color == ui.accent_primary {
587 Some(SemanticForegroundRole::Action)
588 } else if color == ui.status_working
589 || color == ui.accent_secondary
590 || color == ui.tool_running
591 // `UiTheme::info` is the sky/worker lane used by ambient and live
592 // surfaces. Several shipped themes intentionally alias it to their
593 // working color, so it must not precede the live buckets.
594 || color == ui.info
595 {
596 Some(SemanticForegroundRole::Live)
597 } else if color == ui.accent_action {
598 Some(SemanticForegroundRole::Human)
599 } else if color == ui.warning || color == ui.status_warning {
600 Some(SemanticForegroundRole::Warning)
601 } else if color == ui.error_fg || color == ui.tool_failed || color == ui.diff_deleted_fg {
602 Some(SemanticForegroundRole::Danger)
603 } else if color == ui.success || color == ui.tool_success || color == ui.diff_added_fg {
604 Some(SemanticForegroundRole::Success)
605 } else {
606 None
607 }
608 }
609
610 /// Adapt a resolved foreground to terminal depth while retaining the semantic
611 /// role carried by the original cell color. Truecolor and ANSI-256 preserve the
612 /// resolved theme value; ANSI-16 uses a fixed, injective role matrix instead of
613 /// an arbitrary nearest-color guess.
614 #[must_use]
615 pub fn adapt_fg_for_depth(
616 source: Color,
617 resolved: Color,
618 depth: ColorDepth,
619 ui: &UiTheme,
620 ) -> Color {
621 if depth == ColorDepth::Ansi16
622 && let Some(role) = raw_semantic_foreground_role(source)
623 .or_else(|| theme_semantic_foreground_role(source, ui))
624 {
625 role.ansi16()
626 } else {
627 adapt_color(resolved, depth)
628 }
629 }
630
631 impl ColorDepth {
632 /// Detect the active terminal's color depth. Honors `COLORTERM`
633 /// (truecolor / 24bit) first, then falls back to `TERM`. Defaults to
634 /// `TrueColor` because most modern terminals support it; the conservative
635 /// fallback is `Ansi16` so background tints disappear safely.
636 #[must_use]
637 pub fn detect() -> Self {
638 Self::detect_with(|key| std::env::var_os(key))
639 }
640
641 /// Pure decision core over an injected environment reader, so the
642 /// `NO_COLOR` contract is testable without mutating process env.
643 ///
644 /// `NO_COLOR` (no-color.org): present and non-empty ⇒ suppress color.
645 /// Monochrome is distinct from a terminal that supports ANSI-16 hues.
646 /// Text modifiers and Unicode symbols remain independent preferences.
647 #[must_use]
648 pub(crate) fn detect_with(get: impl Fn(&str) -> Option<std::ffi::OsString>) -> Self {
649 if let Some(no_color) = get("NO_COLOR")
650 && !no_color.is_empty()
651 {
652 return Self::Monochrome;
653 }
654 if let Some(ct) = get("COLORTERM") {
655 let ct = ct.to_string_lossy().to_ascii_lowercase();
656 if ct.contains("truecolor") || ct.contains("24bit") {
657 return Self::TrueColor;
658 }
659 }
660 if get("WT_SESSION").is_some() {
661 return Self::TrueColor;
662 }
663 if let Some(term_program) = get("TERM_PROGRAM") {
664 let term_program = term_program.to_string_lossy().to_ascii_lowercase();
665 if term_program.contains("iterm")
666 || term_program.contains("wezterm")
667 || term_program.contains("vscode")
668 || term_program.contains("warp")
669 {
670 return Self::TrueColor;
671 }
672 }
673 let term = get("TERM")
674 .map(|t| t.to_string_lossy().to_ascii_lowercase())
675 .unwrap_or_default();
676 if term.contains("truecolor") || term.contains("24bit") {
677 Self::TrueColor
678 } else if term.contains("256") {
679 Self::Ansi256
680 } else if term.is_empty() || term == "dumb" {
681 Self::Ansi16
682 } else {
683 // Unknown TERM strings should not receive 24-bit SGR by default.
684 // Older macOS/remote terminals can render truecolor backgrounds as
685 // bright cyan blocks; 256-color output is the safer compromise.
686 Self::Ansi256
687 }
688 }
689 }
690
691 /// Adapt a foreground color to the terminal's color depth.
692 ///
693 /// On TrueColor, `color` passes through. ANSI-256 uses the stable extended
694 /// palette; ANSI-16 uses a generic nearest named color. Rendered semantic
695 /// foregrounds must go through [`adapt_fg_for_depth`] so role identity is not
696 /// inferred from RGB proximity.
697 #[allow(dead_code)]
698 #[must_use]
699 pub fn adapt_color(color: Color, depth: ColorDepth) -> Color {
700 match (color, depth) {
701 (_, ColorDepth::Monochrome) => Color::Reset,
702 (_, ColorDepth::TrueColor) => color,
703 (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
704 (Color::Rgb(r, g, b), ColorDepth::Ansi16) => nearest_ansi16(r, g, b),
705 _ => color,
706 }
707 }
708
709 /// Adapt a background color. On Ansi16 terminals background tints are noisy,
710 /// so we drop them to `Color::Reset` rather than attempt a coarse named-color
711 /// match — a quiet background reads cleaner than a wrong one.
712 #[allow(dead_code)]
713 #[must_use]
714 pub fn adapt_bg(color: Color, depth: ColorDepth) -> Color {
715 match (color, depth) {
716 (_, ColorDepth::TrueColor) => color,
717 (Color::Rgb(r, g, b), ColorDepth::Ansi256) => Color::Indexed(rgb_to_ansi256(r, g, b)),
718 (_, ColorDepth::Ansi256) => color,
719 (_, ColorDepth::Ansi16 | ColorDepth::Monochrome) => Color::Reset,
720 }
721 }
722
723 /// Mix two RGB colors at `alpha` (0.0 = `bg`, 1.0 = `fg`). Anything that's not
724 /// RGB falls back to `fg` — there's no meaningful alpha blend on a named
725 /// palette entry.
726 #[allow(dead_code)]
727 #[must_use]
728 pub fn blend(fg: Color, bg: Color, alpha: f32) -> Color {
729 let alpha = alpha.clamp(0.0, 1.0);
730 match (fg, bg) {
731 (Color::Rgb(fr, fg_, fb), Color::Rgb(br, bg_, bb)) => {
732 let mix = |a: u8, b: u8| -> u8 {
733 let a = f32::from(a);
734 let b = f32::from(b);
735 (b + (a - b) * alpha).round().clamp(0.0, 255.0) as u8
736 };
737 Color::Rgb(mix(fr, br), mix(fg_, bg_), mix(fb, bb))
738 }
739 _ => fg,
740 }
741 }
742
743 /// Return the dedicated reasoning surface tint for terminals that can render
744 /// background colors faithfully. ANSI-16 terminals disable the tint because
745 /// the nearest named background is too coarse for this subtle treatment.
746 #[must_use]
747 pub fn reasoning_surface_tint(depth: ColorDepth) -> Option<Color> {
748 match depth {
749 ColorDepth::Ansi16 | ColorDepth::Monochrome => None,
750 _ => Some(adapt_bg(SURFACE_REASONING_TINT, depth)),
751 }
752 }
753
754 /// Pulse `color` between 30% and 100% brightness on a 2s cycle keyed off
755 /// `now_ms` (epoch ms). The minimum keeps the glyph readable at trough; the
756 /// maximum is the source color verbatim. Linear interpolation between them
757 /// reads as a slow heartbeat.
758 #[must_use]
759 pub fn pulse_brightness(color: Color, now_ms: u64) -> Color {
760 // 2 s = 2000 ms full cycle; sin gives a smooth 0..1..0 swing.
761 let phase = (now_ms % 2000) as f32 / 2000.0;
762 let t = (phase * std::f32::consts::TAU).sin() * 0.5 + 0.5; // 0..1
763 let alpha = 0.30 + t * 0.70; // 30%..100%
764 match color {
765 Color::Rgb(r, g, b) => {
766 let s = |c: u8| -> u8 { ((f32::from(c)) * alpha).round().clamp(0.0, 255.0) as u8 };
767 Color::Rgb(s(r), s(g), s(b))
768 }
769 other => other,
770 }
771 }
772
773 /// Map an RGB triple to its closest ANSI-16 named color. Only used by
774 /// `adapt_color` on Ansi16 terminals; we lean on hue dominance + lightness so
775 /// brand colors land on the obviously-related named entry (sky → cyan, blue →
776 /// blue, red → red, etc.) rather than dithering around grey.
777 #[allow(dead_code)]
778 pub(crate) fn nearest_ansi16(r: u8, g: u8, b: u8) -> Color {
779 let lum = (u16::from(r) + u16::from(g) + u16::from(b)) / 3;
780 if lum < 24 {
781 return Color::Black;
782 }
783 if r > 220 && g > 220 && b > 220 {
784 return Color::White;
785 }
786 let bright = lum > 144;
787 let max = r.max(g).max(b);
788 let min = r.min(g).min(b);
789 if max.saturating_sub(min) < 16 {
790 return if bright { Color::Gray } else { Color::DarkGray };
791 }
792 if r >= g && r >= b {
793 if g > b + 24 {
794 if bright {
795 Color::LightYellow
796 } else {
797 Color::Yellow
798 }
799 } else if b > r.saturating_sub(24) {
800 if bright {
801 Color::LightMagenta
802 } else {
803 Color::Magenta
804 }
805 } else if bright {
806 Color::LightRed
807 } else {
808 Color::Red
809 }
810 } else if g >= r && g >= b {
811 if b > r + 24 {
812 if bright {
813 Color::LightCyan
814 } else {
815 Color::Cyan
816 }
817 } else if bright {
818 Color::LightGreen
819 } else {
820 Color::Green
821 }
822 } else if r.saturating_add(48) >= b && r > g + 24 {
823 if bright {
824 Color::LightMagenta
825 } else {
826 Color::Magenta
827 }
828 } else if g.saturating_add(48) >= b && g > r + 24 {
829 if bright {
830 Color::LightCyan
831 } else {
832 Color::Cyan
833 }
834 } else if bright {
835 Color::LightBlue
836 } else {
837 Color::Blue
838 }
839 }
840
841 /// Map an RGB color to the nearest xterm 256-color palette index. We use only
842 /// the stable 6x6x6 cube and grayscale ramp (16..255), not the terminal's
843 /// user-configurable 0..15 colors.
844 #[allow(dead_code)]
845 pub(crate) fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
846 const CUBE_LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
847
848 fn nearest_cube_level(channel: u8) -> usize {
849 CUBE_LEVELS
850 .iter()
851 .enumerate()
852 .min_by_key(|(_, level)| channel.abs_diff(**level))
853 .map(|(idx, _)| idx)
854 .unwrap_or(0)
855 }
856
857 fn dist_sq(a: (u8, u8, u8), b: (u8, u8, u8)) -> u32 {
858 let dr = i32::from(a.0) - i32::from(b.0);
859 let dg = i32::from(a.1) - i32::from(b.1);
860 let db = i32::from(a.2) - i32::from(b.2);
861 (dr * dr + dg * dg + db * db) as u32
862 }
863
864 let ri = nearest_cube_level(r);
865 let gi = nearest_cube_level(g);
866 let bi = nearest_cube_level(b);
867 let cube_rgb = (CUBE_LEVELS[ri], CUBE_LEVELS[gi], CUBE_LEVELS[bi]);
868 let cube_index = 16 + (36 * ri) as u8 + (6 * gi) as u8 + bi as u8;
869
870 let avg = ((u16::from(r) + u16::from(g) + u16::from(b)) / 3) as u8;
871 let gray_i = if avg <= 8 {
872 0
873 } else if avg >= 238 {
874 23
875 } else {
876 ((u16::from(avg) - 8 + 5) / 10).min(23) as u8
877 };
878 let gray = 8 + 10 * gray_i;
879 let gray_index = 232 + gray_i;
880
881 if dist_sq((r, g, b), (gray, gray, gray)) < dist_sq((r, g, b), cube_rgb) {
882 gray_index
883 } else {
884 cube_index
885 }
886 }
887
887 lines RUST