1use std::fmt::Write as _;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use serde::Deserialize;
18
19use crate::config::{
20 DEFAULT_STYLESHEET, LoadProblem, StyleSource, Stylesheet, SystemTheme, ThemeConfig, ThemeMode,
21 default_structure,
22};
23use crate::highlight;
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct OmarchyTheme {
28 pub name: String,
30 pub colors: Colors,
31 pub font_family: Option<String>,
33}
34
35#[derive(Debug, Clone, PartialEq, Deserialize)]
40pub struct Colors {
41 pub accent: String,
42 pub selection: String,
43 pub muted: String,
44 pub background: String,
45 pub dark_background: String,
46 pub darker_background: String,
47 pub lighter_background: String,
48 pub foreground: String,
49 pub dark_foreground: String,
50 pub bright_foreground: String,
51 pub red: String,
52 pub green: String,
53 #[serde(default)]
56 pub blue: String,
57 #[serde(default)]
58 pub cyan: String,
59 #[serde(default)]
60 pub yellow: String,
61 #[serde(default)]
62 pub orange: String,
63 #[serde(default)]
64 pub magenta: String,
65}
66
67impl Colors {
68 fn palette(&self) -> [(&'static str, &str); 13] {
70 [
71 ("tn_bg", &self.background),
72 ("tn_bg_dark", &self.dark_background),
73 ("tn_bg_darker", &self.darker_background),
74 ("tn_bg_light", &self.lighter_background),
75 ("tn_fg", &self.foreground),
76 ("tn_fg_bright", &self.bright_foreground),
77 ("tn_fg_dim", &self.dark_foreground),
78 ("tn_muted", &self.muted),
79 ("tn_selection", &self.selection),
80 ("tn_blue", &self.accent),
82 ("tn_accent", &self.accent),
83 ("tn_green", &self.green),
84 ("tn_red", &self.red),
85 ]
86 }
87}
88
89pub fn omarchy_stylesheet() -> Stylesheet {
92 match load_omarchy_theme() {
93 Some(theme) => Stylesheet {
94 css: render(&theme),
95 source: StyleSource::System,
96 problem: None,
97 },
98 None => Stylesheet {
99 css: DEFAULT_STYLESHEET.to_owned(),
100 source: StyleSource::Builtin,
101 problem: Some(LoadProblem::SystemTheme(
102 "no active Omarchy theme found; using the built-in default".to_owned(),
103 )),
104 },
105 }
106}
107
108pub fn syntax_palette(theme: &ThemeConfig) -> highlight::Palette {
113 match theme.mode {
114 ThemeMode::Custom => highlight::Palette::dark(),
115 ThemeMode::System => match theme.system {
116 SystemTheme::Omarchy => load_omarchy_theme()
117 .map(|theme| palette_from(&theme.colors))
118 .unwrap_or_else(highlight::Palette::dark),
119 },
120 }
121}
122
123fn palette_from(colors: &Colors) -> highlight::Palette {
126 let fallback = highlight::Palette::dark();
127 let pick = |value: &str, default: &str| {
128 if value.is_empty() { default.to_owned() } else { value.to_owned() }
129 };
130 highlight::Palette {
131 keyword: pick(&colors.accent, &fallback.keyword),
132 type_: pick(&colors.cyan, &fallback.type_),
133 function: pick(&colors.blue, &fallback.function),
134 string: pick(&colors.green, &fallback.string),
135 comment: pick(&colors.dark_foreground, &fallback.comment),
136 number: pick(&colors.orange, &fallback.number),
137 constant: pick(&colors.magenta, &fallback.constant),
138 preprocessor: pick(&colors.yellow, &fallback.preprocessor),
139 tag: pick(&colors.red, &fallback.tag),
140 attribute: pick(&colors.magenta, &fallback.attribute),
141 heading: pick(&colors.accent, &fallback.heading),
142 link: pick(&colors.cyan, &fallback.link),
143 }
144}
145
146pub fn load_omarchy_theme() -> Option<OmarchyTheme> {
149 let dir = current_theme_dir()?;
150 load_omarchy_theme_from(&dir)
151}
152
153pub fn load_omarchy_theme_from(dir: &Path) -> Option<OmarchyTheme> {
156 let raw = std::fs::read_to_string(dir.join("colors.toml")).ok()?;
157 let colors: Colors = toml::from_str(&raw).ok()?;
158 let name = std::fs::read_to_string(dir.join("theme.name"))
159 .map(|name| name.trim().to_owned())
160 .unwrap_or_default();
161 Some(OmarchyTheme { name, colors, font_family: omarchy_font_family() })
162}
163
164pub fn render(theme: &OmarchyTheme) -> String {
168 let mut css = String::from(
169 "/* Generated from the active Omarchy theme; main.css is not read in system mode. */\n",
170 );
171 for (name, value) in theme.colors.palette() {
172 let _ = writeln!(css, "@define-color {name} {value};");
173 }
174 css.push_str(default_structure());
175 if let Some(family) = &theme.font_family {
176 let _ = write!(
177 css,
178 "\n.panel, .bookmark-editor-root, .props {{ font-family: \"{family}\"; }}\n"
179 );
180 }
181 css
182}
183
184fn current_theme_dir() -> Option<PathBuf> {
186 let state = std::env::var_os("XDG_STATE_HOME")
187 .filter(|value| !value.is_empty())
188 .map(PathBuf::from)
189 .or_else(|| {
190 std::env::var_os("HOME")
191 .filter(|home| !home.is_empty())
192 .map(|home| PathBuf::from(home).join(".local/state"))
193 })?;
194 Some(state.join("omarchy").join("current").join("theme"))
195}
196
197fn omarchy_font_family() -> Option<String> {
201 let output = Command::new("fc-match")
202 .args(["monospace", "-f", "%{family}"])
203 .output()
204 .ok()?;
205 if !output.status.success() {
206 return None;
207 }
208 let families = String::from_utf8(output.stdout).ok()?;
209 let first = families.split(',').next()?.trim();
210 (!first.is_empty()).then(|| first.to_owned())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 const SAMPLE: &str = r##"
218mode = "dark"
219
220accent = "#7aa2f7"
221selection = "#292e42"
222muted = "#414868"
223background = "#1a1b26"
224dark_background = "#13141c"
225darker_background = "#0e0e14"
226lighter_background = "#24283b"
227foreground = "#a9b1d6"
228dark_foreground = "#565f89"
229bright_foreground = "#c0caf5"
230red = "#f7768e"
231green = "#9ece6a"
232blue = "#7aa2f7"
233"##;
234
235 fn sample_colors() -> Colors {
236 toml::from_str(SAMPLE).unwrap()
237 }
238
239 #[test]
240 fn colors_parse_from_colors_toml() {
241 let colors = sample_colors();
242 assert_eq!(colors.background, "#1a1b26");
243 assert_eq!(colors.accent, "#7aa2f7");
244 assert_eq!(colors.green, "#9ece6a");
245 }
246
247 #[test]
248 fn render_defines_the_palette_and_reuses_the_structure() {
249 let theme = OmarchyTheme {
250 name: "tokyo-night".to_owned(),
251 colors: sample_colors(),
252 font_family: Some("JetBrainsMono Nerd Font".to_owned()),
253 };
254 let css = render(&theme);
255 assert!(css.contains("@define-color tn_bg #1a1b26;"));
257 assert!(css.contains("@define-color tn_accent #7aa2f7;"));
258 assert!(css.contains("@define-color tn_green #9ece6a;"));
259 assert!(css.contains("@define-color tn_red #f7768e;"));
260 assert!(css.contains("@define-color tn_blue #7aa2f7;"));
262 assert!(css.contains(".tree-row {"));
264 let structure = css.split_once(".tree-row {").unwrap().1;
265 assert!(!structure.contains("@define-color tn_bg"));
266 assert!(css.contains(
268 ".panel, .bookmark-editor-root, .props { font-family: \"JetBrainsMono Nerd Font\"; }"
269 ));
270 }
271
272 #[test]
273 fn render_omits_the_font_override_without_a_family() {
274 let without = render(&OmarchyTheme {
275 name: String::new(),
276 colors: sample_colors(),
277 font_family: None,
278 });
279 assert!(!without.contains(".panel, .bookmark-editor-root, .props {"));
282 }
283
284 #[test]
285 fn missing_colors_toml_is_not_a_theme() {
286 let dir = tempfile::tempdir().unwrap();
287 assert!(load_omarchy_theme_from(dir.path()).is_none());
288 }
289
290 #[test]
291 fn theme_loads_from_an_explicit_directory() {
292 let dir = tempfile::tempdir().unwrap();
293 std::fs::write(dir.path().join("colors.toml"), SAMPLE).unwrap();
294 std::fs::write(dir.path().join("theme.name"), "tokyo-night\n").unwrap();
295 let theme = load_omarchy_theme_from(dir.path()).unwrap();
296 assert_eq!(theme.name, "tokyo-night");
297 assert_eq!(theme.colors.background, "#1a1b26");
298 }
299}