Once Again, to Hammerspoon

Why oh why doesn’t macOS have a more powerful window manager…

Once upon a time, I moved from primarily Windows to primarily (at the time) OSX. I missed Aerospace–the ability to use Win+Left/Right to snap windows to half the screen–so I wrote a fix: Duplicating AeroSnap on OSX with Hammerspoon.

Since then, I eventually discovered and moved to Magnet and all was well.

But more recently, I’ve been wanting two things:

  • a bit more control (once again), to define more arbitrary sizes and keystrokes
  • the ability to automatically arrange windows to various Mission Control Spaces

And what a journey it’s been…

What I tried

Not really. Mostly, I sent out a call for what other options there were and tried them out:

  • Spectacle - discontinued
  • Divvy - works pretty well, especially for automatically tiling and grid layout, although having to specify the grid with the mouse is suboptimal
  • yabai - requires disabling SIP, is CLI 👍 but needs another tool for keyboard shortcuts 👎
  • i3 / awesome - automatic tiling; I don’t think I’m quite ready for this yet, perhaps another day?
  • Rectangle - follow up to Spectacle, really is pretty much the same thing as Magnet
  • Rectangle Pro - it’s… a separate app? but with “Arrange an entire workspace of apps with just one shortcut.” this might do exactly what I want

But finally (for the time being), I came back to Hammerspoon.

What can I say, I’m a tinkerer at heart. Sometimes to my own detriment, I like to be able to take a sane set of defaults and then tweak them how I want. Hammerspoon more or less gives me that.

So, let’s approach my goals.

Pushing windows around

First, to snap windows, I implemented a few functions in ~/.hammerspoon/functions/push.lua:

-- Move a window to the given coordinates
-- top/left/width/height as a percent of the screen
-- window (optional) the window to move, defaults to the focused window
function push(params)
  local window = params["window"] or hs.window.focusedWindow()
  local windowFrame = window:frame()
  local screen = window:screen()
  local screenFrame = screen:frame()

  local moved = false
  function cas(old, new)
    if old ~= new then 
      moved = true
    end
    return new
  end

  windowFrame.x = cas(windowFrame.x, screenFrame.x + (screenFrame.w * (params["left"] or 0)))
  windowFrame.y = cas(windowFrame.y, screenFrame.y + (screenFrame.h * (params["top"] or 0)))
  windowFrame.w = cas(windowFrame.w, screenFrame.w * (params["width"] or 1))
  windowFrame.h = cas(windowFrame.h, screenFrame.h * (params["height"] or 1))

  window:setFrame(windowFrame)
  return moved
end

function thunk_push(params)
  function thunk()
    push(params)
  end
  return thunk
end

function grid(cell)
  hs.grid.set(hs.window.focusedWindow(), cell)
  return true
end

function thunk_grid(cell) 
  function thunk()
    grid(cell)
  end
  return thunk
end

The goal here is to take a table that can contain top, left, width, and/or height as numbers 0 to 1 and then apply those to the current window, defaulting left and top to 0 and width and height to 1. So if I want a window on the right half, that’s {left: 1/2, width: 1/2}. Easy enough. thunk_push is a wrapper around that that will take the arguments up and wrap them in a thunk–a function with no parameters.

With that, I can add a bunch of my keybindings to ~/.hammerspoon/keys.lua:

-- ⌘ ⌃ ⌥ ⇧

-- show grid
hs.hotkey.bind("⌘⇧", "g",     hs.grid.show)

-- full screens
hs.hotkey.bind("⌘⇧", "up",    thunk_push{width=1, height=1})
hs.hotkey.bind("⌘⇧", "down",  thunk_push{top=1/8, left=1/8, width=3/4, height=3/4})
  
-- half screens
function thunk_left_or_move()
    if not push{left=0, width=1/2} then
        local window = hs.window.focusedWindow()
        local screen = window:screen():previous()
        window:moveToScreen(screen)

        push{left=1/2, width=1/2}
    end
end    
 
function thunk_right_or_move()
    if not push{left=1/2, width=1/2} then
        local window = hs.window.focusedWindow()
        local screen = window:screen():next()
        window:moveToScreen(screen)

        push{left=0, width=1/2}
    end
end

hs.hotkey.bind("⌘⇧", "left",  thunk_left_or_move)
hs.hotkey.bind("⌘⇧", "right", thunk_right_or_move)
hs.hotkey.bind("⌘⇧", "pad8",  thunk_push{height=1/2})
hs.hotkey.bind("⌘⇧", "pad2",  thunk_push{top=1/2, height=1/2})

-- third screens
hs.hotkey.bind("⌘⇧", "pad4",  thunk_push{width=2/3})
hs.hotkey.bind("⌘⇧", "pad6",  thunk_push{width=2/3, left=1/3})

hs.hotkey.bind("⌘⇧", "pad7",  thunk_push{top=0, left=0, width=1/3, height=1/2})
hs.hotkey.bind("⌘⇧", "pad9",  thunk_push{top=0, left=2/3, width=1/3, height=1/2})
hs.hotkey.bind("⌘⇧", "pad1",  thunk_push{top=1/2, left=0, width=1/3, height=1/2})
hs.hotkey.bind("⌘⇧", "pad3",  thunk_push{top=1/2, left=2/3, width=1/3, height=1/2})

We’ll come back to ⌘⇧ g. But otherwise, all the rest of them move windows with ⌘⇧ plus a key:

  • left - move a window to the left half of the screen, if it’s already there, move to the previous screen (the right half of it)
  • right - the same, but right and then the next screen
  • up - maximize
  • down - partial center; I originally had this restore windows to what they were before Hammerspoon took over, but I haven’t re-implemented that yet
  • numpad:
    • 4 / 6 - take up the left / right two thirds of the window (to be used with the other keys)
    • 7 / 9 / 1 / 3 - take up the top left, top right, bottom left, or bottom right third (left/right) of the screen and half (top/bottom); basically so I can tile one big and two small windows

And that’s really it. So back to ⌘⇧ g. That does this:

Then hit the two keys (top left and bottom right) and the window will snap to that. So I can center 2/3, full height a window with ⌘⇧ g, w, g. Or set a window to the right 2/3 with ``⌘⇧ g, e, h. That ... is pretty cool. The specific size of the grid I have defined in init.lua`, which we’ll come back to.

Sending windows to the correct screen/desktop

Okay. Now, the second and harder problem. I want to be able to send my windows to specific screens. When my Mac reboots, it restores all of the windows, but has a tendency to just put them wherever. When I use a KVM to switch monitors, sometimes things get scrambled. There’s theoretically a ’lock to desktop’ option… but I cannot for the life of me get it to work.

So… how do we do that?

Well, the hs.window namespace can list all windows with hs.window.list… but that’s slow. Instead, what you want is to make an hs.window.filter over all windows. Then you can actually subscribe to events on it, specifically windowCreated and windowDestroyed. I’ll use that to keep a local copy of allWindows that are opened.

Second, I now have a window, but that doesn’t completely help. I need the window:application():name() to get the name of the application running (a la Firefox) but also window:title() for the specific title of specific windows. And unfortunately, there’s no built in way (specifically for Firefox) to title windows, so I needed the Window Titler extension. Oy. Anyways, that’s enough to allow me to select a specific window by application, title, or both.

Third and finally, hs.spaces. That will give the ability to control macOS Spaces. It’s mentioned in the doc that this is experimental and uses private APIs that Apple could easily change… which I suppose is why most window managers don’t seem able to do this. But on the other hand… it does work. So that’s pretty cool!

As a side note, they do mention that they looked at the Yobai source, so despite the note about SIP, that’s a good sign I believe.

Specifically though, I use hs.screen.find(screenQuery) to get one of my screens (a monitor) by the type of monitor (luckily one of mine is LG and the other DELL) then hs.spaces.allSpaces() with that ID to list all spaces on that screen. That table is in order but with IDs created in the order the desktops were created, so rather than directly use the ID, I count through that list and get the ID.

All that gives me

And finally, here’s the end result, ~/.hammerspoon/functions/rehome.lua

windowFilter = hs.window.filter.new()
windowFilter:setDefaultFilter{}
windowFilter:setSortOrder(hs.window.filter.sortByFocusedLast)

-- Keep a local copy of all windows
allWindows = {}
for _, window in pairs(windowFilter:getWindows()) do 
  allWindows[window:id()] = window
end

windowFilter:subscribe("windowCreated", function(window, name, event)
  print("window created", window)
  allWindows[window:id()] = window
end)

windowFilter:subscribe("windowDestroyed", function(window, name, event)
  print("window destroyed", window)
  allWindows[window:id()] = nil
end)

function rehome(windowQuery, screenQuery, spaceIndex, pushArgs)
  for id, w in pairs(allWindows) do
    local name = w:application():name() .. " - " .. w:title()
    if name ~= nil and name:find(windowQuery) then 
      window = w
    end
  end

  if window == nil then
    print("[ERROR in rehome] Window now found", windowQuery)
    return
  end

  local screen = hs.screen.find(screenQuery)
  if screen == nil then
    print("[ERROR in rehome] Screen now found", screenQuery)
    return
  end

  local spaceIDs = hs.spaces.allSpaces()[screen:getUUID()]
  if spaceIndex > #spaceIDs then
    print("[ERROR in rehome] spaceIndex too large", spaceIndex, ">", #spaceIDs)
    return
  end
  local spaceID = spaceIDs[spaceIndex]
  local spaceName = hs.spaces.missionControlSpaceNames()[screen:getUUID()][spaceID]

  print("Moving", window, "to", spaceID, "=", spaceName, "on", screen)
  hs.spaces.moveWindowToSpace(window, spaceID)
  
  if pushArgs ~= nil then
    pushArgs["window"] = window
    push(pushArgs)
  end
end

That lets me do something like this:

rehome("Firefox:Main",  "LG",   3, {left=1/3, width=2/3})

That means, take the window with Firefox:Main in the title and put it on the LG monitor in the 3rd desktop on that screen. After that, call push (see above) to move it to the right 2/3 of the screen.

Nice. Put a few of those together, ~/.hammerspoon/layout.lua:

if hs.host.names()[1]:lower():find("mercury") then
    -- Left monitor: LG HDR
    rehome("Firefox:Main",  "LG",   3, {left=1/3, width=2/3})
    rehome("Obsidian",      "LG",   3, {width=1/3, height=1/2})
    rehome("todo",          "LG",   3, {width=1/3, height=1/2, top=1/2})

    -- Right monitor: DELL 
    rehome("Mail",          "DELL", 1)
    rehome("Bitwarden",     "DELL", 1)
    rehome("Cryptomator",   "DELL", 1)

    rehome("Calendar",      "DELL", 2, {width=1, height=1})

    rehome("Firefox:Media", "DELL", 3, {width=1/2})
    rehome("Slack",         "DELL", 3, {left=1/2, width=1/2, height=1/2})
    rehome("Messages",      "DELL", 3, {top=1/2, left=1/2, width=1/2, height=1/2})
end

And away we go.

Firefox, Obsidian, and ’todo’ (a VSCode window with my todo workspace open) on one monitor in thirds. On the other, a few background windows (Mail/Bitwarden/Cryptomator) moved out of the way, Calendar on the second monitor full screen, and another Firefox (for YouTube etc)/Slack/Messages on the smaller Dell monitor. Pretty cool, no?

I do have this set right now to my computer’s hostname (mercury) so I can define layouts for more than one machine, but for now, that’s all I need.

Pulling it all together (init.lua)

And finally, the core/main function that sets this all up, ~/.hammerspoon/init.lua:

hs.grid.MARGINX = 0
hs.grid.MARGINY = 0
hs.grid.GRIDWIDTH = 6
hs.grid.GRIDHEIGHT = 2

require "functions/push"
require "functions/rehome"

require "keys"
require "layout"

That’s where we define the grid, load the two functions mentioned earlier and then add keybindings and a default layout. That’s really it. Pretty cool, no?

Is it perfect? Nah. Does it do exactly what I need? For now, absolutely!

If you like what you see, do let me know. If you have any tricks, absolutely let me know! I’ll probably go trolling for Hammerspoon dotfiles at some point (and need to push my to git), but for the moment, this already does everything Magnet did only better.

Onward!

One small caveat: next()

Originally, I had a next function to match with push for moving to the “next” monitor. Turns out… next is built in to Lua iterators.

That… actually managed to crash the built in macOS display manager and make me log in a few times, likely because I ended up causing an infinite loop. Cool footgun, yo.

Useful docs

Here are the parts of the Hammerspoon docs I used (their docs are for the most part really good):