Vectrum
Sign In
112 lines · 3.8 KB · Rust
//! The start wallpaper: the image Settings → Appearance can put behind the
//! new-thread screen. It's decoded once on a background thread, scaled down
//! to what a screen can show, and kept until the file changes, so drawing it
//! costs nothing after the first frame.

use std::{
    collections::HashMap,
    path::{Path, PathBuf},
    sync::Arc,
    time::SystemTime,
};

use gpui_kit::*;

/// Wider or taller images are scaled down to this before they're kept.
const MAX_SIDE: u32 = 2560;

/// A wallpaper by path, as it was when last read.
enum Entry {
    Loading(Option<SystemTime>),
    Ready(Option<SystemTime>, Arc<RenderImage>),
    Failed(Option<SystemTime>),
}

impl Entry {
    fn modified(&self) -> Option<SystemTime> {
        match self {
            Entry::Loading(modified) | Entry::Ready(modified, _) | Entry::Failed(modified) => *modified,
        }
    }
}

#[derive(Default)]
struct Wallpapers {
    entries: HashMap<PathBuf, Entry>,
}

impl Global for Wallpapers {}

/// The wallpaper at `path`, once it's decoded. The first call starts
/// decoding it and `view` repaints once it's ready; a file that changed
/// since is read again.
pub fn image(path: &Path, view: EntityId, cx: &mut App) -> Option<Arc<RenderImage>> {
    let modified = std::fs::metadata(path).and_then(|meta| meta.modified()).ok();
    let cache = cx.default_global::<Wallpapers>();
    match cache.entries.get(path) {
        Some(entry) if entry.modified() == modified => {
            return match entry {
                Entry::Ready(_, image) => Some(image.clone()),
                _ => None,
            };
        }
        _ => {}
    }
    cache.entries.insert(path.to_path_buf(), Entry::Loading(modified));
    let file = path.to_path_buf();
    let decode = cx.background_executor().spawn(async move { decode(&file) });
    let path = path.to_path_buf();
    cx.spawn(async move |cx| {
        let result = decode.await;
        cx.update(|cx| {
            let entry = match result {
                Ok(image) => Entry::Ready(modified, Arc::new(image)),
                Err(err) => {
                    eprintln!("vectrum-code: couldn't load wallpaper {}: {err}", path.display());
                    Entry::Failed(modified)
                }
            };
            cx.default_global::<Wallpapers>().entries.insert(path, entry);
            cx.notify(view);
            cx.refresh_windows();
        });
    })
    .detach();
    None
}

/// Reads an image file into the BGRA frame GPUI draws, no larger than
/// [`MAX_SIDE`] on either side.
fn decode(path: &Path) -> anyhow::Result<RenderImage> {
    let decoded = ::image::ImageReader::open(path)?.with_guessed_format()?.decode()?;
    let decoded = if decoded.width() > MAX_SIDE || decoded.height() > MAX_SIDE {
        decoded.resize(MAX_SIDE, MAX_SIDE, ::image::imageops::FilterType::Triangle)
    } else {
        decoded
    };
    let mut pixels = decoded.into_rgba8();
    for pixel in pixels.chunks_exact_mut(4) {
        pixel.swap(0, 2);
    }
    Ok(RenderImage::new(vec![::image::Frame::new(pixels)]))
}

#[cfg(test)]
mod tests {
    use super::{MAX_SIDE, decode};

    #[::core::prelude::v1::test]
    fn decodes_and_scales_down() {
        let dir = std::env::temp_dir().join(format!("vectrum-wallpaper-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("wide.png");
        ::image::RgbaImage::from_pixel(4000, 1000, ::image::Rgba([255, 0, 0, 255])).save(&file).unwrap();
        let image = decode(&file).unwrap();
        let size = image.size(0);
        assert_eq!((size.width.0, size.height.0), (MAX_SIDE as i32, MAX_SIDE as i32 / 4));
        // Red comes out as BGRA.
        assert_eq!(&image.as_bytes(0).unwrap()[..4], &[0, 0, 255, 255]);
        assert!(decode(&dir.join("missing.png")).is_err());
        let _ = std::fs::remove_file(&file);
    }
}