返回 CodeWhale
detect.rs
根目录 / crates / tui / src / palette / detect.rs
1 //! Terminal palette-mode and color-depth detection.
2 //!
3 //! Detection returns *evidence*, not just a verdict: [`TerminalBackground`]
4 //! carries the background color we actually learned (when we learned one) and
5 //! [`BackgroundSource`] records how. The contrast floor in [`super::contrast`]
6 //! needs the color — a mode enum cannot tell you whether text clears 4.5:1 —
7 //! and the provenance is what lets us distinguish "measured dark" from
8 //! "assumed dark because nothing answered".
9
10 #[cfg(target_os = "macos")]
11 use std::process::Command;
12 use std::sync::OnceLock;
13
14 use ratatui::style::Color;
15
16 use super::contrast::relative_luminance;
17 use super::osc11;
18
19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
20 pub enum PaletteMode {
21 Dark,
22 Light,
23 Grayscale,
24 SolarizedLight,
25 }
26
27 /// How the terminal background was learned. Ordered strongest-first: an
28 /// answered OSC 11 query is a measurement, `COLORFGBG` is a hint, macOS
29 /// appearance is an inference about the OS rather than the terminal, and
30 /// `Unknown` means we are guessing.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32 pub enum BackgroundSource {
33 /// The terminal answered an OSC 11 query with its background color.
34 Osc11,
35 /// `COLORFGBG` was set. Carries a palette index, not an RGB value.
36 ColorFgBg,
37 /// macOS `AppleInterfaceStyle`. Describes the system, not the terminal —
38 /// a dark-mode Mac can still be running a light-profile terminal.
39 MacOsAppearance,
40 /// No evidence at all. Callers must not treat this as "dark" for anything
41 /// but choosing a default theme.
42 Unknown,
43 }
44
45 /// What we know about the surface the TUI is drawing onto.
46 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
47 pub struct TerminalBackground {
48 mode: PaletteMode,
49 color: Option<Color>,
50 source: BackgroundSource,
51 }
52
53 impl TerminalBackground {
54 #[must_use]
55 pub const fn new(mode: PaletteMode, color: Option<Color>, source: BackgroundSource) -> Self {
56 Self {
57 mode,
58 color,
59 source,
60 }
61 }
62
63 /// The evidence-free default. Stays [`PaletteMode::Dark`] so terminals we
64 /// cannot measure keep exactly the theme they get today, and carries no
65 /// color so the contrast floor declines to act rather than acting on a
66 /// guess.
67 #[must_use]
68 pub const fn unknown() -> Self {
69 Self::new(PaletteMode::Dark, None, BackgroundSource::Unknown)
70 }
71
72 #[must_use]
73 pub const fn mode(&self) -> PaletteMode {
74 self.mode
75 }
76
77 /// The measured background, or `None` when the source could not supply one
78 /// (`COLORFGBG` indices 0–15, macOS appearance, no evidence).
79 #[must_use]
80 pub const fn color(&self) -> Option<Color> {
81 self.color
82 }
83
84 #[must_use]
85 pub const fn source(&self) -> BackgroundSource {
86 self.source
87 }
88 }
89
90 /// Luminance at which black text and white text have equal contrast against a
91 /// surface. Above it a surface is light; at or below it, dark. Derived from
92 /// the WCAG contrast formula: `(L+0.05)/0.05 == 1.05/(L+0.05)`.
93 const LIGHT_SURFACE_LUMINANCE: f32 = 0.179_129_5;
94
95 /// Classify a background color as light or dark by relative luminance. This is
96 /// the only place polarity is decided, so `#FFFFFF` and a pale ivory reach the
97 /// same verdict without anyone maintaining a list.
98 #[must_use]
99 pub fn palette_mode_for_background(color: Color) -> Option<PaletteMode> {
100 let luminance = relative_luminance(color)?;
101 Some(if luminance > LIGHT_SURFACE_LUMINANCE {
102 PaletteMode::Light
103 } else {
104 PaletteMode::Dark
105 })
106 }
107
108 impl PaletteMode {
109 /// Parse `COLORFGBG`, whose last numeric segment is the terminal
110 /// background color. Values >= 8 conventionally indicate a light profile.
111 #[must_use]
112 pub fn from_colorfgbg(value: &str) -> Option<Self> {
113 let bg = colorfgbg_index(value)?;
114 Some(if bg >= 8 { Self::Light } else { Self::Dark })
115 }
116
117 /// Detect the active palette mode. See [`terminal_background`] for the
118 /// resolution order; this is the mode-only view of the same evidence.
119 #[must_use]
120 pub fn detect() -> Self {
121 terminal_background().mode()
122 }
123 }
124
125 /// The background segment of `COLORFGBG` — the last numeric field.
126 fn colorfgbg_index(value: &str) -> Option<u16> {
127 value
128 .split(';')
129 .rev()
130 .find_map(|part| part.parse::<u16>().ok())
131 }
132
133 /// Split `COLORFGBG` into a palette mode and, when the index is resolvable,
134 /// the background's actual RGB.
135 ///
136 /// Indices 0–15 are remapped by the user's terminal profile, so we report the
137 /// mode without a color rather than inventing one. Indices >= 16 are fixed by
138 /// the xterm specification and can be resolved exactly.
139 fn colorfgbg_background(value: &str) -> Option<(PaletteMode, Option<Color>)> {
140 let index = colorfgbg_index(value)?;
141 if let Ok(index) = u8::try_from(index)
142 && index >= 16
143 && let Some(mode) = palette_mode_for_background(Color::Indexed(index))
144 {
145 return Some((mode, Some(Color::Indexed(index))));
146 }
147 Some((PaletteMode::from_colorfgbg(value)?, None))
148 }
149
150 /// Combine the available evidence into a single [`TerminalBackground`].
151 ///
152 /// Pure, so every branch is testable without a terminal. Strongest evidence
153 /// wins: a measured color beats a palette index, which beats an OS appearance
154 /// setting, which beats nothing.
155 #[must_use]
156 pub fn resolve_terminal_background(
157 osc11_rgb: Option<(u8, u8, u8)>,
158 colorfgbg: Option<&str>,
159 macos_fallback: Option<PaletteMode>,
160 ) -> TerminalBackground {
161 if let Some((r, g, b)) = osc11_rgb {
162 let color = Color::Rgb(r, g, b);
163 if let Some(mode) = palette_mode_for_background(color) {
164 return TerminalBackground::new(mode, Some(color), BackgroundSource::Osc11);
165 }
166 }
167 if let Some((mode, color)) = colorfgbg.and_then(colorfgbg_background) {
168 return TerminalBackground::new(mode, color, BackgroundSource::ColorFgBg);
169 }
170 if let Some(mode) = macos_fallback {
171 return TerminalBackground::new(mode, None, BackgroundSource::MacOsAppearance);
172 }
173 TerminalBackground::unknown()
174 }
175
176 static TERMINAL_BACKGROUND: OnceLock<TerminalBackground> = OnceLock::new();
177
178 /// The detected terminal background, without querying the terminal.
179 ///
180 /// Returns the probed result once [`probe_terminal_background`] has run;
181 /// before that it answers from the environment alone. It deliberately does not
182 /// populate the cache, so an early caller cannot lock in an env-only answer
183 /// that the probe would have improved.
184 #[must_use]
185 pub fn terminal_background() -> TerminalBackground {
186 if let Some(background) = TERMINAL_BACKGROUND.get() {
187 return *background;
188 }
189 resolve_terminal_background(
190 None,
191 std::env::var("COLORFGBG").ok().as_deref(),
192 detect_macos_palette_mode(),
193 )
194 }
195
196 /// Query the terminal for its background and cache the result.
197 ///
198 /// Call this once, from the TUI entry point, after raw mode is enabled and
199 /// before the event loop starts — see the caveat on
200 /// [`osc11::query_terminal_background`]. Safe to call more than once; only the
201 /// first result is kept, so the answer stays stable for the process.
202 pub fn probe_terminal_background() -> TerminalBackground {
203 if let Some(background) = TERMINAL_BACKGROUND.get() {
204 return *background;
205 }
206 let background = resolve_terminal_background(
207 osc11::query_terminal_background(osc11::OSC11_QUERY_TIMEOUT),
208 std::env::var("COLORFGBG").ok().as_deref(),
209 detect_macos_palette_mode(),
210 );
211 *TERMINAL_BACKGROUND.get_or_init(|| background)
212 }
213
214 #[cfg(target_os = "macos")]
215 fn detect_macos_palette_mode() -> Option<PaletteMode> {
216 let output = Command::new("defaults")
217 .args(["read", "-g", "AppleInterfaceStyle"])
218 .output()
219 .ok()?;
220
221 if output.status.success() {
222 Some(palette_mode_from_apple_interface_style(
223 &String::from_utf8_lossy(&output.stdout),
224 ))
225 } else {
226 Some(PaletteMode::Light)
227 }
228 }
229
230 #[cfg(not(target_os = "macos"))]
231 fn detect_macos_palette_mode() -> Option<PaletteMode> {
232 None
233 }
234
235 #[cfg(any(target_os = "macos", test))]
236 pub(crate) fn palette_mode_from_apple_interface_style(value: &str) -> PaletteMode {
237 if value.trim().eq_ignore_ascii_case("dark") {
238 PaletteMode::Dark
239 } else {
240 PaletteMode::Light
241 }
242 }
243
243 lines RUST