//! Theme service: parses `$XDG_CONFIG_HOME/nova-shell/theme.json` once at plugin //! load. Same file-handling rules as `modules_service`. Color values are exposed //! as `QString` (#RRGGBB) which QML auto-coerces to `color` at the binding site. //! //! `reducedMotion` (which combines this config with PowerProfileService) and //! `loadColor()` (a pure JS gradient helper) live in `services/ThemeUtil.qml` - //! they need the QML-side PowerProfileService and don't fit a Rust singleton. use core::pin::Pin; use cxx_qt::CxxQtType; use cxx_qt_lib::{QColor, QString}; use serde::Deserialize; #[cxx_qt::bridge] pub mod qobject { unsafe extern "C++" { include!("cxx-qt-lib/qstring.h"); type QString = cxx_qt_lib::QString; include!("cxx-qt-lib/qcolor.h"); type QColor = cxx_qt_lib::QColor; } extern "RustQt" { #[qobject] #[qml_element] #[qml_singleton] // base16 palette as proper QColor so QML gets `.r/.g/.b` access without Qt.color(). #[qproperty(QColor, base00)] #[qproperty(QColor, base01)] #[qproperty(QColor, base02)] #[qproperty(QColor, base03)] #[qproperty(QColor, base04)] #[qproperty(QColor, base05)] #[qproperty(QColor, base06)] #[qproperty(QColor, base07)] #[qproperty(QColor, base08)] #[qproperty(QColor, base09)] #[qproperty(QColor, base0_a, cxx_name = "base0A")] #[qproperty(QColor, base0_b, cxx_name = "base0B")] #[qproperty(QColor, base0_c, cxx_name = "base0C")] #[qproperty(QColor, base0_d, cxx_name = "base0D")] #[qproperty(QColor, base0_e, cxx_name = "base0E")] #[qproperty(QColor, base0_f, cxx_name = "base0F")] // Typography. #[qproperty(QString, font_family, cxx_name = "fontFamily")] #[qproperty(QString, icon_font_family, cxx_name = "iconFontFamily")] #[qproperty(i32, font_size, cxx_name = "fontSize")] // Layout / decoration. #[qproperty(f64, bar_opacity, cxx_name = "barOpacity")] #[qproperty(i32, bar_height, cxx_name = "barHeight")] #[qproperty(i32, bar_padding, cxx_name = "barPadding")] #[qproperty(i32, module_spacing, cxx_name = "moduleSpacing")] #[qproperty(i32, group_spacing, cxx_name = "groupSpacing")] #[qproperty(i32, group_padding, cxx_name = "groupPadding")] #[qproperty(i32, radius)] #[qproperty(i32, screen_radius, cxx_name = "screenRadius")] // Reduced-motion config flag (the effective value combines this with // PowerProfileService.powerSaver - that combination lives in ThemeUtil.qml). #[qproperty(bool, reduced_motion_config, cxx_name = "reducedMotionConfig")] type ThemeService = super::ThemeServiceRust; } impl cxx_qt::Initialize for ThemeService {} } mod data { use super::Deserialize; #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Colors { #[serde(default = "Colors::d_base00")] pub base00: String, #[serde(default = "Colors::d_base01")] pub base01: String, #[serde(default = "Colors::d_base02")] pub base02: String, #[serde(default = "Colors::d_base03")] pub base03: String, #[serde(default = "Colors::d_base04")] pub base04: String, #[serde(default = "Colors::d_base05")] pub base05: String, #[serde(default = "Colors::d_base06")] pub base06: String, #[serde(default = "Colors::d_base07")] pub base07: String, #[serde(default = "Colors::d_base08")] pub base08: String, #[serde(default = "Colors::d_base09")] pub base09: String, #[serde(default = "Colors::d_base0a", rename = "base0A")] pub base0_a: String, #[serde(default = "Colors::d_base0b", rename = "base0B")] pub base0_b: String, #[serde(default = "Colors::d_base0c", rename = "base0C")] pub base0_c: String, #[serde(default = "Colors::d_base0d", rename = "base0D")] pub base0_d: String, #[serde(default = "Colors::d_base0e", rename = "base0E")] pub base0_e: String, #[serde(default = "Colors::d_base0f", rename = "base0F")] pub base0_f: String, } impl Colors { // Defaults match Theme.qml's catppuccin-style starting palette. fn d_base00() -> String { "#1e1e2e".to_owned() } fn d_base01() -> String { "#181825".to_owned() } fn d_base02() -> String { "#313244".to_owned() } fn d_base03() -> String { "#45475a".to_owned() } fn d_base04() -> String { "#585b70".to_owned() } fn d_base05() -> String { "#cdd6f4".to_owned() } fn d_base06() -> String { "#f5e0dc".to_owned() } fn d_base07() -> String { "#b4befe".to_owned() } fn d_base08() -> String { "#f38ba8".to_owned() } fn d_base09() -> String { "#fab387".to_owned() } fn d_base0a() -> String { "#f9e2af".to_owned() } fn d_base0b() -> String { "#a6e3a1".to_owned() } fn d_base0c() -> String { "#94e2d5".to_owned() } fn d_base0d() -> String { "#89b4fa".to_owned() } fn d_base0e() -> String { "#cba6f7".to_owned() } fn d_base0f() -> String { "#f2cdcd".to_owned() } } impl Default for Colors { fn default() -> Self { Self { base00: Self::d_base00(), base01: Self::d_base01(), base02: Self::d_base02(), base03: Self::d_base03(), base04: Self::d_base04(), base05: Self::d_base05(), base06: Self::d_base06(), base07: Self::d_base07(), base08: Self::d_base08(), base09: Self::d_base09(), base0_a: Self::d_base0a(), base0_b: Self::d_base0b(), base0_c: Self::d_base0c(), base0_d: Self::d_base0d(), base0_e: Self::d_base0e(), base0_f: Self::d_base0f(), } } } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct ThemeData { #[serde(default)] pub colors: Colors, #[serde(default = "ThemeData::d_font_family")] pub font_family: String, #[serde(default = "ThemeData::d_icon_font_family")] pub icon_font_family: String, #[serde(default = "ThemeData::d_font_size")] pub font_size: i32, #[serde(default = "ThemeData::d_bar_opacity")] pub bar_opacity: f64, #[serde(default = "ThemeData::d_bar_height")] pub bar_height: i32, #[serde(default = "ThemeData::d_bar_padding")] pub bar_padding: i32, #[serde(default = "ThemeData::d_module_spacing")] pub module_spacing: i32, #[serde(default = "ThemeData::d_group_spacing")] pub group_spacing: i32, #[serde(default = "ThemeData::d_group_padding")] pub group_padding: i32, #[serde(default = "ThemeData::d_radius")] pub radius: i32, #[serde(default = "ThemeData::d_screen_radius")] pub screen_radius: i32, #[serde(default)] pub reduced_motion: bool, } impl ThemeData { fn d_font_family() -> String { "sans-serif".to_owned() } fn d_icon_font_family() -> String { "Symbols Nerd Font".to_owned() } fn d_font_size() -> i32 { 12 } fn d_bar_opacity() -> f64 { 0.9 } fn d_bar_height() -> i32 { 32 } fn d_bar_padding() -> i32 { 8 } fn d_module_spacing() -> i32 { 4 } fn d_group_spacing() -> i32 { 6 } fn d_group_padding() -> i32 { 8 } fn d_radius() -> i32 { 4 } fn d_screen_radius() -> i32 { 15 } } impl Default for ThemeData { fn default() -> Self { Self { colors: Colors::default(), font_family: Self::d_font_family(), icon_font_family: Self::d_icon_font_family(), font_size: Self::d_font_size(), bar_opacity: Self::d_bar_opacity(), bar_height: Self::d_bar_height(), bar_padding: Self::d_bar_padding(), module_spacing: Self::d_module_spacing(), group_spacing: Self::d_group_spacing(), group_padding: Self::d_group_padding(), radius: Self::d_radius(), screen_radius: Self::d_screen_radius(), reduced_motion: false, } } } } use data::ThemeData; pub struct ThemeServiceRust { base00: QColor, base01: QColor, base02: QColor, base03: QColor, base04: QColor, base05: QColor, base06: QColor, base07: QColor, base08: QColor, base09: QColor, base0_a: QColor, base0_b: QColor, base0_c: QColor, base0_d: QColor, base0_e: QColor, base0_f: QColor, font_family: QString, icon_font_family: QString, font_size: i32, bar_opacity: f64, bar_height: i32, bar_padding: i32, module_spacing: i32, group_spacing: i32, group_padding: i32, radius: i32, screen_radius: i32, reduced_motion_config: bool, } impl Default for ThemeServiceRust { fn default() -> Self { Self::from_data(load_theme_data()) } } /// Parse a `#RRGGBB` (or `#AARRGGBB`) hex string into a `QColor`. Panics on /// malformed input - file-level malformed JSON already does this; reaching /// here with a bad hex means the user wrote `"colors": {"base00": "garbage"}` /// which we'd rather flag immediately than render as default magenta. fn parse_hex(s: &str) -> QColor { let h = s.trim_start_matches('#'); let v = u32::from_str_radix(h, 16) .unwrap_or_else(|_| panic!("[nova-plugin] theme.json: invalid color `{s}`")); match h.len() { 6 => QColor::from_rgb( ((v >> 16) & 0xff) as i32, ((v >> 8) & 0xff) as i32, (v & 0xff) as i32, ), 8 => QColor::from_rgba( ((v >> 16) & 0xff) as i32, ((v >> 8) & 0xff) as i32, (v & 0xff) as i32, ((v >> 24) & 0xff) as i32, ), _ => panic!("[nova-plugin] theme.json: color `{s}` must be #RRGGBB or #AARRGGBB"), } } impl ThemeServiceRust { fn from_data(d: ThemeData) -> Self { let s = |x: &str| QString::from(x); let c = parse_hex; Self { base00: c(&d.colors.base00), base01: c(&d.colors.base01), base02: c(&d.colors.base02), base03: c(&d.colors.base03), base04: c(&d.colors.base04), base05: c(&d.colors.base05), base06: c(&d.colors.base06), base07: c(&d.colors.base07), base08: c(&d.colors.base08), base09: c(&d.colors.base09), base0_a: c(&d.colors.base0_a), base0_b: c(&d.colors.base0_b), base0_c: c(&d.colors.base0_c), base0_d: c(&d.colors.base0_d), base0_e: c(&d.colors.base0_e), base0_f: c(&d.colors.base0_f), font_family: s(&d.font_family), icon_font_family: s(&d.icon_font_family), font_size: d.font_size, bar_opacity: d.bar_opacity, bar_height: d.bar_height, bar_padding: d.bar_padding, module_spacing: d.module_spacing, group_spacing: d.group_spacing, group_padding: d.group_padding, radius: d.radius, screen_radius: d.screen_radius, reduced_motion_config: d.reduced_motion, } } } fn load_theme_data() -> ThemeData { let path = crate::modules_service::config_path("theme.json"); let raw = match std::fs::read_to_string(&path) { Ok(s) => s, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { tracing::info!( target: "nova_plugin::config", "theme.json not found at {}, using defaults", path.display() ); return ThemeData::default(); } Err(e) => panic!("[nova-plugin] cannot read {}: {e}", path.display()), }; if let Ok(serde_json::Value::Object(map)) = serde_json::from_str::(&raw) { let known: &[&str] = &[ "colors", "fontFamily", "iconFontFamily", "fontSize", "barOpacity", "barHeight", "barPadding", "moduleSpacing", "groupSpacing", "groupPadding", "radius", "screenRadius", "reducedMotion", ]; for key in map.keys() { if !known.contains(&key.as_str()) { tracing::warn!( target: "nova_plugin::config", "{}: unknown top-level key `{key}` ignored", path.display() ); } } } serde_json::from_str(&raw) .unwrap_or_else(|e| panic!("[nova-plugin] {}: malformed JSON: {e}", path.display())) } impl cxx_qt::Initialize for qobject::ThemeService { fn initialize(self: Pin<&mut Self>) { let _ = self; } }