diff --git a/Cargo.lock b/Cargo.lock index e8b7ea6..5403369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,9 +169,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "cc" -version = "1.2.64" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -923,9 +923,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1077,9 +1077,9 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ "bitflags", "wayland-backend", diff --git a/README.md b/README.md index e0b6560..d2e5bf5 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,12 @@ Use `wallbash` as a core component of your Wayland desktop environment — set w ## Features - Vulkan-powered GPU acceleration for smooth performance -- Color palette generation for dynamic theming (WIP) -- Fluid transitions and animations (WIP) -- Multi-monitor support (WIP) +- Color palette generation for dynamic theming +- Fluid transitions and animations* +- Multi-monitor support* - Scale and anchor the image to your liking - Dynamic blur fill for mismatched aspect ratios to eliminate black bars +
*work in progress
## Build @@ -37,7 +38,8 @@ wallbash status # Show daemon status # options for "set" wallbash set [option] - -m, --mode # Scaling mode (cover, fit, original) + -p, --palette # Generate color palette (auto, dark, light) + -m, --mode # Scaling mode (cover, fit, original) -a, --anchor <1-9> # Anchor point (1=top-left ... 9=bottom-right) -w, --wall # Wallpaper file /path/to/file.img ``` @@ -57,15 +59,17 @@ src/ ├── wallbash.rs ├── wayland.rs ├── vulkan.rs -└── filters.rs +├── filters.rs +└── colors.rs ``` The core project is structured in simple modules: - `main.rs` Entry point of the binary. Works as a CLI tool to parse arguments and handle the daemon. - `wallbash.rs` The core daemon module. It manages the IPC listener, handles incoming commands, and orchestrates the wallpaper loading and rendering process. - `wayland.rs` Handles the Wayland integration. It creates a Wayland surface, binds to the layer shell protocol, and sets up the layer surface for the wallpaper. -- `vulkan.rs` Manages the Vulkan rendering pipeline. It initializes the Vulkan instance, selects a physical device (preferring a discrete GPU), creates a swapchain, and renders the wallpaper image. +- `vulkan.rs` Manages the Vulkan rendering pipeline. Initializes Vulkan instance, selects physical device (preferring discrete GPU), creates swapchain, and renders the wallpaper. - `filters.rs` – Implements image filters and post‑processing effects, including dynamic background blur, scaling algorithms, and other visual transformations. +- `colors.rs` – Auto detects and generates light and dark color palettes from the wallpaper’s dominant color using k‑means clustering. ###### *
// HyDE
*

diff --git a/src/colors.rs b/src/colors.rs new file mode 100644 index 0000000..6aef07b --- /dev/null +++ b/src/colors.rs @@ -0,0 +1,272 @@ +// --------------------------------------------------------------------- / tittu +// wallbash +// a color generation module for HyDE +// + + +// --------------------------------------------------------------------- / imports + +use image::DynamicImage; + + +// --------------------------------------------------------------------- / datatypes + +pub struct ColorPalette { + group: &'static str, + name: &'static str, + argb: u32, +} + + +// --------------------------------------------------------------------- / k‑means + +pub fn dcol(img: &DynamicImage) -> u32 { + let small = img.resize_exact(64, 64, image::imageops::FilterType::Nearest); + let rgb = small.to_rgb8(); + + let pixels: Vec<[f64; 3]> = rgb + .pixels() + .map(|p| [p[0] as f64, p[1] as f64, p[2] as f64]) + .collect(); + + if pixels.is_empty() { + return rgb_to_argb(128, 128, 128); + } + + let k = 5; + let max_iter = 8; + let mut centroids = Vec::with_capacity(k); + let mut lcg = 42u32; + for _ in 0..k { + lcg = lcg.wrapping_mul(1664525).wrapping_add(1013904223); + centroids.push(pixels[lcg as usize % pixels.len()]); + } + + let mut assignments = vec![0usize; pixels.len()]; + + for _ in 0..max_iter { + for (i, pixel) in pixels.iter().enumerate() { + let mut best_dist = f64::MAX; + let mut best_c = 0; + for (c, centroid) in centroids.iter().enumerate() { + let dr = pixel[0] - centroid[0]; + let dg = pixel[1] - centroid[1]; + let db = pixel[2] - centroid[2]; + let dist = dr * dr + dg * dg + db * db; + if dist < best_dist { best_dist = dist; best_c = c; } + } + assignments[i] = best_c; + } + + let mut sums = vec![[0.0f64; 3]; k]; + let mut counts = vec![0u32; k]; + for (i, &cluster) in assignments.iter().enumerate() { + let p = pixels[i]; + sums[cluster][0] += p[0]; sums[cluster][1] += p[1]; sums[cluster][2] += p[2]; + counts[cluster] += 1; + } + for c in 0..k { + if counts[c] > 0 { + centroids[c][0] = sums[c][0] / counts[c] as f64; + centroids[c][1] = sums[c][1] / counts[c] as f64; + centroids[c][2] = sums[c][2] / counts[c] as f64; + } + } + } + + let mut cluster_sizes = vec![0u32; k]; + for &a in &assignments { cluster_sizes[a] += 1; } + let largest = cluster_sizes.iter().enumerate() + .max_by_key(|&(_, &s)| s).map(|(i, _)| i).unwrap_or(0); + + let dom = centroids[largest]; + let r = dom[0].round().clamp(0.0, 255.0) as u8; + let g = dom[1].round().clamp(0.0, 255.0) as u8; + let b = dom[2].round().clamp(0.0, 255.0) as u8; + rgb_to_argb(r, g, b) +} + + +// --------------------------------------------------------------------- / converters + +fn rgb_to_argb(r: u8, g: u8, b: u8) -> u32 { + 0xFF00_0000 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32) +} + +fn srgb_to_xyz(r: f64, g: f64, b: f64) -> (f64, f64, f64) { + let linear = |c: f64| -> f64 { + if c <= 0.04045 { c / 12.92 } + else { ((c + 0.055) / 1.055).powf(2.4) } + }; + let rl = linear(r); + let gl = linear(g); + let bl = linear(b); + + ( + 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl, + 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl, + 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl, + ) +} + +fn xyz_to_srgb(x: f64, y: f64, z: f64) -> (u8, u8, u8) { + let r_lin = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z; + let g_lin = -0.9692660 * x + 1.8760108 * y + 0.0415560 * z; + let b_lin = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z; + + let delinearize = |c: f64| -> f64 { + if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 } + }; + + ( + (delinearize(r_lin.clamp(0.0, 1.0)) * 255.0).round() as u8, + (delinearize(g_lin.clamp(0.0, 1.0)) * 255.0).round() as u8, + (delinearize(b_lin.clamp(0.0, 1.0)) * 255.0).round() as u8, + ) +} + +fn rgb_to_cielab(r: u8, g: u8, b: u8) -> (f64, f64, f64) { + let r = r as f64 / 255.0; + let g = g as f64 / 255.0; + let b = b as f64 / 255.0; + + let (x, y, z) = srgb_to_xyz(r, g, b); + + let xn = 0.95047; + let yn = 1.0; + let zn = 1.08883; + + let fx = (x / xn).powf(1.0 / 3.0); + let fy = (y / yn).powf(1.0 / 3.0); + let fz = (z / zn).powf(1.0 / 3.0); + + let l = 116.0 * fy - 16.0; + let a = 500.0 * (fx - fy); + let b_val = 200.0 * (fy - fz); + + (l, a, b_val) +} + +fn cielab_to_rgb(l: f64, a: f64, b: f64) -> (u8, u8, u8) { + let yn = 1.0; + let xn = 0.95047; + let zn = 1.08883; + + let fy = (l + 16.0) / 116.0; + let fx = a / 500.0 + fy; + let fz = fy - b / 200.0; + + let delta: f64 = 6.0 / 29.0; + + let x = if fx > delta { xn * fx.powi(3) } else { (fx - 16.0 / 116.0) * 3.0 * delta * delta * xn }; + let y = if l > 8.0 { yn * fy.powi(3) } else { l / 903.3 * yn }; + let z = if fz > delta { zn * fz.powi(3) } else { (fz - 16.0 / 116.0) * 3.0 * delta * delta * zn }; + + xyz_to_srgb(x, y, z) +} + + +// --------------------------------------------------------------------- / generate palette + +pub fn generate_palette(dcol: u32) -> (Vec, Vec) { + let r = ((dcol >> 16) & 0xFF) as u8; + let g = ((dcol >> 8) & 0xFF) as u8; + let b = (dcol & 0xFF) as u8; + + let (_, a_star, b_star) = rgb_to_cielab(r, g, b); + let hue_rad = b_star.atan2(a_star); + let chroma = (a_star * a_star + b_star * b_star).sqrt(); + + let roles: [(&str, &str, f64, f64, f64, f64); 27] = [ + ("Primary", "Primary", 40.0, 80.0, 0.9, 0.0), + ("Primary", "On Primary", 100.0, 20.0, 0.0, 0.0), + ("Primary", "Primary Container", 90.0, 30.0, 0.7, 0.0), + ("Primary", "On Primary Cont.", 10.0, 90.0, 0.0, 2.0), + ("Secondary", "Secondary", 40.0, 80.0, 0.5, 0.0), + ("Secondary", "On Secondary", 100.0, 20.0, 0.0, 0.0), + ("Secondary", "Secondary Container", 90.0, 30.0, 0.4, 0.0), + ("Secondary", "On Secondary Cont.", 10.0, 90.0, 0.0, 2.0), + ("Tertiary", "Tertiary", 40.0, 80.0, 0.6, 0.0), + ("Tertiary", "On Tertiary", 100.0, 20.0, 0.0, 0.0), + ("Tertiary", "Tertiary Container", 90.0, 30.0, 0.5, 0.0), + ("Tertiary", "On Tertiary Cont.", 10.0, 90.0, 0.0, 2.0), + ("Error", "Error", 40.0, 80.0, 0.9, 0.0), + ("Error", "On Error", 100.0, 20.0, 0.0, 0.0), + ("Error", "Error Container", 90.0, 30.0, 0.7, 0.0), + ("Error", "On Error Cont.", 10.0, 90.0, 0.0, 2.0), + ("Surface", "Background", 98.0, 6.0, 0.05, 0.0), + ("Surface", "On Background", 10.0, 90.0, 0.0, 2.0), + ("Surface", "Surface", 98.0, 6.0, 0.05, 0.0), + ("Surface", "On Surface", 10.0, 90.0, 0.0, 2.0), + ("Surface", "Surface Variant", 90.0, 30.0, 0.2, 0.0), + ("Surface", "On Surface Variant", 30.0, 80.0, 0.0, 1.0), + ("Surface", "Outline", 50.0, 60.0, 0.1, 0.0), + ("Surface", "Shadow", 0.0, 0.0, 0.0, 0.0), + ("Surface", "Inverse Surface", 20.0, 90.0, 0.2, 0.0), + ("Surface", "Inverse On Surface", 95.0, 20.0, 0.0, 0.0), + ("Surface", "Inverse Primary", 80.0, 40.0, 0.4, 0.0), + ]; + + let mut light = Vec::new(); + let mut dark = Vec::new(); + + for (group, name, light_tone, dark_tone, chroma_factor, min_chroma) in roles { + let (h, c) = if name.starts_with("Error") { + (0.436332, 45.0) + } else if name.contains("Secondary") { + (hue_rad, chroma * chroma_factor) + } else if name.contains("Tertiary") { + (hue_rad + 1.04719755, chroma * chroma_factor) + } else { + (hue_rad, chroma * chroma_factor) + }; + + // light theme + let chroma_val = c.max(min_chroma); + let a = chroma_val * h.cos(); + let b_val = chroma_val * h.sin(); + let (rr, gg, bb) = cielab_to_rgb(light_tone, a, b_val); + light.push(ColorPalette { group, name, argb: rgb_to_argb(rr, gg, bb) }); + + // dark theme + let dark_min_chroma = if dark_tone <= 30.0 { 4.0 } else { 0.0 }; + let chroma_val = c.max(min_chroma).max(dark_min_chroma); + let a = chroma_val * h.cos(); + let b_val = chroma_val * h.sin(); + let (rr, gg, bb) = cielab_to_rgb(dark_tone, a, b_val); + dark.push(ColorPalette { group, name, argb: rgb_to_argb(rr, gg, bb) }); + } + + (light, dark) +} + + +// --------------------------------------------------------------------- / print palette + +pub fn print_palette(dcol: u32, mode: &str) { + let (light, dark) = generate_palette(dcol); + let r = ((dcol >> 16) & 0xFF) as u8; + let g = ((dcol >> 8) & 0xFF) as u8; + let b = (dcol & 0xFF) as u8; + let (_, _, l_star) = rgb_to_cielab(r, g, b); + + print!("\x1b[48;2;{};{};{}m \x1b[0m", r, g, b); + if mode == "light" || (mode == "auto" && l_star > 55.0) { + println!(" #{:06X} :: Light :: Dominant Color", dcol); + group_palette(&light); + } else { + println!(" #{:06X} :: Dark :: Dominant Color", dcol); + group_palette(&dark); + } +} + +fn group_palette(palette: &[ColorPalette]) { + for entry in palette { + let r = ((entry.argb >> 16) & 0xFF) as u8; + let g = ((entry.argb >> 8) & 0xFF) as u8; + let b = (entry.argb & 0xFF) as u8; + print!("\x1b[48;2;{};{};{}m \x1b[0m", r, g, b); + println!(" #{:06X} :: {:<9} :: {}", entry.argb, entry.group, entry.name); + } +} + diff --git a/src/main.rs b/src/main.rs index 92b2217..35232fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,8 @@ pub mod wallbash; pub mod wayland; pub mod vulkan; pub mod filters; +pub mod colors; + use std::{ env, io::Write, os::unix::net::UnixStream, @@ -17,6 +19,7 @@ use std::{ thread::sleep, time::Duration, }; + const SOCKET_PATH: &str = "/tmp/wallbash.sock"; const LOG_FILE: &str = "/tmp/wallbash.log"; @@ -33,7 +36,8 @@ fn print_usage() { ::Options wallbash set [option] - -m, --mode | Scaling mode (cover, fit, original) + -p, --palette | Generate color palette (auto, dark, light) + -m, --mode | Scaling mode (cover, fit, original) -a, --anchor <1-9> | Anchor point (1=top-left ... 9=bottom-right) -w, --wall | Wallpaper file /path/to/file.img " ); @@ -59,7 +63,7 @@ fn wait_loop() -> Result<(), Box> { Err("Waiting for daemon...".into()) } -fn parse_args(args: &[String]) -> (String, String, f32, f32) { +fn parse_args(args: &[String]) -> (String, String, f32, f32, String) { // wallpaper – mandatory let wall = args.iter().position(|a| a == "--wall" || a == "-w") @@ -84,6 +88,13 @@ fn parse_args(args: &[String]) -> (String, String, f32, f32) { std::process::exit(1); }); + // color generation - default "skip" + let palette = args.iter().position(|a| a == "--palette" || a == "-p") + .and_then(|i| args.get(i + 1)) + .filter(|s| matches!(s.as_str(), "auto" | "dark" | "light")) + .map(|s| s.clone()) + .unwrap_or_else(|| "skip".into()); + // mode – default "cover" let mode = args.iter().position(|a| a == "--mode" || a == "-m") .and_then(|i| args.get(i + 1)) @@ -110,7 +121,7 @@ fn parse_args(args: &[String]) -> (String, String, f32, f32) { _ => (0.5, 0.5), }; - (wall, mode, ax, ay) + (wall, mode, ax, ay, palette) } @@ -129,8 +140,8 @@ fn main() { } } Some("set") => { - let (wall, mode, ax, ay) = parse_args(&args); - let cmd = format!("set{}\x01{}\x01{}\x01{}", mode, ax, ay, wall); + let (wall, mode, ax, ay, palette) = parse_args(&args); + let cmd = format!("set{}\x01{}\x01{}\x01{}\x01{}", palette, mode, ax, ay, wall); if !check_daemon() { println!("Starting daemon"); let log_file = std::fs::File::create(LOG_FILE).expect("Cannot create log"); diff --git a/src/wallbash.rs b/src/wallbash.rs index d00e01b..4939fd8 100644 --- a/src/wallbash.rs +++ b/src/wallbash.rs @@ -6,7 +6,7 @@ // --------------------------------------------------------------------- / imports -use crate::{vulkan, wayland, filters}; +use crate::{vulkan, wayland, filters, colors}; use ash::vk; use std::{ os::unix::net::{UnixListener, UnixStream}, @@ -20,7 +20,7 @@ use std::{ enum Command { Stop, Status, - Set { mode: String, anchor_x: f32, anchor_y: f32, path: String }, + Set { palette: String, mode: String, anchor_x: f32, anchor_y: f32, path: String }, } struct DaemonState { @@ -43,12 +43,13 @@ impl Command { if raw == "status" { return Command::Status; } if raw.starts_with("set") { let payload = &raw[3..]; - let mut parts = payload.splitn(4, '\x01'); + let mut parts = payload.splitn(5, '\x01'); + let palette = parts.next().unwrap().to_string(); let mode = parts.next().unwrap().to_string(); let anchor_x = parts.next().unwrap().parse().unwrap(); let anchor_y = parts.next().unwrap().parse().unwrap(); let path = parts.next().unwrap().to_string(); - return Command::Set { mode, anchor_x, anchor_y, path }; + return Command::Set { palette, mode, anchor_x, anchor_y, path }; } panic!("unknown internal command: {}", raw); } @@ -138,6 +139,7 @@ fn set_wallpaper( anchor_y: f32, mode: &str, effect: impl FnOnce(&vulkan::VulkanTexture) -> Option, + palette: &String, ) -> Result<(), Box> { // load the wallpaper @@ -189,6 +191,12 @@ fn set_wallpaper( } } + // generate colors + if palette != "skip" { + let dcol = timer("dcols", || colors::dcol(&img)); + colors::print_palette(dcol, &palette); + } + Ok(()) } @@ -235,6 +243,7 @@ fn set_surfchain( impl DaemonState { fn set_command( &mut self, + palette: String, mode: String, anchor_x: f32, anchor_y: f32, @@ -243,7 +252,7 @@ impl DaemonState { let resolved = std::fs::canonicalize(&path) .map(|p| p.to_string_lossy().to_string()) .unwrap_or(path); - println!("[wallbash] loading '{}' ({}|ax:{:?}|ay:{:?})", resolved, mode, anchor_x, anchor_y); + println!("[wallbash] loading '{}' ({}|{}|ax:{:?}|ay:{:?})", resolved, palette, mode, anchor_x, anchor_y); let effect = |tex: &vulkan::VulkanTexture| { if mode != "cover" { @@ -270,6 +279,7 @@ impl DaemonState { anchor_y, &mode, effect, + &palette, ) { Ok(()) => { println!("[wallbash] wallpaper set."); @@ -321,6 +331,7 @@ impl DaemonState { anchor_y, &mode, effect, + &palette, ) { eprintln!("[wallbash] error after swapchain recreation {}", e3); } @@ -359,8 +370,8 @@ pub fn run(socket_path: &str) -> Result<(), Box> { Command::Status => { println!("[wallbash] daemon is running."); } - Command::Set { mode, anchor_x, anchor_y, path } => { - if state.set_command(mode, anchor_x, anchor_y, path).is_err() + Command::Set { palette, mode, anchor_x, anchor_y, path } => { + if state.set_command(palette, mode, anchor_x, anchor_y, path).is_err() { continue; }