1818# ^[h run-help -> alt-h is 'focus left'
1919# ^[l OMZ ls widget -> alt-l is 'focus right'
2020# ^[1..^[9 digit-argument -> workspace switching
21+ #
22+ # alt-ctrl-shift-<letter> reaches the terminal as ^[^<letter> (Shift is dropped
23+ # for control characters), costing two more. Both keep an alternative:
24+ # ^[^H backward-kill-word -> alt-ctrl-shift-h throws a window one monitor left
25+ # (alt-backspace, ^[^?, still does it)
26+ # ^[^L clear-screen -> alt-ctrl-shift-l throws a window one monitor right
27+ # (ctrl-l still does it)
28+ # ^[F forward-word -> alt-shift-f is 'fullscreen' (^[f is the same widget)
29+ #
30+ # ON ANIMATIONS: there are none, and none are possible. AeroSpace repositions a
31+ # window with a single Accessibility API write — there is no tween and no config
32+ # option for one. (Upstream only discusses animation to explain why it replaced
33+ # native macOS Spaces, whose switching animation can't be disabled.) The focus
34+ # cue below is the substitute: JankyBorders draws a ring on the focused window,
35+ # and that ring pulses on every focus change.
2136
2237config-version = 2
2338
2439
2540# ============================================================
2641# Startup
2742# ============================================================
28- # Left off deliberately while evaluating: a tiling WM rearranges every window on
29- # the desktop, and "just don't launch it" is the cheapest escape hatch .
30- start-at-login = false
43+ # The escape hatch is `aerospace enable toggle`, which suspends window management
44+ # without quitting — so there's no reason to keep launching it by hand .
45+ start-at-login = true
3146
3247# Re-reads this file on save. Per upstream, reload once by hand after enabling
3348# for the auto-reloading to start.
3449auto-reload-config = true
3550
36- after-startup-command = []
51+ # JankyBorders — draws the focus ring (see the focus cues under Behaviour).
52+ # Run with no arguments so it reads ~/.config/borders/bordersrc, which is the
53+ # stowed `borders` package. Started from here rather than `brew services` so the
54+ # ring's lifetime is tied to the window manager's: quit AeroSpace, no orphan.
55+ after-startup-command = [
56+ ' exec-and-forget borders' ,
57+ # The status bar. Both run with no arguments so they read their own stowed
58+ # config (~/.config/borders/bordersrc, ~/.config/sketchybar/sketchybarrc).
59+ ' exec-and-forget sketchybar' ,
60+ ]
3761on-mode-changed = []
3862
3963
@@ -53,14 +77,55 @@ gaps.inner.horizontal = 8
5377gaps.inner.vertical = 8
5478gaps.outer.left = 8
5579gaps.outer.bottom = 8
56- gaps.outer.top = 8
80+ # Per-monitor, because the built-in display has a notch and the externals don't.
81+ #
82+ # SketchyBar's islands are 26pt tall centred in a 32pt transparent bar, so their
83+ # bottom edge is at y=29; 37 = 29 + the usual 8pt gap. The bar draws inside the
84+ # area AeroSpace tiles into, so without this windows slide underneath it.
85+ #
86+ # The built-in gets 5, not 37. AeroSpace tiles inside the monitor's VISIBLE rect,
87+ # and macOS reserves that display's top 32pt as notch safe area even with the
88+ # menu bar auto-hidden (measured: safeAreaInsets.top = 32, visibleFrame 1512x950
89+ # of a 1512x982 frame). So the 32 is already excluded and only the remaining 5
90+ # has to be asked for. Measured to confirm: with a flat 34 here, a window on the
91+ # built-in landed at y=66 (= 32 + 34) while one on an external landed at y=34 —
92+ # a 32pt dead strip on the laptop only. 5 + 32 = 37 puts both at the same place.
93+ #
94+ # Also paired with the macOS menu bar being auto-hidden (see the install script):
95+ # with the menu bar visible this would sit on top of its 30, and the bar would
96+ # land below it rather than at the top of the screen.
97+ gaps.outer.top = [{ monitor.'built-in' = 5 }, 37 ]
5798gaps.outer.right = 8
5899
59100
60101# ============================================================
61102# Behaviour
62103# ============================================================
104+ # Focus cue. AeroSpace can't animate a window move (see the note at the top), so
105+ # the JankyBorders ring flashes lavender for ~120ms and settles back to mauve.
106+ # Re-invoking `borders` reconfigures the running instance rather than starting a
107+ # second one, which is what makes the two-frame pulse work.
108+ # Colours are Catppuccin Frappé: lavender #babbf1, mauve #ca9ee6 (the same mauve
109+ # kitty uses for active_tab_background). They must stay in sync with bordersrc.
110+ # If a held-down alt-h/alt-l ever makes the pulse look like flicker, drop this
111+ # callback — the static ring survives on its own.
112+ #
113+ # Deliberately NOT 'move-mouse window-lazy-center' here: the pointer stays put
114+ # on window focus changes. The monitor callback below is a different thing — it
115+ # only fires when the focused *monitor* changes.
116+ on-focus-changed = [
117+ ' exec-and-forget /bin/sh -c "borders active_color=0xffbabbf1; sleep 0.12; borders active_color=0xffca9ee6"' ,
118+ ]
63119on-focused-monitor-changed = [' move-mouse monitor-lazy-center' ]
120+
121+ # Pushes workspace switches to SketchyBar, which has no way to learn about them
122+ # on its own. Inert while sketchybar isn't running — the command just fails into
123+ # the void — which is why it's safe to leave here while the bar is still being
124+ # trialled. $AEROSPACE_FOCUSED_WORKSPACE is set by AeroSpace for this callback.
125+ exec-on-workspace-change = [
126+ ' /bin/bash' , ' -c' ,
127+ ' sketchybar --trigger aerospace_workspace_change FOCUSED_WORKSPACE=$AEROSPACE_FOCUSED_WORKSPACE' ,
128+ ]
64129focus-follows-mouse.enabled = false
65130automatically-unhide-macos-hidden-apps = false
66131key-mapping.preset = ' qwerty'
@@ -69,22 +134,113 @@ key-mapping.preset = 'qwerty'
69134persistent-workspaces = [" 1" , " 2" , " 3" , " 4" , " 5" , " 6" , " 7" , " 8" , " 9" ]
70135
71136
137+ # ============================================================
138+ # Window rules
139+ # ============================================================
140+ # Runs every time a new window is detected. Two syntaxes exist; this is the
141+ # current one (`if = 'test ...'`). The `if.app-id = '...'` form seen in most
142+ # blog posts is the deprecated legacy spelling.
143+ #
144+ # `check-further-callbacks` defaults to false, so the FIRST matching rule wins
145+ # and everything below it is skipped. The rules here are mutually exclusive, so
146+ # order doesn't bite yet — it will the moment two conditions can both match.
147+ #
148+ # Bundle IDs were read off each app's Info.plist, not guessed. To add one:
149+ # `aerospace list-apps` while it's running.
150+ #
151+ # Deliberately NOT here: browsers, kitty, VS Code. This fires for *every* new
152+ # window, so a placement rule on those would yank a window you just opened onto
153+ # another monitor mid-task.
154+ on-window-detected = [
155+ # Float — utilities and preference panes; a tile only distorts the layout.
156+ { if = ' test %{app-bundle-id} = com.apple.systempreferences' , run = ' layout floating' },
157+ { if = ' test %{app-bundle-id} = com.apple.calculator' , run = ' layout floating' },
158+ { if = ' test %{app-bundle-id} = com.fasttracksoftware.adminbyrequest' , run = ' layout floating' },
159+ { if = ' test %{app-bundle-id} = app.monitorcontrol.MonitorControl' , run = ' layout floating' },
160+ { if = ' test %{app-bundle-id} = com.logi.optionsplus' , run = ' layout floating' },
161+ { if = ' test %{app-bundle-id} = com.crystalidea.macsfancontrol' , run = ' layout floating' },
162+ { if = ' test %{app-bundle-id} = com.focusrite.control' , run = ' layout floating' },
163+ { if = ' test %{app-bundle-id} = com.titanium.OnyX' , run = ' layout floating' },
164+ { if = ' test %{app-bundle-id} = com.jetbrains.toolbox' , run = ' layout floating' },
165+ # Raycast — the launcher panel itself is a non-activating window AeroSpace
166+ # already ignores, but its Settings window tiles. Left COMMENTED because the
167+ # bundle ID is unverified: Raycast is in the Brewfile but not installed here,
168+ # and the Homebrew cask carries no zap/quit stanza to read it from. Per the
169+ # rule above, read it rather than guess — install Raycast, open its Settings
170+ # window, run `aerospace list-apps`, paste what it reports, and uncomment.
171+ # A wrong ID does not error; the rule just never fires.
172+ # { if = 'test %{app-bundle-id} = com.raycast.macos', run = 'layout floating' },
173+
174+ # Place — chat/mail/notes open on their home screens (see the map below).
175+ { if = ' test %{app-bundle-id} = com.microsoft.teams2' , run = ' move-node-to-workspace 6' },
176+ { if = ' test %{app-bundle-id} = com.microsoft.Outlook' , run = ' move-node-to-workspace 7' },
177+ { if = ' test %{app-bundle-id} = md.obsidian' , run = ' move-node-to-workspace 8' },
178+ ]
179+
180+
181+ # ============================================================
182+ # Monitors
183+ # ============================================================
184+ # The desk, left to right — AeroSpace ordinals are ordered the same way, so its
185+ # numbering already matches the physical layout:
186+ # 1 = Built-in Retina Display (left, laptop)
187+ # 2 = P24h-2L (1) (middle, macOS main display — the work screen)
188+ # 3 = P24h-2L (2) (right, side monitor)
189+ #
190+ # Both externals report the same name ("P24h-2L"); macOS appends (1)/(2) and
191+ # those suffixes can swap on reconnect, so they are never matched by name.
192+ #
193+ # The low numbers go to the middle screen deliberately: easiest keys on the
194+ # busiest monitor, at the cost of alt-N no longer tracking desk position.
195+ #
196+ # LAPTOP MAIN (middle) SIDE
197+ # ┌───────┐ ┌─────────────────┐ ┌───────┐
198+ # │ 8 9 │ │ 1 2 3 4 5 │ │ 6 7 │
199+ # └───────┘ └─────────────────┘ └───────┘
200+ #
201+ # Every entry ends in a pattern that always resolves, so undocking or closing
202+ # the lid collapses workspaces onto a live screen instead of stranding them.
203+ #
204+ # NOTE: force-assignment makes `move-workspace-to-monitor` a no-op. Use
205+ # `move-node-to-monitor` (alt-ctrl-shift-h/l below) to shuffle windows instead.
206+ [workspace-to-monitor-force-assignment ]
207+ 1 = ' main' # middle — the work screen
208+ 2 = ' main'
209+ 3 = ' main'
210+ 4 = ' main'
211+ 5 = ' main'
212+ 6 = [' 3' , ' secondary' ] # right — ordinal with 3 screens, secondary with 2
213+ 7 = [' 3' , ' secondary' ]
214+ 8 = [' built-in' , ' main' ] # laptop — 'main' covers clamshell
215+ 9 = [' built-in' , ' main' ]
216+
217+
72218# ============================================================
73219# Main bindings
74220# ============================================================
75221[mode .main .binding ]
76222
77- # Focus
78- alt-h = ' focus left'
223+ # Launch a terminal, i3/sway style. Deliberately alt-SHIFT-enter and not the
224+ # idiomatic alt-enter: AeroSpace binds system-wide, ahead of kitty and zsh,
225+ # so alt-enter would permanently swallow ^[^M — which is self-insert-unmeta
226+ # in zsh's emacs keymap and "insert newline without submitting" in Claude
227+ # Code. Neither can be won back per-app once AeroSpace has the key.
228+ # `open -na` for a new instance each time; kitty has no single-instance or
229+ # remote-control setup here for a new window to attach to.
230+ alt-shift-enter = ' exec-and-forget open -na kitty'
231+
232+ # Focus — the three monitors sit side by side, so h/l treat them as one
233+ # continuous frame and walk across the edges. j/k stay workspace-local.
234+ alt-h = ' focus --boundaries all-monitors-outer-frame left'
79235 alt-j = ' focus down'
80236 alt-k = ' focus up'
81- alt-l = ' focus right'
237+ alt-l = ' focus --boundaries all-monitors-outer-frame right'
82238
83- # Move the focused window
84- alt-shift-h = ' move left'
239+ # Move the focused window — h/l carry it onto the neighbouring monitor
240+ alt-shift-h = ' move --boundaries all-monitors-outer-frame left'
85241 alt-shift-j = ' move down'
86242 alt-shift-k = ' move up'
87- alt-shift-l = ' move right'
243+ alt-shift-l = ' move --boundaries all-monitors-outer-frame right'
88244
89245 # Resize
90246 alt-minus = ' resize smart -50'
@@ -93,6 +249,8 @@ persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
93249 # Layout
94250 alt-slash = ' layout tiles horizontal vertical'
95251 alt-comma = ' layout accordion horizontal vertical'
252+ # ^[F is bound to forward-word, but so is ^[f — this costs a duplicate.
253+ alt-shift-f = ' fullscreen'
96254
97255 # Workspaces
98256 alt-1 = ' workspace 1'
@@ -117,7 +275,14 @@ persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
117275 alt-shift-9 = ' move-node-to-workspace 9'
118276
119277 alt-tab = ' workspace-back-and-forth'
120- alt-shift-tab = ' move-workspace-to-monitor --wrap-around next'
278+ # Window-level counterpart to alt-tab. ^[` is unbound in emacs mode.
279+ alt-backtick = ' focus-back-and-forth'
280+ # Was move-workspace-to-monitor, which force-assignment turns into a no-op.
281+ alt-shift-tab = ' focus-monitor --wrap-around next'
282+
283+ # Throw the focused window one screen over and follow it there
284+ alt-ctrl-shift-h = ' move-node-to-monitor --focus-follows-window left'
285+ alt-ctrl-shift-l = ' move-node-to-monitor --focus-follows-window right'
121286
122287 alt-shift-semicolon = ' mode service'
123288
@@ -130,6 +295,8 @@ persistent-workspaces = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
130295 esc = [' reload-config' , ' mode main' ]
131296 r = [' flatten-workspace-tree' , ' mode main' ] # reset layout
132297 f = [' layout floating tiling' , ' mode main' ] # toggle float/tile
298+ b = [' balance-sizes' , ' mode main' ] # equalise all windows
299+ s = [' swap --swap-focus dfs-next' , ' mode main' ] # swap with next window
133300 backspace = [' close-all-windows-but-current' , ' mode main' ]
134301
135302 alt-shift-h = [' join-with left' , ' mode main' ]
0 commit comments