diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f9a19f..7e5fe19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,6 @@ jobs: - name: cargo test run: | - cargo test -p shiftpaper-config + cargo test -p shiftpaper-config --all-features cargo test -p shiftpaper-cli --no-default-features --features load-dynamic cargo test -p shiftpaper-daemon diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b750b..4769d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Added - Slideshows. `shiftpaper slideshow` bakes a set of images, or folders of them, and the daemon moves through them on a timer, in order or with `--shuffle`. They pause while idle or on low battery, and `shiftpaper slideshow --stop` keeps whichever image is showing. -- Transitions. Changing wallpaper, whether by slideshow or `shiftpaper set` and a reload, morphs from one to the next through the depth of the scene, and the parallax carries on throughout. `transition` under `[daemon]` picks which, and `transition_secs` how long it takes (3 by default): +- Transitions. Changing wallpaper, whether by slideshow or `shiftpaper set` and a reload, morphs from one to the next through the depth of the scene, and the parallax carries on throughout. `shiftpaper transition [--secs N]` picks which and how long it takes (3 seconds by default), and `shiftpaper transition --help` describes them all: - `sweep-in` (the default): a wave sweeps into the scene, near things first. - `sweep-out`: the background goes first and the nearest things last. - `morph`: everything at once, the shape of the scene a little ahead of its colours. @@ -16,6 +16,8 @@ ### Changed +- `set` and `slideshow` apply straight away: they tell a running shiftpaperd to reload, so there's no `systemctl --user reload` to run. +- Changing wallpaper no longer freezes the parallax while a big image loads: it loads in the background, and only once however many monitors show it. Settings changes that don't change the wallpaper don't reload it. - Wallpapers much bigger than the screen are scaled down as they load. They draw several times faster and use far less memory. ## 0.3.0 - 2026-10-04 diff --git a/Cargo.lock b/Cargo.lock index 7d14eb9..69e03e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,6 +1662,7 @@ dependencies = [ "image", "indicatif", "libloading 0.9.0", + "nix", "ort", "shiftpaper-config", "tempfile", diff --git a/README.md b/README.md index 235b7f3..b946b7b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ shiftpaper set ~/Pictures/wallpaper.jpg systemctl --user enable --now shiftpaperd ``` -To change wallpaper, `shiftpaper set` another image and run `systemctl --user reload shiftpaperd`. The new one morphs in through the depth of the scene. +To change wallpaper, `shiftpaper set` another image. The new one morphs in through the depth of the scene. -For a slideshow, run `shiftpaper slideshow ~/Pictures/walls --interval 10m` and reload. Add `--shuffle` to mix up the order, and `shiftpaper slideshow --stop` keeps whichever image is showing. +For a slideshow, run `shiftpaper slideshow ~/Pictures/walls --interval 10m`. Add `--shuffle` to mix up the order, and `shiftpaper slideshow --stop` keeps whichever image is showing. `shiftpaper fetch-model --list` shows the other depth models. Their licences are in [THIRD_PARTY.md](THIRD_PARTY.md). diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 06334a2..6cde96c 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -33,6 +33,8 @@ toml_edit = "0.25" directories = "6" ureq = "3" indicatif = "0.18" +# Signals a running shiftpaperd to reload, after a change to the config. +nix = { version = "0.31", features = ["signal"] } [dev-dependencies] tempfile = "3" diff --git a/crates/cli/src/daemon.rs b/crates/cli/src/daemon.rs new file mode 100644 index 0000000..358f9a9 --- /dev/null +++ b/crates/cli/src/daemon.rs @@ -0,0 +1,65 @@ +//! Finding a running shiftpaperd and telling it to reload its config. + +use nix::sys::signal::{Signal, kill}; +use nix::unistd::Pid; +use std::os::unix::fs::MetadataExt; + +/// Tell every shiftpaperd this user is running to reload its config, the +/// same as `systemctl --user reload shiftpaperd` but without needing the +/// systemd unit. Returns how many were told. +pub fn reload() -> usize { + running().filter(|&pid| hup(pid)).count() +} + +fn hup(pid: Pid) -> bool { + kill(pid, Signal::SIGHUP).is_ok() +} + +/// The shiftpaperd processes owned by this user, found through /proc. +fn running() -> impl Iterator { + let me = std::fs::metadata("/proc/self").map(|m| m.uid()).ok(); + std::fs::read_dir("/proc") + .into_iter() + .flatten() + .flatten() + .filter_map(move |entry| { + let pid: i32 = entry.file_name().to_str()?.parse().ok()?; + let name = std::fs::read_to_string(entry.path().join("comm")).ok()?; + let owner = entry.metadata().ok()?.uid(); + (name.trim_end() == "shiftpaperd" && Some(owner) == me).then(|| Pid::from_raw(pid)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::process::ExitStatusExt; + use std::process::Command; + + /// Start a stand-in daemon: a copy of `sleep` named shiftpaperd. + fn stand_in(dir: &std::path::Path) -> std::process::Child { + let sleep = ["/usr/bin/sleep", "/bin/sleep"] + .into_iter() + .find(|p| std::path::Path::new(p).exists()) + .expect("sleep is installed"); + let exe = dir.join("shiftpaperd"); + std::fs::copy(sleep, &exe).unwrap(); + Command::new(&exe).arg("30").spawn().unwrap() + } + + // Only the stand-in is signalled, so running the tests doesn't reload + // a real daemon. + #[test] + fn finds_a_running_daemon_and_signals_it() { + let dir = tempfile::tempdir().unwrap(); + let mut child = stand_in(dir.path()); + let pid = Pid::from_raw(child.id() as i32); + // Give it a moment to exec and take its name. + std::thread::sleep(std::time::Duration::from_millis(100)); + + assert!(running().any(|p| p == pid)); + assert!(hup(pid)); + // sleep doesn't handle SIGHUP, so it ends from it. + assert_eq!(child.wait().unwrap().signal(), Some(Signal::SIGHUP as i32)); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index a5760b1..4e5956b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,12 +1,13 @@ mod cache; mod config; +mod daemon; mod depth; mod fetch_model; mod moge; use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; -use shiftpaper_config::{Config, TrackingMode}; +use shiftpaper_config::{Config, TrackingMode, Transition}; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; use std::time::SystemTime; @@ -55,9 +56,9 @@ enum Command { /// Bake an image and set it as the active wallpaper. /// /// Performs the same baking as `bake`, then points the daemon's - /// config.toml at it, replacing any slideshow. Reload the daemon to - /// show it. The resolved model path is also persisted to [inference] - /// so future invocations don't need --model. + /// config.toml at it, replacing any slideshow, and a running daemon + /// changes to it. The resolved model path is also persisted to + /// [inference] so future invocations don't need --model. Set { /// Source image (jpeg, png, or webp). input: PathBuf, @@ -72,8 +73,8 @@ enum Command { /// /// Bakes each image like `set` does, then lists them in config.toml's /// [slideshow]. A folder adds the images directly inside it, in name - /// order. Reload the daemon to start the slideshow. --stop, or `set`, - /// goes back to a single wallpaper. + /// order. A running daemon starts the slideshow straight away. --stop, + /// or `set`, goes back to a single wallpaper. Slideshow { /// Source images (jpeg, png, or webp), or folders of them. #[arg(required_unless_present = "stop")] @@ -113,6 +114,20 @@ enum Command { force: bool, }, + /// Show or change how one wallpaper changes into the next. + /// + /// Applies to every change of wallpaper, from `set` or a slideshow, + /// and a running daemon picks it up straight away. With no arguments, + /// prints the current transition and how long it takes. + Transition { + /// The transition to use. + #[arg(value_enum)] + name: Option, + /// How long it takes, in seconds, like 3 or 1.5. + #[arg(short, long, value_parser = parse_secs)] + secs: Option, + }, + /// Show or change the cursor tracking mode. /// /// Pointer mode (the default) uses Wayland's native pointer events. @@ -166,6 +181,7 @@ fn main() -> Result<()> { slideshow(&inputs, interval, shuffle, &model) } Command::FetchModel { name, list, force } => fetch_model_cmd(name.as_deref(), list, force), + Command::Transition { name, secs } => transition_cmd(name, secs), Command::Mode { mode } => mode_cmd(mode), } } @@ -240,14 +256,19 @@ fn set(input: &Path, model: &Path) -> Result<()> { print_paths(&paths); update_daemon_config(&paths, model)?; eprintln!(); - eprintln!("wallpaper set. reload the daemon to apply:"); - print_reload_hint(); + tell_daemon("wallpaper set", "shiftpaperd is changing to it"); Ok(()) } -fn print_reload_hint() { - eprintln!(" systemctl --user reload shiftpaperd"); - eprintln!(" # or: kill -HUP $(pidof shiftpaperd)"); +/// Say what changed and reload a running shiftpaperd so it takes effect, +/// or say how to start one. +fn tell_daemon(done: &str, happening: &str) { + if daemon::reload() > 0 { + eprintln!("{done}. {happening}."); + } else { + eprintln!("{done}. shiftpaperd isn't running, so start it to see it:"); + eprintln!(" systemctl --user enable --now shiftpaperd"); + } } fn slideshow(inputs: &[PathBuf], interval: NonZeroU32, shuffle: bool, model: &Path) -> Result<()> { @@ -266,11 +287,10 @@ fn slideshow(inputs: &[PathBuf], interval: NonZeroU32, shuffle: bool, model: &Pa update_slideshow_config(&baked, interval, shuffle, model)?; eprintln!(); let plural = if baked.len() == 1 { "" } else { "s" }; - eprintln!( - "slideshow of {} image{plural} set. reload the daemon to start it:", - baked.len() + tell_daemon( + &format!("slideshow of {} image{plural} set", baked.len()), + "shiftpaperd is starting it", ); - print_reload_hint(); Ok(()) } @@ -282,8 +302,10 @@ fn stop_slideshow() -> Result<()> { }; let (color, depth) = slideshow.slide(slideshow.due(SystemTime::now())); config::edit(|doc| write_wallpaper(doc, color, &depth))?; - eprintln!("slideshow stopped on the image it was showing. reload the daemon to apply:"); - print_reload_hint(); + tell_daemon( + "slideshow stopped", + "shiftpaperd is keeping the image it was showing", + ); Ok(()) } @@ -358,6 +380,48 @@ fn mode_cmd(mode: Option) -> Result<()> { Ok(()) } +fn transition_cmd(name: Option, secs: Option) -> Result<()> { + if name.is_none() && secs.is_none() { + let daemon = Config::load()?.map(|cfg| cfg.daemon).unwrap_or_default(); + println!( + "{} ({}s)", + daemon.transition.as_str(), + daemon.transition_secs + ); + return Ok(()); + } + config::edit(|doc| { + let daemon = config::table(doc, "daemon")?; + if let Some(name) = name { + daemon["transition"] = value(name.as_str()); + } + if let Some(secs) = secs { + daemon["transition_secs"] = value(secs); + } + Ok(()) + })?; + let daemon = Config::load()?.map(|cfg| cfg.daemon).unwrap_or_default(); + tell_daemon( + &format!( + "transition set to {} ({}s)", + daemon.transition.as_str(), + daemon.transition_secs + ), + "shiftpaperd will use it for the next change", + ); + Ok(()) +} + +/// Parse a transition length in seconds, like 3, 1.5 or 2s. +fn parse_secs(text: &str) -> Result { + text.strip_suffix('s') + .unwrap_or(text) + .parse::() + .ok() + .filter(|&secs| std::time::Duration::try_from_secs_f64(secs).is_ok()) + .ok_or_else(|| format!("`{text}` isn't a usable number of seconds")) +} + fn update_tracking_mode(mode: TrackingMode) -> Result<()> { config::edit(|doc| { config::table(doc, "daemon")?["tracking_mode"] = value(mode.as_str()); @@ -455,6 +519,24 @@ mod tests { assert_eq!(secs("2h"), Ok(7200)); } + #[test] + fn transition_lengths_parse() { + assert_eq!(parse_secs("3"), Ok(3.0)); + assert_eq!(parse_secs("1.5"), Ok(1.5)); + assert_eq!(parse_secs("2s"), Ok(2.0)); + assert_eq!(parse_secs("0"), Ok(0.0)); + for text in ["", "-1", "inf", "nan", "fast", "1e30"] { + assert!(parse_secs(text).is_err(), "{text}"); + } + } + + #[test] + fn transitions_parse_by_name() { + assert!(parse(&["transition", "tide-out", "--secs", "4"]).is_ok()); + assert!(parse(&["transition"]).is_ok()); + assert!(parse(&["transition", "flatten"]).is_err()); + } + #[test] fn unusable_intervals_are_rejected() { for text in ["", "0", "0m", "m", "5d", "1.5h", "-1", "9999999h"] { diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index b3e8a95..c735b8c 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -65,6 +65,7 @@ impl TrackingMode { /// How one wallpaper changes into the next. #[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[cfg_attr(feature = "clap", derive(clap::ValueEnum))] #[serde(rename_all = "kebab-case")] pub enum Transition { /// A wave sweeps into the scene: the new wallpaper's nearest things @@ -92,6 +93,31 @@ pub enum Transition { TideOut, } +impl Transition { + pub const ALL: [Self; 7] = [ + Self::SweepIn, + Self::SweepOut, + Self::Morph, + Self::Dissolve, + Self::Portal, + Self::TideIn, + Self::TideOut, + ]; + + /// The value as written in config.toml. + pub fn as_str(self) -> &'static str { + match self { + Self::SweepIn => "sweep-in", + Self::SweepOut => "sweep-out", + Self::Morph => "morph", + Self::Dissolve => "dissolve", + Self::Portal => "portal", + Self::TideIn => "tide-in", + Self::TideOut => "tide-out", + } + } +} + #[derive(Debug, Deserialize, Clone)] #[serde(deny_unknown_fields)] pub struct WallpaperConfig { @@ -99,7 +125,7 @@ pub struct WallpaperConfig { pub depth: Option, } -#[derive(Debug, Deserialize, Clone)] +#[derive(Debug, Deserialize, Clone, PartialEq)] #[serde(deny_unknown_fields)] pub struct SlideshowConfig { /// Baked color images, shown in turn. Each one's depth map is found @@ -534,6 +560,28 @@ mod tests { } } + #[cfg(feature = "clap")] + #[test] + fn transition_names_on_the_command_line_match_the_config() { + use clap::ValueEnum; + assert_eq!(Transition::value_variants(), Transition::ALL); + for transition in Transition::ALL { + let name = transition.to_possible_value().unwrap(); + assert_eq!(name.get_name(), transition.as_str()); + } + } + + #[test] + fn transition_strings_match_the_parser() { + for transition in Transition::ALL { + let cfg = parse(&format!( + "[daemon]\ntransition = \"{}\"", + transition.as_str() + )); + assert_eq!(cfg.daemon.transition, transition); + } + } + #[test] fn unusable_transition_times_are_rejected() { for secs in ["-1.0", "nan", "inf", "1e30"] { diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 6847357..b374d7f 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -155,6 +155,7 @@ fn main() -> Result<()> { app.reload_config(); } + app.update_reload(&qh); app.update_slideshow(&qh); if app.needs_render { diff --git a/crates/daemon/src/renderer.rs b/crates/daemon/src/renderer.rs index 7dedbd7..beb50e7 100644 --- a/crates/daemon/src/renderer.rs +++ b/crates/daemon/src/renderer.rs @@ -3,7 +3,7 @@ use anyhow::{Context, Result}; use bytemuck::Zeroable; use image::imageops::{self, FilterType}; use shiftpaper_config::Transition; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; use wgpu::CurrentSurfaceTexture; @@ -100,17 +100,21 @@ pub struct DecodedWallpaper { /// From `DepthMap::ranks` and `DepthMap::height_ranks`, for ordering /// transitions. ranks: Vec<[f32; 2]>, + files: WallpaperFiles, } +/// The color and depth images a wallpaper is loaded from. +pub type WallpaperFiles = (PathBuf, PathBuf); + impl DecodedWallpaper { /// Load a baked wallpaper. If it's bigger than any of `screens` needs, /// it's scaled down, which makes every frame cheaper to draw and /// saves memory. - pub fn load(color: &Path, depth: &Path, screens: &[(u32, u32)]) -> Result { - let mut color = image::open(color) - .with_context(|| format!("failed to load image: {}", color.display()))? + pub fn load(color_path: &Path, depth_path: &Path, screens: &[(u32, u32)]) -> Result { + let mut color = image::open(color_path) + .with_context(|| format!("failed to load image: {}", color_path.display()))? .to_rgba8(); - let mut depth = match load_depth_map(depth) { + let mut depth = match load_depth_map(depth_path) { Ok(map) => Some(map), Err(e) => { warn!("using a flat depth map: {e:#}"); @@ -139,6 +143,7 @@ impl DecodedWallpaper { color, depth, ranks, + files: (color_path.to_path_buf(), depth_path.to_path_buf()), }) } } @@ -170,6 +175,7 @@ pub struct Wallpaper { ranks: wgpu::TextureView, /// Image size in pixels, for cropping to the screen's aspect ratio. size: (u32, u32), + pub files: WallpaperFiles, } /// A transition to another wallpaper that's under way. @@ -215,6 +221,15 @@ impl OutputRenderState { } } + /// The wallpaper this output is showing, or changing to if a transition + /// is under way. + pub fn showing(&self) -> &Wallpaper { + match &self.transition { + Some(t) => &t.next, + None => &self.current, + } + } + /// Show a wallpaper straight away, cancelling any transition. pub fn set_wallpaper(&mut self, renderer: &Renderer, wallpaper: Wallpaper) { self.transition = None; @@ -560,6 +575,7 @@ impl Renderer { }, ranks: self.upload_ranks(&decoded.ranks), size: decoded.color.dimensions(), + files: decoded.files.clone(), } } diff --git a/crates/daemon/src/slideshow.rs b/crates/daemon/src/slideshow.rs index dcfd98c..5b99371 100644 --- a/crates/daemon/src/slideshow.rs +++ b/crates/daemon/src/slideshow.rs @@ -1,6 +1,6 @@ //! Steps through [slideshow]'s images on a timer. -use crate::renderer::{DecodedWallpaper, Renderer, Wallpaper}; +use crate::renderer::{DecodedWallpaper, Renderer, Wallpaper, WallpaperFiles}; use anyhow::{Result, anyhow}; use shiftpaper_config::SlideshowConfig; use std::thread::JoinHandle; @@ -28,6 +28,23 @@ impl Slideshow { } } + /// The slide the clock says to show now, and its files. + pub fn due(&self) -> (usize, WallpaperFiles) { + let index = self.config.due(SystemTime::now()); + let (color, depth) = self.config.slide(index); + (index, (color.to_path_buf(), depth)) + } + + /// Note that slide `index` is on screen, loaded elsewhere, so it isn't + /// loaded again. Without a wallpaper, it's still loading or failed to, + /// and the slideshow waits for the next slide rather than retrying. + pub fn showing(&mut self, index: usize, wallpaper: Option) { + self.index = Some(index); + if wallpaper.is_some() { + self.wallpaper = wallpaper; + } + } + /// The slide to show now, loaded straight away if it isn't already, for /// an output that needs something to show. Skips slides that fail to /// load. `screens` are the screen sizes to scale slides down to. diff --git a/crates/daemon/src/wayland.rs b/crates/daemon/src/wayland.rs index eeef06e..7e4221a 100644 --- a/crates/daemon/src/wayland.rs +++ b/crates/daemon/src/wayland.rs @@ -1,3 +1,4 @@ +use anyhow::anyhow; use anyhow::{Context, Result}; use smithay_client_toolkit::{ compositor::{CompositorHandler, CompositorState, FrameCallbackData}, @@ -21,6 +22,7 @@ use smithay_client_toolkit::{ use std::collections::HashMap; use std::ffi::c_void; use std::ptr::NonNull; +use std::thread::JoinHandle; use std::time::Duration; use tracing::{debug, info, warn}; use wayland_client::{ @@ -37,7 +39,7 @@ use wayland_protocols::wp::{ }; use crate::cursor::HyprlandCursor; -use crate::renderer::{OutputRenderState, Renderer, Wallpaper}; +use crate::renderer::{DecodedWallpaper, OutputRenderState, Renderer, Wallpaper, WallpaperFiles}; use crate::slideshow::Slideshow; use raw_window_handle::{ RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, @@ -120,6 +122,8 @@ pub struct App { pub renderer: Option, pub render_targets: HashMap, slideshow: Option, + /// A reload whose wallpapers are still decoding. + pending: Option, pub cursor: Option, pub pointer: Option, pub seat: Option, @@ -160,6 +164,7 @@ impl App { Ok(Self { slideshow: config.slideshow.clone().map(Slideshow::new), + pending: None, config, registry_state, compositor_state, @@ -203,9 +208,10 @@ impl App { } } - /// Hot-reload config.toml. Reloads wallpaper textures and rebuilds - /// bind groups for every output. Tracking mode and idle timeout - /// changes require a full restart. + /// Hot-reload config.toml. Wallpapers that change are decoded on + /// background threads, while the old ones keep animating, and + /// `update_reload` starts the transitions once they're ready. Tracking + /// mode and idle timeout changes require a full restart. pub fn reload_config(&mut self) { let mut new_cfg = match crate::config::load() { Ok(c) => c, @@ -225,40 +231,110 @@ impl App { warn!("idle_timeout_secs changed, restart required to take effect"); } - // Start the slideshow afresh, as its images may have changed. - self.slideshow = new_cfg.slideshow.clone().map(Slideshow::new); - let renderer = match &self.renderer { - Some(r) => r, - None => { - self.config = new_cfg; - return; - } + // A slideshow that hasn't changed carries on undisturbed. A new one + // starts on the slide the clock says is due. + let slide = if new_cfg.slideshow != self.config.slideshow { + self.slideshow = new_cfg.slideshow.clone().map(Slideshow::new); + self.slideshow.as_mut().map(|slideshow| { + let (index, files) = slideshow.due(); + // Taking the slide now stops the slideshow loading it too. + // render_targets rather than self, which slideshow borrows. + slideshow.showing(index, showing(&self.render_targets, &files)); + (index, files) + }) + } else { + None }; + self.config = new_cfg; + self.needs_render = true; + info!("config reloaded via SIGHUP"); + if self.renderer.is_none() { + return; + } - let (duration, style) = transition_settings(&new_cfg); + // Work out what each output should show, and start decoding + // whatever isn't on screen already, once however many outputs show + // it. Settings like the transition don't change any wallpaper. let screens = self.screen_sizes(); - for output in &self.outputs { - if !output.configured { + let due = self.slideshow.as_ref().map(Slideshow::due); + let mut changes = Vec::new(); + let mut decoding = HashMap::new(); + for output in self.outputs.iter().filter(|o| o.configured) { + let Some(rt) = self.render_targets.get(&output.name) else { continue; - } - if let Some(rt) = self.render_targets.get_mut(&output.name) { - let Some(wallpaper) = load_wallpaper_for( - renderer, - &new_cfg, - self.slideshow.as_mut(), - &output.name, - &screens, + }; + let files = if self.config.in_slideshow(&output.name) { + match &due { + Some((_, files)) => files.clone(), + None => continue, + } + } else { + let (Some(color), Some(depth)) = ( + self.config.color_for(&output.name), + self.config.depth_for(&output.name), ) else { + warn!( + name = output.name, + "reload: no wallpaper set for this output" + ); continue; }; - rt.start_transition(renderer, wallpaper, duration, style); - info!(name = output.name, "reloaded wallpaper"); + (color.to_path_buf(), depth) + }; + if rt.showing().files == files { + continue; } + decoding + .entry(files.clone()) + .or_insert_with(|| decode(files.clone(), screens.clone())); + changes.push((output.name.clone(), files)); } + self.pending = Some(PendingReload { + changes, + decoding, + slide, + }); + } + + /// Finish a reload once its wallpapers have decoded: upload them and + /// start each output's transition. Called from the main loop. + pub fn update_reload(&mut self, qh: &QueueHandle) { + if let Some(pending) = &self.pending + && !pending.decoding.values().all(JoinHandle::is_finished) + { + return; + } + let (Some(pending), Some(renderer)) = (self.pending.take(), &self.renderer) else { + return; + }; - self.config = new_cfg; - self.needs_render = true; - info!("config reloaded via SIGHUP"); + let mut ready = HashMap::new(); + for (files, handle) in pending.decoding { + let decoded = handle + .join() + .unwrap_or_else(|_| Err(anyhow!("the loading thread panicked"))); + match decoded { + Ok(decoded) => { + ready.insert(files, renderer.upload_wallpaper(&decoded)); + } + Err(e) => warn!("reload: {e:#}"), + } + } + if let (Some(slideshow), Some((index, files))) = (&mut self.slideshow, pending.slide) { + slideshow.showing(index, ready.get(&files).cloned()); + } + let (duration, style) = transition_settings(&self.config); + for (name, files) in pending.changes { + if let (Some(wallpaper), Some(rt)) = + (ready.get(&files), self.render_targets.get_mut(&name)) + { + rt.start_transition(renderer, wallpaper.clone(), duration, style); + info!(name, "reloaded wallpaper"); + } + } + for idx in 0..self.outputs.len() { + self.advance(qh, idx); + } } /// Move the slideshow on once the next slide is due and loaded. Called @@ -425,9 +501,41 @@ impl App { } } +/// A reload waiting for its wallpapers to decode on background threads, so +/// the parallax doesn't stall on a big image. +struct PendingReload { + /// Each output changing wallpaper, and the files it's changing to. + changes: Vec<(String, WallpaperFiles)>, + /// Each wallpaper being decoded, once however many outputs show it. + decoding: HashMap>>, + /// The slide a restarted slideshow begins on. + slide: Option<(usize, WallpaperFiles)>, +} + +/// Decode a wallpaper on a background thread. +fn decode( + files: WallpaperFiles, + screens: Vec<(u32, u32)>, +) -> JoinHandle> { + std::thread::spawn(move || DecodedWallpaper::load(&files.0, &files.1, &screens)) +} + +/// The wallpaper with these files if one of `targets` is showing it. +fn showing( + targets: &HashMap, + files: &WallpaperFiles, +) -> Option { + targets + .values() + .map(OutputRenderState::showing) + .find(|wallpaper| &wallpaper.files == files) + .cloned() +} + /// Load what an output should show: the current slide if it's in the -/// slideshow, else its own wallpaper or the global one. Logs why if there's -/// nothing to show. +/// slideshow, else its own wallpaper or the global one. Another output's +/// copy is shared if one is showing it already. Logs why if there's nothing +/// to show. /// /// This takes the parts of `App` it needs rather than `&mut self`, so /// callers can hold the renderer and a render target at the same time. @@ -435,6 +543,7 @@ fn load_wallpaper_for( renderer: &Renderer, config: &Config, slideshow: Option<&mut Slideshow>, + targets: &HashMap, output_name: &str, screens: &[(u32, u32)], ) -> Option { @@ -450,6 +559,9 @@ fn load_wallpaper_for( warn!(name = output_name, "no wallpaper set for this output"); return None; }; + if let Some(wallpaper) = showing(targets, &(color.to_path_buf(), depth.clone())) { + return Some(wallpaper); + } renderer .load_wallpaper(color, &depth, screens) .inspect_err(|e| warn!(name = output_name, "{e:#}")) @@ -703,6 +815,7 @@ impl LayerShellHandler for App { renderer, &self.config, self.slideshow.as_mut(), + &self.render_targets, &output_name, &screens, ) else {