Practical Applications8 min readshipped

Fixing macOS Window Chaos: How Hammerspoon and Karabiner Solved My Docking Nightmare

Fixing macOS Window Chaos: How Hammerspoon and Karabiner Solved My Docking Nightmare

TLDR: Just Want It Working?

Copy this prompt into Claude Code, Codex, or any AI coding assistant and let it build the whole thing for you:

Install Hammerspoon (brew install --cask hammerspoon) and create a window
layout manager in (local path) with two files: init.lua and
window_layout.lua.

Requirements:
- Fingerprint the current display configuration by hashing monitor names,
  resolutions, and positions (use hs.screen.allScreens)
- Save all window positions (app name, bundle ID, title, frame, screen name,
  fullscreen/minimized state) to JSON files in (local path)
  keyed by the display fingerprint hash
- Prompt the user to name each new display profile (e.g. "Home Office",
  "Work", "Laptop Only")
- Auto-restore: use hs.screen.watcher to detect display changes (dock/undock),
  debounce for 1.5s, then restore the matching saved layout
- Window matching: match saved entries to current windows by bundle ID first,
  then app name, then window title. Use a scoring system for best match.
- Move windows to the correct screen before setting frame position
- Add require("hs.ipc") for CLI access
- Bind hotkeys using Hyper (cmd+alt+ctrl+shift): G=save, B=restore, V=show
  profile, N=list profiles, Q=delete profile
  (Pick different keys if these conflict with your existing Karabiner setup)

After creating the files, open Hammerspoon, remind me to grant Accessibility
permissions, and test with `hs -c 'print("ok")'`.

That's it. One prompt, one session, done. Read on if you want to understand why this works when Rectangle, Moom, and BetterSnapTool don't.

Every developer with a multi-monitor setup knows this pain. You undock your MacBook, walk to a meeting, come back, plug in, and every single window is piled on top of each other on one screen. Your carefully arranged terminal, browser, Outlook, Slack, VS Code, Obsidian, all of it, smashed into one corner like a digital car wreck.

I've been fighting this for years. Literally years.

The Problem

My home office runs four displays: a Dell S3225QS ultrawide as the main screen, two Dell S2721QS flanking it, and the built-in MacBook display below. Dozens of windows, each sized and positioned exactly where I want them. Terminal on the ultrawide. Chrome and Teams on the right monitor. VS Code split across the left. Obsidian and messaging apps on the laptop screen.

Then I unplug to go to the office. Or switch to my secondary home office. Or just close the lid for five minutes. When I plug back in, macOS helpfully redistributes every window into a random pile on whatever display it feels like. The ultrawide gets 15 stacked windows. The side monitors sit empty. Nothing is where it was.

This isn't a minor annoyance. It's a 5-10 minute tax every time I dock. Multiple times a day. Across three different locations with three different monitor setups.

Everything I Tried (and Why It Failed)

I've thrown money and time at this problem:

  • Rectangle / Rectangle Pro: Great for snapping windows to zones, but doesn't remember positions across display changes. You're still manually rearranging after every dock.
  • Moom: Has layout saving, but the restore was unreliable with multiple displays. Windows would end up on the wrong screen or at wrong sizes.
  • BetterSnapTool: Same story. Snapping is fine, memory across display configs is not.
  • macOS Spaces: Made things worse. Spaces rearrange on their own, and windows jump between spaces unpredictably.
  • Stage Manager: I wanted to like this. I really did. It just added another layer of chaos on top of the existing chaos.

The core issue is that none of these tools fingerprint your actual display configuration. They save window positions in absolute coordinates, but when your monitor arrangement changes (different desk, different monitors, or just macOS deciding to reorder things), the positions don't map correctly.

The Solution: Hammerspoon + Karabiner

The fix turned out to be about 200 lines of Lua, a free tool called Hammerspoon, and my existing Karabiner-Elements setup for hotkeys. I built this in a single Claude Code session, from concept to working solution in about 20 minutes.

Here's what it does:

  1. Fingerprints your display setup. It reads every connected monitor's name, resolution, and position, then hashes that into a unique identifier. "Home Office" (4 displays) gets a different fingerprint than "AZ Work" (2 displays) or "Laptop Only" (1 display).

  2. Saves every window's state. App name, window title, frame coordinates, which screen it's on, whether it's fullscreen or minimized. The whole thing, serialized to JSON.

  3. Auto-restores on display change. Hammerspoon watches for display configuration changes. When you dock, it detects the new fingerprint, looks up the matching saved layout, and moves every window back to its saved position. No manual intervention.

  4. Supports multiple locations. Each display fingerprint maps to a named profile. You save once per location, and it just works from then on.

The Display Fingerprint

This is the key insight that makes it work. Instead of saving absolute pixel coordinates and hoping for the best, the system creates a unique hash from your actual display setup:

local function getDisplayFingerprint()
    local screens = hs.screen.allScreens()
    local parts = {}
    for _, s in ipairs(screens) do
        local f = s:frame()
        local name = s:name() or "Unknown"
        table.insert(parts, string.format(
            "%s:%dx%d@%d,%d", name, f.w, f.h, f.x, f.y
        ))
    end
    table.sort(parts)
    return table.concat(parts, "|")
end

This produces something like Built-in Retina Display:1800x1129@685,1930|DELL S2721QS (1):2952x1661@-2952,229|DELL S2721QS (2):3008x1661@3360,229|DELL S3225QS:3360x1859@0,31. That string gets SHA256 hashed into a short key for the filename. Different monitor arrangement at work? Different hash. Different profile. Different saved layout.

Window Matching

When restoring, the system needs to match saved window entries to currently running windows. It does this by bundle ID first (most reliable), then app name, then window title. This handles the case where you had 3 Chrome windows before and now only have 2, or where a window title changed because you switched tabs.

local function findMatchingWindow(entry, usedWindows)
    local wins = hs.window.allWindows()
    local candidates = {}
    for _, w in ipairs(wins) do
        if w:isStandard() and not usedWindows[w:id()] then
            local app = w:application()
            local bundle = app and app:bundleID() or ""
            if bundle == entry.appBundle or
               (app and app:name() == entry.appName) then
                local score = 0
                if w:title() == entry.title then score = score + 10 end
                if bundle == entry.appBundle then score = score + 5 end
                table.insert(candidates, { window = w, score = score })
            end
        end
    end
    table.sort(candidates, function(a, b) return a.score > b.score end)
    return candidates[1] and candidates[1].window or nil
end

The Hotkeys

I already use Karabiner-Elements to turn Caps Lock into a "Hyper" key (Cmd+Alt+Ctrl+Shift when held, Escape when tapped). I had about a dozen AI hotkeys mapped (Hyper+S for summarize, Hyper+R for research, etc.), so I needed to pick non-conflicting keys for window management.

Key Conflict Warning

Karabiner processes keystrokes at the kernel level, before Hammerspoon sees them. If you have Karabiner mappings on the same keys, Hammerspoon will never fire. Check your existing Karabiner rules before choosing hotkeys.

The final mapping:

HotkeyMnemonicAction
Hyper+GGrabSave current window layout
Hyper+BBring backRestore saved layout
Hyper+VViewShow active profile info
Hyper+NNamesList all saved profiles
Hyper+QQuitDelete current profile

Auto-Restore on Dock/Undock

The real magic is the display watcher. Hammerspoon fires a callback whenever the display configuration changes (which happens when you dock or undock). The system debounces this (displays fire multiple events), waits for macOS to settle, then restores:

function M.watchDisplays()
    displayWatcher = hs.screen.watcher.new(function()
        if restoreTimer then restoreTimer:stop() end
        restoreTimer = hs.timer.doAfter(1.5, function()
            local hash = fingerprintHash(getDisplayFingerprint())
            local name = getProfileName(hash)
            local data = loadLayoutFromFile(hash)
            if data and name then
                hs.alert.show("Restoring '" .. name .. "'...", 2)
                hs.timer.doAfter(1.0, function() M.restore() end)
            else
                hs.alert.show("New display config. Press Hyper+G to save.", 5)
            end
        end)
    end)
    displayWatcher:start()
end

The 1.5-second debounce plus 1-second settle delay is important. Without it, you get partial restores as macOS is still shuffling displays around.

Setup Guide

1. Install Hammerspoon

brew install --cask hammerspoon

Open it, grant Accessibility permissions (System Settings > Privacy & Security > Accessibility), and enable "Launch at login" in preferences.

2. Install Karabiner-Elements (if you don't have it)

brew install --cask karabiner-elements

Add the Caps Lock to Hyper rule. You can import it from the Karabiner complex modifications gallery or add it manually. The rule maps Caps Lock to Cmd+Alt+Ctrl+Shift when held, and Escape when tapped alone.

3. Create the Config

Drop two files in `(local path)

  • init.lua (keybindings and startup, ~40 lines)
  • window_layout.lua (all the logic, ~200 lines)

The full source is in my dotfiles. The init.lua is straightforward:

require("hs.ipc")  -- Enable CLI access
local layoutManager = require("window_layout")

local hyper = {"cmd", "alt", "ctrl", "shift"}

hs.hotkey.bind(hyper, "g", function() layoutManager.save() end)
hs.hotkey.bind(hyper, "b", function() layoutManager.restore() end)
hs.hotkey.bind(hyper, "v", function() layoutManager.showProfile() end)
hs.hotkey.bind(hyper, "n", function() layoutManager.listProfiles() end)
hs.hotkey.bind(hyper, "q", function() layoutManager.deleteProfile() end)

layoutManager.watchDisplays()
hs.alert.show("Window Layout Manager loaded", 2)

4. Save Your First Profile

Arrange your windows exactly how you want them. Press Hyper+G. Name it ("Home Office", "Work", whatever). Done. The layout is saved as JSON in `(local path)

5. Repeat at Each Location

Next time you're at a different desk with different monitors, arrange your windows and press Hyper+G again. Each monitor configuration gets its own profile automatically.

What the Saved Data Looks Like

Each profile is a JSON file keyed by the display fingerprint hash:

{
  "fingerprint": "Built-in Retina Display:1800x1129@685,1930|DELL S2721QS...",
  "timestamp": "2026-02-20 07:02:25",
  "displayCount": 4,
  "windows": [
    {
      "appName": "Warp",
      "appBundle": "dev.warp.Warp-Stable",
      "title": "Mac-CCM-7",
      "frame": { "x": 0, "y": 56, "w": 3360, "h": 1834 },
      "screenName": "DELL S3225QS",
      "isFullScreen": false,
      "isMinimized": false
    }
  ]
}

Human-readable, version-controllable, and easy to debug if something goes wrong. You can even hand-edit positions if you want to tweak things without rearranging physically.

The Result

My first save captured 12 windows across 4 displays. I unplugged, plugged back in, and every window slid back to its exact position. Terminal on the ultrawide. Chrome on the right. VS Code on the left. Obsidian on the laptop screen.

After years of fighting this problem, the fix was 200 lines of Lua and a free app.

Tools Used

Why This Worked When Others Didn't

Three things make this approach different:

  1. Display fingerprinting, not absolute coordinates. The system knows which monitor setup you're using and restores the layout designed for that exact setup.

  2. Full state capture. It saves which screen each window is on, not just its pixel coordinates. When restoring, it moves windows to the correct screen first, then positions them.

  3. Event-driven restore. It doesn't poll or require manual triggering (though you can). It hooks into macOS display change events and restores automatically with appropriate debouncing.

The commercial tools I tried all had some variation of "save layout, restore layout" but without the display-aware fingerprinting. They'd try to restore a 4-monitor layout onto a single laptop screen, or put windows on the wrong monitor because the display IDs changed.

What I'd Add Next

  • Per-app launch. If a saved app isn't running, optionally launch it before positioning.
  • Partial restore. Only restore windows for specific apps (useful if you've intentionally rearranged some things).
  • Profile switching hotkey. Cycle through profiles for the same display config (e.g., "coding" vs "meeting" layouts on the same monitors).

But honestly, the basic version solves 95% of the problem. I've been running it for a day and already wonder how I lived without it.


Related Articles

  • Auto-Updating CLI Tools for Claude Code
  • When LaunchAgents Attack: A $100 API Crash Loop Story
  • Syncing Claude Code Configs Across Machines

About the Author: Justin Johnson builds AI systems and writes about practical AI development.

justinhjohnson.com | Twitter | LinkedIn | Run Data Run | Subscribe

Follow the lab

Get the next experiment

Enjoyed the breakdown on Fixing macOS Window Chaos: How Hammerspoon and Karabiner Solved My Docking Nightmare? New entries land roughly weekly. No digest, no roundup. Just the next build log, when it ships.