hamacs/hammerspoon.org
Howard Abrams 94f14495d1 Fix bug between Emacs and Hammerspoon
Now, on my Mac laptop, if I am clocked into a task, shutting my
laptop, clocks out of the task before going to sleep.
2026-08-10 14:24:01 -07:00

13 KiB
Raw Blame History

Hammerspoon Configuration

A literate programming file for configuring Hammerspoon.

Introduction

Ever since I got a Mac, Ive used various tools to script it. With my limited number of applications, my UI needs are simple (I mean, what more do you need once Emacs is in full screen). That said, Ive been using Hammerspoon for those few needs. While I refer to the standard documentation, I often steal snippets of code from others.

Simple to use. Use hs.execute to run a script, ha.alert.show to print a small message on the screen, and for something longer:

  hs.notify.new({title="Hammerspoon", informativeText="Hello World"}):send()

The Command Line

The hs binary talks to a running Hammerspoon over a Mach port, and that port only exists while the hs.ipc module is loaded. Without this line, the tool answers every request with cant access Hammerspoon message port, however well it is installed:

  require("hs.ipc")

For instance, using the Zoom spoon, I can toggle the mute button from a script:

  hs -c "spoon.Zoom:toggleMute()"     # your Zoom spoon, from anywhere

Or callable from Emacs.

Other ideas:

  1. Tail the Hammerspoon console in a terminal with hs -C, which shows anything called with printf.
  2. While I already built out a notification system, I could use Hammerspoon to replace beep CLI:

      make build; hs -c 'hs.notify.new({title="Build", informativeText="done"}):send()'
  3. Query Hammerspoon to query the system:

      hs -c 'return hs.wifi.currentNetwork()'

    This should return iTerm2, or Emacs when running from my literate config:

      hs -c "return hs.application.frontmostApplication():name()"

    Or screwing with my monitors:

      hs -c "return hs.screen.mainScreen():name()"

    Or the volume level as a float value:

      hs -c "return hs.audiodevice.defaultOutputDevice():volume()"

Flags worth knowing, from the binary's usage text:

  • -i interactive REPL
  • -q quiet (only the result, ideal for scripting)
  • -c repeatable
  • -A auto-launch Hammerspoon if not running, and a bare file path to run a .lua file.

Shortcut Keybindings

Ive created left and right Meh keys on my Moonlander keyboard:

  • Left Meh Key: C-M-S-s (Control Option/Meta Command Shift)
  • Right Meh Key: C-M-S (Control Option/Meta Shift)

To create specific key bindings for starting applications, I can:

    ----------------------------------------------------------------------
    -- Launcher replaces iCanHazShortcuts

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "T", function()
          hs.application.launchOrFocus("iTerm")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "S", function()
          hs.application.launchOrFocus("Slack")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "F", function()
          hs.application.launchOrFocus("Vivaldi")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "C", function()
          -- hs.osascript.applescriptFromFile("~/bin/chrome.scr")
          hs.execute("~/bin/chrome.scr")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "B", function()
          hs.application.launchOrFocus("Microsoft Outlook")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "Z", function()
          hs.application.launchOrFocus("zoom.us")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "Q", function()
          hs.application.launchOrFocus("KeepassXC")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "G", function()
          hs.application.launchOrFocus("Ghostty")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "W", function()
          hs.application.launchOrFocus("VLC")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "E", function()
          hs.execute("FOR_WORK=yes open -a /Applications/Emacs.app")
    end)

    -- Special Emacs Guys
    -- Right Meh key:
    hs.hotkey.bind({"alt", "ctrl", "shift"}, "X", function()
          hs.execute("~/bin/emacs-capture")
    end)

    -- Left Meh key:
    hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "X", function()
          hs.execute("~/bin/emacs-capture-clock")
    end)

    hs.hotkey.bind({"alt", "ctrl", "shift"}, "M", function()
          hs.execute("~/bin/emacs-capture-meeting")
    end)

    -- Current music system is actually in Emacs:
    hs.hotkey.bind({"alt", "ctrl", "shift"}, "R", function()
          hs.execute("/opt/homebrew/bin/emacsclient -s work -e '(ready-player-toggle-play-stop)'")
    end)

Zoom

Spoons are library extensions to Hammerspoon: usally small Lua scripts, like init.lua, stored in the Spoons subdirectory. Installable from a git clone, typically. To use the Zoom spoon, clone it:

  git clone https://github.com/jpf/Zoom.spoon.git

I noticed that this library does most of its work by calling Zooms menus, and with the latest version of Zoom, they changed the case of the menu, meaning that I needed to create a pull request.

From this nice essay, we create a menu bar item that shows the status, as well as allowing me to click it to toggle:

  zoomStatusMenuBarItem = hs.menubar.new(true)
  zoomStatusMenuBarItem:setClickCallback(function()
      spoon.Zoom:toggleMute()
  end)

  updateZoomStatus = function(event)
    hs.printf("updateZoomStatus(%s)", event)
    if (event == "from-running-to-meeting") then
      zoomStatusMenuBarItem:returnToMenuBar()
    elseif (event == "muted") then
      zoomStatusMenuBarItem:setTitle("🔴")
    elseif (event == "unmuted") then
      zoomStatusMenuBarItem:setTitle("🟢")
    elseif (event == "from-meeting-to-running") then
      zoomStatusMenuBarItem:removeFromMenuBar()
    end
  end

Now we can load, instantiate it, as well as create a callback loop to call updateZoomStatus:

  hs.loadSpoon("Zoom")
  spoon.Zoom:setStatusCallback(updateZoomStatus)
  spoon.Zoom:start()

And bind a key to the mute ability that works good for both keyboards:

  hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "M", function()
        spoon.Zoom:toggleMute()
  end)

  hs.hotkey.bind({"cmd", "alt", "ctrl"}, "M", function()
        spoon.Zoom:toggleMute()
  end)

Lovely bit of code and shows the true power of Hammerspoon to fix the various app issues.

Automatically Adjust Volume

Leaving my home office and hopping on the train can be socially awkward when my laptop suddenly screams, so if I leave my home, I just readjust the volume and turn it off:

  ----------------------------------------------------------------------
  -- When leaving house, turn off the volume:

  wifiWatcher = nil
  homeSSID = "Intertubes"
  workSSID = "workdaysecure"
  hotspotSSID = "Pixie Dust"
  lastSSID = hs.wifi.currentNetwork()

  function ssidChangedCallback()
      newSSID = hs.wifi.currentNetwork()

      if newSSID == homeSSID and lastSSID ~= homeSSID then
          -- joined our home WiFi network
          hs.audiodevice.defaultOutputDevice():setVolume(25)
      elseif newSSID == workSSID and lastSSID ~= workSSID then
          -- joined our work WiFi network
          hs.audiodevice.defaultOutputDevice():setVolume(0)
      elseif newSSID == hotspotSSID and lastSSID ~= hotspotSSID then
          -- joined our hotspot WiFi network
          hs.audiodevice.defaultOutputDevice():setVolume(0)
      elseif newSSID ~= homeSSID and lastSSID == homeSSID then
          -- departed our home WiFi network
          hs.audiodevice.defaultOutputDevice():setVolume(0)
      end

      lastSSID = newSSID
  end

  wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback)
  wifiWatcher:start()

Why yes, Im looking for features to use Hammerspoon.

Clocking out a Task

I want to use Orgs task clocking ability, but I often forget to clock out. This code allows me to clock out whenever my computer goes to sleep … which works well when closing the laptop lid:

  function sleepWatch(eventType)
     if (eventType == hs.caffeinate.watcher.systemWillSleep) then
        if hs.application.find("Emacs") then
           hs.execute("/opt/homebrew/bin/emacsclient --socket work --eval '(ha-clock-out)'")
        end
     end
  end

  sleepWatcher = hs.caffeinate.watcher.new(sleepWatch)
  sleepWatcher:start()

Discovered a subtle bug, solving it meant assigning the watcher to a variable, rather than starting it and dropping the value. For instance, if I call the start this way:

  hs.caffeinate.watcher.new(sleepWatch):start()

Since nothing else refers to that object once the line finishes, Lua may collect it, and a collected watcher stops delivering events without a word about it.

Note if I want to do something for Wake:

  if (eventType == hs.caffeinate.watcher.systemDidWake) then
    ...
  end

Oh, and here is a helper for kanata to put the display to sleep:

  hs.hotkey.bind({}, "F16", function()
        hs.execute("pmset displaysleepnow")
  end)

Monitors

My company gave me a nice monitor … maybe a little too nice, as I dont care to shift my neck to the extreme sides (serious first-world problem), so here I can center a window Keeping it my field of view:

  ----------------------------------------------------------------------
  -- Centering a window on the large monitors at Work:

  function centerWindow()
     local win = hs.window.focusedWindow()
     if not win then return end -- Safety check in case no window is focused

     local app = win:application()
     local screen = hs.screen.find("DELL P3424WE")
     local maxBounds = screen:frame() -- This gets the absolute coordinates of that specific monitor

     -- 2. Define your desired size
     local desiredW = 2200
     local desiredH = 1470

     if app then
        local name = app:name()
        if name == "Slack" or name == "iTerm2" then
           desiredH = 1200
        end
     end

     -- 3. Calculate relative position based on the external monitor's bounds
     -- This centers the window perfectly on the target screen
     local f = {
        x = maxBounds.x + ((maxBounds.w - desiredW) / 2),
        y = maxBounds.y, -- + ((maxBounds.h - desiredH) / 2),
        w = desiredW,
        h = desiredH
     }

     win:setFrame(f)
     hs.alert.show("Centered Window")
  end

  hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "Y", centerWindow)

Auto Reload Configuration

Whenever my configuration file is altered (or with a Meh-R key), I reload the configuration:

  ----------------------------------------------------------------------
  -- Automatically reload the Hammerspoon configuration

  hs.hotkey.bind({"cmd", "alt", "ctrl", "shift"}, "R", function()
        hs.reload()
        hs.alert.show("Reloaded Hammerspoon Config")
  end)

  function reloadConfig(files)
      doReload = false
      for _,file in pairs(files) do
          if file:sub(-4) == ".lua" then
              doReload = true
          end
      end
      if doReload then
          hs.reload()
      end
  end

  configWatcher = hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig)
  configWatcher:start()

Every watcher in this file now lives in a variable of its own, for the reason described under clocking out. This one failing is the quietest of the three, as a dead configuration watcher looks exactly like a file that saved without incident.