返回 CodeWhale
detect.rs
1 //! Light and dark detection.
2 //!
3 //! Extracted from the Codewhale engine's `crates/palette/src/detect.rs`
4 //! (`Hmbown/CodeWhale` `58b1dd3dd`, last changed in `c6416b203`). Detection
5 //! returns evidence, not just a verdict: [`TerminalBackground`] carries the
6 //! color we actually measured and [`BackgroundSource`] records how we learned
7 //! it. Only a measurement (OSC 11) or the terminal's own hint (`COLORFGBG`)
8 //! yields a known [`Appearance`]; the macOS system setting describes the OS,
9 //! not the terminal, so it is kept as a hint and never trusted for painting.
10
11 #[cfg(target_os = "macos")]
12 use std::process::Command;
13 use std::sync::OnceLock;
14
15 use ratatui::style::Color;
16
17 use crate::color::relative_luminance;
18 use crate::osc11;
19
20 /// Whether the terminal's ground is light or dark, as far as we know.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22 pub enum Appearance {
23 Dark,
24 Light,
25 /// Nothing measured the ground. Components paint no grounds and use the
26 /// terminal's own named colors, which were chosen for its own ground.
27 Unknown,
28 }
29
30 /// How the terminal background was learned, strongest first.
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: a
38 /// dark-mode Mac can run a light terminal profile.
39 MacOsAppearance,
40 /// No evidence at all.
41 Unknown,
42 }
43
44 /// What we know about the surface we are drawing onto.
45 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
46 pub struct TerminalBackground {
47 polarity: Appearance,
48 color: Option<Color>,
49 source: BackgroundSource,
50 }
51
52 impl TerminalBackground {
53 #[must_use]
54 pub const fn new(polarity: Appearance, color: Option<Color>, source: BackgroundSource) -> Self {
55 Self {
56 polarity,
57 color,
58 source,
59 }
60 }
61
62 /// No evidence.
63 #[must_use]
64 pub const fn unknown() -> Self {
65 Self::new(Appearance::Unknown, None, BackgroundSource::Unknown)
66 }
67
68 /// The appearance components may paint for. `Unknown` unless the
69 /// terminal itself told us (OSC 11 or `COLORFGBG`).
70 #[must_use]
71 pub const fn appearance(&self) -> Appearance {
72 match self.source {
73 BackgroundSource::Osc11 | BackgroundSource::ColorFgBg => self.polarity,
74 BackgroundSource::MacOsAppearance | BackgroundSource::Unknown => Appearance::Unknown,
75 }
76 }
77
78 /// Best guess including the macOS system setting. Use it to pick a
79 /// default theme, never to decide contrast.
80 #[must_use]
81 pub const fn hint(&self) -> Appearance {
82 self.polarity
83 }
84
85 /// The measured background, when a source supplied one.
86 #[must_use]
87 pub const fn color(&self) -> Option<Color> {
88 self.color
89 }
90
91 #[must_use]
92 pub const fn source(&self) -> BackgroundSource {
93 self.source
94 }
95 }
96
97 /// Luminance at which black and white text have equal contrast against a
98 /// surface: `(L+0.05)/0.05 == 1.05/(L+0.05)`. Above it a surface is light.
99 const LIGHT_SURFACE_LUMINANCE: f32 = 0.179_129_5;
100
101 /// Classify a background color as light or dark by relative luminance, or
102 /// `None` when the terminal owns the color's RGB.
103 #[must_use]
104 pub fn appearance_for_background(color: Color) -> Option<Appearance> {
105 let luminance = relative_luminance(color)?;
106 Some(if luminance > LIGHT_SURFACE_LUMINANCE {
107 Appearance::Light
108 } else {
109 Appearance::Dark
110 })
111 }
112
113 /// The background segment of `COLORFGBG`: the last numeric field.
114 fn colorfgbg_index(value: &str) -> Option<u16> {
115 value
116 .split(';')
117 .rev()
118 .find_map(|part| part.parse::<u16>().ok())
119 }
120
121 /// Parse `COLORFGBG`. Indices 0-15 are remapped by the terminal profile, so
122 /// they yield a polarity without a color (>= 8 means a light profile);
123 /// indices >= 16 are fixed by xterm and resolve exactly.
124 fn colorfgbg_background(value: &str) -> Option<(Appearance, Option<Color>)> {
125 let index = colorfgbg_index(value)?;
126 if let Ok(index) = u8::try_from(index)
127 && index >= 16
128 && let Some(appearance) = appearance_for_background(Color::Indexed(index))
129 {
130 return Some((appearance, Some(Color::Indexed(index))));
131 }
132 Some((
133 if index >= 8 {
134 Appearance::Light
135 } else {
136 Appearance::Dark
137 },
138 None,
139 ))
140 }
141
142 /// Combine the available evidence. Pure, so every branch is testable without
143 /// a terminal. A measured color beats a palette index, which beats an OS
144 /// setting, which beats nothing.
145 #[must_use]
146 pub fn resolve_terminal_background(
147 osc11_rgb: Option<(u8, u8, u8)>,
148 colorfgbg: Option<&str>,
149 macos_fallback: Option<Appearance>,
150 ) -> TerminalBackground {
151 if let Some((r, g, b)) = osc11_rgb {
152 let color = Color::Rgb(r, g, b);
153 if let Some(appearance) = appearance_for_background(color) {
154 return TerminalBackground::new(appearance, Some(color), BackgroundSource::Osc11);
155 }
156 }
157 if let Some((appearance, color)) = colorfgbg.and_then(colorfgbg_background) {
158 return TerminalBackground::new(appearance, color, BackgroundSource::ColorFgBg);
159 }
160 if let Some(appearance) = macos_fallback {
161 return TerminalBackground::new(appearance, None, BackgroundSource::MacOsAppearance);
162 }
163 TerminalBackground::unknown()
164 }
165
166 static TERMINAL_BACKGROUND: OnceLock<TerminalBackground> = OnceLock::new();
167
168 /// The detected background without querying the terminal. Returns the probed
169 /// result once [`probe_terminal_background`] has run; before that it answers
170 /// from the environment and does not cache, so an early caller cannot lock in
171 /// an answer the probe would have improved.
172 #[must_use]
173 pub fn terminal_background() -> TerminalBackground {
174 if let Some(background) = TERMINAL_BACKGROUND.get() {
175 return *background;
176 }
177 resolve_terminal_background(
178 None,
179 std::env::var("COLORFGBG").ok().as_deref(),
180 detect_macos_appearance(),
181 )
182 }
183
184 /// Query the terminal (OSC 11) and cache the result for the process.
185 ///
186 /// Call once, after raw mode is enabled and before the event loop reads
187 /// stdin; see [`osc11::query_terminal_background`]. Replay the user's
188 /// type-ahead afterwards with [`osc11::take_carried_type_ahead`].
189 pub fn probe_terminal_background() -> TerminalBackground {
190 if let Some(background) = TERMINAL_BACKGROUND.get() {
191 return *background;
192 }
193 let background = resolve_terminal_background(
194 osc11::query_terminal_background(osc11::OSC11_QUERY_TIMEOUT),
195 std::env::var("COLORFGBG").ok().as_deref(),
196 detect_macos_appearance(),
197 );
198 *TERMINAL_BACKGROUND.get_or_init(|| background)
199 }
200
201 /// The macOS system setting, read once per process. It is only a hint (see
202 /// [`TerminalBackground::appearance`]), and reading it starts a process, so
203 /// a host calling [`crate::Theme::detect`] every frame must not pay for it
204 /// every frame.
205 #[cfg(target_os = "macos")]
206 fn detect_macos_appearance() -> Option<Appearance> {
207 static MACOS: OnceLock<Option<Appearance>> = OnceLock::new();
208 *MACOS.get_or_init(read_macos_appearance)
209 }
210
211 #[cfg(target_os = "macos")]
212 fn read_macos_appearance() -> Option<Appearance> {
213 let output = Command::new("defaults")
214 .args(["read", "-g", "AppleInterfaceStyle"])
215 .output()
216 .ok()?;
217 if output.status.success() {
218 Some(appearance_from_apple_interface_style(
219 &String::from_utf8_lossy(&output.stdout),
220 ))
221 } else {
222 Some(Appearance::Light)
223 }
224 }
225
226 #[cfg(not(target_os = "macos"))]
227 fn detect_macos_appearance() -> Option<Appearance> {
228 None
229 }
230
231 #[cfg(any(target_os = "macos", test))]
232 fn appearance_from_apple_interface_style(value: &str) -> Appearance {
233 if value.trim().eq_ignore_ascii_case("dark") {
234 Appearance::Dark
235 } else {
236 Appearance::Light
237 }
238 }
239
240 #[cfg(test)]
241 mod tests {
242 use super::*;
243
244 #[test]
245 fn osc11_measurement_beats_every_hint() {
246 let bg = resolve_terminal_background(
247 Some((0xfa, 0xf8, 0xf5)),
248 Some("15;0"),
249 Some(Appearance::Dark),
250 );
251 assert_eq!(bg.appearance(), Appearance::Light);
252 assert_eq!(bg.source(), BackgroundSource::Osc11);
253 assert_eq!(bg.color(), Some(Color::Rgb(0xfa, 0xf8, 0xf5)));
254 }
255
256 #[test]
257 fn colorfgbg_yields_polarity_and_exact_color_only_when_fixed() {
258 let light = resolve_terminal_background(None, Some("0;15"), None);
259 assert_eq!(light.appearance(), Appearance::Light);
260 assert_eq!(light.color(), None);
261 let dark = resolve_terminal_background(None, Some("15;0"), None);
262 assert_eq!(dark.appearance(), Appearance::Dark);
263 let fixed = resolve_terminal_background(None, Some("7;234"), None);
264 assert_eq!(fixed.appearance(), Appearance::Dark);
265 assert_eq!(fixed.color(), Some(Color::Indexed(234)));
266 }
267
268 #[test]
269 fn macos_setting_is_a_hint_not_an_appearance() {
270 let bg = resolve_terminal_background(None, None, Some(Appearance::Dark));
271 assert_eq!(bg.hint(), Appearance::Dark);
272 assert_eq!(bg.appearance(), Appearance::Unknown);
273 assert_eq!(
274 resolve_terminal_background(None, None, None).appearance(),
275 Appearance::Unknown
276 );
277 }
278
279 #[test]
280 fn apple_interface_style_parses() {
281 assert_eq!(
282 appearance_from_apple_interface_style("Dark\n"),
283 Appearance::Dark
284 );
285 assert_eq!(appearance_from_apple_interface_style(""), Appearance::Light);
286 }
287 }
288
288 lines RUST