1use std::path::{Path, PathBuf};
16
17use serde::Deserialize;
18
19const ONE_DARK_GREEN: &str = "#98c379";
21const ONE_DARK_YELLOW: &str = "#e5c07b";
22const ONE_DARK_ORANGE: &str = "#d19a66";
23const ONE_DARK_RED: &str = "#e06c75";
24const ONE_DARK_BLUE: &str = "#61afef";
25const ONE_DARK_DIM: &str = "#5c6370";
26const ONE_DARK_FG: &str = "#abb2bf";
27const ONE_DARK_BAR_EMPTY: &str = "#3e4451";
28const ONE_DARK_MARKER: &str = "#d19a66";
29
30#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Theme {
36 pub green: String,
37 pub yellow: String,
38 pub orange: String,
39 pub red: String,
40 pub blue: String,
41 pub dim: String,
42 pub fg: String,
43 pub bar_empty: String,
44 pub marker: String,
45}
46
47impl Default for Theme {
48 fn default() -> Self {
49 Self {
50 green: ONE_DARK_GREEN.into(),
51 yellow: ONE_DARK_YELLOW.into(),
52 orange: ONE_DARK_ORANGE.into(),
53 red: ONE_DARK_RED.into(),
54 blue: ONE_DARK_BLUE.into(),
55 dim: ONE_DARK_DIM.into(),
56 fg: ONE_DARK_FG.into(),
57 bar_empty: ONE_DARK_BAR_EMPTY.into(),
58 marker: ONE_DARK_MARKER.into(),
59 }
60 }
61}
62
63#[derive(Debug, Default, Deserialize)]
68struct OmarchyTheme {
69 accent: Option<String>,
70 foreground: Option<String>,
71 background: Option<String>,
72 red: Option<String>,
73 green: Option<String>,
74 yellow: Option<String>,
75 color1: Option<String>,
76 color2: Option<String>,
77 color3: Option<String>,
78}
79
80impl Theme {
81 pub fn with_overrides(
84 mut self,
85 low: Option<String>,
86 mid: Option<String>,
87 high: Option<String>,
88 critical: Option<String>,
89 ) -> Self {
90 if let Some(v) = low {
91 self.green = v;
92 }
93 if let Some(v) = mid {
94 self.yellow = v;
95 }
96 if let Some(v) = high {
97 self.orange = v;
98 }
99 if let Some(v) = critical {
100 self.red = v;
101 }
102 self
103 }
104
105 pub fn merged_with_omarchy(self) -> Self {
109 let Some(path) = omarchy_theme_path() else {
110 return self;
111 };
112 self.merged_with_omarchy_file(&path)
113 }
114
115 pub fn merged_with_omarchy_file(mut self, path: &Path) -> Self {
117 let Ok(contents) = std::fs::read_to_string(path) else {
118 return self;
119 };
120 let Ok(parsed) = toml::from_str::<OmarchyTheme>(&contents) else {
121 return self;
122 };
123
124 if let Some(v) = parsed.accent {
134 self.blue = v;
135 }
136 if let Some(v) = parsed.foreground.clone() {
137 self.fg = v;
138 }
139 if let Some(v) = parsed.red.or(parsed.color1) {
140 self.red = v.clone();
141 self.orange = v;
142 }
143 if let Some(v) = parsed.green.or(parsed.color2) {
144 self.green = v;
145 }
146 if let Some(v) = parsed.yellow.or(parsed.color3) {
147 self.yellow = v;
148 }
149 if let (Some(fg), Some(bg)) = (&parsed.foreground, &parsed.background)
150 && let Some(dim) = hex_blend(fg, bg)
151 {
152 self.dim = dim;
153 self.marker = self.dim.clone();
154 if let Some(bar_empty) = hex_blend(bg, &self.dim) {
155 self.bar_empty = bar_empty;
156 }
157 }
158 self
159 }
160}
161
162fn omarchy_theme_path() -> Option<PathBuf> {
165 let home = std::env::var_os("HOME")?;
166 omarchy_theme_path_in(Path::new(&home))
167}
168
169fn omarchy_theme_path_in(home: &Path) -> Option<PathBuf> {
171 [
172 home.join(".local/state/omarchy/current/theme/colors.toml"),
174 home.join(".config/omarchy/current/theme/colors.toml"),
176 ]
177 .into_iter()
178 .find(|path| path.is_file())
179}
180
181pub fn hex_blend(a: &str, b: &str) -> Option<String> {
185 let (ar, ag, ab) = parse_hex_rgb(a)?;
186 let (br, bg, bb) = parse_hex_rgb(b)?;
187 Some(format!(
188 "#{:02x}{:02x}{:02x}",
189 (u16::from(ar) + u16::from(br)) / 2,
190 (u16::from(ag) + u16::from(bg)) / 2,
191 (u16::from(ab) + u16::from(bb)) / 2,
192 ))
193}
194
195pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
196 let s = s.strip_prefix('#').unwrap_or(s);
197 let [r1, r2, g1, g2, b1, b2] = s.as_bytes() else {
198 return None;
199 };
200 Some((
201 hex_pair(*r1, *r2)?,
202 hex_pair(*g1, *g2)?,
203 hex_pair(*b1, *b2)?,
204 ))
205}
206
207fn hex_pair(hi: u8, lo: u8) -> Option<u8> {
208 Some((hex_nibble(hi)? << 4) | hex_nibble(lo)?)
209}
210
211fn hex_nibble(b: u8) -> Option<u8> {
212 match b {
213 b'0'..=b'9' => Some(b - b'0'),
214 b'a'..=b'f' => Some(b - b'a' + 10),
215 b'A'..=b'F' => Some(b - b'A' + 10),
216 _ => None,
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::io::Write;
224 use tempfile::NamedTempFile;
225
226 #[test]
227 fn one_dark_is_default() {
228 let t = Theme::default();
229 assert_eq!(t.green, ONE_DARK_GREEN);
230 assert_eq!(t.red, ONE_DARK_RED);
231 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
232 }
233
234 #[test]
235 fn hex_blend_averages() {
236 assert_eq!(hex_blend("#000000", "#ffffff"), Some("#7f7f7f".into()));
238 assert_eq!(hex_blend("#ff0000", "#0000ff"), Some("#7f007f".into()));
240 }
241
242 #[test]
243 fn hex_blend_rejects_garbage() {
244 assert_eq!(hex_blend("not-hex", "#000000"), None);
245 assert_eq!(hex_blend("#fff", "#000000"), None); assert_eq!(hex_blend("#xxxxxx", "#000000"), None);
247 assert_eq!(hex_blend("aéabc", "#000000"), None);
248 }
249
250 #[test]
251 fn hex_blend_strips_optional_hash() {
252 assert_eq!(hex_blend("000000", "ffffff"), Some("#7f7f7f".into()));
253 }
254
255 #[test]
256 fn cli_overrides_win_over_defaults() {
257 let t = Theme::default().with_overrides(
258 Some("#111111".into()),
259 None,
260 Some("#222222".into()),
261 None,
262 );
263 assert_eq!(t.green, "#111111");
264 assert_eq!(t.orange, "#222222");
265 assert_eq!(t.red, ONE_DARK_RED);
266 assert_eq!(t.yellow, ONE_DARK_YELLOW);
267 }
268
269 #[test]
270 fn missing_omarchy_file_is_silent() {
271 let t = Theme::default();
272 let merged = t
273 .clone()
274 .merged_with_omarchy_file(Path::new("/nonexistent/path.toml"));
275 assert_eq!(t, merged);
276 }
277
278 #[test]
279 fn omarchy_named_keys_override_palette() {
280 let mut f = NamedTempFile::new().unwrap();
283 writeln!(
284 f,
285 r##"
286 accent = "#aabbcc"
287 foreground = "#ffffff"
288 background = "#000000"
289 red = "#ff0000"
290 green = "#00ff00"
291 yellow = "#ffff00"
292 "##
293 )
294 .unwrap();
295
296 let t = Theme::default().merged_with_omarchy_file(f.path());
297 assert_eq!(t.blue, "#aabbcc");
298 assert_eq!(t.fg, "#ffffff");
299 assert_eq!(t.red, "#ff0000");
300 assert_eq!(t.orange, "#ff0000"); assert_eq!(t.green, "#00ff00");
302 assert_eq!(t.yellow, "#ffff00");
303 }
304
305 #[test]
306 fn omarchy_named_keys_win_over_aliases() {
307 let mut f = NamedTempFile::new().unwrap();
310 writeln!(
311 f,
312 r##"
313 red = "#111111"
314 green = "#222222"
315 yellow = "#333333"
316 color1 = "#aaaaaa"
317 color2 = "#bbbbbb"
318 color3 = "#cccccc"
319 "##
320 )
321 .unwrap();
322
323 let t = Theme::default().merged_with_omarchy_file(f.path());
324 assert_eq!(t.red, "#111111");
325 assert_eq!(t.orange, "#111111");
326 assert_eq!(t.green, "#222222");
327 assert_eq!(t.yellow, "#333333");
328 }
329
330 #[test]
331 fn omarchy_theme_path_follows_where_the_theme_is_applied() {
332 let home = tempfile::tempdir().unwrap();
333 assert_eq!(omarchy_theme_path_in(home.path()), None);
334
335 let legacy = home
338 .path()
339 .join(".config/omarchy/current/theme/colors.toml");
340 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
341 std::fs::write(&legacy, "accent = \"#123456\"\n").unwrap();
342 assert_eq!(omarchy_theme_path_in(home.path()), Some(legacy.clone()));
343
344 let applied = home
345 .path()
346 .join(".local/state/omarchy/current/theme/colors.toml");
347 std::fs::create_dir_all(applied.parent().unwrap()).unwrap();
348 std::fs::write(&applied, "accent = \"#654321\"\n").unwrap();
349 assert_eq!(omarchy_theme_path_in(home.path()), Some(applied));
350 }
351
352 #[test]
353 fn omarchy_overrides_palette() {
354 let mut f = NamedTempFile::new().unwrap();
355 writeln!(
356 f,
357 r##"
358 accent = "#aabbcc"
359 foreground = "#ffffff"
360 background = "#000000"
361 color1 = "#ff0000"
362 color2 = "#00ff00"
363 color3 = "#ffff00"
364 "##
365 )
366 .unwrap();
367
368 let t = Theme::default().merged_with_omarchy_file(f.path());
369 assert_eq!(t.blue, "#aabbcc");
370 assert_eq!(t.fg, "#ffffff");
371 assert_eq!(t.red, "#ff0000");
372 assert_eq!(t.orange, "#ff0000"); assert_eq!(t.green, "#00ff00");
374 assert_eq!(t.yellow, "#ffff00");
375 assert_eq!(t.dim, "#7f7f7f");
377 assert_eq!(t.marker, "#7f7f7f");
378 assert_eq!(t.bar_empty, "#3f3f3f");
380 }
381
382 #[test]
383 fn omarchy_partial_keys_keep_defaults_for_unset() {
384 let mut f = NamedTempFile::new().unwrap();
385 writeln!(f, r##"accent = "#123456""##).unwrap();
386 let t = Theme::default().merged_with_omarchy_file(f.path());
387 assert_eq!(t.blue, "#123456");
388 assert_eq!(t.green, ONE_DARK_GREEN);
390 assert_eq!(t.red, ONE_DARK_RED);
391 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
392 }
393
394 #[test]
395 fn omarchy_with_only_bg_keeps_default_dim() {
396 let mut f = NamedTempFile::new().unwrap();
398 writeln!(f, r##"background = "#000000""##).unwrap();
399 let t = Theme::default().merged_with_omarchy_file(f.path());
400 assert_eq!(t.dim, ONE_DARK_DIM);
401 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
402 }
403
404 #[test]
405 fn omarchy_garbage_is_silent() {
406 let mut f = NamedTempFile::new().unwrap();
407 writeln!(f, "this is not toml = = =").unwrap();
408 let before = Theme::default();
409 let after = before.clone().merged_with_omarchy_file(f.path());
410 assert_eq!(before, after);
411 }
412}