Skip to main content

max / alloy

Put the machine's own verbs and keys on the desktop background An installed machine shipped no keybinding reference at all: the only copy is docs/manual/05-keybindings.md and no COPY puts docs/ into the image. The background was a flat colour. It is now a shop surface on the wlr-layer-shell background layer printing the 17 alloy verbs and the 58 sway binds, sized to the output and taking no input, so a click or a keypress goes to whatever is in front of it. Both lists are derived, never transcribed. The verbs come from `alloy --help` and the binds from the sway config the session loaded, so a verb the console gains or a bind someone adds is on the panel at the next login rather than in a second list that rots. A test holds that: it counts the bindsym lines in the template and the rows the panel draws and fails when they disagree, which is the only signal a dropped bind would ever produce. Mode-scoped binds carry their mode. Printed bare, `h` beside the global binds reads as $mod+h, which is focus left. The flat colour stays underneath. shop exits where the compositor has no wlr-layer-shell, and a desktop with no background is worse than a plain one. The Containerfile asserts both halves on the client profile and removes the script on server, where no session could start it. It checks the binds and not the verbs on purpose: the console is not in /usr/bin at build time, so the guard runs the panel under exactly the condition the first boot has, where it must still draw every key. No colour of its own. Only SGR bold and dim, resolved by shop out of the theme in ~/.config/shop/config.toml, so `alloy theme apply` moves the backdrop and the terminal together.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 15:42 UTC
Signed with PGP, not checked
Commit: 64cdf8aabd14593a21d12fa69e27e5ca0f476df2
Parent: b9e6ea4
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