mirror of
https://github.com/outbackdingo/hyprcosmic.git
synced 2026-08-25 07:10:09 +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.
|
||||
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]]
|
||||
name = "cosmic-conf"
|
||||
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"
|
||||
|
||||
[dependencies]
|
||||
ron = "0.12"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
|
||||
[features]
|
||||
# `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.
|
||||
default = []
|
||||
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
|
||||
//! running. Only `emit` binds to cosmic-config, behind the `emit` feature.
|
||||
|
||||
pub mod emit;
|
||||
pub mod parser;
|
||||
pub mod resolve;
|
||||
pub mod schema;
|
||||
|
||||
pub use emit::{EmitError, Emitter, Planned};
|
||||
pub use parser::{parse, Ast, ParseError, Span};
|
||||
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),
|
||||
F32(f32),
|
||||
Str(String),
|
||||
/// Straight RGBA bytes; conversion to COSMIC's f32 colour struct happens in `emit`.
|
||||
Color(u8, u8, u8, u8),
|
||||
/// `Option<Srgb>` target — no alpha channel.
|
||||
Rgb(u8, u8, u8),
|
||||
/// `Option<Srgba>` target — carries alpha.
|
||||
Rgba(u8, u8, u8, u8),
|
||||
}
|
||||
|
||||
#[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
|
||||
/// value would be stripped before reaching here. Hyprland makes the same
|
||||
/// 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 hex = if let Some(inner) = s.strip_prefix("rgba(").and_then(|s| s.strip_suffix(')')) {
|
||||
inner.trim().to_string()
|
||||
@@ -154,19 +156,10 @@ fn parse_color(raw: &str) -> Option<Value> {
|
||||
};
|
||||
|
||||
let hex = hex.trim_start_matches('#');
|
||||
let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
|
||||
match hex.len() {
|
||||
6 => 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()?,
|
||||
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()?,
|
||||
)),
|
||||
6 => Some((byte(0)?, byte(2)?, byte(4)?, 255)),
|
||||
8 => Some((byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -193,7 +186,17 @@ fn coerce(raw: &str, ty: Ty, span: Span) -> Result<Value, Diagnostic> {
|
||||
.map(Value::F32)
|
||||
.map_err(|_| bad("a number")),
|
||||
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]
|
||||
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");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
|
||||
WriteKind::Projected(f) => {
|
||||
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 255));
|
||||
}
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent"),
|
||||
&WriteKind::Whole(Value::Rgb(0x6b, 0x9f, 0xed))
|
||||
);
|
||||
}
|
||||
|
||||
#[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
|
||||
@@ -447,13 +469,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rgba_keeps_alpha() {
|
||||
let r = resolved("theme {\n accent = rgba(6b9fed80)\n}\n");
|
||||
match find(&r, "com.system76.CosmicTheme.Dark.Builder", "accent") {
|
||||
WriteKind::Projected(f) => {
|
||||
assert_eq!(f[&Vec::<String>::new()], Value::Color(0x6b, 0x9f, 0xed, 0x80));
|
||||
}
|
||||
other => panic!("expected Projected, got {other:?}"),
|
||||
}
|
||||
// `bg_color` is Option<Srgba> — theme.rs:852 — so alpha survives.
|
||||
let r = resolved("theme {\n bg_color = rgba(6b9fed80)\n}\n");
|
||||
assert_eq!(
|
||||
find(&r, "com.system76.CosmicTheme.Dark.Builder", "bg_color"),
|
||||
&WriteKind::Whole(Value::Rgba(0x6b, 0x9f, 0xed, 0x80))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,9 +11,13 @@ pub enum Ty {
|
||||
U32,
|
||||
F32,
|
||||
Str,
|
||||
/// `rgb(rrggbb)` or `rgba(rrggbbaa)`. Bare `#rrggbb` is not accepted —
|
||||
/// `#` begins a comment.
|
||||
Color,
|
||||
/// `rgb(rrggbb)` -> `Option<Srgb>` (no alpha). Bare `#rrggbb` is not
|
||||
/// accepted: `#` begins a comment.
|
||||
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.
|
||||
@@ -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] = &[
|
||||
// ---- general ---------------------------------------------------------
|
||||
Entry {
|
||||
@@ -203,21 +218,21 @@ pub const REGISTRY: &[Entry] = &[
|
||||
version: 1,
|
||||
key: "is_dark",
|
||||
}],
|
||||
ty: Ty::Str,
|
||||
ty: Ty::Mode,
|
||||
validate: None,
|
||||
doc: "`dark` or `light`",
|
||||
},
|
||||
Entry {
|
||||
conf: "theme.accent",
|
||||
targets: both_themes!("accent", &[]),
|
||||
ty: Ty::Color,
|
||||
targets: both_themes_direct!("accent"),
|
||||
ty: Ty::Rgb,
|
||||
validate: None,
|
||||
doc: "Accent colour as rgb(rrggbb) or rgba(rrggbbaa)",
|
||||
},
|
||||
Entry {
|
||||
conf: "theme.bg_color",
|
||||
targets: both_themes!("bg_color", &[]),
|
||||
ty: Ty::Color,
|
||||
targets: both_themes_direct!("bg_color"),
|
||||
ty: Ty::Rgba,
|
||||
validate: None,
|
||||
doc: "Background base colour",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user