414 lines
14 KiB
Rust
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;
|
|
}
|
|
}
|