kimun_notes/settings/themes/
color_depth.rs1use super::{Theme, ThemeColor};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ColorDepth {
18 TrueColor,
20 Ansi256,
22 Ansi16,
24}
25
26pub fn detect() -> ColorDepth {
31 static DEPTH: std::sync::OnceLock<ColorDepth> = std::sync::OnceLock::new();
32 *DEPTH.get_or_init(|| {
33 from_env(
34 std::env::var("COLORTERM").ok().as_deref(),
35 std::env::var("TERM").ok().as_deref(),
36 )
37 })
38}
39
40fn from_env(colorterm: Option<&str>, term: Option<&str>) -> ColorDepth {
42 if let Some(ct) = colorterm {
43 let ct = ct.to_ascii_lowercase();
44 if ct.contains("truecolor") || ct.contains("24bit") {
45 return ColorDepth::TrueColor;
46 }
47 }
48 if let Some(t) = term {
49 let t = t.to_ascii_lowercase();
50 if t.contains("direct") || t.contains("truecolor") {
52 return ColorDepth::TrueColor;
53 }
54 if t.contains("256color") {
55 return ColorDepth::Ansi256;
56 }
57 }
58 ColorDepth::Ansi16
59}
60
61impl Theme {
62 pub fn adapt_to_terminal(self) -> Theme {
67 self.adapt(detect())
68 }
69
70 pub fn adapt(self, depth: ColorDepth) -> Theme {
74 match depth {
75 ColorDepth::TrueColor => self,
76 ColorDepth::Ansi256 => self.into_quantized_256(),
77 ColorDepth::Ansi16 => self.into_ansi16(),
78 }
79 }
80
81 fn into_quantized_256(mut self) -> Theme {
83 for color in self.roles_mut() {
84 if let ThemeColor::Rgb(r, g, b) = *color {
85 *color = ThemeColor::Ansi(nearest_256(r, g, b));
86 }
87 }
88 self
89 }
90
91 fn into_ansi16(self) -> Theme {
99 Theme {
100 name: self.name,
101 ..Theme::ansi()
102 }
103 }
104
105 fn roles_mut(&mut self) -> impl Iterator<Item = &mut ThemeColor> {
107 [
108 &mut self.bg,
109 &mut self.bg_hard,
110 &mut self.bg_soft,
111 &mut self.bg_panel,
112 &mut self.selection_bg,
113 &mut self.fg,
114 &mut self.fg_bright,
115 &mut self.fg_secondary,
116 &mut self.gray,
117 &mut self.selection_fg,
118 &mut self.border_dim,
119 &mut self.focus_border,
120 &mut self.accent,
121 &mut self.cursor,
122 &mut self.red,
123 &mut self.green,
124 &mut self.yellow,
125 &mut self.blue,
126 &mut self.purple,
127 &mut self.aqua,
128 &mut self.orange,
129 &mut self.color_directory,
130 &mut self.color_journal_date,
131 &mut self.color_search_match,
132 &mut self.color_tag,
133 &mut self.blockquote_bar,
134 &mut self.code_bg,
135 ]
136 .into_iter()
137 }
138}
139
140fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
146 let cube_idx = |c: u8| -> u8 {
148 if c < 48 {
150 0
151 } else if c < 115 {
152 1
153 } else {
154 ((c as u16 - 35) / 40).min(5) as u8
155 }
156 };
157 let level = |i: u8| -> u8 { if i == 0 { 0 } else { 55 + i * 40 } };
158 let (ci, cg, cb) = (cube_idx(r), cube_idx(g), cube_idx(b));
159 let cube = (16 + 36 * ci as u16 + 6 * cg as u16 + cb as u16) as u8;
160 let cube_rgb = (level(ci), level(cg), level(cb));
161
162 let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
164 let gi = if gray_avg < 8 {
165 0
166 } else {
167 (((gray_avg - 8) + 5) / 10).min(23)
168 };
169 let gray = (232 + gi) as u8;
170 let gl = (8 + 10 * gi) as u8;
171 let gray_rgb = (gl, gl, gl);
172
173 let dist = |(cr, cg2, cb2): (u8, u8, u8)| -> u32 {
174 let dr = r as i32 - cr as i32;
175 let dg = g as i32 - cg2 as i32;
176 let db = b as i32 - cb2 as i32;
177 (dr * dr + dg * dg + db * db) as u32
178 };
179
180 if dist(gray_rgb) < dist(cube_rgb) {
181 gray
182 } else {
183 cube
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn detects_truecolor_from_colorterm() {
193 assert_eq!(
194 from_env(Some("truecolor"), Some("xterm-256color")),
195 ColorDepth::TrueColor
196 );
197 assert_eq!(from_env(Some("24bit"), None), ColorDepth::TrueColor);
198 }
199
200 #[test]
201 fn detects_truecolor_from_term_direct() {
202 assert_eq!(from_env(None, Some("xterm-direct")), ColorDepth::TrueColor);
203 }
204
205 #[test]
206 fn detects_256color_from_term() {
207 assert_eq!(from_env(None, Some("xterm-256color")), ColorDepth::Ansi256);
208 assert_eq!(
209 from_env(Some(""), Some("screen-256color")),
210 ColorDepth::Ansi256
211 );
212 }
213
214 #[test]
215 fn falls_back_to_ansi16() {
216 assert_eq!(from_env(None, Some("xterm")), ColorDepth::Ansi16);
217 assert_eq!(from_env(None, None), ColorDepth::Ansi16);
218 assert_eq!(from_env(Some("yes"), Some("vt100")), ColorDepth::Ansi16);
219 }
220
221 #[test]
222 fn nearest_256_known_values() {
223 assert_eq!(nearest_256(0, 0, 0), 16); assert_eq!(nearest_256(255, 255, 255), 231); assert_eq!(nearest_256(255, 0, 0), 196); assert_eq!(nearest_256(0, 255, 0), 46); assert_eq!(nearest_256(0, 0, 255), 21); let gray = nearest_256(128, 128, 128);
230 assert!((232..=255).contains(&gray), "got {}", gray);
231 }
232
233 #[test]
234 fn truecolor_adapt_is_identity() {
235 let theme = Theme::gruvbox_dark();
236 assert_eq!(theme.clone().adapt(ColorDepth::TrueColor), theme);
237 }
238
239 #[test]
240 fn ansi256_adapt_leaves_no_rgb() {
241 let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi256);
242 let mut theme = theme;
243 for color in theme.roles_mut() {
244 assert!(
245 !matches!(color, ThemeColor::Rgb(..)),
246 "RGB role survived 256-color adaptation: {}",
247 color
248 );
249 }
250 }
251
252 #[test]
253 fn ansi16_adapt_delegates_to_builtin_ansi_mapping() {
254 let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi16);
255 let expected = Theme {
258 name: "Gruvbox Dark".to_string(),
259 ..Theme::ansi()
260 };
261 assert_eq!(theme, expected);
262 }
263
264 #[test]
265 fn ansi16_adapt_has_no_rgb_for_any_builtin() {
266 for theme in Theme::builtins() {
267 let name = theme.name.clone();
268 let mut adapted = theme.adapt(ColorDepth::Ansi16);
269 for color in adapted.roles_mut() {
270 assert!(
271 !matches!(color, ThemeColor::Rgb(..)),
272 "theme {:?}: RGB role survived 16-color adaptation",
273 name
274 );
275 }
276 }
277 }
278}