//! `alloy-drift` — the desktop background: the keybinding legend, and what runs //! behind it once the legend is dismissed. //! //! Started from the sway config as `shop --layer background -e //! /usr/bin/alloy-drift`, which is a wlr-layer-shell surface sized to the //! output, anchored to all four edges, with an empty input region and no //! keyboard interactivity. //! //! THE SURFACE CANNOT BE CLICKED OR TYPED AT, and that is what shapes this //! whole binary. "Dismiss the legend" cannot be a keypress on the backdrop, //! because the backdrop never receives one — the empty input region is the //! reason a click on the desktop does not steal focus from the window in front //! of it, and it is not worth giving up. So the dismissal arrives as a signal: //! SIGUSR1 toggles legend and animation, and the sway config binds a key to //! `pkill -USR1 -x alloy-drift`. The bind is printed on the legend, because the //! legend derives itself from the same config's `bindsym` lines — the way to //! put the legend away is written on the legend. //! //! THE LEGEND IS NOT LOST, which is the reason for a toggle rather than a //! timeout. `usr/bin/alloy-backdrop` is the only keybinding reference an //! installed machine carries: `docs/` is not copied into the image. A backdrop //! that faded to an animation after five minutes would delete the manual from //! the machine, quietly, on every desktop, and would do it right around the //! time a new user stopped reading it and started needing it. //! //! THE LEGEND IS STILL DERIVED BY THE SCRIPT. This process runs //! `alloy-backdrop --once` and writes what it says. Both of that script's lists //! come from the machine rather than from a transcription — the verbs from //! `alloy --help`, the keys from the sway config the session actually loaded — //! and `crates/alloy/tests/backdrop.rs` plus the Containerfile both assert that //! parsing against the shipped config. None of that moves into Rust for the //! sake of one process boundary. //! //! What is here instead is the loop, which is the part a shell script is bad //! at: a frame clock, a diffed repaint, and signal handling that does not cost //! a wakeup while the legend is up. //! //! mod patterns; mod render; mod rng; use std::io::{self, Write}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use patterns::{CYCLE, Pattern}; use render::Screen; /// The reference the legend comes from. Overridable so the panel can be /// rendered from a checkout without installing anything. const LEGEND: &str = "/usr/bin/alloy-backdrop"; /// Frames per second, and it is deliberately not a number anyone would call /// smooth. This process runs for the whole life of a session on every output, /// including while it is completely occluded by a fullscreen window, and every /// frame it emits is a surface the compositor re-rasterises. Four is enough for /// Rule 30 and 10 PRINT to read as falling and slow enough that the cost does /// not show up beside an idle desktop. const FPS: u64 = 4; /// Seconds on each pattern in `cycle`. Long enough that Langton's ant reaches /// its highway and a Life soup goes through several reseeds. const ROTATE: u64 = 900; static TOGGLE: AtomicBool = AtomicBool::new(false); static RESIZED: AtomicBool = AtomicBool::new(false); static QUIT: AtomicBool = AtomicBool::new(false); extern "C" fn on_toggle(_: libc::c_int) { TOGGLE.store(true, Ordering::Relaxed); } extern "C" fn on_resize(_: libc::c_int) { RESIZED.store(true, Ordering::Relaxed); } extern "C" fn on_quit(_: libc::c_int) { QUIT.store(true, Ordering::Relaxed); } struct Options { pattern: String, fps: u64, rotate: u64, seed: u64, legend: bool, once: bool, } const USAGE: &str = "\ alloy-drift — the desktop backdrop Usage: alloy-drift [OPTIONS] Options: --pattern rule30, tenprint, ant, life, or cycle [default: cycle] --fps frames per second [default: 4] --rotate seconds per pattern under --pattern cycle [default: 900] --seed seed the generator, for a reproducible render --no-legend start on the animation rather than the legend --once draw one frame to stdout and exit -h, --help print this SIGUSR1 toggles between the legend and the animation. SIGWINCH redraws at the new size. The legend itself is rendered by alloy-backdrop --once. "; fn parse() -> Result { let mut options = Options { pattern: "cycle".to_string(), fps: FPS, rotate: ROTATE, seed: SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |since| since.as_nanos() as u64), legend: true, once: false, }; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { // A helper written the way its siblings in usr/bin are: a flag, its // value, and an error for anything else. Six options do not earn a // dependency in a process that is idle by design. let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value")); match arg.as_str() { "--pattern" => options.pattern = value()?, "--fps" => { options.fps = value()? .parse() .map_err(|_| "--fps wants a number".to_string())?; if options.fps == 0 || options.fps > 60 { return Err("--fps must be between 1 and 60".to_string()); } } "--rotate" => { options.rotate = value()? .parse() .map_err(|_| "--rotate wants a number".to_string())?; } "--seed" => { options.seed = value()? .parse() .map_err(|_| "--seed wants a number".to_string())?; } "--no-legend" => options.legend = false, "--once" => options.once = true, "-h" | "--help" => { print!("{USAGE}"); std::process::exit(0); } other => return Err(format!("unknown argument {other}")), } } if options.pattern != "cycle" && !CYCLE.contains(&options.pattern.as_str()) { return Err(format!( "unknown pattern {}; try one of {}", options.pattern, CYCLE.join(", ") )); } Ok(options) } /// The surface size, in rows and columns. /// /// `ALLOY_BACKDROP_SIZE` first, and it is the same variable the script reads /// for the same reason: a test harness has no pty, and the layout that matters /// is asserted without allocating one. Then `TIOCGWINSZ`. Then a small sane /// grid, because a backdrop that refused to draw would leave the screen black. fn surface_size() -> (usize, usize) { if let Ok(size) = std::env::var("ALLOY_BACKDROP_SIZE") { let mut parts = size.split_whitespace(); if let (Some(Ok(rows)), Some(Ok(cols))) = (parts.next().map(str::parse), parts.next().map(str::parse)) { return (rows, cols); } } let mut winsize: libc::winsize = unsafe { std::mem::zeroed() }; // SAFETY: TIOCGWINSZ writes a `winsize` and nothing else, into a struct // this frame owns. A non-tty stdout fails the call and leaves it zeroed, // which the check below treats as no answer. let answered = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut winsize) } == 0; if answered && winsize.ws_row > 0 && winsize.ws_col > 0 { return (winsize.ws_row as usize, winsize.ws_col as usize); } (24, 80) } /// Ask the script for the panel, at the size this surface actually is. /// /// Its output already begins with a clear and a home, so it is written through /// untouched. A failure is not fatal and not reported on screen: the backdrop /// is behind every window on the desktop and an error message there would be /// both unreadable and permanent. It falls through to the animation instead, /// which is the more useful of the two things this process can show when it /// cannot show the other. fn legend(rows: usize, cols: usize) -> Option> { let path = std::env::var("ALLOY_DRIFT_LEGEND").unwrap_or_else(|_| LEGEND.to_string()); let output = Command::new(path) .arg("--once") .env("ALLOY_BACKDROP_SIZE", format!("{rows} {cols}")) .output() .ok()?; output.status.success().then_some(output.stdout) } fn install_signals() { // SAFETY: each handler stores into a static AtomicBool and does nothing // else, which is the one thing a handler is unconditionally allowed to do. unsafe { libc::signal(libc::SIGUSR1, on_toggle as *const () as libc::sighandler_t); libc::signal(libc::SIGWINCH, on_resize as *const () as libc::sighandler_t); libc::signal(libc::SIGTERM, on_quit as *const () as libc::sighandler_t); libc::signal(libc::SIGINT, on_quit as *const () as libc::sighandler_t); } } fn main() { let options = match parse() { Ok(options) => options, Err(message) => { eprintln!("alloy-drift: {message}"); eprint!("{USAGE}"); std::process::exit(2); } }; let (rows, cols) = surface_size(); let mut stdout = io::stdout(); if options.once { // One frame, no terminal setup and no signals. What lets the layout be // asserted without a compositor, and what `alloy-drift --once | cat -A` // gives someone checking which escapes reach the surface. let name = if options.pattern == "cycle" { CYCLE[0] } else { options.pattern.as_str() }; if let Some(panel) = options.legend.then(|| legend(rows, cols)).flatten() { let _ = stdout.write_all(&panel); return; } let mut pattern = patterns::build(name, options.seed).expect("parse checked the name"); let mut screen = Screen::new(rows, cols); pattern.resize(rows, cols); for _ in 0..=pattern.warmup() { pattern.frame(&mut screen); } let _ = screen.flush(&mut stdout); let _ = stdout.write_all(b"\n"); return; } install_signals(); // Hide the cursor. A block cursor parked in the corner of every desktop is // the one piece of chrome a background surface must not have. let _ = stdout.write_all(b"\x1b[?25l\x1b[2J"); let _ = stdout.flush(); run(&options, &mut stdout); let _ = stdout.write_all(b"\x1b[0m\x1b[?25h\x1b[2J\x1b[H"); let _ = stdout.flush(); } fn run(options: &Options, stdout: &mut impl Write) { let (mut rows, mut cols) = surface_size(); let mut screen = Screen::new(rows, cols); let mut showing_legend = options.legend; let mut needs_legend = true; let mut cycling = options.pattern == "cycle"; let mut index = 0usize; let mut pattern: Box = patterns::build( if cycling { CYCLE[0] } else { options.pattern.as_str() }, options.seed, ) .expect("parse checked the name"); pattern.resize(rows, cols); let mut warmed = false; let mut since_switch = Instant::now(); let interval = Duration::from_millis(1000 / options.fps); while !QUIT.load(Ordering::Relaxed) { if RESIZED.swap(false, Ordering::Relaxed) { let (new_rows, new_cols) = surface_size(); if (new_rows, new_cols) != (rows, cols) { rows = new_rows; cols = new_cols; screen.resize(rows, cols); pattern.resize(rows, cols); warmed = false; } screen.invalidate(); needs_legend = true; } if TOGGLE.swap(false, Ordering::Relaxed) { showing_legend = !showing_legend; screen.invalidate(); needs_legend = true; let _ = stdout.write_all(b"\x1b[0m\x1b[2J"); } if showing_legend { if needs_legend { match legend(rows, cols) { Some(panel) => { let _ = stdout.write_all(&panel); let _ = stdout.flush(); needs_legend = false; } // The script is gone or broken. Show the animation rather // than a blank surface, and do not come back here until // somebody asks again. None => showing_legend = false, } } if showing_legend { // No frame clock, no polling, no wakeups at all: the legend is // a static panel and the only things that can change it are the // two signals. `pause` returns when one arrives. // // SAFETY: pause takes nothing and only sleeps. unsafe { libc::pause() }; continue; } } if cycling && since_switch.elapsed() >= Duration::from_secs(options.rotate) { index = (index + 1) % CYCLE.len(); pattern = patterns::build(CYCLE[index], options.seed.wrapping_add(index as u64)) .expect("the cycle names only patterns that build"); pattern.resize(rows, cols); warmed = false; since_switch = Instant::now(); screen.invalidate(); let _ = stdout.write_all(b"\x1b[0m\x1b[2J"); } // `--rotate 0` is a rotation that fires every frame, which is not a // thing anyone means by it. Read it as "do not rotate". if options.rotate == 0 { cycling = false; } if !warmed { for _ in 0..pattern.warmup() { pattern.frame(&mut screen); } warmed = true; } pattern.frame(&mut screen); if screen.flush(stdout).is_err() { // shop has gone. Nothing downstream is reading, and a background // surface writing into a closed pipe forever is the one way this // process becomes a problem rather than a picture. return; } std::thread::sleep(interval); } }