max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01JS3nSvZv2Z6TpuVhvvcxrM
11 files changed,
+1489 insertions,
-9 deletions
| @@ -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" |
| @@ -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 | + | } |