max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
5 files changed,
+566 insertions,
-4 deletions
| @@ -3059,6 +3059,59 @@ | |||
| 3059 | 3059 | echo "private clipboard: nothing to gate on a headless profile"; \ | |
| 3060 | 3060 | fi | |
| 3061 | 3061 | ||
| 3062 | + | # ===================================================================== | |
| 3063 | + | # alloy-backdrop — the desktop background, and the reference it is. | |
| 3064 | + | # | |
| 3065 | + | # An installed machine ships no keybinding reference at all: the only copy is | |
| 3066 | + | # docs/manual/05-keybindings.md and no COPY puts docs/ into the image. This | |
| 3067 | + | # panel is it. So a build where the script is absent, unparseable, or simply | |
| 3068 | + | # not started leaves a machine whose keys are documented nowhere the person | |
| 3069 | + | # using it can reach. | |
| 3070 | + | # | |
| 3071 | + | # Two halves, and each is silent on its own. A missing script means shop exits | |
| 3072 | + | # immediately and the desktop falls back to the flat colour, which looks like a | |
| 3073 | + | # deliberate plain background rather than a failure. A missing exec line means | |
| 3074 | + | # the script is present, correct and never run. | |
| 3075 | + | # | |
| 3076 | + | # The flat colour is asserted too. It is the fallback for a compositor with no | |
| 3077 | + | # wlr-layer-shell, and losing it would turn that case from a plain background | |
| 3078 | + | # into no background at all. | |
| 3079 | + | # | |
| 3080 | + | # THE BIND COUNT IS THE ONE CHECKED HERE, and the verbs deliberately are not. | |
| 3081 | + | # The console is not in /usr/bin at build time: it travels as an uninstalled | |
| 3082 | + | # RPM and alloy-layer-components.service lays it down on the first boot (see | |
| 3083 | + | # "The component repo" above). So `alloy --help` answers nothing here, which is | |
| 3084 | + | # also true of the first boot before layering finishes. Running the panel under | |
| 3085 | + | # exactly that condition is the point: it must still draw every key, because a | |
| 3086 | + | # backdrop that needed the console would be blank on the one boot where a | |
| 3087 | + | # person most needs to know which keys exist. The verb half is covered by | |
| 3088 | + | # crates/alloy/tests/backdrop.rs against a stub. | |
| 3089 | + | RUN set -eu; \ | |
| 3090 | + | if [ "$PROFILE" = client ]; then \ | |
| 3091 | + | test -x /usr/bin/alloy-backdrop \ | |
| 3092 | + | || { 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; }; \ | |
| 3093 | + | sh -n /usr/bin/alloy-backdrop \ | |
| 3094 | + | || { echo "alloy-backdrop does not parse; shop would exit at once and the background would silently fall back" >&2; exit 1; }; \ | |
| 3095 | + | conf=/etc/skel/.config/sway/config; \ | |
| 3096 | + | grep -q '^exec \$term --layer background -e /usr/bin/alloy-backdrop$' "$conf" \ | |
| 3097 | + | || { echo "the session does not start alloy-backdrop; the script would ship and never run" >&2; exit 1; }; \ | |
| 3098 | + | grep -q '^output \* bg .* solid_color$' "$conf" \ | |
| 3099 | + | || { echo "the solid colour fallback is gone; a compositor without wlr-layer-shell would have no background at all" >&2; exit 1; }; \ | |
| 3100 | + | binds=$(grep -c '^[[:space:]]*bindsym[[:space:]]' "$conf"); \ | |
| 3101 | + | drawn=$(ALLOY_BACKDROP_CONFIG="$conf" ALLOY_BACKDROP_SIZE="500 1" \ | |
| 3102 | + | /usr/bin/alloy-backdrop --once \ | |
| 3103 | + | | sed -e 's/\x1b\[[0-9;]*m//g' \ | |
| 3104 | + | | awk '/^ keys$/{k=1;next} k && NF {n++} END{print n+0}'); \ | |
| 3105 | + | [ "$binds" = "$drawn" ] \ | |
| 3106 | + | || { 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; }; \ | |
| 3107 | + | echo "backdrop: $drawn of $binds binds drawn with no console present"; \ | |
| 3108 | + | else \ | |
| 3109 | + | rm -f /usr/bin/alloy-backdrop; \ | |
| 3110 | + | test ! -e /usr/bin/alloy-backdrop \ | |
| 3111 | + | || { echo "profile=server still carries alloy-backdrop; there is no sway session to start it" >&2; exit 1; }; \ | |
| 3112 | + | echo "backdrop: none on this profile; there is no desktop to put one behind"; \ | |
| 3113 | + | fi | |
| 3114 | + | ||
| 3062 | 3115 | # ===================================================================== | |
| 3063 | 3116 | # alloy-dim — the lock warning, and the two ways it goes quiet. | |
| 3064 | 3117 | # |
| @@ -50,10 +50,23 @@ | |||
| 50 | 50 | ||
| 51 | 51 | ## The background | |
| 52 | 52 | ||
| 53 | - | The intended default is a panel: a terminal surface behind your windows that | |
| 54 | - | prints the `alloy` verbs and the sway keybindings, so the reference is on screen | |
| 55 | - | without being asked for. It is not in the image yet. Until it lands the | |
| 56 | - | background is a flat colour from the palette. | |
| 53 | + | The default is a panel: a terminal surface behind your windows that prints the | |
| 54 | + | `alloy` verbs and the sway keybindings, so the reference is on screen without | |
| 55 | + | being asked for. It takes no input, so clicking or typing goes to whatever is in | |
| 56 | + | front of it. | |
| 57 | + | ||
| 58 | + | Both lists are read off the machine at login rather than written down, so a verb | |
| 59 | + | the console gains or a keybinding you add to `~/.config/sway/config` is on the | |
| 60 | + | panel the next time you log in. Bindings that belong to a mode carry the mode | |
| 61 | + | name, since `h` in resize mode is not the same key as `Super+h`. | |
| 62 | + | ||
| 63 | + | To read it without moving your windows out of the way: | |
| 64 | + | ||
| 65 | + | alloy-backdrop --once | less -R | |
| 66 | + | ||
| 67 | + | Where the compositor has no `wlr-layer-shell` the panel cannot open, and the | |
| 68 | + | background falls back to a flat colour from the palette. It follows your theme | |
| 69 | + | the same way the terminal does, so `alloy theme apply` moves both. | |
| 57 | 70 | ||
| 58 | 71 | Alloy ships **swww** if you would rather have a picture. It starts no daemon and | |
| 59 | 72 | provisions no images: |
| @@ -59,6 +59,19 @@ | |||
| 59 | 59 | # scale 1.0 | |
| 60 | 60 | # } | |
| 61 | 61 | output * bg @{surface.page} solid_color | |
| 62 | + | # The backdrop, and the flat colour above is its fallback. Both lines stay: | |
| 63 | + | # shop exits when the compositor has no wlr-layer-shell, and a desktop with no | |
| 64 | + | # background at all is worse than a plain one. | |
| 65 | + | # | |
| 66 | + | # `--layer background` is sized to the output, anchored to all four edges and | |
| 67 | + | # takes no input, so it never steals a click or a keypress from the window in | |
| 68 | + | # front of it. The panel prints this machine's own `alloy` verbs and the binds | |
| 69 | + | # from this file, which is the only keybinding reference an installed machine | |
| 70 | + | # carries: docs/ is not copied into the image. | |
| 71 | + | # | |
| 72 | + | # It reads its colours from ~/.config/shop/config.toml like any other shop, so | |
| 73 | + | # `alloy theme apply` moves the backdrop and the terminal together. | |
| 74 | + | exec $term --layer background -e /usr/bin/alloy-backdrop | |
| 62 | 75 | include /etc/sway/config.d/* | |
| 63 | 76 | # `alloy display` owns 50-display.conf in here, and this include is what makes | |
| 64 | 77 | # the console's writes reach sway at the next login. Ordered after the system |
| @@ -1,0 +1,251 @@ | |||
| 1 | + | //! The desktop background prints this machine's own verbs and keys, and every | |
| 2 | + | //! way it goes wrong is quiet. | |
| 3 | + | //! | |
| 4 | + | //! `usr/bin/alloy-backdrop` is the only keybinding reference an installed | |
| 5 | + | //! machine carries: `docs/manual/05-keybindings.md` exists in the repo and no | |
| 6 | + | //! `COPY` puts `docs/` into the image. So a bind that stops reaching the panel | |
| 7 | + | //! is a bind nobody can look up, and nothing on screen says so. | |
| 8 | + | //! | |
| 9 | + | //! Both lists are derived rather than transcribed, which is what these tests | |
| 10 | + | //! are really guarding. The failure they exist to catch is somebody adding a | |
| 11 | + | //! `bindsym` to the sway template that the panel's parser does not recognise: | |
| 12 | + | //! the panel keeps drawing, the new bind is simply absent, and the only way to | |
| 13 | + | //! notice is to already know it should be there. | |
| 14 | + | //! | |
| 15 | + | //! The Containerfile checks the script is present, executable and parses; that | |
| 16 | + | //! needs a build. This runs under `cargo test`, which is where the script and | |
| 17 | + | //! the config template are actually edited. | |
| 18 | + | ||
| 19 | + | use std::path::{Path, PathBuf}; | |
| 20 | + | use std::process::Command; | |
| 21 | + | ||
| 22 | + | fn root() -> PathBuf { | |
| 23 | + | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | fn read(relative: &str) -> String { | |
| 27 | + | let path = root().join(relative); | |
| 28 | + | std::fs::read_to_string(&path) | |
| 29 | + | .unwrap_or_else(|error| panic!("reading {}: {error}", path.display())) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | fn script() -> String { | |
| 33 | + | read("usr/bin/alloy-backdrop") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | fn sway_template() -> String { | |
| 37 | + | read("templates/etc/skel/.config/sway/config.in") | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | /// A stand-in for the console, so the panel can be rendered without building | |
| 41 | + | /// the binary first. It answers `--help` in clap's shape and nothing else, | |
| 42 | + | /// which is all the script reads. | |
| 43 | + | fn stub_alloy(dir: &Path) -> PathBuf { | |
| 44 | + | let path = dir.join("alloy-stub"); | |
| 45 | + | std::fs::write( | |
| 46 | + | &path, | |
| 47 | + | "#!/bin/sh\n\ | |
| 48 | + | cat <<'EOF'\n\ | |
| 49 | + | Alloy Console\n\ | |
| 50 | + | \n\ | |
| 51 | + | Usage: alloy [OPTIONS] <COMMAND>\n\ | |
| 52 | + | \n\ | |
| 53 | + | Commands:\n\ | |
| 54 | + | \x20 net Network interfaces and connections\n\ | |
| 55 | + | \x20 audio Audio outputs and inputs\n\ | |
| 56 | + | \x20 help Print this message\n\ | |
| 57 | + | \n\ | |
| 58 | + | Options:\n\ | |
| 59 | + | \x20 -h, --help Print help\n\ | |
| 60 | + | EOF\n", | |
| 61 | + | ) | |
| 62 | + | .expect("writing the stub"); | |
| 63 | + | let mut perms = std::fs::metadata(&path) | |
| 64 | + | .expect("stub metadata") | |
| 65 | + | .permissions(); | |
| 66 | + | std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); | |
| 67 | + | std::fs::set_permissions(&path, perms).expect("making the stub executable"); | |
| 68 | + | path | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Render the panel once. `cols` of 1 forces a single column, so every entry is | |
| 72 | + | /// on its own line and can be counted without unpicking the layout. | |
| 73 | + | fn render(cols: u32) -> String { | |
| 74 | + | let dir = std::env::temp_dir().join(format!("alloy-backdrop-test-{cols}")); | |
| 75 | + | std::fs::create_dir_all(&dir).expect("scratch dir"); | |
| 76 | + | let stub = stub_alloy(&dir); | |
| 77 | + | ||
| 78 | + | let out = Command::new("sh") | |
| 79 | + | .arg(root().join("usr/bin/alloy-backdrop")) | |
| 80 | + | .arg("--once") | |
| 81 | + | .env( | |
| 82 | + | "ALLOY_BACKDROP_CONFIG", | |
| 83 | + | root().join("templates/etc/skel/.config/sway/config.in"), | |
| 84 | + | ) | |
| 85 | + | .env("ALLOY_BACKDROP_ALLOY", &stub) | |
| 86 | + | .env("ALLOY_BACKDROP_SIZE", format!("500 {cols}")) | |
| 87 | + | .output() | |
| 88 | + | .expect("running alloy-backdrop"); | |
| 89 | + | ||
| 90 | + | assert!( | |
| 91 | + | out.status.success(), | |
| 92 | + | "alloy-backdrop --once failed: {}", | |
| 93 | + | String::from_utf8_lossy(&out.stderr) | |
| 94 | + | ); | |
| 95 | + | strip_sgr(&String::from_utf8_lossy(&out.stdout)) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// The panel styles with SGR bold and dim and nothing else, so dropping escape | |
| 99 | + | /// sequences leaves exactly the text a person reads. | |
| 100 | + | fn strip_sgr(text: &str) -> String { | |
| 101 | + | let mut out = String::with_capacity(text.len()); | |
| 102 | + | let mut chars = text.chars(); | |
| 103 | + | while let Some(c) = chars.next() { | |
| 104 | + | if c != '\u{1b}' { | |
| 105 | + | out.push(c); | |
| 106 | + | continue; | |
| 107 | + | } | |
| 108 | + | for c in chars.by_ref() { | |
| 109 | + | if c.is_ascii_alphabetic() { | |
| 110 | + | break; | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | out | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Rows under a heading, to the next blank line. | |
| 118 | + | fn section(rendered: &str, heading: &str) -> Vec<String> { | |
| 119 | + | rendered | |
| 120 | + | .lines() | |
| 121 | + | .skip_while(|line| line.trim() != heading) | |
| 122 | + | .skip(1) | |
| 123 | + | .take_while(|line| !line.trim().is_empty()) | |
| 124 | + | .map(|line| line.trim().to_string()) | |
| 125 | + | .collect() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | #[test] | |
| 129 | + | fn the_backdrop_is_a_posix_shell_script() { | |
| 130 | + | assert!(script().starts_with("#!/bin/sh\n"), "{}", script()); | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // The wiring. Everything else here can hold and the desktop still be a flat | |
| 134 | + | // colour, because nothing would have started the panel. | |
| 135 | + | #[test] | |
| 136 | + | fn the_session_starts_the_panel_on_the_background_layer() { | |
| 137 | + | let config = sway_template(); | |
| 138 | + | assert!( | |
| 139 | + | config.contains("exec $term --layer background -e /usr/bin/alloy-backdrop"), | |
| 140 | + | "the sway template does not start the backdrop:\n{config}" | |
| 141 | + | ); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // The fallback. shop exits where the compositor has no wlr-layer-shell, and a | |
| 145 | + | // desktop with no background at all is worse than a plain one. | |
| 146 | + | #[test] | |
| 147 | + | fn the_flat_colour_survives_beneath_the_panel() { | |
| 148 | + | let config = sway_template(); | |
| 149 | + | assert!( | |
| 150 | + | config.contains("output * bg @{surface.page} solid_color"), | |
| 151 | + | "the solid colour fallback is gone, so a compositor without \ | |
| 152 | + | wlr-layer-shell would have no background at all:\n{config}" | |
| 153 | + | ); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // The one that catches a new bind falling off the panel. Both numbers are read | |
| 157 | + | // rather than written down: a bind the parser does not recognise makes these | |
| 158 | + | // disagree, which is the only signal that would ever appear. | |
| 159 | + | #[test] | |
| 160 | + | fn every_bind_in_the_config_reaches_the_panel() { | |
| 161 | + | let expected = sway_template() | |
| 162 | + | .lines() | |
| 163 | + | .filter(|line| line.trim_start().starts_with("bindsym ")) | |
| 164 | + | .count(); | |
| 165 | + | let rendered = render(1); | |
| 166 | + | let keys = section(&rendered, "keys"); | |
| 167 | + | ||
| 168 | + | assert_eq!( | |
| 169 | + | keys.len(), | |
| 170 | + | expected, | |
| 171 | + | "the config has {expected} bindsym lines and the panel drew {}. \ | |
| 172 | + | A bind the parser does not recognise is absent from the only \ | |
| 173 | + | keybinding reference the image ships.\n{}", | |
| 174 | + | keys.len(), | |
| 175 | + | keys.join("\n") | |
| 176 | + | ); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // A mode bind is not reachable the way the others are, so it carries its mode. | |
| 180 | + | // Printed bare, `h` beside the global binds reads as $mod+h, which is | |
| 181 | + | // `focus left` and does something else entirely. | |
| 182 | + | #[test] | |
| 183 | + | fn a_mode_scoped_bind_says_which_mode_it_belongs_to() { | |
| 184 | + | let keys = section(&render(1), "keys"); | |
| 185 | + | let resize: Vec<_> = keys | |
| 186 | + | .iter() | |
| 187 | + | .filter(|row| row.starts_with("resize ")) | |
| 188 | + | .collect(); | |
| 189 | + | ||
| 190 | + | assert!( | |
| 191 | + | !resize.is_empty(), | |
| 192 | + | "no bind carried its mode, so the resize binds are either missing or \ | |
| 193 | + | drawn as though they were global:\n{}", | |
| 194 | + | keys.join("\n") | |
| 195 | + | ); | |
| 196 | + | assert!( | |
| 197 | + | keys.iter().any(|row| row.starts_with("Super+r")), | |
| 198 | + | "the bind that enters resize mode is itself missing:\n{}", | |
| 199 | + | keys.join("\n") | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // The verbs come from the binary, so the panel cannot list a verb the console | |
| 204 | + | // does not have. `help` is clap's own and is not something the machine does. | |
| 205 | + | #[test] | |
| 206 | + | fn the_verbs_come_from_the_binary_and_exclude_claps_own() { | |
| 207 | + | let verbs = section(&render(1), "alloy"); | |
| 208 | + | ||
| 209 | + | assert!( | |
| 210 | + | verbs.iter().any(|row| row.starts_with("net ")), | |
| 211 | + | "the stub offered net and the panel did not draw it:\n{}", | |
| 212 | + | verbs.join("\n") | |
| 213 | + | ); | |
| 214 | + | assert!( | |
| 215 | + | !verbs.iter().any(|row| row.starts_with("help")), | |
| 216 | + | "help is clap's own verb and is not a thing the machine does:\n{}", | |
| 217 | + | verbs.join("\n") | |
| 218 | + | ); | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // The panel must never exit while it is the background: shop closes when the | |
| 222 | + | // program it runs finishes, and a closed background surface is a black screen | |
| 223 | + | // rather than a flat one. | |
| 224 | + | #[test] | |
| 225 | + | fn the_panel_parks_rather_than_returning() { | |
| 226 | + | let text = script(); | |
| 227 | + | assert!( | |
| 228 | + | text.contains("sleep 86400") && text.contains("while :;"), | |
| 229 | + | "the backdrop no longer parks after drawing, so shop would close and \ | |
| 230 | + | leave the desktop black:\n{text}" | |
| 231 | + | ); | |
| 232 | + | assert!( | |
| 233 | + | text.contains("trap 'render' WINCH"), | |
| 234 | + | "nothing redraws on resize, so the panel keeps whatever size it saw \ | |
| 235 | + | first and `alloy display --reconcile` runs after it starts:\n{text}" | |
| 236 | + | ); | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // No hex anywhere near this file (docs/TOKENS.md). The panel styles with bold | |
| 240 | + | // and dim and lets shop resolve the palette out of the theme it was given, so | |
| 241 | + | // `alloy theme apply` moves the backdrop and the terminal together. | |
| 242 | + | #[test] | |
| 243 | + | fn the_panel_carries_no_colour_of_its_own() { | |
| 244 | + | for line in script().lines() { | |
| 245 | + | assert!( | |
| 246 | + | !line.contains("\\033[3") && !line.contains("\\033[4"), | |
| 247 | + | "the backdrop sets a colour itself, which would survive a theme \ | |
| 248 | + | change the terminal followed: {line}" | |
| 249 | + | ); | |
| 250 | + | } | |
| 251 | + | } |
| @@ -1,0 +1,232 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # alloy-backdrop — the desktop background: this machine's own verbs and keys. | |
| 3 | + | # | |
| 4 | + | # Runs as a shop surface on the wlr-layer-shell background layer, started from | |
| 5 | + | # the sway config. A running Alloy machine otherwise ships no keybinding | |
| 6 | + | # reference at all: the only other copy is docs/manual/05-keybindings.md, and no | |
| 7 | + | # COPY puts docs/ into the image. So an empty workspace shows the reference | |
| 8 | + | # instead of a flat colour. | |
| 9 | + | # | |
| 10 | + | # BOTH LISTS ARE DERIVED, never transcribed. The verbs come from `alloy --help` | |
| 11 | + | # and the keys from the sway config this session actually loaded. A second copy | |
| 12 | + | # of either would be a list to maintain, and the one that rots is the one | |
| 13 | + | # nobody reads because it is behind their windows. | |
| 14 | + | # | |
| 15 | + | # A SCRIPT RATHER THAN A VERB, and that is the reason: `alloy backdrop` would | |
| 16 | + | # appear in the verb list this panel prints. usr/bin is where the session's own | |
| 17 | + | # helpers already live (alloy-menu, alloy-clipmenu, alloy-layer-notice). | |
| 18 | + | # | |
| 19 | + | # IT TAKES NO INPUT. shop's `--layer background` sets an empty input region and | |
| 20 | + | # no keyboard interactivity, so clicks and keys pass through to whatever is | |
| 21 | + | # actually focused. Nothing here should ever want a keypress. | |
| 22 | + | # | |
| 23 | + | # NO COLOUR OF ITS OWN. Only SGR bold and dim, which shop resolves out of the | |
| 24 | + | # theme named in ~/.config/shop/config.toml. `alloy theme apply` swaps that file | |
| 25 | + | # for its .night sibling, so the backdrop follows the desktop without knowing a | |
| 26 | + | # palette exists (docs/TOKENS.md: no hex outside the theme files). | |
| 27 | + | # | |
| 28 | + | # IT MUST NOT EXIT. shop closes when the program it runs finishes, and a closed | |
| 29 | + | # background surface is a black screen, not a flat one. So this parks after | |
| 30 | + | # drawing, and redraws on SIGWINCH: the surface is sized to the output, and | |
| 31 | + | # `alloy display --reconcile` runs at login, so the first size this sees is | |
| 32 | + | # often not the one it keeps. | |
| 33 | + | ||
| 34 | + | set -u | |
| 35 | + | ||
| 36 | + | CONFIG="${ALLOY_BACKDROP_CONFIG:-$HOME/.config/sway/config}" | |
| 37 | + | ALLOY="${ALLOY_BACKDROP_ALLOY:-alloy}" | |
| 38 | + | ||
| 39 | + | # The verbs, from the binary rather than from a list here. clap indents each | |
| 40 | + | # command by two spaces under "Commands:" and ends the block with a blank line. | |
| 41 | + | # `help` is clap's own and is dropped: it is not something the machine does. | |
| 42 | + | verbs() { | |
| 43 | + | "$ALLOY" --help 2>/dev/null | awk ' | |
| 44 | + | /^Commands:/ { inside = 1; next } | |
| 45 | + | inside && /^[[:space:]]*$/ { exit } | |
| 46 | + | inside && /^ [a-z]/ { | |
| 47 | + | name = $1 | |
| 48 | + | if (name == "help") next | |
| 49 | + | $1 = "" | |
| 50 | + | sub(/^[[:space:]]+/, "") | |
| 51 | + | print name "\t" $0 | |
| 52 | + | } | |
| 53 | + | ' | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | # The keys, from the config the session loaded. Variables are resolved from the | |
| 57 | + | # file's own `set` lines, so $mod reads as the key someone presses rather than | |
| 58 | + | # as sway's spelling of it, and $term reads as the terminal this image ships. | |
| 59 | + | # Reading only the main config on purpose: the drop-ins under config.d are the | |
| 60 | + | # machine's and the user's, and a backdrop that printed someone's private binds | |
| 61 | + | # onto the desktop would be a surprise. | |
| 62 | + | keys() { | |
| 63 | + | [ -r "$CONFIG" ] || return 0 | |
| 64 | + | awk ' | |
| 65 | + | # Collect `set $name value` first; sway allows use before definition, | |
| 66 | + | # so substitution waits until the whole file is read. | |
| 67 | + | /^set[[:space:]]+\$/ { | |
| 68 | + | name = $2 | |
| 69 | + | $1 = ""; $2 = "" | |
| 70 | + | sub(/^[[:space:]]+/, "") | |
| 71 | + | vars[name] = $0 | |
| 72 | + | next | |
| 73 | + | } | |
| 74 | + | # A `mode "resize" { ... }` block scopes its binds to that mode. They | |
| 75 | + | # are not reachable from the desktop the way the others are, so they | |
| 76 | + | # carry the mode on the chord: an untagged `h` printed beside the | |
| 77 | + | # global binds would read as Super+h, which does something else. | |
| 78 | + | /^mode[[:space:]]+"/ { | |
| 79 | + | mode = $2 | |
| 80 | + | gsub(/"/, "", mode) | |
| 81 | + | next | |
| 82 | + | } | |
| 83 | + | mode != "" && /^}/ { mode = ""; next } | |
| 84 | + | /^[[:space:]]*bindsym[[:space:]]/ { | |
| 85 | + | line = $0 | |
| 86 | + | sub(/^[[:space:]]*bindsym[[:space:]]+/, "", line) | |
| 87 | + | # `--release` and friends are modifiers on the bind, not the chord. | |
| 88 | + | while (line ~ /^--[a-z-]+[[:space:]]/) { | |
| 89 | + | sub(/^--[a-z-]+[[:space:]]+/, "", line) | |
| 90 | + | } | |
| 91 | + | split(line, part, /[[:space:]]+/) | |
| 92 | + | chord = part[1] | |
| 93 | + | action = line | |
| 94 | + | sub(/^[^[:space:]]+[[:space:]]+/, "", action) | |
| 95 | + | # `exec` is how sway spells "run a command" and is on most of these | |
| 96 | + | # lines; printing it on every row costs width and says nothing. | |
| 97 | + | sub(/^exec[[:space:]]+/, "", action) | |
| 98 | + | if (mode != "") chord = mode " " chord | |
| 99 | + | binds[++n] = chord "\t" action | |
| 100 | + | } | |
| 101 | + | END { | |
| 102 | + | for (i = 1; i <= n; i++) { | |
| 103 | + | line = binds[i] | |
| 104 | + | for (name in vars) { | |
| 105 | + | gsub("\\" name, vars[name], line) | |
| 106 | + | } | |
| 107 | + | # Mod4 is what sway calls it and Super is what is printed on | |
| 108 | + | # the key, which is the whole point of resolving the variable. | |
| 109 | + | gsub(/Mod4/, "Super", line) | |
| 110 | + | gsub(/Mod1/, "Alt", line) | |
| 111 | + | print line | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ' "$CONFIG" | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | # Terminal size. `stty size` answers from the pty shop gave us; the fallback is | |
| 118 | + | # a small sane grid rather than an error, because a backdrop that refused to | |
| 119 | + | # draw would leave the screen black. | |
| 120 | + | # | |
| 121 | + | # ALLOY_BACKDROP_SIZE overrides it as "<rows> <cols>". That exists so the layout | |
| 122 | + | # can be asserted without allocating a pty: the test that matters is that every | |
| 123 | + | # bind reaches the panel at any width, and a test harness has no terminal. | |
| 124 | + | size() { | |
| 125 | + | if [ -n "${ALLOY_BACKDROP_SIZE:-}" ]; then | |
| 126 | + | printf '%s\n' "$ALLOY_BACKDROP_SIZE" | |
| 127 | + | return | |
| 128 | + | fi | |
| 129 | + | s=$(stty size 2>/dev/null) || s="" | |
| 130 | + | case "$s" in | |
| 131 | + | [0-9]*' '[0-9]*) printf '%s\n' "$s" ;; | |
| 132 | + | *) printf '%s\n' "24 80" ;; | |
| 133 | + | esac | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | render() { | |
| 137 | + | set -- $(size) | |
| 138 | + | rows=$1 | |
| 139 | + | cols=$2 | |
| 140 | + | ||
| 141 | + | printf '\033[2J\033[H' | |
| 142 | + | ||
| 143 | + | { verbs | sed 's/^/V\t/'; keys | sed 's/^/K\t/'; } | awk \ | |
| 144 | + | -v rows="$rows" -v cols="$cols" -F '\t' ' | |
| 145 | + | function spaces(n, out) { | |
| 146 | + | out = "" | |
| 147 | + | while (n-- > 0) out = out " " | |
| 148 | + | return out | |
| 149 | + | } | |
| 150 | + | # Flow a section into as many columns as the width allows, filling each | |
| 151 | + | # column top to bottom so the reading order down a column is the order | |
| 152 | + | # the source had. | |
| 153 | + | # | |
| 154 | + | # Padding is computed from the VISIBLE length, which is why the styled | |
| 155 | + | # cell is assembled here rather than handed in ready-made: length() | |
| 156 | + | # counts the SGR bytes, so padding a styled string aligns the escapes | |
| 157 | + | # and not the glyphs. | |
| 158 | + | function columns(key, val, count, kw, width, label, per, cn, r, c, idx, line, cell, vis, gap) { | |
| 159 | + | if (count == 0) return | |
| 160 | + | print "" | |
| 161 | + | gap = 3 | |
| 162 | + | cn = int((cols - 2 + gap) / (width + gap)) | |
| 163 | + | if (cn < 1) cn = 1 | |
| 164 | + | per = int((count + cn - 1) / cn) | |
| 165 | + | printf "\033[1m %s\033[0m\n", label | |
| 166 | + | for (r = 0; r < per; r++) { | |
| 167 | + | line = "" | |
| 168 | + | for (c = 0; c < cn; c++) { | |
| 169 | + | idx = c * per + r + 1 | |
| 170 | + | if (idx > count) continue | |
| 171 | + | if (c > 0) line = line spaces(gap) | |
| 172 | + | cell = "\033[1m" key[idx] "\033[0m" spaces(kw - length(key[idx])) \ | |
| 173 | + | " \033[2m" val[idx] "\033[0m" | |
| 174 | + | vis = kw + 1 + length(val[idx]) | |
| 175 | + | line = line cell spaces(width - vis) | |
| 176 | + | } | |
| 177 | + | sub(/[[:space:]]+$/, "", line) | |
| 178 | + | print " " line | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | { | |
| 182 | + | if ($1 == "V") { | |
| 183 | + | vn++ | |
| 184 | + | vkey[vn] = $2 | |
| 185 | + | vval[vn] = $3 | |
| 186 | + | if (length($2) > vw) vw = length($2) | |
| 187 | + | } else { | |
| 188 | + | kn++ | |
| 189 | + | kkey[kn] = $2 | |
| 190 | + | kval[kn] = $3 | |
| 191 | + | if (length($2) > kw) kw = length($2) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | END { | |
| 195 | + | for (i = 1; i <= vn; i++) { | |
| 196 | + | vlen = vw + 1 + length(vval[i]) | |
| 197 | + | if (vlen > vwidth) vwidth = vlen | |
| 198 | + | } | |
| 199 | + | for (i = 1; i <= kn; i++) { | |
| 200 | + | klen = kw + 1 + length(kval[i]) | |
| 201 | + | if (klen > kwidth) kwidth = klen | |
| 202 | + | } | |
| 203 | + | columns(vkey, vval, vn, vw, vwidth, "alloy") | |
| 204 | + | columns(kkey, kval, kn, kw, kwidth, "keys") | |
| 205 | + | } | |
| 206 | + | ' | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | case "${1:-}" in | |
| 210 | + | --once) | |
| 211 | + | # Draw and leave. What `alloy-backdrop --once | less` gives a person who | |
| 212 | + | # wants the reference in front of them, and what lets the panel be | |
| 213 | + | # asserted without a compositor or a pty. | |
| 214 | + | render | |
| 215 | + | exit 0 | |
| 216 | + | ;; | |
| 217 | + | "") ;; | |
| 218 | + | *) | |
| 219 | + | echo "alloy-backdrop: expected --once or no argument" >&2 | |
| 220 | + | exit 2 | |
| 221 | + | ;; | |
| 222 | + | esac | |
| 223 | + | ||
| 224 | + | render | |
| 225 | + | trap 'render' WINCH | |
| 226 | + | ||
| 227 | + | # Park. `wait` is what makes the trap prompt rather than waiting out the sleep, | |
| 228 | + | # so a resize redraws now instead of up to a day later. | |
| 229 | + | while :; do | |
| 230 | + | sleep 86400 & | |
| 231 | + | wait $! 2>/dev/null || true | |
| 232 | + | done |