1use std::path::{Path, PathBuf};
13
14use serde::Deserialize;
15
16const ONE_DARK_GREEN: &str = "#98c379";
18const ONE_DARK_YELLOW: &str = "#e5c07b";
19const ONE_DARK_ORANGE: &str = "#d19a66";
20const ONE_DARK_RED: &str = "#e06c75";
21const ONE_DARK_BLUE: &str = "#61afef";
22const ONE_DARK_DIM: &str = "#5c6370";
23const ONE_DARK_FG: &str = "#abb2bf";
24const ONE_DARK_BAR_EMPTY: &str = "#3e4451";
25const ONE_DARK_MARKER: &str = "#d19a66";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Theme {
33 pub green: String,
34 pub yellow: String,
35 pub orange: String,
36 pub red: String,
37 pub blue: String,
38 pub dim: String,
39 pub fg: String,
40 pub bar_empty: String,
41 pub marker: String,
42}
43
44impl Default for Theme {
45 fn default() -> Self {
46 Self {
47 green: ONE_DARK_GREEN.into(),
48 yellow: ONE_DARK_YELLOW.into(),
49 orange: ONE_DARK_ORANGE.into(),
50 red: ONE_DARK_RED.into(),
51 blue: ONE_DARK_BLUE.into(),
52 dim: ONE_DARK_DIM.into(),
53 fg: ONE_DARK_FG.into(),
54 bar_empty: ONE_DARK_BAR_EMPTY.into(),
55 marker: ONE_DARK_MARKER.into(),
56 }
57 }
58}
59
60#[derive(Debug, Default, Deserialize)]
63struct OmarchyTheme {
64 accent: Option<String>,
65 foreground: Option<String>,
66 background: Option<String>,
67 color1: Option<String>,
68 color2: Option<String>,
69 color3: Option<String>,
70}
71
72impl Theme {
73 pub fn with_overrides(
76 mut self,
77 low: Option<String>,
78 mid: Option<String>,
79 high: Option<String>,
80 critical: Option<String>,
81 ) -> Self {
82 if let Some(v) = low {
83 self.green = v;
84 }
85 if let Some(v) = mid {
86 self.yellow = v;
87 }
88 if let Some(v) = high {
89 self.orange = v;
90 }
91 if let Some(v) = critical {
92 self.red = v;
93 }
94 self
95 }
96
97 pub fn merged_with_omarchy(self) -> Self {
101 let Some(path) = omarchy_theme_path() else {
102 return self;
103 };
104 self.merged_with_omarchy_file(&path)
105 }
106
107 pub fn merged_with_omarchy_file(mut self, path: &Path) -> Self {
109 let Ok(contents) = std::fs::read_to_string(path) else {
110 return self;
111 };
112 let Ok(parsed) = toml::from_str::<OmarchyTheme>(&contents) else {
113 return self;
114 };
115
116 if let Some(v) = parsed.accent {
125 self.blue = v;
126 }
127 if let Some(v) = parsed.foreground.clone() {
128 self.fg = v;
129 }
130 if let Some(v) = parsed.color1 {
131 self.red = v.clone();
132 self.orange = v;
133 }
134 if let Some(v) = parsed.color2 {
135 self.green = v;
136 }
137 if let Some(v) = parsed.color3 {
138 self.yellow = v;
139 }
140 if let (Some(fg), Some(bg)) = (&parsed.foreground, &parsed.background) {
141 if let Some(dim) = hex_blend(fg, bg) {
142 self.dim = dim;
143 self.marker = self.dim.clone();
144 if let Some(bar_empty) = hex_blend(bg, &self.dim) {
145 self.bar_empty = bar_empty;
146 }
147 }
148 }
149 self
150 }
151}
152
153fn omarchy_theme_path() -> Option<PathBuf> {
156 let home = std::env::var_os("HOME")?;
157 Some(PathBuf::from(home).join(".config/omarchy/current/theme/colors.toml"))
158}
159
160pub fn hex_blend(a: &str, b: &str) -> Option<String> {
164 let (ar, ag, ab) = parse_hex_rgb(a)?;
165 let (br, bg, bb) = parse_hex_rgb(b)?;
166 Some(format!(
167 "#{:02x}{:02x}{:02x}",
168 (u16::from(ar) + u16::from(br)) / 2,
169 (u16::from(ag) + u16::from(bg)) / 2,
170 (u16::from(ab) + u16::from(bb)) / 2,
171 ))
172}
173
174pub(crate) fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
175 let s = s.strip_prefix('#').unwrap_or(s);
176 let [r1, r2, g1, g2, b1, b2] = s.as_bytes() else {
177 return None;
178 };
179 Some((
180 hex_pair(*r1, *r2)?,
181 hex_pair(*g1, *g2)?,
182 hex_pair(*b1, *b2)?,
183 ))
184}
185
186fn hex_pair(hi: u8, lo: u8) -> Option<u8> {
187 Some((hex_nibble(hi)? << 4) | hex_nibble(lo)?)
188}
189
190fn hex_nibble(b: u8) -> Option<u8> {
191 match b {
192 b'0'..=b'9' => Some(b - b'0'),
193 b'a'..=b'f' => Some(b - b'a' + 10),
194 b'A'..=b'F' => Some(b - b'A' + 10),
195 _ => None,
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use std::io::Write;
203 use tempfile::NamedTempFile;
204
205 #[test]
206 fn one_dark_is_default() {
207 let t = Theme::default();
208 assert_eq!(t.green, ONE_DARK_GREEN);
209 assert_eq!(t.red, ONE_DARK_RED);
210 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
211 }
212
213 #[test]
214 fn hex_blend_averages() {
215 assert_eq!(hex_blend("#000000", "#ffffff"), Some("#7f7f7f".into()));
217 assert_eq!(hex_blend("#ff0000", "#0000ff"), Some("#7f007f".into()));
219 }
220
221 #[test]
222 fn hex_blend_rejects_garbage() {
223 assert_eq!(hex_blend("not-hex", "#000000"), None);
224 assert_eq!(hex_blend("#fff", "#000000"), None); assert_eq!(hex_blend("#xxxxxx", "#000000"), None);
226 assert_eq!(hex_blend("aéabc", "#000000"), None);
227 }
228
229 #[test]
230 fn hex_blend_strips_optional_hash() {
231 assert_eq!(hex_blend("000000", "ffffff"), Some("#7f7f7f".into()));
232 }
233
234 #[test]
235 fn cli_overrides_win_over_defaults() {
236 let t = Theme::default().with_overrides(
237 Some("#111111".into()),
238 None,
239 Some("#222222".into()),
240 None,
241 );
242 assert_eq!(t.green, "#111111");
243 assert_eq!(t.orange, "#222222");
244 assert_eq!(t.red, ONE_DARK_RED);
245 assert_eq!(t.yellow, ONE_DARK_YELLOW);
246 }
247
248 #[test]
249 fn missing_omarchy_file_is_silent() {
250 let t = Theme::default();
251 let merged = t
252 .clone()
253 .merged_with_omarchy_file(Path::new("/nonexistent/path.toml"));
254 assert_eq!(t, merged);
255 }
256
257 #[test]
258 fn omarchy_overrides_palette() {
259 let mut f = NamedTempFile::new().unwrap();
260 writeln!(
261 f,
262 r##"
263 accent = "#aabbcc"
264 foreground = "#ffffff"
265 background = "#000000"
266 color1 = "#ff0000"
267 color2 = "#00ff00"
268 color3 = "#ffff00"
269 "##
270 )
271 .unwrap();
272
273 let t = Theme::default().merged_with_omarchy_file(f.path());
274 assert_eq!(t.blue, "#aabbcc");
275 assert_eq!(t.fg, "#ffffff");
276 assert_eq!(t.red, "#ff0000");
277 assert_eq!(t.orange, "#ff0000"); assert_eq!(t.green, "#00ff00");
279 assert_eq!(t.yellow, "#ffff00");
280 assert_eq!(t.dim, "#7f7f7f");
282 assert_eq!(t.marker, "#7f7f7f");
283 assert_eq!(t.bar_empty, "#3f3f3f");
285 }
286
287 #[test]
288 fn omarchy_partial_keys_keep_defaults_for_unset() {
289 let mut f = NamedTempFile::new().unwrap();
290 writeln!(f, r##"accent = "#123456""##).unwrap();
291 let t = Theme::default().merged_with_omarchy_file(f.path());
292 assert_eq!(t.blue, "#123456");
293 assert_eq!(t.green, ONE_DARK_GREEN);
295 assert_eq!(t.red, ONE_DARK_RED);
296 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
297 }
298
299 #[test]
300 fn omarchy_with_only_bg_keeps_default_dim() {
301 let mut f = NamedTempFile::new().unwrap();
303 writeln!(f, r##"background = "#000000""##).unwrap();
304 let t = Theme::default().merged_with_omarchy_file(f.path());
305 assert_eq!(t.dim, ONE_DARK_DIM);
306 assert_eq!(t.bar_empty, ONE_DARK_BAR_EMPTY);
307 }
308
309 #[test]
310 fn omarchy_garbage_is_silent() {
311 let mut f = NamedTempFile::new().unwrap();
312 writeln!(f, "this is not toml = = =").unwrap();
313 let before = Theme::default();
314 let after = before.clone().merged_with_omarchy_file(f.path());
315 assert_eq!(before, after);
316 }
317}