randpaper 0.5.1

A customizable wallpaper daemon for per-monitor cycling, one-shot application, and optional theme synchronization for Waybar and terminals.
use anyhow::Context;
use std::fmt::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
// use std::time::Duration;
use std::fs;

/// Represents a color in the Red-Green-Blue color space.
#[derive(Clone, Copy, Debug)]
struct Rgb {
    r: u8,
    g: u8,
    b: u8,
}

impl Rgb {
    /// Returns the color as a CSS-style hex string (e.g., "#ffffff").
    fn hex(self) -> String {
        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
    }

    /// Calculates the relative luminance of the color.
    /// Used to determine how "bright" a color appears to the human eye.
    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)),
        )
    }

    /// A simple heuristic for color saturation by calculating the
    /// distance between the most and least dominant channels.
    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
    }

    /// Returns the contrast ratio between two colors (simplified WCAG formula).
    /// A ratio >= 4.5 ia considered accessible for normal text.
    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 }
    }
}

/// Assigns specific UI roles (background, foreground, accent, etc.)
/// to colors based on their luminance and saturation.
fn pick_roles(colors: &[Rgb]) -> (Rgb, Rgb, Rgb, Rgb, Rgb) {
    let mut sorted = colors.to_vec();
    // Sort by brightness (darkest to lightest)
    sorted.sort_by(|a, b| a.luminance().total_cmp(&b.luminance()));

    // Background is the darkest, Foreground is the lightest
    let bg = sorted[0];

    let candidate_fg = *sorted.last().unwrap_or(&Rgb {
        r: 225,
        g: 225,
        b: 225,
    });

    // If contrast is too low (both dark or both light), fall back to a safe color
    let fg = if bg.contrast_ratio(candidate_fg) < 3.0 {
        // bg is dark → use near-white; bg is light → use near-black
        if bg.luminance() < 128.0 {
            Rgb {
                r: 220,
                g: 220,
                b: 220,
            } // near-white fallback
        } else {
            Rgb {
                r: 30,
                g: 30,
                b: 30,
            } // near-black fallback
        }
    } else {
        candidate_fg
    };

    // Accent is the most "vibrant" color in the pallete
    let accent = sorted
        .iter()
        .copied()
        .max_by_key(|c| c.saturation_proxy())
        .unwrap_or(fg);

    // Naive selection for status colors based on position in the sorted list
    let warn = sorted.get(2).copied().unwrap_or(accent);
    let ok = sorted.get(4).copied().unwrap_or(accent);

    (bg, fg, accent, warn, ok)
}

/// Performs an atomic write by writing to a temporary file and then renaming it.
/// This prevents partial writes if the power cuts or the process crashes.
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();

    // Use .display() if you ever print the path,
    // but for building the filename, string lossy is perfect.
    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(())
}

/// Generates a CSS file for Waybar containing @define-color variables
/// based on the extracted palette.
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)
}

/// Ensures the Waybar theme file exists with a default Catppuccin-style palette.
/// Call this once at startup to prevent Waybar from crashing on @import.
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 the file already exists, we're done
    if waybar_css.exists() {
        return Ok(());
    }

    // Create directory and write a default theme
    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(())
}

/// The primary entry point for updating system themes.
/// 1. Extracts a color palette from the provided image.
/// 2. Generates configuration files for Waybar, Ghostty, Kitty, and Foot.
/// 3. Triggers a live reload for all supported applications.
pub fn update_theme_file(image_path: &Path) -> anyhow::Result<()> {
    log::info!("updating theme for image: {}", image_path.display());

    // Load and downsample image for faster color extraction
    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();

    // Extract dominant colors
    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");

    // 1. Write Waybar CSS (Atomic)
    let _ = write_waybar_css(&theme_dir, &palette)?;

    // 2. Write Terminal Configs (Atomic)
    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);

    // 3. Persist terminal configs
    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());

    // Small delay to ensure filesystem has flushed the writes
    // thread::sleep(Duration::from_millis(100));

    // // 4. Reload Waybar
    // reload_waybar_only();

    // 5. Best-effort to reload foot
    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:?}");

    // 6. Reload Kitty (Requires remote control / unix socket enabled)
    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:?}");
    }

    // 7. Reload Ghostty (SIGUSR2 triggers config reload)
    let _ = Command::new("pkill")
        .args(["-USR2", "-x", "ghostty"])
        .status();

    Ok(())
}