use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
#[derive(Debug)]
pub(crate) struct Wallpaper {
pub rgba: Vec<u8>,
pub width: u32,
pub height: u32,
}
const SIDE: u32 = 160;
const SIGMA: f32 = 6.0;
pub(crate) type Slot = Arc<Mutex<Option<Result<Arc<Wallpaper>, String>>>>;
pub(crate) const READS: bool = cfg!(not(target_os = "macos"));
pub(crate) fn spawn(wake: impl FnOnce() + Send + 'static) -> Slot {
let slot: Slot = Arc::new(Mutex::new(None));
let out = slot.clone();
let spawned = std::thread::Builder::new()
.name("kui-wallpaper".into())
.spawn(move || {
let got = wallpaper_path()
.ok_or_else(|| "the desktop names no wallpaper file kui can read".to_string())
.and_then(|path| load(&path));
*out.lock().unwrap_or_else(|e| e.into_inner()) = Some(got);
wake();
});
if let Err(e) = spawned {
*slot.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Err(format!("cannot start the wallpaper thread: {e}")));
}
slot
}
type Cached = (PathBuf, Option<SystemTime>, Arc<Wallpaper>);
static CACHE: Mutex<Option<Cached>> = Mutex::new(None);
pub(crate) fn load(path: &Path) -> Result<Arc<Wallpaper>, String> {
let mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
if let Some((p, t, w)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref()
&& p == path
&& *t == mtime
{
return Ok(w.clone());
}
let img = image::ImageReader::open(path)
.and_then(|r| r.with_guessed_format())
.map_err(|e| format!("{}: {e}", path.display()))?
.decode()
.map_err(|e| format!("{}: {e}", path.display()))?;
let wallpaper = Arc::new(soften(img.to_rgba8()));
*CACHE.lock().unwrap_or_else(|e| e.into_inner()) =
Some((path.to_path_buf(), mtime, wallpaper.clone()));
Ok(wallpaper)
}
pub(crate) fn soften(img: image::RgbaImage) -> Wallpaper {
let (w, h) = img.dimensions();
let scale = SIDE as f32 / w.max(h).max(1) as f32;
let (tw, th) = if scale < 1.0 {
(
((w as f32 * scale).round() as u32).max(1),
((h as f32 * scale).round() as u32).max(1),
)
} else {
(w.max(1), h.max(1))
};
let small = image::imageops::thumbnail(&img, tw, th);
let soft = image::imageops::blur(&small, SIGMA);
Wallpaper {
width: soft.width(),
height: soft.height(),
rgba: soft.into_raw(),
}
}
pub(crate) struct Ground {
slot: Slot,
wallpaper: Option<Arc<Wallpaper>>,
uploaded: bool,
moved: bool,
}
impl Ground {
pub(crate) fn new(slot: Slot) -> Self {
Ground {
slot,
wallpaper: None,
uploaded: false,
moved: true,
}
}
pub(crate) fn moved(&mut self) {
self.moved = true;
}
pub(crate) fn renderer_replaced(&mut self) {
self.uploaded = false;
self.moved = true;
}
pub(crate) fn settle(&mut self) -> Result<(), String> {
if self.wallpaper.is_none() {
let got = self.slot.lock().unwrap_or_else(|e| e.into_inner()).take();
match got {
None => {}
Some(Err(e)) => return Err(e),
Some(Ok(w)) => self.wallpaper = Some(w),
}
}
Ok(())
}
pub(crate) fn prepare(
&mut self,
window: &winit::window::Window,
renderer: &mut kui_wgpu::Renderer,
) {
let Some(w) = &self.wallpaper else {
return;
};
if !self.uploaded {
renderer.set_ground(&w.rgba, w.width, w.height);
self.uploaded = true;
self.moved = true;
}
if self.moved {
self.moved = false;
let size = window.inner_size();
let position = window
.inner_position()
.ok()
.map(|p| (p.x as f64, p.y as f64));
let monitor = window.current_monitor().map(|m| {
let (p, s) = (m.position(), m.size());
(p.x as f64, p.y as f64, s.width as f64, s.height as f64)
});
renderer.set_ground_uv(uv(
(size.width as f64, size.height as f64),
position,
monitor,
(w.width, w.height),
));
}
}
}
pub(crate) type ScreenRect = (f64, f64, f64, f64);
pub(crate) fn uv(
(ww, wh): (f64, f64),
position: Option<(f64, f64)>,
monitor: Option<ScreenRect>,
(iw, ih): (u32, u32),
) -> [f32; 4] {
let (mx, my, mw, mh) = monitor.unwrap_or((0.0, 0.0, ww.max(1.0), wh.max(1.0)));
let (wx, wy) = position.unwrap_or((mx + (mw - ww) / 2.0, my + (mh - wh) / 2.0));
let (iw, ih) = (iw.max(1) as f64, ih.max(1) as f64);
let s = (mw / iw).max(mh / ih).max(f64::MIN_POSITIVE);
let (dw, dh) = (iw * s, ih * s);
let (ox, oy) = (mx + (mw - dw) / 2.0, my + (mh - dh) / 2.0);
[
((wx - ox) / dw) as f32,
((wy - oy) / dh) as f32,
((wx + ww - ox) / dw) as f32,
((wy + wh - oy) / dh) as f32,
]
}
pub(crate) fn wallpaper_path() -> Option<PathBuf> {
platform::wallpaper_path().filter(|p| p.is_file())
}
#[cfg(target_os = "windows")]
mod platform {
use std::path::PathBuf;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SPI_GETDESKWALLPAPER, SystemParametersInfoW,
};
pub(super) fn wallpaper_path() -> Option<PathBuf> {
let mut buf = [0u16; 1024];
let ok = unsafe {
SystemParametersInfoW(
SPI_GETDESKWALLPAPER,
buf.len() as u32,
buf.as_mut_ptr().cast(),
0,
)
};
if ok == 0 {
return None;
}
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let s = String::from_utf16_lossy(&buf[..len]);
(!s.is_empty()).then(|| PathBuf::from(s))
}
}
#[cfg(target_os = "macos")]
mod platform {
pub(super) fn wallpaper_path() -> Option<std::path::PathBuf> {
None
}
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
mod platform {
use std::path::PathBuf;
pub(super) fn wallpaper_path() -> Option<PathBuf> {
let desktop = std::env::var("XDG_CURRENT_DESKTOP").unwrap_or_default();
if desktop.split(':').any(|d| d.eq_ignore_ascii_case("KDE")) {
let home = std::env::var_os("HOME")?;
let config = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(&home).join(".config"));
let ini =
std::fs::read_to_string(config.join("plasma-org.kde.plasma.desktop-appletsrc"))
.ok()?;
return super::plasma_image(&ini).and_then(|v| super::resolve(&v));
}
let gsettings = |schema: &str, key: &str| -> Option<String> {
let out = std::process::Command::new("gsettings")
.args(["get", schema, key])
.output()
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
};
let dark = gsettings("org.gnome.desktop.interface", "color-scheme")
.is_some_and(|s| s.contains("dark"));
let key = if dark {
"picture-uri-dark"
} else {
"picture-uri"
};
let uri = gsettings("org.gnome.desktop.background", key)
.filter(|s| !super::unquote(s).is_empty())
.or_else(|| gsettings("org.gnome.desktop.background", "picture-uri"))?;
super::resolve(super::unquote(&uri))
}
}
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
pub(crate) fn unquote(s: &str) -> &str {
let s = s.trim();
s.strip_prefix('\'')
.and_then(|s| s.strip_suffix('\''))
.or_else(|| s.strip_prefix('"').and_then(|s| s.strip_suffix('"')))
.unwrap_or(s)
}
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
pub(crate) fn plasma_image(ini: &str) -> Option<String> {
let mut inside = false;
for line in ini.lines() {
let line = line.trim();
if line.starts_with('[') {
inside = line.contains("[Wallpaper][org.kde.image][General]");
continue;
}
if inside && let Some(v) = line.strip_prefix("Image=") {
return Some(v.trim().to_string());
}
}
None
}
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
pub(crate) fn resolve(value: &str) -> Option<PathBuf> {
let path = PathBuf::from(percent_decode(
value.strip_prefix("file://").unwrap_or(value),
));
if path.is_dir() {
let images = path.join("contents").join("images");
let mut best: Option<(u64, PathBuf)> = None;
for entry in std::fs::read_dir(images).ok()?.flatten() {
let p = entry.path();
let name = p.file_stem()?.to_string_lossy().to_string();
let area = name
.split_once('x')
.and_then(|(w, h)| Some(w.parse::<u64>().ok()? * h.parse::<u64>().ok()?))
.unwrap_or(0);
if best.as_ref().is_none_or(|(a, _)| area > *a) {
best = Some((area, p));
}
}
return best.map(|(_, p)| p);
}
if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("xml"))
{
let xml = std::fs::read_to_string(&path).ok()?;
return slideshow_first(&xml).map(PathBuf::from);
}
Some(path)
}
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
pub(crate) fn slideshow_first(xml: &str) -> Option<String> {
let start = xml.find("<file>")? + "<file>".len();
let end = start + xml[start..].find("</file>")?;
Some(xml[start..end].trim().to_string())
}
#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
pub(crate) fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let Some(v) = std::str::from_utf8(&b[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
out.push(v);
i += 3;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_window_shows_the_part_of_the_wallpaper_behind_it() {
let monitor = Some((0.0, 0.0, 1920.0, 1080.0));
assert_eq!(
uv((1920.0, 1080.0), Some((0.0, 0.0)), monitor, (192, 108)),
[0.0, 0.0, 1.0, 1.0]
);
let [u0, v0, u1, v1] = uv((960.0, 540.0), Some((960.0, 540.0)), monitor, (192, 108));
assert_eq!([u0, v0, u1, v1], [0.5, 0.5, 1.0, 1.0]);
let right = Some((1920.0, 0.0, 1920.0, 1080.0));
assert_eq!(
uv((960.0, 1080.0), Some((1920.0, 0.0)), right, (192, 108)),
[0.0, 0.0, 0.5, 1.0]
);
}
#[test]
fn a_wallpaper_of_another_shape_covers_the_screen() {
let monitor = Some((0.0, 0.0, 1000.0, 1000.0));
let [u0, v0, u1, v1] = uv((1000.0, 1000.0), Some((0.0, 0.0)), monitor, (200, 100));
assert_eq!([u0, v0, u1, v1], [0.25, 0.0, 0.75, 1.0]);
}
#[test]
fn a_window_with_no_position_is_taken_as_centred() {
let monitor = Some((0.0, 0.0, 2000.0, 1000.0));
assert_eq!(
uv((1000.0, 500.0), None, monitor, (200, 100)),
[0.25, 0.25, 0.75, 0.75]
);
assert_eq!(
uv((400.0, 200.0), None, None, (200, 100)),
[0.0, 0.0, 1.0, 1.0]
);
}
#[test]
fn a_wallpaper_is_scaled_down_and_blurred() {
let mut img = image::RgbaImage::from_pixel(1600, 900, image::Rgba([0, 0, 0, 255]));
for y in 300..600 {
for x in 600..1000 {
img.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
}
}
let w = soften(img);
assert_eq!((w.width, w.height), (SIDE, 90));
assert_eq!(w.rgba.len(), (w.width * w.height * 4) as usize);
let at = |x: u32, y: u32| w.rgba[((y * w.width + x) * 4) as usize];
assert!(at(80, 45) > 100, "{}", at(80, 45));
assert!(at(5, 5) < 10);
let ramp: Vec<u8> = (50..70).map(|x| at(x, 45)).collect();
assert!(ramp.windows(2).all(|p| p[0] <= p[1]), "{ramp:?}");
assert!(ramp[0] < ramp[ramp.len() - 1]);
}
#[test]
fn the_answer_is_settled_before_the_view() {
let slot: Slot = Arc::new(Mutex::new(None));
let mut g = Ground::new(slot.clone());
assert_eq!(g.settle(), Ok(()));
assert!(g.wallpaper.is_none(), "still loading");
*slot.lock().unwrap() = Some(Err("none".into()));
assert_eq!(g.settle(), Err("none".into()));
assert_eq!(g.settle(), Ok(()), "said once");
let slot: Slot = Arc::new(Mutex::new(None));
let mut g = Ground::new(slot.clone());
let w = Arc::new(soften(image::RgbaImage::from_pixel(
4,
4,
image::Rgba([1, 2, 3, 255]),
)));
*slot.lock().unwrap() = Some(Ok(w.clone()));
assert_eq!(g.settle(), Ok(()));
assert!(Arc::ptr_eq(g.wallpaper.as_ref().unwrap(), &w));
assert_eq!(g.settle(), Ok(()));
}
#[test]
fn a_wallpaper_is_decoded_once_per_file() {
let dir = std::env::temp_dir().join(format!("kui-ground-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("TranscodedWallpaper");
image::RgbaImage::from_pixel(64, 32, image::Rgba([10, 120, 200, 255]))
.save_with_format(&path, image::ImageFormat::Png)
.unwrap();
let a = load(&path).unwrap();
let b = load(&path).unwrap();
assert!(Arc::ptr_eq(&a, &b), "the cache answered the second load");
assert_eq!(
(a.width, a.height),
(64, 32),
"small already: kept its size"
);
assert_eq!(&a.rgba[..3], &[10, 120, 200]);
assert!(load(&dir.join("missing.jpg")).is_err());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_desktops_settings_are_read() {
assert_eq!(
unquote("'file:///usr/share/backgrounds/My%20Hills.jpg'\n"),
"file:///usr/share/backgrounds/My%20Hills.jpg"
);
assert_eq!(
percent_decode("/a/My%20Hills%2Cdark.jpg"),
"/a/My Hills,dark.jpg"
);
assert_eq!(percent_decode("/a/100%"), "/a/100%");
let ini = "[Containments][1][General]\nImage=nope\n\n\
[Containments][1][Wallpaper][org.kde.image][General]\n\
Image=file:///usr/share/wallpapers/Next/\nPreviewImage=x\n";
assert_eq!(
plasma_image(ini).as_deref(),
Some("file:///usr/share/wallpapers/Next/")
);
assert_eq!(plasma_image("[General]\nImage=x\n"), None);
let xml = "<background><static><duration>1</duration>\
<file> /usr/share/backgrounds/a.jpg </file></static></background>";
assert_eq!(
slideshow_first(xml).as_deref(),
Some("/usr/share/backgrounds/a.jpg")
);
let dir = std::env::temp_dir().join(format!("kui-pkg-{}", std::process::id()));
let images = dir.join("contents").join("images");
std::fs::create_dir_all(&images).unwrap();
for name in ["1920x1080.png", "3840x2160.png", "1280x800.png"] {
std::fs::write(images.join(name), b"").unwrap();
}
let got = resolve(&format!("file://{}", dir.display())).unwrap();
assert_eq!(got.file_name().unwrap(), "3840x2160.png");
std::fs::remove_dir_all(&dir).ok();
}
}