Add per-output palettes and window-scoped theme binding
Some checks failed
dev bakery / build (push) Failing after 1s
dev bread-theme / build (push) Failing after 1s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 1s
release bread-theme / build (push) Failing after 1s
Build and publish package / package (push) Failing after 39s
Some checks failed
dev bakery / build (push) Failing after 1s
dev bread-theme / build (push) Failing after 1s
beta (rc) bakery / build (push) Has been skipped
beta (rc) bread-theme / build (push) Has been skipped
release bakery / build (push) Failing after 1s
release bread-theme / build (push) Failing after 1s
Build and publish package / package (push) Failing after 39s
Each Hyprland/GDK connector can have its own palette and stylesheet
under $XDG_RUNTIME_DIR/bread/{palettes,themes}/. GTK apps bind a
widget-level provider so two windows in one process can follow
different wallpapers. Bump workspace version to 0.7.4 for the tag.
This commit is contained in:
parent
11c0e844e5
commit
fcba376038
8 changed files with 1016 additions and 58 deletions
338
bread-theme/src/output.rs
Normal file
338
bread-theme/src/output.rs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
//! Per-output (per-monitor) palette and stylesheet paths under
|
||||
//! `$XDG_RUNTIME_DIR/bread/{palettes,themes}/`.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::palette::{from_wal_json, Palette};
|
||||
use crate::{load_palette, stylesheet};
|
||||
|
||||
/// Session-scoped `$XDG_RUNTIME_DIR/bread`, same fallback as [`crate::shared_css_path`].
|
||||
pub(crate) fn runtime_bread_dir() -> PathBuf {
|
||||
if let Ok(rt) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
if !rt.is_empty() {
|
||||
return PathBuf::from(rt).join("bread");
|
||||
}
|
||||
}
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("bread")
|
||||
}
|
||||
|
||||
/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`.
|
||||
pub fn sanitize_output(output: &str) -> String {
|
||||
let s: String = output
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if s.is_empty() {
|
||||
"_".into()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub fn themes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("themes")
|
||||
}
|
||||
|
||||
pub fn palettes_dir() -> PathBuf {
|
||||
runtime_bread_dir().join("palettes")
|
||||
}
|
||||
|
||||
pub fn output_css_path(output: &str) -> PathBuf {
|
||||
themes_dir().join(format!("{}.css", sanitize_output(output)))
|
||||
}
|
||||
|
||||
pub fn output_palette_path(output: &str) -> PathBuf {
|
||||
palettes_dir().join(format!("{}.json", sanitize_output(output)))
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let tmp = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(name) => path.with_file_name(format!("{name}.tmp")),
|
||||
None => path.with_extension("tmp"),
|
||||
};
|
||||
std::fs::write(&tmp, contents)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Accents only — never persist pywal's light background/surface/overlay/fg.
|
||||
#[derive(Serialize)]
|
||||
struct StoredColors {
|
||||
color1: String,
|
||||
color2: String,
|
||||
color3: String,
|
||||
color4: String,
|
||||
color5: String,
|
||||
color6: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StoredPalette {
|
||||
colors: StoredColors,
|
||||
}
|
||||
|
||||
/// Parse on-disk JSON: wal `colors.json` shape, or a flat `{color1..color6}` object.
|
||||
/// Always forces FIXED background/foreground/color0/color7 via [`from_wal_json`].
|
||||
pub fn palette_from_json(json: &str) -> Option<Palette> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
if value
|
||||
.get("colors")
|
||||
.and_then(|c| c.as_object())
|
||||
.is_some_and(|o| !o.is_empty())
|
||||
{
|
||||
return from_wal_json(json);
|
||||
}
|
||||
if value.get("color1").is_some()
|
||||
|| value.get("color2").is_some()
|
||||
|| value.get("color3").is_some()
|
||||
|| value.get("color4").is_some()
|
||||
|| value.get("color5").is_some()
|
||||
|| value.get("color6").is_some()
|
||||
{
|
||||
let wrapped = serde_json::json!({ "colors": value });
|
||||
return from_wal_json(&wrapped.to_string());
|
||||
}
|
||||
from_wal_json(json)
|
||||
}
|
||||
|
||||
/// Load `palettes/<output>.json`; fall back to [`load_palette`].
|
||||
pub fn load_palette_for(output: &str) -> Palette {
|
||||
std::fs::read_to_string(output_palette_path(output))
|
||||
.ok()
|
||||
.and_then(|s| palette_from_json(&s))
|
||||
.unwrap_or_else(load_palette)
|
||||
}
|
||||
|
||||
pub fn write_output_palette(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_palette_path(output);
|
||||
let stored = StoredPalette {
|
||||
colors: StoredColors {
|
||||
color1: palette.color1.clone(),
|
||||
color2: palette.color2.clone(),
|
||||
color3: palette.color3.clone(),
|
||||
color4: palette.color4.clone(),
|
||||
color5: palette.color5.clone(),
|
||||
color6: palette.color6.clone(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string_pretty(&stored)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
atomic_write(&path, &json)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn write_output_css(output: &str, palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = output_css_path(output);
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Like [`crate::write_shared_css`] but from an explicit palette.
|
||||
pub fn write_shared_css_from(palette: &Palette) -> std::io::Result<PathBuf> {
|
||||
let path = crate::shared_css_path();
|
||||
atomic_write(&path, &stylesheet(palette))?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Isolated `wal -i <image> -n -q` with `XDG_CACHE_HOME` set to a unique temp
|
||||
/// dir so the user's `~/.cache/wal` is not clobbered.
|
||||
pub fn palette_from_image(path: &Path) -> std::io::Result<Palette> {
|
||||
let pid = std::process::id();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let tmp = std::env::temp_dir().join(format!("bread-theme-wal-{pid}-{nanos}"));
|
||||
std::fs::create_dir_all(&tmp)?;
|
||||
struct Rm(PathBuf);
|
||||
impl Drop for Rm {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = Rm(tmp.clone());
|
||||
|
||||
// Classic pywal ignores XDG_CACHE_HOME and writes $HOME/.cache/wal.
|
||||
// Point HOME at the temp dir so a per-output extract cannot clobber
|
||||
// the session cache (or the other monitor's last `wal -i`).
|
||||
let status = match std::process::Command::new("wal")
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.args(["-n", "-q"])
|
||||
.env("HOME", &tmp)
|
||||
.env("XDG_CACHE_HOME", tmp.join(".cache"))
|
||||
.status()
|
||||
{
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal is not installed",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Ok(s) => s,
|
||||
};
|
||||
if !status.success() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
format!("wal failed with {status}"),
|
||||
));
|
||||
}
|
||||
|
||||
let json_path = [
|
||||
tmp.join(".cache").join("wal").join("colors.json"),
|
||||
tmp.join("wal").join("colors.json"),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"wal did not write colors.json under the isolated cache",
|
||||
)
|
||||
})?;
|
||||
let json = std::fs::read_to_string(&json_path)?;
|
||||
from_wal_json(&json).ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"wal produced unparseable colors.json",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`palette_from_image`] + [`write_output_palette`] + [`write_output_css`].
|
||||
pub fn generate_output(output: &str, image: &Path) -> std::io::Result<PathBuf> {
|
||||
let palette = palette_from_image(image)?;
|
||||
write_output_palette(output, &palette)?;
|
||||
write_output_css(output, &palette)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) static XDG_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::palette::{FIXED_BACKGROUND, FIXED_FOREGROUND, FIXED_OVERLAY, FIXED_SURFACE};
|
||||
|
||||
fn lock_xdg() -> std::sync::MutexGuard<'static, ()> {
|
||||
XDG_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn with_runtime_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
|
||||
let _lock = lock_xdg();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"bread-theme-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let old = std::env::var("XDG_RUNTIME_DIR").ok();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", &dir);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&dir)));
|
||||
match old {
|
||||
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
|
||||
None => std::env::remove_var("XDG_RUNTIME_DIR"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(e) => std::panic::resume_unwind(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_keeps_hyprland_connectors() {
|
||||
assert_eq!(sanitize_output("HDMI-A-1"), "HDMI-A-1");
|
||||
assert_eq!(sanitize_output("eDP-1"), "eDP-1");
|
||||
assert_eq!(sanitize_output("DP-2"), "DP-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_output_replaces_unsafe_chars() {
|
||||
assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1");
|
||||
assert_eq!(sanitize_output("foo/bar"), "foo_bar");
|
||||
assert_eq!(sanitize_output(""), "_");
|
||||
assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_paths_use_sanitize_and_sit_under_dirs() {
|
||||
let _lock = lock_xdg();
|
||||
std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234");
|
||||
let css = output_css_path("HDMI A:1");
|
||||
let pal = output_palette_path("HDMI A:1");
|
||||
assert_eq!(css, themes_dir().join("HDMI_A_1.css"));
|
||||
assert_eq!(pal, palettes_dir().join("HDMI_A_1.json"));
|
||||
assert!(css.starts_with(themes_dir()));
|
||||
assert!(pal.starts_with(palettes_dir()));
|
||||
assert_eq!(
|
||||
output_css_path("eDP-1"),
|
||||
PathBuf::from("/run/user/1234/bread/themes/eDP-1.css")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_missing_file_has_fixed_bg() {
|
||||
with_runtime_dir(|_| {
|
||||
let p = load_palette_for("no-such-output");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
assert!(p.color4.starts_with('#'));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_output_palette_roundtrips_color4() {
|
||||
with_runtime_dir(|_| {
|
||||
let mut p = Palette::default();
|
||||
p.color4 = "#7aa2f7".into();
|
||||
p.background = "#ffffff".into();
|
||||
write_output_palette("HDMI-A-1", &p).unwrap();
|
||||
let loaded = load_palette_for("HDMI-A-1");
|
||||
assert_eq!(loaded.color4, "#7aa2f7");
|
||||
assert_eq!(loaded.background, FIXED_BACKGROUND);
|
||||
assert_eq!(loaded.foreground, FIXED_FOREGROUND);
|
||||
assert_eq!(loaded.color0, FIXED_SURFACE);
|
||||
assert_eq!(loaded.color7, FIXED_OVERLAY);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_shared_css_from_writes_shared_css_path() {
|
||||
with_runtime_dir(|rt| {
|
||||
let path = write_shared_css_from(&Palette::default()).unwrap();
|
||||
assert_eq!(path, crate::shared_css_path());
|
||||
assert_eq!(path, rt.join("bread").join("theme.css"));
|
||||
let css = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(css.contains("@define-color accent "));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_for_accepts_flat_color_object() {
|
||||
with_runtime_dir(|_| {
|
||||
let path = output_palette_path("DP-1");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, r##"{"color4":"#112233","color1":"#abcdef"}"##).unwrap();
|
||||
let p = load_palette_for("DP-1");
|
||||
assert_eq!(p.color4, "#112233");
|
||||
assert_eq!(p.color1, "#abcdef");
|
||||
assert_eq!(p.background, FIXED_BACKGROUND);
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue