Skip to main content

max / alloy

14.2 KB · 372 lines History Blame Raw
1 //! `alloy-drift` — the desktop background: the keybinding legend, and what runs
2 //! behind it once the legend is dismissed.
3 //!
4 //! Started from the sway config as `shop --layer background -e
5 //! /usr/bin/alloy-drift`, which is a wlr-layer-shell surface sized to the
6 //! output, anchored to all four edges, with an empty input region and no
7 //! keyboard interactivity.
8 //!
9 //! THE SURFACE CANNOT BE CLICKED OR TYPED AT, and that is what shapes this
10 //! whole binary. "Dismiss the legend" cannot be a keypress on the backdrop,
11 //! because the backdrop never receives one — the empty input region is the
12 //! reason a click on the desktop does not steal focus from the window in front
13 //! of it, and it is not worth giving up. So the dismissal arrives as a signal:
14 //! SIGUSR1 toggles legend and animation, and the sway config binds a key to
15 //! `pkill -USR1 -x alloy-drift`. The bind is printed on the legend, because the
16 //! legend derives itself from the same config's `bindsym` lines — the way to
17 //! put the legend away is written on the legend.
18 //!
19 //! THE LEGEND IS NOT LOST, which is the reason for a toggle rather than a
20 //! timeout. `usr/bin/alloy-backdrop` is the only keybinding reference an
21 //! installed machine carries: `docs/` is not copied into the image. A backdrop
22 //! that faded to an animation after five minutes would delete the manual from
23 //! the machine, quietly, on every desktop, and would do it right around the
24 //! time a new user stopped reading it and started needing it.
25 //!
26 //! THE LEGEND IS STILL DERIVED BY THE SCRIPT. This process runs
27 //! `alloy-backdrop --once` and writes what it says. Both of that script's lists
28 //! come from the machine rather than from a transcription — the verbs from
29 //! `alloy --help`, the keys from the sway config the session actually loaded —
30 //! and `crates/alloy/tests/backdrop.rs` plus the Containerfile both assert that
31 //! parsing against the shipped config. None of that moves into Rust for the
32 //! sake of one process boundary.
33 //!
34 //! What is here instead is the loop, which is the part a shell script is bad
35 //! at: a frame clock, a diffed repaint, and signal handling that does not cost
36 //! a wakeup while the legend is up.
37 //!
38 //! <!-- wiki: alloy-console -->
39
40 mod patterns;
41 mod render;
42 mod rng;
43
44 use std::io::{self, Write};
45 use std::process::Command;
46 use std::sync::atomic::{AtomicBool, Ordering};
47 use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
48
49 use patterns::{CYCLE, Pattern};
50 use render::Screen;
51
52 /// The reference the legend comes from. Overridable so the panel can be
53 /// rendered from a checkout without installing anything.
54 const LEGEND: &str = "/usr/bin/alloy-backdrop";
55
56 /// Frames per second, and it is deliberately not a number anyone would call
57 /// smooth. This process runs for the whole life of a session on every output,
58 /// including while it is completely occluded by a fullscreen window, and every
59 /// frame it emits is a surface the compositor re-rasterises. Four is enough for
60 /// Rule 30 and 10 PRINT to read as falling and slow enough that the cost does
61 /// not show up beside an idle desktop.
62 const FPS: u64 = 4;
63
64 /// Seconds on each pattern in `cycle`. Long enough that Langton's ant reaches
65 /// its highway and a Life soup goes through several reseeds.
66 const ROTATE: u64 = 900;
67
68 static TOGGLE: AtomicBool = AtomicBool::new(false);
69 static RESIZED: AtomicBool = AtomicBool::new(false);
70 static QUIT: AtomicBool = AtomicBool::new(false);
71
72 extern "C" fn on_toggle(_: libc::c_int) {
73 TOGGLE.store(true, Ordering::Relaxed);
74 }
75
76 extern "C" fn on_resize(_: libc::c_int) {
77 RESIZED.store(true, Ordering::Relaxed);
78 }
79
80 extern "C" fn on_quit(_: libc::c_int) {
81 QUIT.store(true, Ordering::Relaxed);
82 }
83
84 struct Options {
85 pattern: String,
86 fps: u64,
87 rotate: u64,
88 seed: u64,
89 legend: bool,
90 once: bool,
91 }
92
93 const USAGE: &str = "\
94 alloy-drift — the desktop backdrop
95
96 Usage: alloy-drift [OPTIONS]
97
98 Options:
99 --pattern <NAME> rule30, tenprint, ant, life, or cycle [default: cycle]
100 --fps <N> frames per second [default: 4]
101 --rotate <SECS> seconds per pattern under --pattern cycle [default: 900]
102 --seed <N> seed the generator, for a reproducible render
103 --no-legend start on the animation rather than the legend
104 --once draw one frame to stdout and exit
105 -h, --help print this
106
107 SIGUSR1 toggles between the legend and the animation. SIGWINCH redraws at the
108 new size. The legend itself is rendered by alloy-backdrop --once.
109 ";
110
111 fn parse() -> Result<Options, String> {
112 let mut options = Options {
113 pattern: "cycle".to_string(),
114 fps: FPS,
115 rotate: ROTATE,
116 seed: SystemTime::now()
117 .duration_since(UNIX_EPOCH)
118 .map_or(0, |since| since.as_nanos() as u64),
119 legend: true,
120 once: false,
121 };
122 let mut args = std::env::args().skip(1);
123 while let Some(arg) = args.next() {
124 // A helper written the way its siblings in usr/bin are: a flag, its
125 // value, and an error for anything else. Six options do not earn a
126 // dependency in a process that is idle by design.
127 let mut value = || args.next().ok_or_else(|| format!("{arg} needs a value"));
128 match arg.as_str() {
129 "--pattern" => options.pattern = value()?,
130 "--fps" => {
131 options.fps = value()?
132 .parse()
133 .map_err(|_| "--fps wants a number".to_string())?;
134 if options.fps == 0 || options.fps > 60 {
135 return Err("--fps must be between 1 and 60".to_string());
136 }
137 }
138 "--rotate" => {
139 options.rotate = value()?
140 .parse()
141 .map_err(|_| "--rotate wants a number".to_string())?;
142 }
143 "--seed" => {
144 options.seed = value()?
145 .parse()
146 .map_err(|_| "--seed wants a number".to_string())?;
147 }
148 "--no-legend" => options.legend = false,
149 "--once" => options.once = true,
150 "-h" | "--help" => {
151 print!("{USAGE}");
152 std::process::exit(0);
153 }
154 other => return Err(format!("unknown argument {other}")),
155 }
156 }
157 if options.pattern != "cycle" && !CYCLE.contains(&options.pattern.as_str()) {
158 return Err(format!(
159 "unknown pattern {}; try one of {}",
160 options.pattern,
161 CYCLE.join(", ")
162 ));
163 }
164 Ok(options)
165 }
166
167 /// The surface size, in rows and columns.
168 ///
169 /// `ALLOY_BACKDROP_SIZE` first, and it is the same variable the script reads
170 /// for the same reason: a test harness has no pty, and the layout that matters
171 /// is asserted without allocating one. Then `TIOCGWINSZ`. Then a small sane
172 /// grid, because a backdrop that refused to draw would leave the screen black.
173 fn surface_size() -> (usize, usize) {
174 if let Ok(size) = std::env::var("ALLOY_BACKDROP_SIZE") {
175 let mut parts = size.split_whitespace();
176 if let (Some(Ok(rows)), Some(Ok(cols))) =
177 (parts.next().map(str::parse), parts.next().map(str::parse))
178 {
179 return (rows, cols);
180 }
181 }
182 let mut winsize: libc::winsize = unsafe { std::mem::zeroed() };
183 // SAFETY: TIOCGWINSZ writes a `winsize` and nothing else, into a struct
184 // this frame owns. A non-tty stdout fails the call and leaves it zeroed,
185 // which the check below treats as no answer.
186 let answered =
187 unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &raw mut winsize) } == 0;
188 if answered && winsize.ws_row > 0 && winsize.ws_col > 0 {
189 return (winsize.ws_row as usize, winsize.ws_col as usize);
190 }
191 (24, 80)
192 }
193
194 /// Ask the script for the panel, at the size this surface actually is.
195 ///
196 /// Its output already begins with a clear and a home, so it is written through
197 /// untouched. A failure is not fatal and not reported on screen: the backdrop
198 /// is behind every window on the desktop and an error message there would be
199 /// both unreadable and permanent. It falls through to the animation instead,
200 /// which is the more useful of the two things this process can show when it
201 /// cannot show the other.
202 fn legend(rows: usize, cols: usize) -> Option<Vec<u8>> {
203 let path = std::env::var("ALLOY_DRIFT_LEGEND").unwrap_or_else(|_| LEGEND.to_string());
204 let output = Command::new(path)
205 .arg("--once")
206 .env("ALLOY_BACKDROP_SIZE", format!("{rows} {cols}"))
207 .output()
208 .ok()?;
209 output.status.success().then_some(output.stdout)
210 }
211
212 fn install_signals() {
213 // SAFETY: each handler stores into a static AtomicBool and does nothing
214 // else, which is the one thing a handler is unconditionally allowed to do.
215 unsafe {
216 libc::signal(libc::SIGUSR1, on_toggle as *const () as libc::sighandler_t);
217 libc::signal(libc::SIGWINCH, on_resize as *const () as libc::sighandler_t);
218 libc::signal(libc::SIGTERM, on_quit as *const () as libc::sighandler_t);
219 libc::signal(libc::SIGINT, on_quit as *const () as libc::sighandler_t);
220 }
221 }
222
223 fn main() {
224 let options = match parse() {
225 Ok(options) => options,
226 Err(message) => {
227 eprintln!("alloy-drift: {message}");
228 eprint!("{USAGE}");
229 std::process::exit(2);
230 }
231 };
232
233 let (rows, cols) = surface_size();
234 let mut stdout = io::stdout();
235
236 if options.once {
237 // One frame, no terminal setup and no signals. What lets the layout be
238 // asserted without a compositor, and what `alloy-drift --once | cat -A`
239 // gives someone checking which escapes reach the surface.
240 let name = if options.pattern == "cycle" {
241 CYCLE[0]
242 } else {
243 options.pattern.as_str()
244 };
245 if let Some(panel) = options.legend.then(|| legend(rows, cols)).flatten() {
246 let _ = stdout.write_all(&panel);
247 return;
248 }
249 let mut pattern = patterns::build(name, options.seed).expect("parse checked the name");
250 let mut screen = Screen::new(rows, cols);
251 pattern.resize(rows, cols);
252 for _ in 0..=pattern.warmup() {
253 pattern.frame(&mut screen);
254 }
255 let _ = screen.flush(&mut stdout);
256 let _ = stdout.write_all(b"\n");
257 return;
258 }
259
260 install_signals();
261 // Hide the cursor. A block cursor parked in the corner of every desktop is
262 // the one piece of chrome a background surface must not have.
263 let _ = stdout.write_all(b"\x1b[?25l\x1b[2J");
264 let _ = stdout.flush();
265
266 run(&options, &mut stdout);
267
268 let _ = stdout.write_all(b"\x1b[0m\x1b[?25h\x1b[2J\x1b[H");
269 let _ = stdout.flush();
270 }
271
272 fn run(options: &Options, stdout: &mut impl Write) {
273 let (mut rows, mut cols) = surface_size();
274 let mut screen = Screen::new(rows, cols);
275 let mut showing_legend = options.legend;
276 let mut needs_legend = true;
277
278 let mut cycling = options.pattern == "cycle";
279 let mut index = 0usize;
280 let mut pattern: Box<dyn Pattern> = patterns::build(
281 if cycling {
282 CYCLE[0]
283 } else {
284 options.pattern.as_str()
285 },
286 options.seed,
287 )
288 .expect("parse checked the name");
289 pattern.resize(rows, cols);
290 let mut warmed = false;
291 let mut since_switch = Instant::now();
292 let interval = Duration::from_millis(1000 / options.fps);
293
294 while !QUIT.load(Ordering::Relaxed) {
295 if RESIZED.swap(false, Ordering::Relaxed) {
296 let (new_rows, new_cols) = surface_size();
297 if (new_rows, new_cols) != (rows, cols) {
298 rows = new_rows;
299 cols = new_cols;
300 screen.resize(rows, cols);
301 pattern.resize(rows, cols);
302 warmed = false;
303 }
304 screen.invalidate();
305 needs_legend = true;
306 }
307
308 if TOGGLE.swap(false, Ordering::Relaxed) {
309 showing_legend = !showing_legend;
310 screen.invalidate();
311 needs_legend = true;
312 let _ = stdout.write_all(b"\x1b[0m\x1b[2J");
313 }
314
315 if showing_legend {
316 if needs_legend {
317 match legend(rows, cols) {
318 Some(panel) => {
319 let _ = stdout.write_all(&panel);
320 let _ = stdout.flush();
321 needs_legend = false;
322 }
323 // The script is gone or broken. Show the animation rather
324 // than a blank surface, and do not come back here until
325 // somebody asks again.
326 None => showing_legend = false,
327 }
328 }
329 if showing_legend {
330 // No frame clock, no polling, no wakeups at all: the legend is
331 // a static panel and the only things that can change it are the
332 // two signals. `pause` returns when one arrives.
333 //
334 // SAFETY: pause takes nothing and only sleeps.
335 unsafe { libc::pause() };
336 continue;
337 }
338 }
339
340 if cycling && since_switch.elapsed() >= Duration::from_secs(options.rotate) {
341 index = (index + 1) % CYCLE.len();
342 pattern = patterns::build(CYCLE[index], options.seed.wrapping_add(index as u64))
343 .expect("the cycle names only patterns that build");
344 pattern.resize(rows, cols);
345 warmed = false;
346 since_switch = Instant::now();
347 screen.invalidate();
348 let _ = stdout.write_all(b"\x1b[0m\x1b[2J");
349 }
350 // `--rotate 0` is a rotation that fires every frame, which is not a
351 // thing anyone means by it. Read it as "do not rotate".
352 if options.rotate == 0 {
353 cycling = false;
354 }
355
356 if !warmed {
357 for _ in 0..pattern.warmup() {
358 pattern.frame(&mut screen);
359 }
360 warmed = true;
361 }
362 pattern.frame(&mut screen);
363 if screen.flush(stdout).is_err() {
364 // shop has gone. Nothing downstream is reading, and a background
365 // surface writing into a closed pipe forever is the one way this
366 // process becomes a problem rather than a picture.
367 return;
368 }
369 std::thread::sleep(interval);
370 }
371 }
372