Skip to main content

max / alloy

Draw automata on the desktop behind the keybinding panel The backdrop was a panel and nothing else: alloy-backdrop printed the machine's verbs and keys and parked forever. This adds what runs when the panel is put away, and a way to put it away. crates/backdrop is alloy-drift, which takes over the background surface and runs alloy-backdrop --once for the panel. The derivation stays in the script, where the tests and the Containerfile already assert it against the shipped sway config; what moves into Rust is the loop, which a shell script cannot do at four frames a second without costing more than the picture is worth. Four patterns, and the rule that picked them is that each is its own definition rather than a drawing: Rule 30 is eight bits, 10 PRINT is one line of Commodore BASIC, Langton's ant is two sentences, Life is four. No seeds, palettes or constants tuned until they looked right, and nothing authored anywhere in them. They run on a torus, in the tiling glyph tier, in SGR weight only, so alloy theme apply moves them with the terminal and no colour is spelled out in Rust. Dismissal is SIGUSR1, because the background layer has an empty input region and never receives a key -- that is what stops a click on the desktop stealing focus, and it is worth more than a keypress. $mod+F1 sends it, and because the panel derives its key list from the same config, the way to dismiss the reference is printed on the reference. A timeout was the alternative and would have deleted the manual from the machine: docs/ is not in the image, and the panel is the only keybinding reference an installed machine carries.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01JS3nSvZv2Z6TpuVhvvcxrM
Author: Max Johnson <me@maxj.phd> · 2026-09-03 19:59 UTC
Signed with PGP, not checked
Commit: f5b883f2e40881082436fd814b5305ffb94a728c
Parent: 7d659fa
11 files changed, +1489 insertions, -9 deletions
M Cargo.lock +7
@@ -37,6 +37,13 @@
37 37 "zbus",
38 38 ]
39 39
40 + [[package]]
41 + name = "alloy-drift"
42 + version = "0.1.0"
43 + dependencies = [
44 + "libc",
45 + ]
46 +
40 47 [[package]]
41 48 name = "alloy_tui"
42 49 version = "15.0.0"
M Containerfile +40 -7
@@ -459,19 +459,26 @@
459 459 # every console edit re-downloads and rebuilds every crate underneath it,
460 460 # and the console is the part of this image that changes most often.
461 461 #
462 - # Both workspace members are named, or cargo cannot resolve the workspace and
463 - # the whole split silently degrades to a full rebuild every time.
462 + # EVERY workspace member is named, or cargo cannot resolve the workspace and
463 + # the whole split silently degrades to a full rebuild every time. Adding a
464 + # crate under crates/ means adding it here as well; there is no glob that
465 + # would do it, because a glob would copy the sources this stage exists to
466 + # leave behind.
464 467 COPY Cargo.toml Cargo.lock ./
465 468 COPY crates/alloy/Cargo.toml crates/alloy/Cargo.toml
466 469 COPY crates/skelgen/Cargo.toml crates/skelgen/Cargo.toml
467 - RUN mkdir -p crates/alloy/src crates/skelgen/src \
470 + COPY crates/backdrop/Cargo.toml crates/backdrop/Cargo.toml
471 + RUN mkdir -p crates/alloy/src crates/skelgen/src crates/backdrop/src \
468 472 && echo 'fn main() {}' > crates/alloy/src/main.rs \
469 473 && echo 'fn main() {}' > crates/skelgen/src/main.rs \
474 + && echo 'fn main() {}' > crates/backdrop/src/main.rs \
470 475 && cargo build --release --locked \
471 476 && rm -rf target/release/alloy target/release/deps/alloy-* \
472 477 target/release/.fingerprint/alloy-* \
473 478 target/release/alloy-skelgen target/release/deps/alloy_skelgen-* \
474 - target/release/deps/skelgen-* target/release/.fingerprint/skelgen-*
479 + target/release/deps/skelgen-* target/release/.fingerprint/skelgen-* \
480 + target/release/alloy-drift target/release/deps/alloy_drift-* \
481 + target/release/.fingerprint/alloy-drift-*
475 482
476 483 # Then the real source. The removals above are what make cargo rebuild
477 484 # the binary rather than find the stub's artifact already in place.
@@ -501,6 +508,14 @@
501 508 RUN /src/target/release/alloy-skelgen --help | grep -q -- '--templates' \
502 509 || { echo "built skelgen is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
503 510
511 + # alloy-drift gets the same assertion, and it is the one of the three where the
512 + # stub would be hardest to notice: `fn main() {}` on the background layer draws
513 + # nothing and exits, shop closes with it, and the desktop falls back to the flat
514 + # colour -- which is what a machine whose backdrop was never built looks like
515 + # too. `--help` is again the whole difference.
516 + RUN /src/target/release/alloy-drift --help | grep -q -- '--pattern' \
517 + || { echo "built alloy-drift is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
518 +
504 519 # The one subcommand name that is spelled out in a shell script rather than
505 520 # resolved by the compiler. usr/bin/alloy-session runs `alloy theme apply` and
506 521 # swallows its exit code, because nothing before the exec is allowed to fail a
@@ -2452,6 +2467,16 @@
2452 2467 COPY usr/ /usr/
2453 2468 COPY --from=rust-build /staged-skel/ /
2454 2469
2470 + # The backdrop's loop. A COPY into /usr/bin, which the shop stage above calls
2471 + # the worst of the shapes for anything hotfixable -- an unowned file cannot even
2472 + # be layered over. It is the right shape here anyway, and for the same reason
2473 + # the sibling helpers beside it arrive by `COPY usr/ /usr/`: the backdrop must
2474 + # draw on the first boot, before alloy-layer-components.service has laid the
2475 + # console down, because that is the boot where somebody most needs to be told
2476 + # which keys exist. Something that has to be present before layering cannot be
2477 + # delivered by layering.
2478 + COPY --from=rust-build /src/target/release/alloy-drift /usr/bin/alloy-drift
2479 +
2455 2480 # =====================================================================
2456 2481 # dconf: the GTK schema defaults, compiled and proven to answer.
2457 2482 # =====================================================================
@@ -3143,9 +3168,15 @@
3143 3168 || { echo "alloy-backdrop is missing or not executable; the desktop would be a flat colour and the keybindings documented nowhere on the machine" >&2; exit 1; }; \
3144 3169 sh -n /usr/bin/alloy-backdrop \
3145 3170 || { echo "alloy-backdrop does not parse; shop would exit at once and the background would silently fall back" >&2; exit 1; }; \
3171 + test -x /usr/bin/alloy-drift \
3172 + || { echo "alloy-drift is missing or not executable; nothing would run the panel and the desktop would be a flat colour" >&2; exit 1; }; \
3173 + /usr/bin/alloy-drift --help | grep -q -- '--pattern' \
3174 + || { echo "alloy-drift does not answer --help; the loop that draws the backdrop is not the binary that was built" >&2; exit 1; }; \
3146 3175 conf=/etc/skel/.config/sway/config; \
3147 - grep -q '^exec \$term --layer background -e /usr/bin/alloy-backdrop$' "$conf" \
3148 - || { echo "the session does not start alloy-backdrop; the script would ship and never run" >&2; exit 1; }; \
3176 + grep -q '^exec \$term --layer background -e /usr/bin/alloy-drift$' "$conf" \
3177 + || { echo "the session does not start alloy-drift; the backdrop would ship and never run" >&2; exit 1; }; \
3178 + grep -q '^bindsym .* exec pkill -USR1 -x alloy-drift$' "$conf" \
3179 + || { echo "no bind toggles the backdrop; the panel could never be put away and, because the key list is derived from these binds, nothing on the machine would say so" >&2; exit 1; }; \
3149 3180 grep -q '^output \* bg .* solid_color$' "$conf" \
3150 3181 || { echo "the solid colour fallback is gone; a compositor without wlr-layer-shell would have no background at all" >&2; exit 1; }; \
3151 3182 binds=$(grep -c '^[[:space:]]*bindsym[[:space:]]' "$conf"); \
@@ -3157,9 +3188,11 @@
3157 3188 || { echo "the config has $binds binds and the panel draws $drawn; a bind the parser does not recognise is missing from the only reference the image ships" >&2; exit 1; }; \
3158 3189 echo "backdrop: $drawn of $binds binds drawn with no console present"; \
3159 3190 else \
3160 - rm -f /usr/bin/alloy-backdrop; \
3191 + rm -f /usr/bin/alloy-backdrop /usr/bin/alloy-drift; \
3161 3192 test ! -e /usr/bin/alloy-backdrop \
3162 3193 || { echo "profile=server still carries alloy-backdrop; there is no sway session to start it" >&2; exit 1; }; \
3194 + test ! -e /usr/bin/alloy-drift \
3195 + || { echo "profile=server still carries alloy-drift; there is no sway session to start it" >&2; exit 1; }; \
3163 3196 echo "backdrop: none on this profile; there is no desktop to put one behind"; \
3164 3197 fi
3165 3198
@@ -59,6 +59,22 @@
59 59 | U+25B8 U+25C2 | `▸ ◂` | starship segment separators |
60 60 | U+2718 | `✘` | starship, error state |
61 61
62 + **Twenty since the backdrop landed.** `crates/backdrop` adds U+2571 and U+2572, the two
63 + box-drawing diagonals, which are 10 PRINT's whole vocabulary. They are not in the table
64 + above because they are not authored: the eighteen are drawn glyph by glyph in
65 + `quasi-type`'s manifest, and the diagonals arrive from its `[[generated]]` box-drawing
66 + block, which cuts the whole of U+2500-U+257F cell-exact so it tiles. That is also why
67 + they are the right two glyphs for the job -- a maze made of diagonals only reads as a
68 + maze if they meet at the cell corners, which is a property of the generation and not of
69 + anyone's taste.
70 +
71 + The consequence for the coverage floor: `ALLOY_SURFACE` in `src/assert.rs` does not name
72 + them, so a face that somehow shipped the eighteen and no box drawing would pass the
73 + assertion and draw tofu on every desktop. Nothing can currently produce such a face --
74 + the generated blocks are unconditional -- so this is recorded rather than fixed. The day
75 + a base is added that supplies box drawing of its own and the generation is made
76 + conditional, the floor is where it has to be caught.
77 +
62 78 Latin-1 punctuation, arrows, four block-element characters and one dingbat. No Private
63 79 Use Area: **Alloy itself emits no Nerd Font icons**, and there is no
64 80 Private Use Area in the image to emit. The PUA reasoning in
@@ -13,6 +13,7 @@
13 13 | `Mod+Ctrl+L` | Lock the screen |
14 14 | `Mod+Shift+C` | Reload the sway config |
15 15 | `Mod+Shift+E` | Exit the session |
16 + | `Mod+F1` | Put the keybinding panel on the desktop away, or bring it back |
16 17
17 18 ## Windows
18 19
@@ -92,3 +93,27 @@
92 93 Alloy does not rebind the applications it adopts. helix, yazi, zathura, imv,
93 94 mpv and bottom keep their own upstream keys, all of them vim-shaped. Their own
94 95 help (`?` in yazi and bottom, `:tutor` in helix) is the reference.
96 +
97 + ## The desktop background
98 +
99 + The desktop shows this page. Not a copy of it: the panel behind your windows
100 + derives its two lists from the machine itself, the verbs from `alloy --help`
101 + and the keys from your own `~/.config/sway/config`, so a bind you add appears
102 + there the next time you log in.
103 +
104 + `Mod+F1` puts it away and brings it back. Behind it is a cellular automaton,
105 + which changes every fifteen minutes and repeats never:
106 +
107 + | Pattern | What it is |
108 + |---|---|
109 + | Rule 30 | Wolfram's elementary cellular automaton, from one live cell |
110 + | 10 PRINT | `10 PRINT CHR$(205.5+RND(1)); : GOTO 10`, the Commodore 64 one-liner |
111 + | Langton's ant | Two rules, ten thousand steps of scribble, then a highway |
112 + | Life | Conway's B3/S23, reseeded whenever it settles |
113 +
114 + Nothing there is a picture. Each is a rule short enough to state in a sentence,
115 + and what you see is what the rule does.
116 +
117 + Run `alloy-backdrop --once | less` for the panel in a window, or
118 + `alloy-drift --pattern life --no-legend` for one of the automata in a terminal.
119 + `alloy-drift --help` lists the rest.
@@ -132,11 +132,15 @@
132 132
133 133 // The wiring. Everything else here can hold and the desktop still be a flat
134 134 // colour, because nothing would have started the panel.
135 + //
136 + // The session starts `alloy-drift` and `alloy-drift` runs this script, so the
137 + // script being correct and the session starting something else is a shape that
138 + // now exists and that this asserts against.
135 139 #[test]
136 140 fn the_session_starts_the_panel_on_the_background_layer() {
137 141 let config = sway_template();
138 142 assert!(
139 - config.contains("exec $term --layer background -e /usr/bin/alloy-backdrop"),
143 + config.contains("exec $term --layer background -e /usr/bin/alloy-drift"),
140 144 "the sway template does not start the backdrop:\n{config}"
141 145 );
142 146 }
@@ -249,3 +253,26 @@
249 253 );
250 254 }
251 255 }
256 +
257 + // The panel is dismissible, and the only reason anybody can find out how is
258 + // that the bind is in this file: the key list the panel prints is derived from
259 + // these `bindsym` lines. A toggle wired from somewhere else -- a drop-in, a
260 + // systemd unit, a hardcoded key inside the binary -- would work and would be
261 + // undiscoverable, which for the machine's only keybinding reference is the
262 + // whole failure.
263 + #[test]
264 + fn the_way_to_dismiss_the_panel_is_printed_on_the_panel() {
265 + let config = sway_template();
266 + assert!(
267 + config.contains("exec pkill -USR1 -x alloy-drift"),
268 + "nothing in the sway template toggles the backdrop:\n{config}"
269 + );
270 + let bind = config
271 + .lines()
272 + .find(|line| line.contains("pkill -USR1 -x alloy-drift"))
273 + .expect("the toggle line was just asserted to exist");
274 + assert!(
275 + bind.trim_start().starts_with("bindsym "),
276 + "the toggle is not a bindsym, so the panel will never print it: {bind}"
277 + );
278 + }
@@ -69,9 +69,14 @@
69 69 # from this file, which is the only keybinding reference an installed machine
70 70 # carries: docs/ is not copied into the image.
71 71 #
72 + # alloy-drift runs the surface and alloy-backdrop still writes the panel: the
73 + # reference stays derived by the script that derives it, and what the binary
74 + # adds is the frame clock and the automata that run once the panel is put away
75 + # ($mod+F1, below). The panel is what it shows on login, every login.
76 + #
72 77 # It reads its colours from ~/.config/shop/config.toml like any other shop, so
73 78 # `alloy theme apply` moves the backdrop and the terminal together.
74 - exec $term --layer background -e /usr/bin/alloy-backdrop
79 + exec $term --layer background -e /usr/bin/alloy-drift
75 80 include /etc/sway/config.d/*
76 81 # `alloy display` owns 50-display.conf in here, and this include is what makes
77 82 # the console's writes reach sway at the next login. Ordered after the system
@@ -240,6 +245,18 @@
240 245 # bind. That is not hypothetical; it is how this file's $term was found wrong.
241 246 bindsym $mod+Shift+c reload
242 247 bindsym $mod+Shift+e exit
248 + # The keybinding reference, on the desktop behind everything. It is what the
249 + # background shows by default and this puts it away and brings it back; behind
250 + # it are automata that draw nothing anybody authored (crates/backdrop).
251 + #
252 + # A signal rather than a key the backdrop reads, because the background layer
253 + # has an empty input region and never receives one -- that is what stops a click
254 + # on the desktop stealing focus, and it is worth more than a keypress. `-x` so
255 + # this cannot match anything but the backdrop itself.
256 + #
257 + # This line is why the bind is discoverable: the panel derives its key list from
258 + # this file, so the way to dismiss the reference is printed on the reference.
259 + bindsym $mod+F1 exec pkill -USR1 -x alloy-drift
243 260
244 261 # Window ops
245 262 bindsym $mod+q kill
@@ -1,0 +1,31 @@
1 + [package]
2 + name = "alloy-drift"
3 + version = "0.1.0"
4 + description = "The desktop backdrop: the keybinding legend, and the automata that run behind it."
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + license.workspace = true
8 + repository.workspace = true
9 + authors.workspace = true
10 +
11 + [[bin]]
12 + name = "alloy-drift"
13 + path = "src/main.rs"
14 +
15 + # libc and nothing else, on purpose.
16 + #
17 + # This process runs for the whole life of a session on the background layer of
18 + # every output, so its two costs are the frames it asks the compositor to
19 + # rasterise and the memory it holds while doing nothing. Neither is helped by a
20 + # dependency. What libc is here for is the three things std does not expose:
21 + # `TIOCGWINSZ` for the surface size, `signal` for the toggle and the resize, and
22 + # `pause` so the legend costs no wakeups at all.
23 + #
24 + # No clap. The sibling helpers in usr/bin parse their own arguments in a `case`
25 + # and this has six flags, so the derived `--help` is not worth linking an
26 + # argument parser into a surface that is idle by design.
27 + [dependencies]
28 + libc = "0.2"
29 +
30 + [lints]
31 + workspace = true
@@ -1,0 +1,371 @@
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 + }