Hammerspoon: Handling Windows and Layouts

hammerspoon logo

I use Hammerspoon to enhance my OSX with custom UI and automations, while also creating "monotasking" deep work environments to keep me focused.

One of the main things people do with Hammerspoon is wrangle their window layouts. There is a lot of awesome Hammerspoon code to help you with that, from clones of popular Mac applications like Spectacle to complex tiling systems.

While ten years ago I would have leapt for the tiling options1 (I still would like to give i3wm a go one of these days!) I have been making an effort to only have one application in view at any given time. I used to think multitasking was a super power of mine, now I think it's my kryptonite.

In my configuration, movewindows.lua isn't very complex or interesting, and a lot of it was written originally by Tom Miller. While much of movewindows.lua is very simple, there are two automations that I'm proud of.

Split screen easily

I work on a laptop monitor a lot, and even at my nice setup at my desk I try to keep my main monitor on a single app. One app, one task, single focus.

There is one use case where I'll commonly have two windows open… one to work on, the other for reference. Sometimes it's Dash.app, some other reference material, or a ticket.

The process usually goes like this.

  1. Be me, full screened on my work.
  2. Realize I need some reference material.
  3. Go find that material, open it up full screen.
  4. Flip back and forth (efficiently using hyper.lua!) a few times before realizing that I need them both at the same time.
  5. Switch to my work, enter move mode, move it to the left. (HYPER+m, h)
  6. Switch to my reference, enter move mode, move it to the right. (HYPER+m, l)

Realizing just how common this particular use case is, and inspired by how easy it is to split a screen in vim using fzf.vim, I wrote a function to make my life easier.

Here's what it looks like in action.

Hitting HYPER+m allows me to enter my window movement modal. It is sort of a leader key... I then hit `v` to enter my split window chooser, then I choose the window I want alongside my "work" window and everything is ready to go!

It even maintains the focus on my work. I can just keep typing.

Let's walk though the code... while it's not terribly complex, it's a good example of the powerful tools Hammerspoon gives you to build your own automations.

First we set up the modal and bind it to our old friend Hyper.

local movewindows = hs.hotkey.modal.new()

hyper:bind({}, 'm', nil, function() movewindows:enter() end)

Next we create a specific binding for the movewindows modal for hitting the letter v. I've considered switching it to the same bindings I'm used to in vim, but this will do.

I get all the windows open, and build a lua table of them to pass to the hs.chooser object. I don't include the currently focused window, because it's the "work" window.

movewindows:bind('', 'v', function()
  local windows = hs.fnutils.map(hs.window.filter.new():getWindows(), function(win)
    if win ~= hs.window.focusedWindow() then
      return {
        text = win:title(),
        subText = win:application():title(),
        image = hs.image.imageFromAppBundle(win:application():bundleID()),
        id = win:id()
      }
    end
  end)

Armed with this table of open windows, I setup up the chooser. hs.chooser (docs) provides a simple fuzzy-finder that is really great at filtering to what you want very quickly. If I choose a window, it uses hs.layout to layout the windows, and raises toRead to the foreground without focusing it... this is really nice when I'm still trying to type in my "work" window, I don't have to refocus it.

  local chooser = hs.chooser.new(function(choice)
    if choice ~= nil then
      local layout = {}
      local focused = hs.window.focusedWindow()
      local toRead  = hs.window.find(choice.id)
      if hs.eventtap.checkKeyboardModifiers()['alt'] then
        hs.layout.apply({
          {nil, focused, focused:screen(), hs.layout.left70, 0, 0},
          {nil, toRead, focused:screen(), hs.layout.right30, 0, 0}
        })
      else
        hs.layout.apply({
          {nil, focused, focused:screen(), hs.layout.left50, 0, 0},
          {nil, toRead, focused:screen(), hs.layout.right50, 0, 0}
        })
      end
      toRead:raise()
    end
  end)

Nothing left to do but show the hs.chooser. I add some placeholder text to remind myself hotkeys for the interface, make sure that I can search the subText (which in this case is the application name,) and show the chooser, and exit the modal.

chooser
  :placeholderText("Choose window for 50/50 split. Hold ⎇ for 70/30.")
  :searchSubText(true)
  :choices(windows)
  :show()

movewindows:exit()

(abridged from movewindows.lua)

I use this more than I thought I would, it turned out that most of my window moving was simply to bring two things close together for comparison or reference. It's a very small thing, but it brings me a lot of joy.

Autolayout on docking or undocking the laptop

After using my basic keys for moving windows around, I realized the main use case for me was setting up my windows on my two monitors after docking my laptop.

If I'm on one screen, I want everything full screened, and I switch between them using hyper shortcuts. If I have two screens, I want work right in front of me, and distraction/calendaring applications off to the side. Inspired by Seth Messer's config, I wrote a simplified autolayout.lua system.

Once again, we return to my config variable in init.lua. Using the same applications table as hyper, we add a new key/value pair for preferred_display.

config = {}
config.applications = {
  ['com.brave.Browser'] = {
    bundleID = 'com.brave.Browser',
    preferred_display = 1
  }
}

With that in place, it's pretty simple to iterate over the list in autolayout.lua, getting the windows and moving them to the monitor that I prefer.

autolayout.autoLayout = function()
  for _, app_config in pairs(module.config.applications) do
    -- if we have a preferred display
    if app_config.preferred_display ~= nil then
      application = hs.application.find(app_config.bundleID)

      if application ~= nil and application:mainWindow() ~= nil then
        application
        :mainWindow()
        :moveToScreen(autolayout.target_display(app_config.preferred_display), false, true, 0)
        :moveToUnit(hs.layout.maximized)
      end
    end
  end
end

While I can trigger autolayout.autolayout() manually using a hyper key, I set up a watcher that fires when the number of monitors change. (This brilliant idea is also courtesy of Seth!) This means that when I dock or undock my laptop, instead of spending the next two minutes getting everything set up just the way I like it, my Hammerspoon butler kindly sweeps away all the windows to their proper place every time.

autolayout.start = function(config_table)
  module.config = config_table

  hs.screen.watcher.new(function()
    if num_of_screens ~= #hs.screen.allScreens() then
      autolayout.autoLayout()
      num_of_screens = #hs.screen.allScreens()
    end
  end):start()
end

All of this shows a little bit of what I have been calling meta-automation. Having the power to automate little things like moving a window back and forth gives me the ability to recognize the larger "UX" patterns at play and directly meta-automate them. It's the difference between asking myself "what am I doing right now?" and realizing "what am I trying to accomplish?"2

I love tinkering with this higher-order automation... it's more thoughtful an exciting, and I can't wait to show you what I've been building up to with these posts. This is all going somewhere... I promise!


  1. Some of you may be wondering why I do all this instead of tiling... I have a very specific use case. I want all windows up and full screened, able to transition without a OS X spaces animation. hyper.lua handles that. If I want to compare, I have my movewindows.lua split function. I think if I use a tiling function, I'd lose the ability to quickly switch... but maybe not. I may be just reinventing the wheel, but it's my wheel! 

  2. Outcomes over outputs... 


Changelog
  • 2024-02-20 09:38:14 -0600
    Convert to permalinks

  • 2022-06-08 11:31:29 -0500
    Rename articles

  • 2020-09-28 09:20:43 -0500
    Change repo name

    I decided to change the name of my hammerspoon configuration repo to
    `hammerspoon-config` to avoid confusion... it's not the actual
    hammerspoon executable, it's a configuration for that executable.

  • 2020-06-18 14:26:02 -0500
    Move everything to CST

    Don't know why I didn't do that before. It caused _no_ end of
    problems.

  • 2020-06-14 15:51:50 -0500
    Update articles with series and series partials

    After the refactor, needed to move the series metadata to the actual
    name.

  • 2020-06-12 09:33:47 -0500
    Post: Windows and Layouts