In macOS, if you click on an application’s icon in the dock, it’ll either:

  1. open it, if it isn’t already running,
  2. gently bring it to the foreground if it has a window in the current space, or
  3. violently throw you to who knows where if the application has a window open in a different space that you forgot about.

The third behavior always drove me nuts, although I can see why you’d prefer it, e.g. if you associate particular applications with specific spaces. You can get around this by remembering to always right-click the icon and select “New Window” if the application supports this. But the unpredictability always annoyed me to no end.

I wrote a cursed Hammerspoon script today to get around this. It prevents macOS from throwing you to a different space and instead opens a new window in the current one. It only works for apps hardcoded in the newWindowCommands table; in a previous version I tried sending make new window by default, but sometimes that would cause macOS to act like I held down the mouse button on the dock icon, which is weird.

🚨 Warning: this code will run any time you click anywhere. It will add a delay, albeit a very small one, to how long it takes macOS to respond to mouse clicks.

I don’t actually recommend using this script unless you’re like me and the costs outweigh the benefits. I like to use different spaces for different work, which means that I’ll have e.g. Firefox open in two different spaces. I personally don’t notice the delay at all, but caveat emptor. If you comment in the block which only enables the new behavior when the Shift key is held down, the impact on your clicks should be even lower.

function initNewWindowShortcut()
  local fileNewWindow = {
    menu = "File",
    item = "New Window",
  }
  local newWindowCommands = {
    Safari = "make new document",
    TextEdit = "make new document",
    ["Google Chrome"] = "make new window",
    ["Microsoft Edge"] = "make new window",
    kitty = {
      menu = "Shell",
      item = "New OS Window",
    },
    Firefox = fileNewWindow,
    ["Firefox Nightly"] = fileNewWindow,
    Orion = fileNewWindow,
    ["Orion RC"] = fileNewWindow,
    Mail = {
      menu = "File",
      item = "New Viewer Window",
    },
    Things = {
      menu = "File",
      item = "New Things Window",
    },
  }

  local log = hs.logger.new("newWindow", "debug")

  local bundleIDCache = {}

  function handler(event)
    -- Comment this in to only do anything when Shift is held down.
    --local mkeys = hs.eventtap.checkKeyboardModifiers()
    --if not mkeys.shift then
    --  return false
    --end

    local element = hs.axuielement.systemElementAtPosition(hs.mouse.absolutePosition())
    if not element or element.AXRole ~= "AXDockItem" then
      return false
    end
    local title = element.AXTitle

    local infoPlistPath = element.AXURL.filePath .. "/Contents/Info.plist"
    local bundleID = bundleIDCache[infoPlistPath]
    if not bundleID then
      bundleID = hs.plist.read(infoPlistPath).CFBundleIdentifier
    end

    local script
    local command = newWindowCommands[title]
    if not command then
      return false
    end
    if type(command) == "table" then
      script = [[
  tell application "System Events" to tell process "]] .. title .. [["
    click menu item "]] .. command.item .. [[" of menu 1 of menu bar item "]] .. command.menu .. [[" of menu bar 1
    activate
  end tell
]]
    else
      -- type(command) ~= "table"
      script = [[
tell application "]] .. element.AXTitle .. [["
  ]] .. command .. "\n" .. [[
  activate
end tell
]]
    end

    -- This is slow, so do it after looking up the title in newWindowCommands.
    -- If the app is not already running, or has any windows open in the
    -- current space, let the mouse click pass through.
    local app = hs.application.find(bundleID, true, true)
    if not app or #app:allWindows() > 0 then
      return false
    end

    log.i(script)
    hs.osascript.applescript(script)

    -- This seems to be needed for Orion and Firefox, but not Chrome.
    app:activate()

    return true
  end

  local tap = hs.eventtap.new({ hs.eventtap.event.types.leftMouseDown }, function(event)
    -- Run `handler` in `pcall` because if any code in this event tap errors,
    -- clicks will stop working entirely!
    -- deleteOrError is:
    -- * ok: whether to delete the event
    -- * not ok: the error message caught by pcall
    local ok, deleteOrError = pcall(function()
      return handler(event)
    end)
    if not ok then
      log.e(deleteOrError)
      return false
    else
      return deleteOrError
    end
  end)

  tap:start()
  return tap
end
-- Save event tap so it doesn't get GC'ed.
newWindowTap = initNewWindowShortcut()

In the future, it’d be nice to make this work for all methods of activating an app, e.g. opening an app through Spotlight.