Multi-monitor
Shoji tiles windows within macOS Spaces. Each macOS Space belongs to one screen at a time.
How Shoji handles monitors
Each macOS Space has its own layout, window order, and layout parameters. Moving a window between screens moves it to a macOS Space on the target screen.
Key points:
- Layout settings persist per macOS Space, not per screen
- Multiple macOS Spaces on one screen can have different layouts
- Moving windows between screens retiles both screens
Screen-aware navigation
Focus a window on an adjacent screen using Hammerspoon’s screen APIs:
-- Focus screen in directionlocal function focusScreen(direction) local screen = hs.screen.mainScreen() local target if direction == "west" then target = screen:toWest() elseif direction == "east" then target = screen:toEast() elseif direction == "north" then target = screen:toNorth() elseif direction == "south" then target = screen:toSouth() end
if target then local frame = target:frame() -- Move mouse to center of target screen hs.mouse.absolutePosition({ x = frame.x + frame.w / 2, y = frame.y + frame.h / 2, }) -- Focus a window on that screen local windows = hs.window.filter.new():setScreens({ target }):getWindows() if windows[1] then windows[1]:focus() end endend
hs.hotkey.bind({ "ctrl", "alt" }, "left", function() focusScreen("west")end)hs.hotkey.bind({ "ctrl", "alt" }, "right", function() focusScreen("east")end)Move window to screen
Move a window to another screen. The short delay gives macOS time to register the window on its new macOS Space:
local function moveWindowToScreen(direction) local win = hs.window.focusedWindow() if not win then return end
local screen = win:screen() local target if direction == "west" then target = screen:toWest() elseif direction == "east" then target = screen:toEast() elseif direction == "north" then target = screen:toNorth() elseif direction == "south" then target = screen:toSouth() end
if target then win:moveToScreen(target) -- Retile both screens hs.timer.doAfter(0.1, function() spoon.Shoji:retile() end) endend
hs.hotkey.bind({ "ctrl", "alt", "shift" }, "left", function() moveWindowToScreen("west")end)hs.hotkey.bind({ "ctrl", "alt", "shift" }, "right", function() moveWindowToScreen("east")end)Screen change hook
Fires when monitors connect, disconnect, or rearrange:
spoon.Shoji:configure({ hooks = { screen_changed = function() print("Screen configuration changed") -- Retile all macOS Spaces spoon.Shoji:retile() end, },})Layout based on screen aspect ratio
Switch layouts based on aspect ratio. This example uses the wide layout on ultrawide monitors:
-- Helper to get screen for a macOS Spacelocal function screenForSpace(spaceID) for _, screen in ipairs(hs.screen.allScreens()) do local spaces = hs.spaces.spacesForScreen(screen) if spaces then for _, sid in ipairs(spaces) do if sid == spaceID then return screen end end end end return nilend
spoon.Shoji:configure({ hooks = { after_tile = function(spaceID) local screen = screenForSpace(spaceID) if not screen then return end
local frame = screen:frame() local aspectRatio = frame.w / frame.h
-- Use wide layout on ultrawide monitors (21:9 = 2.33) if aspectRatio > 2.0 then spoon.Shoji.tiling.setLayout("wide") end end, },})Dedicated screens for apps
Move certain apps to specific monitors automatically. Find screen names in System Settings > Displays (hold Option and click “Detect Displays” to see model names):
-- Move Safari to external monitor on launchspoon.Shoji:configure({ hooks = { window_created = function(windowID) local win = hs.window.get(windowID) if not win then return end
local app = win:application() if app and app:bundleID() == "com.apple.Safari" then local external = hs.screen.find("DELL") if external then win:moveToScreen(external) end end end, },})Mirror layouts across screens
Keep the same layout on all screens. When one screen changes layout, apply it to all other visible macOS Spaces:
spoon.Shoji:configure({ hooks = { layout_changed = function(changedSpaceID, layout, _prevLayout) -- Apply the same layout to all other visible macOS Spaces for _, screen in ipairs(hs.screen.allScreens()) do local spaces = hs.spaces.spacesForScreen(screen) if spaces then for _, spaceID in ipairs(spaces) do -- Skip the macOS Space that triggered the change if spaceID ~= changedSpaceID then local spaceType = hs.spaces.spaceType(spaceID) -- Only set layout on user spaces (not fullscreen app spaces) if spaceType == "user" then spoon.Shoji.state:setSpaceLayout(spaceID, layout) end end end end end -- Retile all macOS Spaces with the new layout spoon.Shoji:retileAll() end, },})