Skip to content

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 direction
local 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
end
end
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)
end
end
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 Space
local 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 nil
end
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 launch
spoon.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,
},
})