Skip to content

sailorseashell/spaceswitcher

Repository files navigation

SpaceSwitcher

Per-monitor Space switching for macOS — with animation.

Switch Spaces on any display using independent keyboard shortcuts, regardless of where your mouse or focus is. Unlike tools that use the private SetCurrentSpace API (which teleports instantly), SpaceSwitcher triggers macOS's native slide animation by redirecting synthetic keyboard events through a focus proxy.

How It Works

  1. You press a custom shortcut (e.g. Ctrl+Option+← for your secondary display).
  2. SpaceSwitcher intercepts it via a global CGEventTap.
  3. The mouse cursor is warped to the target display and the event's location is updated to match.
  4. The event's modifiers are stripped down to plain Ctrl+Arrow.
  5. macOS processes the modified event → animated slide transition on the correct display.
  6. After a brief settle delay, the cursor is restored to its original position.

This cursor-warp approach gives you the native macOS animation while letting you target any display from any keyboard shortcut.

Features

  • Animated switching — uses macOS's own slide transition, not an instant teleport
  • Per-monitor shortcuts — assign different key combos to each display
  • Ignore monitors — set "enabled": false for displays with only one Space
  • Wrap-around — optionally wrap from the last Space back to the first (instant, since macOS doesn't natively wrap)
  • Focus restoration — optionally re-focus the previously active app after switching
  • Zero UI — runs as a background process with no Dock icon
  • JSON config — easy to edit, auto-created on first launch

Requirements

  • macOS 13 (Ventura) or later (tested through macOS 15)
  • Xcode Command Line Tools (for swift build)
  • Accessibility permission granted to the built binary

Quick Start

# 1. Build
cd SpaceSwitcher
swift build -c release

# 2. First run — creates default config and prompts for Accessibility access
.build/release/SpaceSwitcher

# 3. Grant Accessibility permission
#    System Settings → Privacy & Security → Accessibility
#    Add the SpaceSwitcher binary (or Terminal if running from there)

# 4. Edit the config to match your setup
nano ~/.config/spaceswitcher/config.json

# 5. Run
.build/release/SpaceSwitcher

Identifying Your Displays

.build/release/SpaceSwitcher --list-displays

Example output:

Connected displays:

  [0] Built-in Retina Display
      UUID: Main
      Spaces: 3 desktop + 0 fullscreen
      Current space ID: 5
      Desktop spaces: →5  6  7

  [1] LG UltraFine
      UUID: 37D8832A-2D66-02CA-B9F7-8F30A301B230
      Spaces: 2 desktop + 1 fullscreen
      Current space ID: 12
      Desktop spaces: →12  13

  [2] Dell U2723QE
      UUID: A1B2C3D4-...
      Spaces: 1 desktop + 0 fullscreen
      Current space ID: 18
      Desktop spaces: →18

Display 2 has only one Space — set "enabled": false for it in your config.

Configuration

Config lives at ~/.config/spaceswitcher/config.json. A default is created on first run, or generate one explicitly with --init-config.

Example Config

{
  "monitorBindings": [
    {
      "displayIndex": 0,
      "label": "Main Display",
      "enabled": true,
      "prevSpaceShortcut": { "key": "left",  "modifiers": ["control"] },
      "nextSpaceShortcut": { "key": "right", "modifiers": ["control"] }
    },
    {
      "displayIndex": 1,
      "label": "Ultrawide",
      "enabled": true,
      "prevSpaceShortcut": { "key": "left",  "modifiers": ["control", "option"] },
      "nextSpaceShortcut": { "key": "right", "modifiers": ["control", "option"] }
    },
    {
      "displayIndex": 2,
      "label": "Vertical Monitor",
      "enabled": false,
      "prevSpaceShortcut": { "key": "left",  "modifiers": ["control", "shift"] },
      "nextSpaceShortcut": { "key": "right", "modifiers": ["control", "shift"] }
    }
  ],
  "wrapAround": false,
  "restoreFocus": false
}

Config Fields

Field Description
displayIndex Monitor number from --list-displays (0-based)
label Human-readable name (for logging only)
enabled Set false to ignore this monitor entirely
prevSpaceShortcut Key combo to switch left (previous Space)
nextSpaceShortcut Key combo to switch right (next Space)
wrapAround If true, going past the last Space wraps to the first (instant, no animation)
restoreFocus If true, re-focus the previously active app after switching. If false, focus follows the switched display.

Available Keys

Arrows: left, right, up, down
Letters: az
Numbers: 09
Function keys: f1f12
Special: space, tab, escape, delete, return

Available Modifiers

control / ctrl, shift, option / alt, command / cmd

Important: macOS Keyboard Shortcut Settings

Since SpaceSwitcher works by posting synthetic Ctrl+Arrow events that macOS processes natively, the built-in "Move left/right a space" shortcuts must stay enabled in:

System Settings → Keyboard → Keyboard Shortcuts → Mission Control

If you've disabled them, re-enable them — they're what actually triggers the animation.

Your custom per-display shortcuts (e.g. Ctrl+Option+Arrow) can be anything you want. SpaceSwitcher intercepts those, then redirects a Ctrl+Arrow to the correct display.

Also recommended — uncheck this:

System Settings → Desktop & Dock → Mission Control → "Automatically rearrange Spaces based on most recent use"

Running at Login

launchd (recommended)

Create ~/Library/LaunchAgents/com.spaceswitcher.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.spaceswitcher</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/SpaceSwitcher</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/spaceswitcher.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/spaceswitcher.log</string>
</dict>
</plist>
sudo cp .build/release/SpaceSwitcher /usr/local/bin/
launchctl load ~/Library/LaunchAgents/com.spaceswitcher.plist

CLI Reference

SpaceSwitcher [OPTIONS]

  --help, -h          Show help
  --verbose, -v       Debug logging
  --list-displays     Show displays and spaces, then exit
  --init-config       Write default config and exit

Troubleshooting

"Could not create CGEventTap"
→ Grant Accessibility access in System Settings → Privacy & Security → Accessibility.

"CGSCopyManagedDisplaySpaces returned nil"
→ The private API symbol may have been renamed in your macOS version. File an issue with your macOS version number.

Spaces switch on wrong monitor
→ Run --list-displays to verify index-to-monitor mapping. Indices can change when monitors are connected/disconnected.

Animation feels delayed
→ There is a 0.7s cursor-restore delay in HotkeyManager.swift that lets the space-switch animation settle before warping the cursor back to its original position. This delay is not user-configurable currently.

Wrap-around has no animation
→ By design. macOS's native Ctrl+Arrow doesn't wrap, so wrap-around uses the direct private API (instant switch). Regular left/right switching always animates.

Architecture

main.swift           Entry point, CLI, config loading
Config.swift         JSON config parsing + shortcut key-code mapping
HotkeyManager.swift  CGEventTap — intercepts shortcuts, dispatches switches
SpaceManager.swift   Queries displays/spaces, posts synthetic events
HelperWindow.swift   1×1 invisible NSWindow (legacy, currently unused)
PrivateAPIs.swift    Dynamic loading of SkyLight/CGS private symbols
DisplayInfo.swift    Data structures for displays and spaces
Logger.swift         Timestamped console logging

License

MIT

About

a per-monitor macOS space switching keybinding program.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages