Skip to main content

max / alloy

stack: add a TUI launcher and a clipboard picker $term was `rio`, and Fedora and Terra install the terminal's binary as rioterm. With no launcher bound, $mod+Return was the only way to reach a terminal from the session, so the wrong name left an installed machine with no way to open one and no way to edit the file that would fix it, short of switching to a VT. Found by hitting it. That is also the argument for a launcher. "None by default" read as terminal-first discipline and worked as one until the terminal bind broke. alloy-menu is fzf over desktop entries, run in rio, bound $mod+D: docs/STACK.md rejects graphical launchers on thesis and named this shape. It does not list every binary on PATH, because a shell is one keystroke away and listing them buries Firefox under coreutils. Entries marked Terminal=true get a terminal to live in; helix, yazi and gammastep all ship one, and detached with no terminal they exit instantly and silently. alloy-clipmenu is the read half of the clipboard history, bound $mod+Shift+V. The two wl-paste watchers have filled that history since the stack landed and nothing could read it back. $mod+Shift+C reloads, which is sway's own default and was absent. Without it the only way to apply a config edit is swaymsg reload from a terminal, which is circular in the one case that matters.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 21:13 UTC
Signed with PGP, not checked
Commit: bdd46c1b0b19e65243cb5cb409d6b2545967f62c
Parent: 64ce521
3 files changed, +130 insertions, -13 deletions
@@ -18,7 +18,12 @@
18 18 # per-machine output in /etc/sway/config.d/.
19 19
20 20 set $mod Mod4
21 - set $term rio
21 + # rioterm, not rio. Fedora and Terra package the terminal as rio but install
22 + # its binary as /usr/bin/rioterm; there is no `rio` on the image. With no
23 + # launcher bound below, $mod+Return is the only way to reach a terminal from
24 + # the session, so this name being wrong left an installed machine with no way
25 + # to open one at all, and no way to edit this file to fix it, short of a VT.
26 + set $term rioterm
22 27
23 28 # -------------------------------------------------------------------
24 29 # Input
@@ -55,7 +60,7 @@
55 60 # mode 1920x1200@60Hz
56 61 # scale 1.0
57 62 # }
58 - output * bg #f7f4ec solid_color
63 + output * bg #e4ded6 solid_color
59 64 include /etc/sway/config.d/*
60 65
61 66 # -------------------------------------------------------------------
@@ -70,13 +75,16 @@
70 75 font pango:IosevkaTerm Nerd Font 10
71 76
72 77 # class border background text indicator child_border
73 - client.focused #7f786d #7f786d #1a1a1a #7f786d #7f786d
74 - client.focused_inactive #dad2c7 #dad2c7 #6b6b6b #dad2c7 #dad2c7
75 - client.unfocused #dad2c7 #dad2c7 #6b6b6b #dad2c7 #dad2c7
76 - client.urgent #b3543f #b3543f #ffffff #b3543f #b3543f
78 + client.focused #7f786d #7f786d #1a1816 #7f786d #7f786d
79 + client.focused_inactive #dad2c7 #dad2c7 #514b45 #dad2c7 #dad2c7
80 + client.unfocused #dad2c7 #dad2c7 #514b45 #dad2c7 #dad2c7
81 + client.urgent #6a2828 #6a2828 #ffffff #6a2828 #6a2828
77 82
78 83 # satty floats; everything else tiles (anyrun rule dropped with anyrun).
79 84 for_window [app_id="satty"] floating enable
85 + # The picker floats centered rather than tiling: it is a transient prompt, and
86 + # tiling it would reflow every window on screen for as long as it is open.
87 + for_window [app_id="alloy-menu"] floating enable, resize set 800 500
80 88
81 89 # -------------------------------------------------------------------
82 90 # Startup
@@ -112,10 +120,19 @@
112 120 # No multiplexer bind. Tabs, splits and panes are sway's job, and a
113 121 # multiplexer inside a tiling compositor is the same feature twice; see
114 122 # docs/STACK.md#terminal.
115 - # Launcher removed with anyrun. Bind a TUI launcher here when chosen,
116 - # e.g. bindsym $mod+d exec $term -e <picker>
117 - # bindsym $mod+d exec ...
123 + # Launcher: a fuzzy picker in a terminal, not a graphical surface
124 + # (docs/STACK.md#launcher). $mod+Return is for typing commands; this is for
125 + # the graphical apps whose binary name nobody remembers.
126 + bindsym $mod+d exec $term --app-id=alloy-menu -e /usr/bin/alloy-menu
127 + # Clipboard history, read half. The two wl-paste watchers above have filled it
128 + # since the stack landed; this is what gets anything back out.
129 + bindsym $mod+Shift+v exec $term --app-id=alloy-menu -e /usr/bin/alloy-clipmenu
118 130 bindsym $mod+Ctrl+l exec swaylock
131 + # Reload. Sway's own default, and absent here until now: without it the only
132 + # way to apply a config edit is `swaymsg reload` from a terminal, which is
133 + # circular in the one case that matters: a config that broke the terminal
134 + # bind. That is not hypothetical; it is how this file's $term was found wrong.
135 + bindsym $mod+Shift+c reload
119 136 bindsym $mod+Shift+e exit
120 137
121 138 # Window ops
@@ -210,9 +227,9 @@
210 227 font pango:IosevkaTerm Nerd Font 10
211 228 status_command while date +'%Y-%m-%d %H:%M'; do sleep 20; done
212 229 colors {
213 - background #f7f4ec
214 - statusline #1a1a1a
215 - focused_workspace #7f786d #7f786d #1a1a1a
216 - inactive_workspace #dad2c7 #dad2c7 #6b6b6b
230 + background #e4ded6
231 + statusline #1a1816
232 + focused_workspace #7f786d #7f786d #1a1816
233 + inactive_workspace #dad2c7 #dad2c7 #514b45
217 234 }
218 235 }
@@ -1,0 +1,20 @@
1 + #!/bin/sh
2 + # alloy-clipmenu — pick something out of the clipboard history and copy it back.
3 + #
4 + # The read half of docs/STACK.md#clipboard-history. The sway config has run two
5 + # `wl-paste --watch cliphist store` watchers since the stack landed, so the
6 + # history was being filled from the first login and there was no way to get
7 + # anything out of it: no bind, no picker, no verb. STACK.md named this exact
8 + # pipeline ("cliphist list piped to a TUI picker") and it was never wired.
9 + #
10 + # cliphist list emits `<id>\t<preview>`; decode takes the id back to the real
11 + # bytes, which is what makes this work for images and for text with newlines.
12 +
13 + set -eu
14 +
15 + cliphist list | fzf \
16 + --delimiter='\t' --with-nth=2.. \
17 + --prompt='paste ' --pointer='>' --marker=' ' \
18 + --layout=reverse --no-multi --no-sort --height=100% --border=none --info=inline \
19 + | cliphist decode \
20 + | wl-copy
@@ -1,0 +1,80 @@
1 + #!/bin/sh
2 + # alloy-menu — pick an installed application and run it.
3 + #
4 + # The launcher for a terminal-first system: a fuzzy picker in a terminal, not a
5 + # graphical surface. docs/STACK.md#launcher rejects graphical launchers on
6 + # thesis and names this shape; this is that.
7 + #
8 + # The split with $mod+Return is the whole design. Commands are typed in a
9 + # shell, which is one keystroke away. This exists for the other case -- the
10 + # graphical apps that have a .desktop file and an icon and a name nobody
11 + # remembers the binary for. So it lists desktop entries and nothing else;
12 + # adding every binary on PATH would bury Firefox under coreutils.
13 + #
14 + # Launched apps are handed to sway rather than run as children of this script,
15 + # so the terminal hosting the picker can close immediately and the app is not
16 + # killed with it.
17 +
18 + set -eu
19 +
20 + FIELD_CODES=' ?%[fFuUickdDnNvm]'
21 +
22 + dirs="/usr/share/applications
23 + ${XDG_DATA_HOME:-$HOME/.local/share}/applications
24 + /var/lib/flatpak/exports/share/applications
25 + ${XDG_DATA_HOME:-$HOME/.local/share}/flatpak/exports/share/applications"
26 +
27 + entries() {
28 + printf '%s\n' "$dirs" | while IFS= read -r dir; do
29 + [ -d "$dir" ] || continue
30 + find "$dir" -maxdepth 2 -name '*.desktop' 2>/dev/null
31 + done | while IFS= read -r file; do
32 + # Only the [Desktop Entry] group. Actions ([Desktop Action ...]) carry
33 + # their own Name/Exec and would otherwise be read as separate apps.
34 + awk -v codes="$FIELD_CODES" '
35 + /^\[Desktop Entry\]/ { group = 1; next }
36 + /^\[/ { group = 0 }
37 + !group { next }
38 + /^Name=/ && name == "" { name = substr($0, 6) }
39 + /^Exec=/ && cmd == "" { cmd = substr($0, 6) }
40 + /^Type=/ { type = substr($0, 6) }
41 + /^NoDisplay=[Tt]rue/ { hide = 1 }
42 + /^Hidden=[Tt]rue/ { hide = 1 }
43 + # Terminal=true means the entry is a TUI and has no window of its own.
44 + # On a TUI-first system this is not the rare case: helix, yazi and
45 + # gammastep all ship one. Launched without a terminal they exit
46 + # immediately with no output, which reads as the launcher being broken.
47 + /^Terminal=[Tt]rue/ { needs_term = 1 }
48 + END {
49 + if (hide || type != "Application" || name == "" || cmd == "") exit
50 + gsub(codes, "", cmd)
51 + printf "%s\t%d\t%s\n", name, needs_term, cmd
52 + }
53 + ' "$file"
54 + done | sort -u -k1,1 -t "$(printf '\t')"
55 + }
56 +
57 + choice=$(entries | fzf \
58 + --delimiter='\t' --with-nth=1 \
59 + --prompt='run ' --pointer='>' --marker=' ' \
60 + --layout=reverse --no-multi --height=100% --border=none --info=inline) || exit 0
61 +
62 + [ -n "$choice" ] || exit 0
63 + needs_term=$(printf '%s' "$choice" | cut -f2)
64 + cmd=$(printf '%s' "$choice" | cut -f3-)
65 + [ -n "$cmd" ] || exit 0
66 +
67 + # TUI entries get a terminal to live in. $TERMINAL so this stays true if the
68 + # terminal pick ever changes; rioterm is the STACK.md default.
69 + if [ "$needs_term" = "1" ]; then
70 + cmd="${TERMINAL:-rioterm} -e $cmd"
71 + fi
72 +
73 + # swaymsg runs it through sh, so a full Exec line keeps its arguments. Outside
74 + # a sway session (a bare tty, or another compositor) fall back to detaching it
75 + # here, so the picker is not silently useless.
76 + if [ -n "${SWAYSOCK:-}" ] && command -v swaymsg >/dev/null 2>&1; then
77 + swaymsg exec "$cmd" >/dev/null
78 + else
79 + setsid sh -c "$cmd" >/dev/null 2>&1 &
80 + fi