| 1 |
@{! variants = default, night } |
| 2 |
# Alloy Sway configuration |
| 3 |
# |
| 4 |
# i3-style tiling Wayland session. Translated from the former niri |
| 5 |
# config on the 2026-07-17 pivot away from the scrolling-tiler model. |
| 6 |
# Palette on chrome per docs/TOKENS.md. |
| 7 |
# |
| 8 |
# HANDOFF (scaffold — refine in Helix): |
| 9 |
# - Env vars: GTK/QT/MOZ backend hints live in |
| 10 |
# ~/.config/environment.d/alloy.conf; verify they reach sway's |
| 11 |
# children under greetd (see the live-verification GO task). |
| 12 |
# - Verify on real hardware: swayosd Fn binds, grim screenshots, |
| 13 |
# per-machine output in /etc/sway/config.d/. |
| 14 |
|
| 15 |
set $mod Mod4 |
| 16 |
# shop, Alloy's own terminal, built from source into the image rather than |
| 17 |
# installed from a repo. The name is the binary name, with none of the |
| 18 |
# package-name-versus-binary-name trap rio had. |
| 19 |
# |
| 20 |
# Get this wrong and an installed machine has no way to open a terminal at |
| 21 |
# all, and no way to edit this file to fix it, short of a VT: with no |
| 22 |
# launcher bound below, $mod+Return is the only route from the session to a |
| 23 |
# shell. The Containerfile asserts the binary exists for that reason. |
| 24 |
set $term shop |
| 25 |
|
| 26 |
# ------------------------------------------------------------------- |
| 27 |
# Input |
| 28 |
# ------------------------------------------------------------------- |
| 29 |
input type:keyboard { |
| 30 |
xkb_layout us |
| 31 |
repeat_delay 400 |
| 32 |
repeat_rate 30 |
| 33 |
} |
| 34 |
|
| 35 |
input type:touchpad { |
| 36 |
tap enabled |
| 37 |
natural_scroll enabled |
| 38 |
pointer_accel 0.2 |
| 39 |
} |
| 40 |
|
| 41 |
input type:pointer { |
| 42 |
accel_profile flat |
| 43 |
} |
| 44 |
|
| 45 |
# niri warped the cursor to the focused window; sway's nearest is |
| 46 |
# warping to the focused container center on focus change. |
| 47 |
mouse_warping container |
| 48 |
focus_follows_mouse no |
| 49 |
|
| 50 |
# Cursor theme + size (was niri's XCURSOR_* environment block). |
| 51 |
seat seat0 xcursor_theme Bibata-Modern-Classic 24 |
| 52 |
|
| 53 |
# ------------------------------------------------------------------- |
| 54 |
# Output — per-machine display config lives in a drop-in so this base |
| 55 |
# stays generic. Ship an example, commented, like the niri config did. |
| 56 |
# ------------------------------------------------------------------- |
| 57 |
# output eDP-1 { |
| 58 |
# mode 1920x1200@60Hz |
| 59 |
# scale 1.0 |
| 60 |
# } |
| 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 |
# 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 |
# |
| 77 |
# It reads its colours from ~/.config/shop/config.toml like any other shop, so |
| 78 |
# `alloy theme apply` moves the backdrop and the terminal together. |
| 79 |
exec $term --layer background -e /usr/bin/alloy-drift |
| 80 |
include /etc/sway/config.d/* |
| 81 |
# `alloy display` owns 50-display.conf in here, and this include is what makes |
| 82 |
# the console's writes reach sway at the next login. Ordered after the system |
| 83 |
# drop-ins deliberately: sway merges every stanza that matches an output, later |
| 84 |
# ones winning on a property both name, so what the user set beats what the |
| 85 |
# image shipped. Anything else in this directory is theirs and is left alone. |
| 86 |
include ~/.config/sway/config.d/* |
| 87 |
|
| 88 |
# ------------------------------------------------------------------- |
| 89 |
# Layout / chrome |
| 90 |
# ------------------------------------------------------------------- |
| 91 |
gaps inner 8 |
| 92 |
gaps outer 0 |
| 93 |
# niri ran border off + a 2px focus-ring; pixel border with no titlebar |
| 94 |
# is the sway equivalent (also covers the old prefer-no-csd intent). |
| 95 |
default_border pixel 2 |
| 96 |
default_floating_border pixel 2 |
| 97 |
font pango:Quasi Mono 10 |
| 98 |
|
| 99 |
# class border background text indicator child_border |
| 100 |
client.focused @{border.strong} @{border.strong} @{content.primary} @{border.strong} @{border.strong} |
| 101 |
client.focused_inactive @{border.subtle} @{border.subtle} @{content.muted} @{border.subtle} @{border.subtle} |
| 102 |
client.unfocused @{border.subtle} @{border.subtle} @{content.muted} @{border.subtle} @{border.subtle} |
| 103 |
client.urgent @{status.danger} @{status.danger} @{readable_on(status.danger)} @{status.danger} @{status.danger} |
| 104 |
|
| 105 |
# satty floats; everything else tiles (anyrun rule dropped with anyrun). |
| 106 |
for_window [app_id="satty"] floating enable |
| 107 |
# The picker floats centered rather than tiling: it is a transient prompt, and |
| 108 |
# tiling it would reflow every window on screen for as long as it is open. |
| 109 |
for_window [app_id="alloy-menu"] floating enable, resize set 800 500 |
| 110 |
|
| 111 |
# ------------------------------------------------------------------- |
| 112 |
# Startup |
| 113 |
# ------------------------------------------------------------------- |
| 114 |
# Systemd user units DO run in this session, so nothing that ships a user unit |
| 115 |
# needs an `exec` line here. The mechanism is the sway-systemd package: |
| 116 |
# /etc/sway/config.d/10-systemd-session.conf ends by exec'ing |
| 117 |
# /usr/libexec/sway-systemd/session.sh, which starts sway-session.target, and |
| 118 |
# that target BindsTo=graphical-session.target and wants |
| 119 |
# graphical-session-pre.target. None of it is Alloy's config, which is why it |
| 120 |
# is invisible from this file and was twice assumed absent. Proven on |
| 121 |
# 2026-07-21 by booting an install in QEMU and reading the uid 1000 journal: |
| 122 |
# all three targets reached, xdg-user-dirs.service finished, pipewire and |
| 123 |
# wireplumber running as the user, and reproduced on the fw12 install on |
| 124 |
# 2026-07-29. So `exec xdg-user-dirs-update` came back out, and ~/Documents and |
| 125 |
# friends are created by xdg-user-dirs.service. |
| 126 |
# |
| 127 |
# The package arrives as a weak dependency (sway -> sway-config-upstream |
| 128 |
# recommends sway-systemd), not as an explicit install line, so a build that |
| 129 |
# turns weak deps off would take the whole user session with it. Rationale for |
| 130 |
# the other half, the presets, is in etc/systemd/user-preset/50-alloy.preset. |
| 131 |
exec mkdir -p ~/Pictures/Screenshots |
| 132 |
# Clipboard history: two watchers per docs/STACK.md#clipboard-history. |
| 133 |
# |
| 134 |
# Through alloy-clipstore rather than `cliphist store` directly, since |
| 135 |
# 2026-08-17. Both are the same one line apart: store the selection, unless |
| 136 |
# alloy-secret-copy marked it as a password on its way to a paste. Before that |
| 137 |
# these two lines archived every password gopass copied, and $mod+Shift+v read |
| 138 |
# them back (GO alloy problem e7a9e38c). |
| 139 |
exec wl-paste --type text --watch alloy-clipstore |
| 140 |
exec wl-paste --type image --watch alloy-clipstore |
| 141 |
# swayosd-server backs the volume/brightness/caps overlays, and this line is |
| 142 |
# the only thing that starts it. Checked rather than assumed, because the note |
| 143 |
# above retires the sibling `exec`: SwayOSD-0.3.2 ships three binaries and |
| 144 |
# exactly one unit, swayosd-libinput-backend.service, which is a SYSTEM unit |
| 145 |
# for reading the lock keys. There is no user unit for the server in the |
| 146 |
# package at all, and the one D-Bus activation file it ships |
| 147 |
# (org.erikreider.swayosd on the system bus) names the backend, not the server. |
| 148 |
# So the server has nothing to start it but the compositor. |
| 149 |
exec swayosd-server |
| 150 |
|
| 151 |
# Monitors follow their settings between ports. |
| 152 |
# |
| 153 |
# `~/.config/sway/config.d/50-display.conf` keys every stanza by connector name, |
| 154 |
# so a monitor moved from DP-1 to DP-2 arrives with whatever was written for the |
| 155 |
# port rather than for the screen. This reads the EDID of what is attached, |
| 156 |
# matches it against Alloy's own table, and moves the settings across. See |
| 157 |
# `crates/alloy/src/monitors.rs` for why the connector is what gets written. |
| 158 |
# |
| 159 |
# Here rather than in `usr/bin/alloy-session`, which is where `alloy theme |
| 160 |
# apply` runs and would be the obvious home: this needs a compositor to ask, and |
| 161 |
# the wrapper runs before there is one. `exec` fires once sway is up, which is |
| 162 |
# the earliest moment `swaymsg -t get_outputs` can answer. |
| 163 |
# |
| 164 |
# Draws nothing and prints only when something moved. |
| 165 |
exec alloy display --reconcile |
| 166 |
|
| 167 |
# The first-boot offer: a mesh for the machines, a sync for the files |
| 168 |
# (docs/CONTINUITY.md). --if-first-boot is what makes this a one-time event: |
| 169 |
# the console records that it asked, and every later login runs this line and |
| 170 |
# exits without drawing anything. Skipping is fine and reversible; `alloy mesh` |
| 171 |
# and `alloy sync` carry the same enrollments afterwards. |
| 172 |
exec $term -e alloy setup --if-first-boot |
| 173 |
|
| 174 |
# Idle: warn at 4.5 minutes, lock at 5, displays off at 10, and always lock |
| 175 |
# before suspend so a closed lid never resumes into a live session. |
| 176 |
# -w makes swayidle wait for the lock to come up before sleeping. |
| 177 |
# Lock styling comes from ~/.config/swaylock/config (it sets daemonize). |
| 178 |
# |
| 179 |
# The 270-second stage is the warning, and it is why this block has four |
| 180 |
# timeouts instead of three. Until it existed the comment here read "dim to |
| 181 |
# lock at 5 minutes" and nothing dimmed: full brightness to swaylock with no |
| 182 |
# intermediate state, so the lock could only ever arrive as an ambush. Thirty |
| 183 |
# seconds of dimmed screen is enough to notice and move the mouse, and the |
| 184 |
# `resume` on that stage is what puts the brightness back — including after an |
| 185 |
# unlock, since typing a password is input and fires it. |
| 186 |
# |
| 187 |
# It is also the nearest thing to a grace period on offer: upstream swaylock has |
| 188 |
# no --grace, and taking the swaylock-effects fork for one flag is the wrong |
| 189 |
# trade for a distro that prefers canonical packages. |
| 190 |
exec swayidle -w \ |
| 191 |
timeout 270 'alloy-dim down' \ |
| 192 |
resume 'alloy-dim up' \ |
| 193 |
timeout 300 'swaylock -f' \ |
| 194 |
timeout 600 'swaymsg "output * power off"' \ |
| 195 |
resume 'swaymsg "output * power on"' \ |
| 196 |
before-sleep 'swaylock -f' |
| 197 |
|
| 198 |
# Do not lock over a video. |
| 199 |
# |
| 200 |
# sway implements the Wayland idle-inhibit protocol and swayidle honours it, so |
| 201 |
# an application that registers an inhibitor while it plays already suppresses |
| 202 |
# the block above without help. These rules cover the case where that does not |
| 203 |
# happen: mpv only inhibits while its stop-screensaver option is on, and a |
| 204 |
# browser that loses the inhibitor mid-session gives no sign of it. Fullscreen |
| 205 |
# is the condition rather than mere focus, because a windowed video is usually |
| 206 |
# something being glanced at rather than watched. |
| 207 |
# |
| 208 |
# Deliberately a named few and not `[app_id=".*"]`. A blanket rule would make |
| 209 |
# any fullscreen window hold the lock off, and on a distro whose primary surface |
| 210 |
# is a terminal that means walking away from a fullscreen shell leaves the |
| 211 |
# machine unlocked indefinitely. The lock is a security control; the exemption |
| 212 |
# belongs to the handful of things that are actually playing something. Add an |
| 213 |
# app here when a real one is found to need it, and use `class` rather than |
| 214 |
# `app_id` if it turns out to be running through XWayland. |
| 215 |
# |
| 216 |
# The browser is matched by a pattern rather than a literal, and stays a pattern |
| 217 |
# now that Firefox is the only one the image carries. A user who installs their |
| 218 |
# own is the case it is for: a Flatpak Chromium arrives with its own app_id and |
| 219 |
# a fullscreen video in it should hold the lock off too. Add one here when a |
| 220 |
# real one is found, and use `class` rather than `app_id` if it turns out to be |
| 221 |
# running through XWayland. Not yet confirmed against a running session: if a |
| 222 |
# fullscreen video stops holding the lock off, this is the line to check first. |
| 223 |
for_window [app_id="mpv"] inhibit_idle fullscreen |
| 224 |
for_window [app_id="^(firefox|chromium)"] inhibit_idle fullscreen |
| 225 |
for_window [app_id="imv"] inhibit_idle fullscreen |
| 226 |
|
| 227 |
# ------------------------------------------------------------------- |
| 228 |
# Binds — session |
| 229 |
# ------------------------------------------------------------------- |
| 230 |
bindsym $mod+Return exec $term |
| 231 |
# No multiplexer bind. Tabs, splits and panes are sway's job, and a |
| 232 |
# multiplexer inside a tiling compositor is the same feature twice; see |
| 233 |
# docs/STACK.md#terminal. |
| 234 |
# Launcher: a fuzzy picker in a terminal, not a graphical surface |
| 235 |
# (docs/STACK.md#launcher). $mod+Return is for typing commands; this is for |
| 236 |
# the graphical apps whose binary name nobody remembers. |
| 237 |
bindsym $mod+d exec $term --app-id=alloy-menu -e /usr/bin/alloy-menu |
| 238 |
# Clipboard history, read half. The two wl-paste watchers above have filled it |
| 239 |
# since the stack landed; this is what gets anything back out. |
| 240 |
bindsym $mod+Shift+v exec $term --app-id=alloy-menu -e /usr/bin/alloy-clipmenu |
| 241 |
bindsym $mod+Ctrl+l exec swaylock |
| 242 |
# Reload. Sway's own default, and absent here until now: without it the only |
| 243 |
# way to apply a config edit is `swaymsg reload` from a terminal, which is |
| 244 |
# circular in the one case that matters: a config that broke the terminal |
| 245 |
# bind. That is not hypothetical; it is how this file's $term was found wrong. |
| 246 |
bindsym $mod+Shift+c reload |
| 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 |
| 260 |
|
| 261 |
# Window ops |
| 262 |
bindsym $mod+q kill |
| 263 |
bindsym $mod+f fullscreen toggle |
| 264 |
bindsym $mod+Shift+f fullscreen toggle global |
| 265 |
|
| 266 |
# Focus (i3 hjkl) |
| 267 |
bindsym $mod+h focus left |
| 268 |
bindsym $mod+j focus down |
| 269 |
bindsym $mod+k focus up |
| 270 |
bindsym $mod+l focus right |
| 271 |
|
| 272 |
# Move |
| 273 |
bindsym $mod+Shift+h move left |
| 274 |
bindsym $mod+Shift+j move down |
| 275 |
bindsym $mod+Shift+k move up |
| 276 |
bindsym $mod+Shift+l move right |
| 277 |
|
| 278 |
# Layout — sway-native (no niri equivalent; core i3 UX) |
| 279 |
bindsym $mod+b splith |
| 280 |
bindsym $mod+v splitv |
| 281 |
bindsym $mod+w layout tabbed |
| 282 |
bindsym $mod+s layout stacking |
| 283 |
bindsym $mod+e layout toggle split |
| 284 |
bindsym $mod+Shift+space floating toggle |
| 285 |
bindsym $mod+space focus mode_toggle |
| 286 |
|
| 287 |
# Resize (was niri's column-width presets) |
| 288 |
bindsym $mod+r mode "resize" |
| 289 |
bindsym $mod+minus resize shrink width 10 ppt |
| 290 |
bindsym $mod+equal resize grow width 10 ppt |
| 291 |
mode "resize" { |
| 292 |
bindsym h resize shrink width 10 ppt |
| 293 |
bindsym l resize grow width 10 ppt |
| 294 |
bindsym k resize shrink height 10 ppt |
| 295 |
bindsym j resize grow height 10 ppt |
| 296 |
bindsym Return mode "default" |
| 297 |
bindsym Escape mode "default" |
| 298 |
} |
| 299 |
|
| 300 |
# Workspaces (kept the named notes/main from niri; media too) |
| 301 |
bindsym $mod+1 workspace number 1 |
| 302 |
bindsym $mod+2 workspace number 2 |
| 303 |
bindsym $mod+3 workspace number 3 |
| 304 |
bindsym $mod+4 workspace number 4 |
| 305 |
bindsym $mod+n workspace notes |
| 306 |
bindsym $mod+m workspace main |
| 307 |
bindsym $mod+Shift+1 move container to workspace number 1 |
| 308 |
bindsym $mod+Shift+2 move container to workspace number 2 |
| 309 |
bindsym $mod+Shift+3 move container to workspace number 3 |
| 310 |
bindsym $mod+Shift+4 move container to workspace number 4 |
| 311 |
|
| 312 |
# ------------------------------------------------------------------- |
| 313 |
# Binds — screenshots (grim; sway has no built-in capture) |
| 314 |
# ------------------------------------------------------------------- |
| 315 |
# All four go through alloy-shot, which captures and then says so. They were |
| 316 |
# four inline grim calls until 2026-07-29, when Print Screen was reported as a |
| 317 |
# dead keybind: it worked the whole time and told nobody, which is the same |
| 318 |
# thing from the keyboard. The script's header has the rest, including why a |
| 319 |
# `&& notify-send` appended here would have named the wrong file. |
| 320 |
# |
| 321 |
# Active window was deferred once on the belief that jq had to be installed for |
| 322 |
# it; jq is a declared dependency of the image, so the deferral cost nothing but |
| 323 |
# the bind. Ctrl+Print was a second copy of the region-select line until then. |
| 324 |
# Whole output. |
| 325 |
bindsym Print exec alloy-shot output |
| 326 |
# Region select. Escape cancels without an error. |
| 327 |
bindsym Shift+Print exec alloy-shot region |
| 328 |
# Active window. |
| 329 |
bindsym Ctrl+Print exec alloy-shot window |
| 330 |
# Annotate the most recent screenshot with satty. |
| 331 |
bindsym $mod+Print exec alloy-shot annotate |
| 332 |
|
| 333 |
# ------------------------------------------------------------------- |
| 334 |
# Binds — volume / brightness / media (swayosd + playerctl) |
| 335 |
# ------------------------------------------------------------------- |
| 336 |
bindsym XF86AudioRaiseVolume exec swayosd-client --output-volume raise |
| 337 |
bindsym XF86AudioLowerVolume exec swayosd-client --output-volume lower |
| 338 |
bindsym XF86AudioMute exec swayosd-client --output-volume mute-toggle |
| 339 |
bindsym XF86AudioMicMute exec swayosd-client --input-volume mute-toggle |
| 340 |
bindsym XF86MonBrightnessUp exec swayosd-client --brightness raise |
| 341 |
bindsym XF86MonBrightnessDown exec swayosd-client --brightness lower |
| 342 |
bindsym XF86AudioPlay exec playerctl play-pause |
| 343 |
bindsym XF86AudioPause exec playerctl play-pause |
| 344 |
bindsym XF86AudioNext exec playerctl next |
| 345 |
bindsym XF86AudioPrev exec playerctl previous |
| 346 |
# Caps-lock indicator (needs swayosd-libinput-backend running). |
| 347 |
bindsym --release Caps_Lock exec swayosd-client --caps-lock |
| 348 |
|
| 349 |
# ------------------------------------------------------------------- |
| 350 |
# Bar — swaybar, with `alloy status --bar` as the status line. |
| 351 |
# ------------------------------------------------------------------- |
| 352 |
# This was `while date; do sleep 20; done` until 2026-07-30: a clock and |
| 353 |
# nothing else, on a distro whose one validated machine is a laptop. Asking |
| 354 |
# whether the machine was charging meant reading /sys by hand. |
| 355 |
# |
| 356 |
# The status line is a console verb rather than a script in usr/bin/ because |
| 357 |
# `alloy audio` and `alloy net` already parse pactl and nmcli into types, and a |
| 358 |
# script would have been a second parse of the same two contracts. It emits |
| 359 |
# swaybar's JSON protocol, so each block carries its own color from the theme; |
| 360 |
# the `colors` block below still sets the bar's own surfaces and the workspace |
| 361 |
# buttons, which are swaybar's to draw and not the status line's. |
| 362 |
bar { |
| 363 |
position top |
| 364 |
font pango:Quasi Mono 10 |
| 365 |
status_command alloy status --bar |
| 366 |
colors { |
| 367 |
background @{surface.page} |
| 368 |
statusline @{content.primary} |
| 369 |
focused_workspace @{border.strong} @{border.strong} @{content.primary} |
| 370 |
inactive_workspace @{border.subtle} @{border.subtle} @{content.muted} |
| 371 |
} |
| 372 |
} |
| 373 |
|