# Fixing macOS Window Chaos: How Hammerspoon and Karabiner Solved My Docking Nightmare > [!tip] 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 ~/.hammerspoon/ 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 ~/.hammerspoon/layouts/, > 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](https://www.hammerspoon.org/), and my existing [Karabiner-Elements](https://karabiner-elements.pqrs.org/) 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: ```lua 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. ```lua 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](https://karabiner-elements.pqrs.org/) 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. > [!tip] 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: | Hotkey | Mnemonic | Action | |--------|----------|--------| | `Hyper+G` | **G**rab | Save current window layout | | `Hyper+B` | **B**ring back | Restore saved layout | | `Hyper+V` | **V**iew | Show active profile info | | `Hyper+N` | **N**ames | List all saved profiles | | `Hyper+Q` | **Q**uit | Delete 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: ```lua 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 ```bash 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) ```bash brew install --cask karabiner-elements ``` Add the Caps Lock to Hyper rule. You can import it from the [Karabiner complex modifications gallery](https://ke-complex-modifications.pqrs.org/) 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 `~/.hammerspoon/`: - `init.lua` (keybindings and startup, ~40 lines) - `window_layout.lua` (all the logic, ~200 lines) The full source is in my [dotfiles](https://github.com/bioinfo). The init.lua is straightforward: ```lua 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 `~/.hammerspoon/layouts/`. ### 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: ```json { "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. > [!info] Tools Used > - [Hammerspoon](https://www.hammerspoon.org/) (free, open source): Lua-based macOS automation > - [Karabiner-Elements](https://karabiner-elements.pqrs.org/) (free, open source): Keyboard customization > - [Claude Code](https://claude.ai/claude-code): Built the entire config in one session ## 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 - [[Practical Applications/auto-updating-claude-code-cli-tools|Auto-Updating CLI Tools for Claude Code]] - [[Practical Applications/when-launchagents-attack-100-dollar-api-crash-loop|When LaunchAgents Attack: A $100 API Crash Loop Story]] - [[Practical Applications/syncing-claude-code-configs-across-machines|Syncing Claude Code Configs Across Machines]] --- <p style="text-align: center;"><strong>About the Author</strong>: Justin Johnson builds AI systems and writes about practical AI development.</p> <p style="text-align: center;"><a href="https://justinhjohnson.com">justinhjohnson.com</a> | <a href="https://twitter.com/bioinfo">Twitter</a> | <a href="https://www.linkedin.com/in/justinhaywardjohnson/">LinkedIn</a> | <a href="https://rundatarun.io">Run Data Run</a> | <a href="https://subscribe.rundatarun.io">Subscribe</a></p>