From c4a37ee091b851a02f2f643b0cd53ba95663ffb8 Mon Sep 17 00:00:00 2001 From: CPritch Date: Mon, 5 Oct 2026 20:34:38 +0100 Subject: [PATCH 1/4] feat: reload the daemon after set and slideshow set, slideshow and slideshow --stop now send SIGHUP to any shiftpaperd the user is running, found through /proc, so the change shows straight away without a separate reload. It's the same signal systemctl --user reload sends, so it also works when the daemon isn't run by systemd. If none is running, they say how to start it. --- CHANGELOG.md | 1 + Cargo.lock | 1 + README.md | 4 +-- crates/cli/Cargo.toml | 2 ++ crates/cli/src/daemon.rs | 65 ++++++++++++++++++++++++++++++++++++++++ crates/cli/src/main.rs | 39 ++++++++++++++---------- 6 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 crates/cli/src/daemon.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b750b..175be5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Changed +- `set` and `slideshow` apply straight away: they tell a running shiftpaperd to reload, so there's no `systemctl --user reload` to run. - 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..acacc31 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,5 +1,6 @@ mod cache; mod config; +mod daemon; mod depth; mod fetch_model; mod moge; @@ -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")] @@ -240,14 +241,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 +272,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 +287,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(()) } From 131371af72063271fb74e3dd3745f495d453af99 Mon Sep 17 00:00:00 2001 From: CPritch Date: Mon, 5 Oct 2026 20:37:08 +0100 Subject: [PATCH 2/4] feat: add a transition command shiftpaper transition shows the current transition and how long it takes, or sets either and tells a running daemon to pick it up, like mode does for tracking. The names come straight from the config's Transition, which now derives clap's ValueEnum, so --help lists each with its description and they tab-complete. Tests check the command line names, the config names and the parser all agree. --- CHANGELOG.md | 2 +- crates/cli/src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++- crates/config/src/lib.rs | 48 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 175be5b..e3c6f0e 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. diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index acacc31..4e5956b 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,7 +7,7 @@ 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; @@ -114,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. @@ -167,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), } } @@ -365,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()); @@ -462,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..fff6db1 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 { @@ -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"] { From cccfc1754e75ebf61ed2ce3bcc183b24069c593b Mon Sep 17 00:00:00 2001 From: CPritch Date: Mon, 5 Oct 2026 20:40:42 +0100 Subject: [PATCH 3/4] feat: load reloaded wallpapers in the background A reload decoded each output's wallpaper on the main thread, so the parallax froze for a second or two with a big image, and an image shown on two monitors was decoded twice. Now the config is read straight away and each changed wallpaper decodes on a background thread, once however many outputs show it, while the old ones keep animating. The main loop starts the transitions when they're all ready. Outputs already showing the right wallpaper are left alone, so a settings change like the transition doesn't reload or redraw anything, and an unchanged slideshow carries on as it was. New outputs share the textures of any output showing the same wallpaper. --- CHANGELOG.md | 1 + crates/config/src/lib.rs | 2 +- crates/daemon/src/main.rs | 1 + crates/daemon/src/renderer.rs | 26 ++++- crates/daemon/src/slideshow.rs | 19 +++- crates/daemon/src/wayland.rs | 173 +++++++++++++++++++++++++++------ 6 files changed, 185 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c6f0e..4769d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### 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/crates/config/src/lib.rs b/crates/config/src/lib.rs index fff6db1..c735b8c 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -125,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 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 { From 768fcf97b375e4799bc9d3267565a8ffebbe40e2 Mon Sep 17 00:00:00 2001 From: CPritch Date: Mon, 5 Oct 2026 20:40:42 +0100 Subject: [PATCH 4/4] ci: run the config tests with the clap feature --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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