Skip to content

Instantly share code, notes, and snippets.

@kkbae-com
Last active September 9, 2026 18:38
Show Gist options
  • Select an option

  • Save kkbae-com/a03b7ec35d355871386e43be8ceb42a1 to your computer and use it in GitHub Desktop.

Select an option

Save kkbae-com/a03b7ec35d355871386e43be8ceb42a1 to your computer and use it in GitHub Desktop.
AeroSpace: floating-center toggle + Hyprland-style 2x2 window grid [tags: aerospace, macos, tiling-window-manager, hyprland, dwindle]

AeroSpace: floating-center toggle + Hyprland-style 2x2 window grid

Tags: aerospace, macos, tiling-window-manager, hyprland, dwindle, window-manager, applescript, jxa, dotfiles

Four small scripts that make AeroSpace (a tiling window manager for macOS) behave a bit more like Hyprland's dwindle layout:

  • A solo window in a workspace can be toggled to a floating, centered window (at 70% of its screen) and back, instead of always filling the whole space.
  • As more windows are added to a workspace, they build a 2x2 grid (window 3 stacks under window 2, window 4 stacks under window 1) instead of AeroSpace's default behavior of just adding new side-by-side columns forever.
  • A 5th+ window overflows to the closest workspace on the same monitor that has room, instead of cramming a 5th column into an already-full workspace.
  • Manually sending a window to another workspace (move-node-to-workspace) re-applies the same 2x2 grid placement in the destination workspace.

Requirements

  • macOS
  • AeroSpace installed and running
  • Bash (macOS ships with one new enough for this)
  • On first run, macOS will prompt for Accessibility permission (System Settings > Privacy & Security > Accessibility) so osascript/System Events can read and set window position/size. Grant it to your terminal/AeroSpace.

Files

  • center-float.sh — toggles the sole window in the focused workspace between tiling and floating+centered at 70% of its current screen. No-op if the workspace has more than one window.
  • restore-tiling.sh [workspace] — whenever a workspace gains a 2nd window, un-floats any window left floating by center-float.sh so it rejoins the tiling layout instead of overlapping the new window. Defaults to the focused workspace if none is given.
  • grid-layout.sh [workspace] [window-id] — runs on every new window (and from move-and-retile.sh, see below). Calls restore-tiling.sh first, then applies the 2x2 grid rule: the 3rd window in a workspace joins under window 2 (right column becomes a vertical stack), the 4th joins under window 1 (left column stacks too). A 5th+ window is moved to the closest workspace on the same monitor with fewer than 4 windows (focus follows), or left as a plain new column if every workspace on that monitor is full. Defaults to the focused workspace/window when called with no arguments (the on-window-detected case); accepts an explicit workspace + window-id so it can also retile a workspace that isn't currently focused (see move-and-retile.sh).
  • move-and-retile.sh [--follow] <target-workspace> — AeroSpace has no callback for "window moved to another workspace" (only on-window-detected, for newly created windows), so a manual move-node-to-workspace never triggered grid-layout.sh in the destination workspace. This script captures the focused window's id, moves it to <target-workspace> via move-node-to-workspace --window-id, then calls grid-layout.sh against wherever that window actually landed — explicitly by window-id, since focus doesn't necessarily follow a plain move. Pass --follow to also switch the current view to the target workspace after the move (for bindings like "send window to next workspace and follow it there").

Install

# Copy the scripts next to your aerospace config
cp center-float.sh restore-tiling.sh grid-layout.sh move-and-retile.sh ~/.config/aerospace/
chmod +x ~/.config/aerospace/{center-float,restore-tiling,grid-layout,move-and-retile}.sh

Then add to your ~/.aerospace.toml:

# Runs the grid-placement logic (and, transitively, restore-tiling.sh) on
# every new window. `if = 'true'` is required by AeroSpace to explicitly
# opt in to matching every window unconditionally.
on-window-detected = [
    { if = 'true', check-further-callbacks = true, run = 'exec-and-forget $HOME/.config/aerospace/grid-layout.sh' },
]

[mode.main.binding]
    # ...your other bindings...
    alt-shift-c = 'exec-and-forget $HOME/.config/aerospace/center-float.sh'

    # Replace plain move-node-to-workspace bindings with move-and-retile.sh
    # so the destination workspace gets re-tiled into the grid too.
    alt-shift-1 = 'exec-and-forget $HOME/.config/aerospace/move-and-retile.sh 1'
    # ...repeat for 2-9...
    alt-shift-left = 'exec-and-forget $HOME/.config/aerospace/move-and-retile.sh --follow prev'
    alt-shift-right = 'exec-and-forget $HOME/.config/aerospace/move-and-retile.sh --follow next'

Reload with aerospace reload-config (or esc in your service binding mode, if you have esc = ['reload-config', 'mode main'] set up like the AeroSpace default config does).

Notes / limitations

  • AeroSpace has no "window closed" event, only "window detected" (created). So none of this reacts to closing windows — e.g. closing window 3 back down to 2 windows won't automatically re-flow the layout. That's a real constraint of AeroSpace's callback model, not a bug here.
  • The overflow rule (5th+ window) assumes your workspaces are grouped by monitor via workspace-to-monitor-force-assignment in .aerospace.toml, and searches only that monitor's group for room, in ascending workspace order.
  • center-float.sh's 70% size is a plain constant (relSize near the top of the script) — change it to whatever ratio you want.
#!/usr/bin/env bash
# Only acts when the focused workspace has exactly one window. Toggles that
# window: tiling -> floats + centers it to 33% of its screen; floating ->
# restores it to tiling. No-op with more than one window in the workspace.
# Bound to alt-shift-c in .aerospace.toml.
set -euo pipefail
window_count=$(aerospace list-windows --workspace focused --count)
if [[ "$window_count" -ne 1 ]]; then
exit 0
fi
layout=$(aerospace list-windows --workspace focused --format '%{window-layout}')
if [[ "$layout" == "floating" ]]; then
aerospace layout tiling
exit 0
fi
aerospace layout floating
osascript -l JavaScript <<'EOF'
ObjC.import('AppKit')
function run() {
const relSize = 0.70
const se = Application('System Events')
const frontProcess = se.applicationProcesses.where({ frontmost: true })[0]
const win = frontProcess.windows[0]
const [winX, winY] = win.position()
const [winW, winH] = win.size()
const centerX = winX + winW / 2
const centerY = winY + winH / 2
const screens = $.NSScreen.screens
const screenCount = screens.count
const primaryHeight = screens.objectAtIndex(0).frame.size.height
let target = null
for (let i = 0; i < screenCount; i++) {
const f = screens.objectAtIndex(i).frame
const left = f.origin.x
const top = primaryHeight - (f.origin.y + f.size.height)
const right = left + f.size.width
const bottom = top + f.size.height
if (centerX >= left && centerX < right && centerY >= top && centerY < bottom) {
target = { left: left, top: top, width: f.size.width, height: f.size.height }
break
}
}
if (!target) {
const f = screens.objectAtIndex(0).frame
target = { left: f.origin.x, top: 0, width: f.size.width, height: primaryHeight }
}
const newW = target.width * relSize
const newH = target.height * relSize
const newX = target.left + (target.width - newW) / 2
const newY = target.top + (target.height - newH) / 2
win.position = [newX, newY]
win.size = [newW, newH]
}
EOF
#!/usr/bin/env bash
# Builds a Hyprland-like 2x2 grid as windows are added to a workspace:
# 1 window: fills the workspace
# 2 windows: side by side (default AeroSpace behavior, no action needed)
# 3rd: joins under window 2 (right column becomes a vertical stack)
# 4th: joins under window 1 (left column becomes a vertical stack)
# A 5th+ overflow window moves to the closest workspace on the same monitor
# that has fewer than 4 windows, focus follows it there, and the same
# 3rd/4th join logic is re-applied in that workspace. If every workspace on
# the monitor is already full, the window is left as a plain new column.
#
# Usage: grid-layout.sh [workspace] [window-id]
# workspace defaults to "focused" (the on-window-detected case, where the
# new window's workspace is whatever's currently focused)
# window-id when given, join-with/move/move-node-to-workspace target this
# window explicitly instead of relying on it being focused
# (needed when called after a manual move-node-to-workspace,
# since focus doesn't necessarily follow the moved window)
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
route_new_window() {
local workspace="${1:-focused}"
local window_id="${2:-}"
if [[ "$workspace" == "focused" ]]; then
workspace=$(aerospace list-workspaces --focused --format '%{workspace}')
fi
"$script_dir/restore-tiling.sh" "$workspace"
local count
count=$(aerospace list-windows --workspace "$workspace" --count)
local id_flag=()
if [[ -n "$window_id" ]]; then
id_flag=(--window-id "$window_id")
fi
if (( count == 3 )); then
aerospace join-with "${id_flag[@]}" left
elif (( count == 4 )); then
aerospace move "${id_flag[@]}" left
aerospace join-with "${id_flag[@]}" left
elif (( count >= 5 )); then
overflow_to_sibling_workspace "$workspace" "$window_id"
fi
}
overflow_to_sibling_workspace() {
local current_workspace="$1"
local window_id="${2:-}"
local monitor_id target ws ws_count
monitor_id=$(aerospace list-workspaces --all --format '%{workspace}%{tab}%{monitor-id}' \
| awk -F'\t' -v ws="$current_workspace" '$1 == ws { print $2; exit }')
target=""
while IFS= read -r ws; do
[[ "$ws" == "$current_workspace" ]] && continue
ws_count=$(aerospace list-windows --workspace "$ws" --count)
if (( ws_count < 4 )); then
target="$ws"
break
fi
done < <(aerospace list-workspaces --monitor "$monitor_id" --format '%{workspace}')
if [[ -z "$target" ]]; then
return 0
fi
if [[ -n "$window_id" ]]; then
aerospace move-node-to-workspace --window-id "$window_id" --focus-follows-window "$target"
else
aerospace move-node-to-workspace --focus-follows-window "$target"
fi
route_new_window "$target" "$window_id"
}
route_new_window "${1:-focused}" "${2:-}"
#!/usr/bin/env bash
# AeroSpace has no callback for "window moved to another workspace" (only
# on-window-detected, which fires for newly created windows). So manually
# sending a window to another workspace via move-node-to-workspace never
# triggered grid-layout.sh there. This script closes that gap: it captures
# the focused window's id, moves it, then calls grid-layout.sh against the
# workspace it actually landed in, targeting that window-id explicitly
# (since focus doesn't necessarily follow it).
#
# Usage: move-and-retile.sh [--follow] <target-workspace>
# --follow also switch the current view to the target workspace after
# the move (matches the old 'move-node-to-workspace X',
# 'workspace X' binding pattern for alt-shift-left/right)
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
follow=false
if [[ "${1:-}" == "--follow" ]]; then
follow=true
shift
fi
target="$1"
window_id=$(aerospace list-windows --focused --format '%{window-id}')
if $follow; then
aerospace move-node-to-workspace --window-id "$window_id" --focus-follows-window "$target"
else
aerospace move-node-to-workspace --window-id "$window_id" "$target"
fi
landed_workspace=$(aerospace list-windows --all --format '%{window-id}%{tab}%{workspace}' \
| awk -F'\t' -v id="$window_id" '$1 == id { print $2; exit }')
"$script_dir/grid-layout.sh" "$landed_workspace" "$window_id"
#!/usr/bin/env bash
# Called by grid-layout.sh (for the workspace it's currently routing a
# window into). If that workspace now has more than one window, any
# floating window(s) left over from center-float.sh get put back into the
# tiling layout so the new window can split the space normally. No-op when
# the workspace still has only one window.
#
# Usage: restore-tiling.sh [workspace] (defaults to the focused workspace)
set -euo pipefail
workspace="${1:-focused}"
window_count=$(aerospace list-windows --workspace "$workspace" --count)
if [[ "$window_count" -le 1 ]]; then
exit 0
fi
while IFS=$'\t' read -r id layout; do
if [[ "$layout" == "floating" ]]; then
aerospace layout tiling --window-id "$id"
fi
done < <(aerospace list-windows --workspace "$workspace" --format '%{window-id}%{tab}%{window-layout}')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment