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

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:
Breadway 2026-08-16 13:20:00 +08:00
parent 11c0e844e5
commit fcba376038
8 changed files with 1016 additions and 58 deletions

338
bread-theme/src/output.rs Normal file
View 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);
});
}
}