mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 14:53:21 +00:00
cosmic-conf: emit + CLI
emit writes RON directly rather than linking libcosmic. Spike 2 showed cosmic-config is a filesystem KV store whose notify watcher keys off file paths (lib.rs:377), so an atomic write is observed identically to the typed API — for the cost of ron instead of the whole libcosmic graph. Two-stage: plan renders without touching disk, apply writes. Composites are read-modify-write against verified upstream defaults — gaps (0,8) at theme.rs:939, CornerRadii at corner.rs:20-31 — so setting one field never drops its siblings. Unmodelled composites error rather than write blind. 52 tests. CLI verified end-to-end: apply, --diff (writes nothing), idempotent rerun, partial update preserving siblings, and multi-diagnostic failure with exit 1 and zero writes.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
# Single-core builds: this crate is developed on a machine where parallel
|
||||||
|
# rustc jobs are not wanted.
|
||||||
|
[build]
|
||||||
|
jobs = 1
|
||||||
Generated
+197
@@ -2,6 +2,203 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cosmic-conf"
|
name = "cosmic-conf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ron",
|
||||||
|
"serde",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell"
|
||||||
|
version = "1.21.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ron"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"once_cell",
|
||||||
|
"serde",
|
||||||
|
"serde_derive",
|
||||||
|
"typeid",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.27.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license = "GPL-3.0-only"
|
|||||||
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
description = "Compiles a single Hyprland-idiom config file into the cosmic-config tree"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ron = "0.12"
|
||||||
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# `emit` links cosmic-config and the component crates. Off by default so the
|
# `emit` links cosmic-config and the component crates. Off by default so the
|
||||||
@@ -13,3 +15,6 @@ description = "Compiles a single Hyprland-idiom config file into the cosmic-conf
|
|||||||
# dependency graph.
|
# dependency graph.
|
||||||
default = []
|
default = []
|
||||||
emit = []
|
emit = []
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
//! Resolved writes -> the cosmic-config tree.
|
||||||
|
//!
|
||||||
|
//! Spike 2 established the mechanism (see the spec's verified-findings table):
|
||||||
|
//! cosmic-config is a filesystem key-value store at
|
||||||
|
//! `$XDG_CONFIG_HOME/cosmic/<component>/v<n>/<key>`, each file holding one RON
|
||||||
|
//! literal. `Config::watch` (`cosmic-config/src/lib.rs:377`) is a `notify`
|
||||||
|
//! inotify watch on that directory which derives changed keys from file paths,
|
||||||
|
//! so a plain atomic write is observed exactly like a write from the typed API.
|
||||||
|
//! That is why this module needs `ron` rather than the whole libcosmic graph.
|
||||||
|
//!
|
||||||
|
//! Emission is two-stage on purpose. `plan` reads current state and renders
|
||||||
|
//! every file's new contents without touching disk; `apply` then writes. A
|
||||||
|
//! failure while planning therefore leaves the desktop untouched, preserving
|
||||||
|
//! the transactional guarantee `resolve` starts.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::resolve::{Resolved, TargetKey, Value, Write, WriteKind};
|
||||||
|
|
||||||
|
/// Mirror of `cosmic_theme::CornerRadii` (`cosmic-theme/src/model/corner.rs:5`).
|
||||||
|
///
|
||||||
|
/// Duplicated rather than depended upon so this crate stays free of the
|
||||||
|
/// libcosmic build graph. The field set and defaults are pinned by tests; if
|
||||||
|
/// upstream adds a radius, round-tripping would silently drop it, so
|
||||||
|
/// `deny_unknown_fields` turns that into a loud parse error instead.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct CornerRadii {
|
||||||
|
radius_0: [f32; 4],
|
||||||
|
radius_xs: [f32; 4],
|
||||||
|
radius_s: [f32; 4],
|
||||||
|
radius_m: [f32; 4],
|
||||||
|
radius_l: [f32; 4],
|
||||||
|
radius_xl: [f32; 4],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CornerRadii {
|
||||||
|
/// `corner.rs:20-31`.
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
radius_0: [0.0; 4],
|
||||||
|
radius_xs: [4.0; 4],
|
||||||
|
radius_s: [8.0; 4],
|
||||||
|
radius_m: [16.0; 4],
|
||||||
|
radius_l: [32.0; 4],
|
||||||
|
radius_xl: [160.0; 4],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CornerRadii {
|
||||||
|
fn field_mut(&mut self, name: &str) -> Option<&mut [f32; 4]> {
|
||||||
|
Some(match name {
|
||||||
|
"radius_0" => &mut self.radius_0,
|
||||||
|
"radius_xs" => &mut self.radius_xs,
|
||||||
|
"radius_s" => &mut self.radius_s,
|
||||||
|
"radius_m" => &mut self.radius_m,
|
||||||
|
"radius_l" => &mut self.radius_l,
|
||||||
|
"radius_xl" => &mut self.radius_xl,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum EmitError {
|
||||||
|
Io(io::Error),
|
||||||
|
/// A projected target whose composite shape this emitter cannot rebuild.
|
||||||
|
UnsupportedComposite { key: String, detail: String },
|
||||||
|
/// An existing file could not be parsed, so read-modify-write is unsafe.
|
||||||
|
Unreadable { path: PathBuf, detail: String },
|
||||||
|
NoConfigDirectory,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for EmitError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
EmitError::Io(e) => write!(f, "io error: {e}"),
|
||||||
|
EmitError::UnsupportedComposite { key, detail } => {
|
||||||
|
write!(f, "cannot write `{key}`: {detail}")
|
||||||
|
}
|
||||||
|
EmitError::Unreadable { path, detail } => {
|
||||||
|
write!(f, "cannot parse existing `{}`: {detail}", path.display())
|
||||||
|
}
|
||||||
|
EmitError::NoConfigDirectory => write!(f, "no config directory available"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for EmitError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for EmitError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
EmitError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One file's worth of pending change. `previous` powers `apply --diff`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Planned {
|
||||||
|
pub path: PathBuf,
|
||||||
|
pub contents: String,
|
||||||
|
pub previous: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Planned {
|
||||||
|
/// A write that would not change anything on disk.
|
||||||
|
pub fn is_noop(&self) -> bool {
|
||||||
|
self.previous.as_deref() == Some(self.contents.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Emitter {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Emitter {
|
||||||
|
/// Locate the cosmic-config root the same way cosmic-config does:
|
||||||
|
/// `$XDG_CONFIG_HOME/cosmic`, falling back to `$HOME/.config/cosmic`.
|
||||||
|
pub fn from_env() -> Result<Self, EmitError> {
|
||||||
|
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||||
|
_ => {
|
||||||
|
let home = std::env::var_os("HOME").ok_or(EmitError::NoConfigDirectory)?;
|
||||||
|
PathBuf::from(home).join(".config")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
root: base.join("cosmic"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_root(root: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_for(&self, target: &TargetKey) -> PathBuf {
|
||||||
|
self.root
|
||||||
|
.join(&target.component)
|
||||||
|
.join(format!("v{}", target.version))
|
||||||
|
.join(&target.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render every write without touching disk.
|
||||||
|
///
|
||||||
|
/// Returns all errors rather than the first, matching `resolve`'s behaviour
|
||||||
|
/// so a user sees the whole picture in one pass.
|
||||||
|
pub fn plan(&self, resolved: &Resolved) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||||
|
let mut planned = Vec::new();
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for write in &resolved.writes {
|
||||||
|
match self.plan_one(write) {
|
||||||
|
Ok(p) => planned.push(p),
|
||||||
|
Err(e) => errors.push(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.is_empty() {
|
||||||
|
Ok(planned)
|
||||||
|
} else {
|
||||||
|
Err(errors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_one(&self, write: &Write) -> Result<Planned, EmitError> {
|
||||||
|
let path = self.path_for(&write.target);
|
||||||
|
let previous = match fs::read_to_string(&path) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let contents = match &write.kind {
|
||||||
|
WriteKind::Whole(v) => render(v),
|
||||||
|
WriteKind::Projected(fields) => {
|
||||||
|
composite(&write.target, fields, previous.as_deref(), &path)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Planned {
|
||||||
|
path,
|
||||||
|
contents,
|
||||||
|
previous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the plan. Callers should `plan` first so that failures surface
|
||||||
|
/// before any file is touched.
|
||||||
|
pub fn apply(&self, planned: &[Planned]) -> Result<usize, EmitError> {
|
||||||
|
let mut written = 0;
|
||||||
|
for p in planned {
|
||||||
|
if p.is_noop() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(dir) = p.path.parent() {
|
||||||
|
fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
atomic_write(&p.path, &p.contents)?;
|
||||||
|
written += 1;
|
||||||
|
}
|
||||||
|
Ok(written)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write via temp-file + rename so a reader never observes a partial file.
|
||||||
|
///
|
||||||
|
/// The temp name carries cosmic-config's `.atomicwrite` prefix
|
||||||
|
/// (`cosmic-config/src/lib.rs:408`) so its watcher ignores the intermediate
|
||||||
|
/// file and reacts only to the final rename.
|
||||||
|
fn atomic_write(path: &Path, contents: &str) -> io::Result<()> {
|
||||||
|
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let tmp = dir.join(format!(".atomicwrite.{name}"));
|
||||||
|
|
||||||
|
fs::write(&tmp, contents)?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a scalar as the RON literal cosmic-config expects.
|
||||||
|
///
|
||||||
|
/// Exact formatting is not load-bearing — cosmic-config reads with
|
||||||
|
/// `ron::from_str` (`lib.rs:468`) — but the *shape* is: `Option<Srgb>` has three
|
||||||
|
/// components, `Option<Srgba>` four.
|
||||||
|
fn render(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::U32(n) => n.to_string(),
|
||||||
|
Value::F32(n) => render_f32(*n),
|
||||||
|
Value::Str(s) => format!("{s:?}"),
|
||||||
|
Value::Rgb(r, g, b) => format!(
|
||||||
|
"Some((red: {}, green: {}, blue: {}))",
|
||||||
|
render_f32(byte_to_f32(*r)),
|
||||||
|
render_f32(byte_to_f32(*g)),
|
||||||
|
render_f32(byte_to_f32(*b)),
|
||||||
|
),
|
||||||
|
Value::Rgba(r, g, b, a) => format!(
|
||||||
|
"Some((red: {}, green: {}, blue: {}, alpha: {}))",
|
||||||
|
render_f32(byte_to_f32(*r)),
|
||||||
|
render_f32(byte_to_f32(*g)),
|
||||||
|
render_f32(byte_to_f32(*b)),
|
||||||
|
render_f32(byte_to_f32(*a)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byte_to_f32(b: u8) -> f32 {
|
||||||
|
b as f32 / 255.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RON needs floats to look like floats: a bare `10` would deserialize as an
|
||||||
|
/// integer and fail a `f32` field.
|
||||||
|
fn render_f32(n: f32) -> String {
|
||||||
|
if n.fract() == 0.0 {
|
||||||
|
format!("{n:.1}")
|
||||||
|
} else {
|
||||||
|
format!("{n}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild a composite value from folded projections plus whatever is already
|
||||||
|
/// on disk.
|
||||||
|
///
|
||||||
|
/// Only shapes that can be reconstructed correctly are supported. Anything else
|
||||||
|
/// is a hard error rather than a partial write, because silently writing an
|
||||||
|
/// incomplete composite would drop the user's other fields.
|
||||||
|
fn composite(
|
||||||
|
target: &TargetKey,
|
||||||
|
fields: &BTreeMap<Vec<String>, Value>,
|
||||||
|
previous: Option<&str>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<String, EmitError> {
|
||||||
|
match target.key.as_str() {
|
||||||
|
// ThemeBuilder.gaps: (u32, u32) ordered (outer, inner) — theme.rs:895,
|
||||||
|
// default (0, 8) — theme.rs:939.
|
||||||
|
"gaps" => {
|
||||||
|
let (mut outer, mut inner) = match previous {
|
||||||
|
Some(text) => ron::from_str::<(u32, u32)>(text).map_err(|e| EmitError::Unreadable {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?,
|
||||||
|
None => (0, 8),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (p, v) in fields {
|
||||||
|
let Value::U32(n) = v else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("expected an integer for index {p:?}"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
match p.first().map(String::as_str) {
|
||||||
|
Some("0") => outer = *n,
|
||||||
|
Some("1") => inner = *n,
|
||||||
|
other => {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("unknown tuple index {other:?}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(format!("({outer}, {inner})"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeBuilder.corner_radii: six [f32; 4] fields — corner.rs:5.
|
||||||
|
"corner_radii" => {
|
||||||
|
let mut radii = match previous {
|
||||||
|
Some(text) => {
|
||||||
|
ron::from_str::<CornerRadii>(text).map_err(|e| EmitError::Unreadable {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
None => CornerRadii::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (p, v) in fields {
|
||||||
|
let Value::F32(n) = v else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("expected a number for {p:?}"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(name) = p.first() else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: "missing radius name".into(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(slot) = radii.field_mut(name) else {
|
||||||
|
return Err(EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: format!("unknown radius `{name}`"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// A single `rounding` value applies to all four corners.
|
||||||
|
*slot = [*n; 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
ron::ser::to_string_pretty(&radii, ron::ser::PrettyConfig::new()).map_err(|e| {
|
||||||
|
EmitError::UnsupportedComposite {
|
||||||
|
key: target.key.clone(),
|
||||||
|
detail: e.to_string(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
other => Err(EmitError::UnsupportedComposite {
|
||||||
|
key: other.to_string(),
|
||||||
|
detail: format!(
|
||||||
|
"composite shape not modelled yet; {} field(s) would be written blind",
|
||||||
|
fields.len()
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{parse, resolve};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn plan_for(src: &str, root: &Path) -> Result<Vec<Planned>, Vec<EmitError>> {
|
||||||
|
let ast = parse(src).expect("parse");
|
||||||
|
let resolved = resolve(&ast).expect("resolve");
|
||||||
|
Emitter::with_root(root).plan(&resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(root: &Path, component: &str, key: &str) -> String {
|
||||||
|
fs::read_to_string(root.join(component).join("v1").join(key))
|
||||||
|
.unwrap_or_else(|e| panic!("reading {component}/v1/{key}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_land_on_the_cosmic_config_path_layout() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
let e = Emitter::with_root(tmp.path());
|
||||||
|
e.apply(&planned).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read(tmp.path(), "com.system76.CosmicComp", "autotile"), "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scalars_render_as_ron_literals() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "general {\n autotile = true\n edge_snap_threshold = 12\n}\n\
|
||||||
|
theme {\n icon_theme = Tela-circle-dracula\n}\n";
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let root = tmp.path();
|
||||||
|
assert_eq!(read(root, "com.system76.CosmicComp", "autotile"), "true");
|
||||||
|
assert_eq!(read(root, "com.system76.CosmicComp", "edge_snap_threshold"), "12");
|
||||||
|
assert_eq!(
|
||||||
|
read(root, "com.system76.CosmicTk", "icon_theme"),
|
||||||
|
"\"Tela-circle-dracula\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The end-to-end form of the folding property: both halves must reach disk
|
||||||
|
/// in one tuple.
|
||||||
|
#[test]
|
||||||
|
fn both_gaps_reach_disk_in_one_tuple() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n gaps_in = 3\n gaps_out = 8\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// (outer, inner) — theme.rs:895
|
||||||
|
for builder in [
|
||||||
|
"com.system76.CosmicTheme.Dark.Builder",
|
||||||
|
"com.system76.CosmicTheme.Light.Builder",
|
||||||
|
] {
|
||||||
|
assert_eq!(read(tmp.path(), builder, "gaps"), "(8, 3)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cosmic-config is sparse: an unset key has no file, so a partial
|
||||||
|
/// projection must fall back to the verified default rather than zero.
|
||||||
|
#[test]
|
||||||
|
fn partial_projection_uses_the_verified_default() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n gaps_in = 5\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// Default is (0, 8); only inner was set, so outer stays 0.
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||||
|
"(0, 5)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-modify-write must preserve the half the user did not mention.
|
||||||
|
#[test]
|
||||||
|
fn partial_projection_preserves_existing_sibling() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp
|
||||||
|
.path()
|
||||||
|
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||||
|
.join("v1");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("gaps"), "(20, 4)").unwrap();
|
||||||
|
|
||||||
|
let planned = plan_for("general {\n gaps_in = 7\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
// Outer 20 survives; inner becomes 7.
|
||||||
|
assert_eq!(
|
||||||
|
read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "gaps"),
|
||||||
|
"(20, 7)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colors_render_with_the_right_component_count() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "theme {\n accent = rgb(ff0000)\n bg_color = rgba(00ff0080)\n}\n";
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let b = "com.system76.CosmicTheme.Dark.Builder";
|
||||||
|
// Option<Srgb>: three components, no alpha.
|
||||||
|
assert_eq!(read(tmp.path(), b, "accent"), "Some((red: 1.0, green: 0.0, blue: 0.0))");
|
||||||
|
// Option<Srgba>: four.
|
||||||
|
let bg = read(tmp.path(), b, "bg_color");
|
||||||
|
assert!(bg.starts_with("Some((red: 0.0, green: 1.0, blue: 0.0, alpha: "), "{bg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounding_sets_all_four_corners_of_radius_m() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let text = read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "corner_radii");
|
||||||
|
let radii: CornerRadii = ron::from_str(&text).expect("round-trips as CornerRadii");
|
||||||
|
assert_eq!(radii.radius_m, [10.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rounding_preserves_sibling_radii() {
|
||||||
|
// The other five radii must survive a read-modify-write untouched.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("decoration {\n rounding = 10\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let text = read(tmp.path(), "com.system76.CosmicTheme.Dark.Builder", "corner_radii");
|
||||||
|
let radii: CornerRadii = ron::from_str(&text).unwrap();
|
||||||
|
let d = CornerRadii::default();
|
||||||
|
assert_eq!(radii.radius_0, d.radius_0);
|
||||||
|
assert_eq!(radii.radius_xs, d.radius_xs);
|
||||||
|
assert_eq!(radii.radius_s, d.radius_s);
|
||||||
|
assert_eq!(radii.radius_l, d.radius_l);
|
||||||
|
assert_eq!(radii.radius_xl, d.radius_xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corner_radii_defaults_match_upstream() {
|
||||||
|
// Pinned against cosmic-theme/src/model/corner.rs:20-31. If upstream
|
||||||
|
// changes these, writing a sparse config would silently shift the theme.
|
||||||
|
let d = CornerRadii::default();
|
||||||
|
assert_eq!(d.radius_0, [0.0; 4]);
|
||||||
|
assert_eq!(d.radius_xs, [4.0; 4]);
|
||||||
|
assert_eq!(d.radius_s, [8.0; 4]);
|
||||||
|
assert_eq!(d.radius_m, [16.0; 4]);
|
||||||
|
assert_eq!(d.radius_l, [32.0; 4]);
|
||||||
|
assert_eq!(d.radius_xl, [160.0; 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn genuinely_unmodelled_composite_is_still_refused() {
|
||||||
|
// A projected target with no shape handler must error rather than
|
||||||
|
// write a partial value.
|
||||||
|
let write = Write {
|
||||||
|
target: TargetKey {
|
||||||
|
component: "com.system76.Whatever".into(),
|
||||||
|
version: 1,
|
||||||
|
key: "palette".into(),
|
||||||
|
},
|
||||||
|
kind: WriteKind::Projected(BTreeMap::from([(
|
||||||
|
vec!["bright_red".to_string()],
|
||||||
|
Value::U32(1),
|
||||||
|
)])),
|
||||||
|
};
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let e = Emitter::with_root(tmp.path());
|
||||||
|
let res = e.plan(&Resolved { writes: vec![write] });
|
||||||
|
assert!(
|
||||||
|
matches!(res.unwrap_err()[0], EmitError::UnsupportedComposite { .. }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unparseable_existing_value_is_refused() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp
|
||||||
|
.path()
|
||||||
|
.join("com.system76.CosmicTheme.Dark.Builder")
|
||||||
|
.join("v1");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("gaps"), "not ron at all").unwrap();
|
||||||
|
|
||||||
|
let errs = plan_for("general {\n gaps_in = 3\n}\n", tmp.path()).unwrap_err();
|
||||||
|
assert!(matches!(errs[0], EmitError::Unreadable { .. }), "{:?}", errs[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Planning must not touch disk — that is what makes emission transactional.
|
||||||
|
#[test]
|
||||||
|
fn plan_does_not_write() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let _ = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
assert!(
|
||||||
|
fs::read_dir(tmp.path()).unwrap().next().is_none(),
|
||||||
|
"plan must leave the tree untouched"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn noop_writes_are_skipped() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let src = "general {\n autotile = true\n}\n";
|
||||||
|
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 1);
|
||||||
|
|
||||||
|
// Second run sees identical contents and writes nothing.
|
||||||
|
let planned = plan_for(src, tmp.path()).unwrap();
|
||||||
|
assert!(planned[0].is_noop());
|
||||||
|
assert_eq!(Emitter::with_root(tmp.path()).apply(&planned).unwrap(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previous_contents_are_captured_for_diffing() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let first = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&first).unwrap();
|
||||||
|
|
||||||
|
let second = plan_for("general {\n autotile = false\n}\n", tmp.path()).unwrap();
|
||||||
|
assert_eq!(second[0].previous.as_deref(), Some("true"));
|
||||||
|
assert_eq!(second[0].contents, "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn atomic_write_leaves_no_temp_file() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let planned = plan_for("general {\n autotile = true\n}\n", tmp.path()).unwrap();
|
||||||
|
Emitter::with_root(tmp.path()).apply(&planned).unwrap();
|
||||||
|
|
||||||
|
let dir = tmp.path().join("com.system76.CosmicComp").join("v1");
|
||||||
|
let leftovers: Vec<_> = fs::read_dir(&dir)
|
||||||
|
.unwrap()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||||
|
.filter(|n| n.starts_with(".atomicwrite"))
|
||||||
|
.collect();
|
||||||
|
assert!(leftovers.is_empty(), "temp files left behind: {leftovers:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_env_honours_xdg_config_home() {
|
||||||
|
// Uses the documented fallback chain rather than a hardcoded path.
|
||||||
|
let prev = std::env::var_os("XDG_CONFIG_HOME");
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-probe");
|
||||||
|
let e = Emitter::from_env().unwrap();
|
||||||
|
assert_eq!(e.root(), Path::new("/tmp/xdg-probe/cosmic"));
|
||||||
|
match prev {
|
||||||
|
Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
|
||||||
|
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,12 @@
|
|||||||
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
//! COSMIC-specific, which keeps the hard logic testable without a compositor
|
||||||
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||||
|
|
||||||
|
pub mod emit;
|
||||||
pub mod parser;
|
pub mod parser;
|
||||||
pub mod resolve;
|
pub mod resolve;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
|
pub use emit::{EmitError, Emitter, Planned};
|
||||||
pub use parser::{parse, Ast, ParseError, Span};
|
pub use parser::{parse, Ast, ParseError, Span};
|
||||||
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
pub use resolve::{resolve, Diagnostic, Resolved, Value, Write, WriteKind};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! `cosmic-conf` — compile a Hyprland-idiom config file into cosmic-config.
|
||||||
|
//!
|
||||||
|
//! Exit codes: 0 success, 1 config error (nothing written), 2 usage error.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use cosmic_conf::{emit::Emitter, parse, render_diagnostic, resolve};
|
||||||
|
|
||||||
|
const USAGE: &str = "\
|
||||||
|
cosmic-conf — compile cosmic.conf into the cosmic-config tree
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
cosmic-conf apply [--diff] [--config <path>]
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--diff Show what would change without writing anything
|
||||||
|
--config <path> Config file (default: $XDG_CONFIG_HOME/hyprcosmic/cosmic.conf)
|
||||||
|
-h, --help Show this help
|
||||||
|
";
|
||||||
|
|
||||||
|
fn default_config_path() -> Option<PathBuf> {
|
||||||
|
let base = match std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
Some(x) if !x.is_empty() => PathBuf::from(x),
|
||||||
|
_ => PathBuf::from(std::env::var_os("HOME")?).join(".config"),
|
||||||
|
};
|
||||||
|
Some(base.join("hyprcosmic").join("cosmic.conf"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
|
||||||
|
if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") {
|
||||||
|
print!("{USAGE}");
|
||||||
|
return ExitCode::SUCCESS;
|
||||||
|
}
|
||||||
|
if args[0] != "apply" {
|
||||||
|
eprintln!("error: unknown command `{}`\n\n{USAGE}", args[0]);
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let diff_only = args.iter().any(|a| a == "--diff");
|
||||||
|
let config_path = match args.iter().position(|a| a == "--config") {
|
||||||
|
Some(i) => match args.get(i + 1) {
|
||||||
|
Some(p) => PathBuf::from(p),
|
||||||
|
None => {
|
||||||
|
eprintln!("error: --config needs a path");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => match default_config_path() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
eprintln!("error: cannot determine config path (no HOME or XDG_CONFIG_HOME)");
|
||||||
|
return ExitCode::from(2);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
match run(&config_path, diff_only) {
|
||||||
|
Ok(msg) => {
|
||||||
|
println!("{msg}");
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
Err(msg) => {
|
||||||
|
eprint!("{msg}");
|
||||||
|
ExitCode::from(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(config_path: &PathBuf, diff_only: bool) -> Result<String, String> {
|
||||||
|
let source = std::fs::read_to_string(config_path)
|
||||||
|
.map_err(|e| format!("error: cannot read {}: {e}\n", config_path.display()))?;
|
||||||
|
|
||||||
|
let ast = parse(&source).map_err(|e| {
|
||||||
|
render_diagnostic(&source, e.span, &e.message, None)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let resolved = resolve(&ast).map_err(|diags| {
|
||||||
|
let mut out = String::new();
|
||||||
|
for d in &diags {
|
||||||
|
out.push_str(&render_diagnostic(&source, d.span, &d.message, d.help.as_deref()));
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
"error: {} problem(s) found; nothing was written\n",
|
||||||
|
diags.len()
|
||||||
|
));
|
||||||
|
out
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let emitter = Emitter::from_env().map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
let planned = emitter.plan(&resolved).map_err(|errs| {
|
||||||
|
let mut out = String::new();
|
||||||
|
for e in &errs {
|
||||||
|
out.push_str(&format!("error: {e}\n"));
|
||||||
|
}
|
||||||
|
out.push_str("error: nothing was written\n");
|
||||||
|
out
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let changes: Vec<_> = planned.iter().filter(|p| !p.is_noop()).collect();
|
||||||
|
|
||||||
|
if diff_only {
|
||||||
|
if changes.is_empty() {
|
||||||
|
return Ok("No changes.".into());
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for p in &changes {
|
||||||
|
let rel = p
|
||||||
|
.path
|
||||||
|
.strip_prefix(emitter.root())
|
||||||
|
.unwrap_or(&p.path)
|
||||||
|
.display();
|
||||||
|
out.push_str(&format!("~ {rel}\n"));
|
||||||
|
match &p.previous {
|
||||||
|
Some(prev) => out.push_str(&format!(" - {}\n", prev.trim())),
|
||||||
|
None => out.push_str(" - (unset)\n"),
|
||||||
|
}
|
||||||
|
out.push_str(&format!(" + {}\n", p.contents.trim()));
|
||||||
|
}
|
||||||
|
out.push_str(&format!("\n{} file(s) would change.", changes.len()));
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let written = emitter.apply(&planned).map_err(|e| format!("error: {e}\n"))?;
|
||||||
|
Ok(format!(
|
||||||
|
"Applied {written} change(s) to {}.",
|
||||||
|
emitter.root().display()
|
||||||
|
))
|
||||||
|
}
|
||||||
+51
-30
@@ -21,8 +21,10 @@ pub enum Value {
|
|||||||
U32(u32),
|
U32(u32),
|
||||||
F32(f32),
|
F32(f32),
|
||||||
Str(String),
|
Str(String),
|
||||||
/// Straight RGBA bytes; conversion to COSMIC's f32 colour struct happens in `emit`.
|
/// `Option<Srgb>` target — no alpha channel.
|
||||||
Color(u8, u8, u8, u8),
|
Rgb(u8, u8, u8),
|
||||||
|
/// `Option<Srgba>` target — carries alpha.
|
||||||
|
Rgba(u8, u8, u8, u8),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -143,7 +145,7 @@ fn eval_arith(input: &str) -> String {
|
|||||||
/// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
|
/// Bare `#rrggbb` is deliberately **not** accepted: `#` begins a comment, so the
|
||||||
/// value would be stripped before reaching here. Hyprland makes the same
|
/// value would be stripped before reaching here. Hyprland makes the same
|
||||||
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
|
/// trade-off, and HyDE themes write colours as `rgba(...)`, so nothing is lost.
|
||||||
fn parse_color(raw: &str) -> Option<Value> {
|
fn parse_color(raw: &str) -> Option<(u8, u8, u8, u8)> {
|
||||||
let s = raw.trim();
|
let s = raw.trim();
|
||||||
let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
|
let hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
|
||||||
inner.trim().to_string()
|
inner.trim().to_string()
|
||||||
@@ -154,19 +156,10 @@ fn parse_color(raw: &str) -> Option<Value> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let hex = hex.trim_start_matches('#');
|
let hex = hex.trim_start_matches('#');
|
||||||
|
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||||
match hex.len() {
|
match hex.len() {
|
||||||
6 => Some(Value::Color(
|
6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)),
|
||||||
u8::from_str_radix(&hex[0..2], 16).ok()?,
|
8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
|
||||||
u8::from_str_radix(&hex[2..4], 16).ok()?,
|
|
||||||
u8::from_str_radix(&hex[4..6], 16).ok()?,
|
|
||||||
255,
|
|
||||||
)),
|
|
||||||
8 => Some(Value::Color(
|
|
||||||
u8::from_str_radix(&hex[0..2], 16).ok()?,
|
|
||||||
u8::from_str_radix(&hex[2..4], 16).ok()?,
|
|
||||||
u8::from_str_radix(&hex[4..6], 16).ok()?,
|
|
||||||
u8::from_str_radix(&hex[6..8], 16).ok()?,
|
|
||||||
)),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +186,17 @@ fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
|
|||||||
.map(Value::F32)
|
.map(Value::F32)
|
||||||
.map_err(|_| bad("a number")),
|
.map_err(|_| bad("a number")),
|
||||||
Ty::Str => Ok(Value::Str(raw.to_string())),
|
Ty::Str => Ok(Value::Str(raw.to_string())),
|
||||||
Ty::Color => parse_color(raw).ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")),
|
Ty::Rgb => parse_color(raw)
|
||||||
|
.map(|(r, g, b, _)| Value::Rgb(r, g, b))
|
||||||
|
.ok_or_else(|| bad("a colour like rgb(6b9fed)")),
|
||||||
|
Ty::Rgba => parse_color(raw)
|
||||||
|
.map(|(r, g, b, a)| Value::Rgba(r, g, b, a))
|
||||||
|
.ok_or_else(|| bad("a colour like rgb(6b9fed) or rgba(6b9fed80)")),
|
||||||
|
Ty::Mode => match raw {
|
||||||
|
"dark" => Ok(Value::Bool(true)),
|
||||||
|
"light" => Ok(Value::Bool(false)),
|
||||||
|
_ => Err(bad("`dark` or `light`")),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,14 +428,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rgb_colors_parse() {
|
fn rgb_colors_parse_and_drop_alpha() {
|
||||||
|
// `accent` is Option<Srgb> — theme.rs:856 — so alpha must not appear.
|
||||||
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
|
let r = resolved("theme {\n accent = rgb(6b9fed)\n}\n");
|
||||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
|
assert_eq!(
|
||||||
WriteKind::Projected(f) => {
|
find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"),
|
||||||
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 255));
|
&WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed))
|
||||||
}
|
);
|
||||||
other => panic!("expected Projected, got {other:?}"),
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
|
fn theme_mode_maps_to_is_dark() {
|
||||||
|
let r = resolved("theme {\n mode = dark\n}\n");
|
||||||
|
assert_eq!(
|
||||||
|
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
|
||||||
|
&WriteKind::Whole(Value::Bool(true))
|
||||||
|
);
|
||||||
|
let r = resolved("theme {\n mode = light\n}\n");
|
||||||
|
assert_eq!(
|
||||||
|
find(&r, "com.system76.CosmicTheme.Mode", "is_dark"),
|
||||||
|
&WriteKind::Whole(Value::Bool(false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_theme_mode_is_rejected() {
|
||||||
|
let d = errors("theme {\n mode = purple\n}\n");
|
||||||
|
assert!(d[0].message.contains("`dark` or `light`"), "{}", d[0].message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `#` always begins a comment, so a bare hex colour is stripped before it
|
/// `#` always begins a comment, so a bare hex colour is stripped before it
|
||||||
@@ -447,13 +469,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rgba_keeps_alpha() {
|
fn rgba_keeps_alpha() {
|
||||||
let r = resolved("theme {\n accent = rgba(6b9fed80)\n}\n");
|
// `bg_color` is Option<Srgba> — theme.rs:852 — so alpha survives.
|
||||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
|
let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n");
|
||||||
WriteKind::Projected(f) => {
|
assert_eq!(
|
||||||
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 0x80));
|
find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"),
|
||||||
}
|
&WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80))
|
||||||
other => panic!("expected Projected, got {other:?}"),
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ pub enum Ty {
|
|||||||
U32,
|
U32,
|
||||||
F32,
|
F32,
|
||||||
Str,
|
Str,
|
||||||
/// `rgb(rrggbb)` or `rgba(rrggbbaa)`. Bare `#rrggbb` is not accepted —
|
/// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
|
||||||
/// `#` begins a comment.
|
/// accepted: `#` begins a comment.
|
||||||
Color,
|
Rgb,
|
||||||
|
/// `rgb(rrggbb)`/`rgba(rrggbbaa)` -> `Option<Srgba>` (with alpha).
|
||||||
|
Rgba,
|
||||||
|
/// `dark`/`light` -> the `is_dark` boolean.
|
||||||
|
Mode,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where a conf key's value lands in the cosmic-config tree.
|
/// Where a conf key's value lands in the cosmic-config tree.
|
||||||
@@ -105,6 +109,17 @@ macro_rules! both_themes {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whole-value fan-out across both theme builders. An empty projection path
|
||||||
|
/// would be a lie: these fields are `Option<..>` written in full.
|
||||||
|
macro_rules! both_themes_direct {
|
||||||
|
($key:literal) => {
|
||||||
|
&[
|
||||||
|
Target::Direct { component: DARK_BUILDER, version: 1, key: $key },
|
||||||
|
Target::Direct { component: LIGHT_BUILDER, version: 1, key: $key },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub const REGISTRY: &[Entry] = &[
|
pub const REGISTRY: &[Entry] = &[
|
||||||
// ---- general ---------------------------------------------------------
|
// ---- general ---------------------------------------------------------
|
||||||
Entry {
|
Entry {
|
||||||
@@ -203,21 +218,21 @@ pub const REGISTRY: &[Entry] = &[
|
|||||||
version: 1,
|
version: 1,
|
||||||
key: "is_dark",
|
key: "is_dark",
|
||||||
}],
|
}],
|
||||||
ty: Ty::Str,
|
ty: Ty::Mode,
|
||||||
validate: None,
|
validate: None,
|
||||||
doc: "`dark` or `light`",
|
doc: "`dark` or `light`",
|
||||||
},
|
},
|
||||||
Entry {
|
Entry {
|
||||||
conf: "theme.accent",
|
conf: "theme.accent",
|
||||||
targets: both_themes!("accent", &[]),
|
targets: both_themes_direct!("accent"),
|
||||||
ty: Ty::Color,
|
ty: Ty::Rgb,
|
||||||
validate: None,
|
validate: None,
|
||||||
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
|
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
|
||||||
},
|
},
|
||||||
Entry {
|
Entry {
|
||||||
conf: "theme.bg_color",
|
conf: "theme.bg_color",
|
||||||
targets: both_themes!("bg_color", &[]),
|
targets: both_themes_direct!("bg_color"),
|
||||||
ty: Ty::Color,
|
ty: Ty::Rgba,
|
||||||
validate: None,
|
validate: None,
|
||||||
doc: "Background base colour",
|
doc: "Background base colour",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user