This repository has been archived on 2026-07-13. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nova-shell/plugin/src/theme_service.rs

414 lines
14 KiB
Rust

//! 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::<serde_json::Value>(&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;
}
}