Skip to main content

tree_space/
theme.rs

1//! Desktop-theme integration for `[theme] mode = "system"`.
2//!
3//! In the default `custom` mode tree-space reads its `main.css`. In `system`
4//! mode it instead builds the palette from the desktop theme and reuses the
5//! theme-independent structure of the shipped stylesheet, so `main.css` is
6//! neither required nor created.
7//!
8//! Only Omarchy is implemented today. Its active theme lives at
9//! `$XDG_STATE_HOME/omarchy/current/theme` (`~/.local/state/omarchy/current/theme`
10//! by default) and carries the color palette in `colors.toml`; the UI font
11//! comes from fontconfig, the same source `omarchy font current` uses.
12
13use 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/// The Omarchy theme currently applied, as read from disk.
26#[derive(Debug, Clone, PartialEq)]
27pub struct OmarchyTheme {
28    /// The theme's name (from `theme.name`), for diagnostics.
29    pub name: String,
30    pub colors: Colors,
31    /// Omarchy's monospace family, or `None` when fontconfig isn't available.
32    pub font_family: Option<String>,
33}
34
35/// The subset of Omarchy's `colors.toml` that tree-space's palette maps onto.
36///
37/// Stock and generated Omarchy themes always include every key; a theme that
38/// omits one falls back to the shipped default (see [`omarchy_stylesheet`]).
39#[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    // Used only for syntax highlighting; a theme that omits one falls back to
54    // the built-in palette for that class.
55    #[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    /// The `@define-color` palette tree-space's structure consumes, in order.
69    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            // The panel's highlights follow the theme's accent.
81            ("tn_blue", &self.accent),
82            ("tn_accent", &self.accent),
83            ("tn_green", &self.green),
84            ("tn_red", &self.red),
85        ]
86    }
87}
88
89/// Build the stylesheet for Omarchy's active theme, falling back to the shipped
90/// default when no theme can be read.
91pub 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
108/// The syntax-highlighting palette for the configured theme. Custom `main.css`
109/// mode is assumed dark (the shipped default); Omarchy mode maps the active
110/// theme's colors so highlighting matches the panel. Falls back to the dark
111/// default when no theme can be read.
112pub 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
123/// Map a theme's `colors.toml` onto the syntax token classes. Missing optional
124/// colors fall back to the built-in dark palette.
125fn 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
146/// Read the active Omarchy theme, or `None` when Omarchy isn't installed or its
147/// state directory is unreadable.
148pub fn load_omarchy_theme() -> Option<OmarchyTheme> {
149    let dir = current_theme_dir()?;
150    load_omarchy_theme_from(&dir)
151}
152
153/// Read an Omarchy theme from an explicit directory (tests). The font is looked
154/// up through fontconfig.
155pub 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
164/// Render a full stylesheet from an Omarchy theme: the theme's palette, then the
165/// theme-independent structure, then a font override so the panel follows the
166/// desktop font.
167pub 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
184/// `$XDG_STATE_HOME/omarchy/current/theme`, or the `~/.local/state` fallback.
185fn 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
197/// Omarchy's monospace family from fontconfig — the same source
198/// `omarchy font current` reads. `fc-match` returns a comma-separated alias
199/// list, so take the first entry.
200fn 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        // Palette values reach the CSS.
256        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        // Highlights follow the theme accent, not a fixed blue.
261        assert!(css.contains("@define-color tn_blue #7aa2f7;"));
262        // The structural rules are reused and never re-declare the palette.
263        assert!(css.contains(".tree-row {"));
264        let structure = css.split_once(".tree-row {").unwrap().1;
265        assert!(!structure.contains("@define-color tn_bg"));
266        // The desktop font overrides the one baked into the structure.
267        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        // The combined override selector is absent; the structure's own
280        // per-widget font rules remain.
281        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}