use anyhow::Context;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::fs;
#[derive(Clone, Copy, Debug)]
struct Rgb {
r: u8,
g: u8,
b: u8,
}
impl Rgb {
fn hex(self) -> String {
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
fn luminance(self) -> f32 {
0.0722f32.mul_add(
f32::from(self.b),
0.7152f32.mul_add(f32::from(self.g), 0.2126 * f32::from(self.r)),
)
}
fn saturation_proxy(self) -> u8 {
let max = self.r.max(self.g).max(self.b);
let min = self.r.min(self.g).min(self.b);
max - min
}
fn contrast_ratio(self, other: Self) -> f32 {
let l1 = self.luminance() / 255.0 + 0.05;
let l2 = other.luminance() / 255.0 + 0.05;
if l1 > l2 { l1 / l2 } else { l2 / l1 }
}
}
fn pick_roles(colors: &[Rgb]) -> (Rgb, Rgb, Rgb, Rgb, Rgb) {
let mut sorted = colors.to_vec();
sorted.sort_by(|a, b| a.luminance().total_cmp(&b.luminance()));
let bg = sorted[0];
let candidate_fg = *sorted.last().unwrap_or(&Rgb {
r: 225,
g: 225,
b: 225,
});
let fg = if bg.contrast_ratio(candidate_fg) < 3.0 {
if bg.luminance() < 128.0 {
Rgb {
r: 220,
g: 220,
b: 220,
} } else {
Rgb {
r: 30,
g: 30,
b: 30,
} }
} else {
candidate_fg
};
let accent = sorted
.iter()
.copied()
.max_by_key(|c| c.saturation_proxy())
.unwrap_or(fg);
let warn = sorted.get(2).copied().unwrap_or(accent);
let ok = sorted.get(4).copied().unwrap_or(accent);
(bg, fg, accent, warn, ok)
}
fn atomic_write(path: &Path, contents: &str) -> anyhow::Result<()> {
let dir = path.parent().context("path has no parent")?;
fs::create_dir_all(dir)?;
let file_name = path
.file_name()
.context("path has no filename")?
.to_string_lossy();
let tmp_name = format!(".{}.tmp.{}", file_name, std::process::id());
let tmp_path = dir.join(tmp_name);
fs::write(&tmp_path, contents)
.with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
fs::rename(&tmp_path, path)
.with_context(|| format!("failed to commit atomic write to {}", path.display()))?;
Ok(())
}
pub fn write_waybar_css(
theme_dir: &Path,
palette: &[color_thief::Color],
) -> anyhow::Result<PathBuf> {
let colors: Vec<Rgb> = palette
.iter()
.map(|c| Rgb {
r: c.r,
g: c.g,
b: c.b,
})
.collect();
let (bg, fg, accent, warn, ok) = pick_roles(&colors);
let mut css = String::new();
let _ = writeln!(css, "/* auto-generated by randpaper */");
let _ = writeln!(css, "@define-color rp_bg {};", bg.hex());
let _ = writeln!(css, "@define-color rp_fg {};", fg.hex());
let _ = writeln!(css, "@define-color rp_accent {};", accent.hex());
let _ = writeln!(css, "@define-color rp_warn {};", warn.hex());
let _ = writeln!(css, "@define-color rp_ok {};", ok.hex());
let _ = writeln!(css, "@define-color rp_border {};", accent.hex());
let _ = writeln!(css, "@define-color rp_muted {};", bg.hex());
let out = theme_dir.join("waybar.css");
atomic_write(&out, &css)?;
Ok(out)
}
pub fn ensure_theme_exists() -> anyhow::Result<()> {
let theme_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
.join("randpaper/themes");
let waybar_css = theme_dir.join("waybar.css");
if waybar_css.exists() {
return Ok(());
}
fs::create_dir_all(&theme_dir)?;
let default_css = r"/* Default randpaper theme - will be replaced on first wallpaper change */
@define-color rp_bg #1e1e2e;
@define-color rp_fg #cdd6f4;
@define-color rp_accent #89b4fa;
@define-color rp_warn #f9e2af;
@define-color rp_ok #a6e3a1;
@define-color rp_border #89b4fa;
@define-color rp_muted #313244;
";
atomic_write(&waybar_css, default_css)?;
log::info!("Created default theme file at {}", waybar_css.display());
Ok(())
}
pub fn update_theme_file(image_path: &Path) -> anyhow::Result<()> {
log::info!("updating theme for image: {}", image_path.display());
let img = image::open(image_path).context("Failed to open image for theming")?;
let img = img.resize(300, 300, image::imageops::FilterType::Nearest);
let buffer = img.to_rgb8();
let palette = color_thief::get_palette(buffer.as_raw(), color_thief::ColorFormat::Rgb, 10, 16)
.map_err(|e| anyhow::anyhow!("Color thief error: {e:?}"))?;
let colors: Vec<Rgb> = palette
.iter()
.map(|c| Rgb {
r: c.r,
g: c.g,
b: c.b,
})
.collect();
let (bg, fg, _accent, _warn, _ok) = pick_roles(&colors);
let theme_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
.join("randpaper/themes");
let _ = write_waybar_css(&theme_dir, &palette)?;
let mut ghostty = String::from("# Auto-generated by randpaper\n");
let mut kitty = String::from("# Auto-generated by randpaper\n");
let mut foot = String::from("# Auto-generated by randpaper\n[colors-dark]\n");
for (i, color) in palette.iter().enumerate() {
let hex = format!("{:02x}{:02x}{:02x}", color.r, color.g, color.b);
let _ = writeln!(ghostty, "palette = {i}=#{hex}");
let _ = writeln!(kitty, "color{i} #{hex}");
if i < 8 {
let _ = writeln!(foot, "regular{i}={hex}");
} else {
let _ = writeln!(foot, "bright{}={hex}", i - 8);
}
}
let _ = writeln!(ghostty, "background = {}", bg.hex());
let _ = writeln!(ghostty, "foreground = {}", fg.hex());
let _ = writeln!(kitty, "background {}", bg.hex());
let _ = writeln!(kitty, "foreground {}", fg.hex());
let _ = writeln!(foot, "background={:02x}{:02x}{:02x}", bg.r, bg.g, bg.b);
let _ = writeln!(foot, "foreground={:02x}{:02x}{:02x}", fg.r, fg.g, fg.b);
atomic_write(&theme_dir.join("ghostty.config"), &ghostty)?;
atomic_write(&theme_dir.join("kitty.conf"), &kitty)?;
atomic_write(&theme_dir.join("foot.ini"), &foot)?;
log::info!("Updated themes in {}", theme_dir.display());
let foot_result = Command::new("sh")
.args(["-c", "pkill -USR1 foot; sleep 0.05; pkill -USR1 foot"])
.status();
log::info!("Foot reload result: {foot_result:?}");
if Path::new("/tmp/mykitty").exists() {
let kitty_conf = theme_dir.join("kitty.conf");
let kitty_result = Command::new("kitten")
.args([
"@",
"--to",
"unix:/tmp/mykitty",
"set-colors",
"--all",
"--configured",
kitty_conf
.to_str()
.ok_or_else(|| anyhow::anyhow!("non-utf8 path"))?,
])
.status();
log::info!("Kitty set-colors result: {kitty_result:?}");
}
let _ = Command::new("pkill")
.args(["-USR2", "-x", "ghostty"])
.status();
Ok(())
}