Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 2 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
65 changes: 65 additions & 0 deletions crates/cli/src/daemon.rs
Original file line number Diff line number Diff line change
@@ -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<Item = Pid> {
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));
}
}
116 changes: 99 additions & 17 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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")]
Expand Down Expand Up @@ -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<Transition>,
/// How long it takes, in seconds, like 3 or 1.5.
#[arg(short, long, value_parser = parse_secs)]
secs: Option<f64>,
},

/// Show or change the cursor tracking mode.
///
/// Pointer mode (the default) uses Wayland's native pointer events.
Expand Down Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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<()> {
Expand All @@ -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(())
}

Expand All @@ -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(())
}

Expand Down Expand Up @@ -358,6 +380,48 @@ fn mode_cmd(mode: Option<TrackingMode>) -> Result<()> {
Ok(())
}

fn transition_cmd(name: Option<Transition>, secs: Option<f64>) -> 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<f64, String> {
text.strip_suffix('s')
.unwrap_or(text)
.parse::<f64>()
.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());
Expand Down Expand Up @@ -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"] {
Expand Down
50 changes: 49 additions & 1 deletion crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -92,14 +93,39 @@ 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 {
pub color: PathBuf,
pub depth: Option<PathBuf>,
}

#[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
Expand Down Expand Up @@ -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"] {
Expand Down
Loading
Loading