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 &mut self.color_replace_preview,
136 ]
137 .into_iter()
138 }
139}
140
141fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
147 let cube_idx = |c: u8| -> u8 {
149 if c < 48 {
151 0
152 } else if c < 115 {
153 1
154 } else {
155 ((c as u16 - 35) / 40).min(5) as u8
156 }
157 };
158 let level = |i: u8| -> u8 { if i == 0 { 0 } else { 55 + i * 40 } };
159 let (ci, cg, cb) = (cube_idx(r), cube_idx(g), cube_idx(b));
160 let cube = (16 + 36 * ci as u16 + 6 * cg as u16 + cb as u16) as u8;
161 let cube_rgb = (level(ci), level(cg), level(cb));
162
163 let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
165 let gi = if gray_avg < 8 {
166 0
167 } else {
168 (((gray_avg - 8) + 5) / 10).min(23)
169 };
170 let gray = (232 + gi) as u8;
171 let gl = (8 + 10 * gi) as u8;
172 let gray_rgb = (gl, gl, gl);
173
174 let dist = |(cr, cg2, cb2): (u8, u8, u8)| -> u32 {
175 let dr = r as i32 - cr as i32;
176 let dg = g as i32 - cg2 as i32;
177 let db = b as i32 - cb2 as i32;
178 (dr * dr + dg * dg + db * db) as u32
179 };
180
181 if dist(gray_rgb) < dist(cube_rgb) {
182 gray
183 } else {
184 cube
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn detects_truecolor_from_colorterm() {
194 assert_eq!(
195 from_env(Some("truecolor"), Some("xterm-256color")),
196 ColorDepth::TrueColor
197 );
198 assert_eq!(from_env(Some("24bit"), None), ColorDepth::TrueColor);
199 }
200
201 #[test]
202 fn detects_truecolor_from_term_direct() {
203 assert_eq!(from_env(None, Some("xterm-direct")), ColorDepth::TrueColor);
204 }
205
206 #[test]
207 fn detects_256color_from_term() {
208 assert_eq!(from_env(None, Some("xterm-256color")), ColorDepth::Ansi256);
209 assert_eq!(
210 from_env(Some(""), Some("screen-256color")),
211 ColorDepth::Ansi256
212 );
213 }
214
215 #[test]
216 fn falls_back_to_ansi16() {
217 assert_eq!(from_env(None, Some("xterm")), ColorDepth::Ansi16);
218 assert_eq!(from_env(None, None), ColorDepth::Ansi16);
219 assert_eq!(from_env(Some("yes"), Some("vt100")), ColorDepth::Ansi16);
220 }
221
222 #[test]
223 fn nearest_256_known_values() {
224 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);
231 assert!((232..=255).contains(&gray), "got {}", gray);
232 }
233
234 #[test]
235 fn truecolor_adapt_is_identity() {
236 let theme = Theme::gruvbox_dark();
237 assert_eq!(theme.clone().adapt(ColorDepth::TrueColor), theme);
238 }
239
240 #[test]
241 fn ansi256_adapt_leaves_no_rgb() {
242 let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi256);
243 let mut theme = theme;
244 for color in theme.roles_mut() {
245 assert!(
246 !matches!(color, ThemeColor::Rgb(..)),
247 "RGB role survived 256-color adaptation: {}",
248 color
249 );
250 }
251 }
252
253 #[test]
254 fn ansi16_adapt_delegates_to_builtin_ansi_mapping() {
255 let theme = Theme::gruvbox_dark().adapt(ColorDepth::Ansi16);
256 let expected = Theme {
259 name: "Gruvbox Dark".to_string(),
260 ..Theme::ansi()
261 };
262 assert_eq!(theme, expected);
263 }
264
265 #[test]
266 fn ansi16_adapt_has_no_rgb_for_any_builtin() {
267 for theme in Theme::builtins() {
268 let name = theme.name.clone();
269 let mut adapted = theme.adapt(ColorDepth::Ansi16);
270 for color in adapted.roles_mut() {
271 assert!(
272 !matches!(color, ThemeColor::Rgb(..)),
273 "theme {:?}: RGB role survived 16-color adaptation",
274 name
275 );
276 }
277 }
278 }
279}