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