#+title:  Hammerspoon Configuration
#+author: Howard X. Abrams
#+date:   2025-11-24
#+filetags: emacs hamacs
#+lastmod: [2026-06-15 Mon]

A literate programming file for configuring Hammerspoon.

* Introduction
Ever since I got a Mac, I’ve 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, I’ve been using [[https://hammerspoon.org][Hammerspoon]] for those few needs. While I refer to the [[https://www.hammerspoon.org/docs/index.html][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:
#+BEGIN_SRC lua :tangle no
  hs.notify.new({title="Hammerspoon", informativeText="Hello World"}):send()
#+END_SRC

* Shortcut Keybindings
I’ve created left and right ~Meh~ keys on my Moonlanders:
  - Left Meh Key: ~C-M-S-s~ (Control Option/Meta Command Shift)
  - Right Meh Key: ~C-M-S~ (Control Option/Meta Shift)

To create special key bindings, I can:
#+BEGIN_SRC lua
    ----------------------------------------------------------------------
    -- 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("Safari")
    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"}, "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)
#+END_SRC
* Zoom
Library extensions to Hammerspoon are called /spoons/, and most of these are a simple Lua script, =init.lua= stored in the =Spoons= subdirectory. We grab these with a =git clone=, typically. To use the Zoom spoon, clone it:

#+BEGIN_SRC sh :dir ~/.hammerspoon/Spoons :tangle no :results silent
  git clone https://github.com/jpf/Zoom.spoon.git
#+END_SRC

I noticed that it does its works by calling Zoom’s 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 [[https://developer.okta.com/blog/2020/10/22/set-up-a-mute-indicator-light-for-zoom-with-hammerspoon][nice essay]], we create a menu bar item that shows the status, as well as allowing me to click it to toggle:

#+BEGIN_SRC lua
  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
#+END_SRC

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

#+BEGIN_SRC lua
  hs.loadSpoon("Zoom")
  spoon.Zoom:setStatusCallback(updateZoomStatus)
  spoon.Zoom:start()
#+END_SRC

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

#+BEGIN_SRC lua
  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)
#+END_SRC

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:

#+BEGIN_SRC lua
  ----------------------------------------------------------------------
  -- 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

  hs.wifi.watcher.new(ssidChangedCallback):start()
#+END_SRC

Why yes, I’m /looking for features/ to use Hammerspoon.
* Clocking out a Task
I want to use Org’s 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:

#+BEGIN_SRC lua
  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

  hs.caffeinate.watcher.new(sleepWatch):start()
#+END_SRC

Not that we could also do something for Wake:

#+BEGIN_SRC lua :tangle no
  if (eventType == hs.caffeinate.watcher.systemDidWake) then
    ...
  end
#+END_SRC

Oh, and here is a helper for [[file:laptop_keyboard.kbd][kanata]] to put the display to sleep:

#+BEGIN_SRC lua
  hs.hotkey.bind({}, "F16", function()
        hs.execute("pmset displaysleepnow")
  end)
#+END_SRC

* Monitors
My company gave me a nice monitor … maybe a little too nice, as I don’t 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:

#+BEGIN_SRC lua
  ----------------------------------------------------------------------
  -- 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)
#+END_SRC

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

#+BEGIN_SRC lua
  ----------------------------------------------------------------------
  -- 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

  hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig):start()
#+END_SRC

* Technical Artifacts                                :noexport:
#+BEGIN_SRC lua
  hs.alert.show("Hammerspoon Configuration")
#+END_SRC


#+DESCRIPTION: Literate Hammerspoon configuration

#+PROPERTY:    header-args:sh :tangle no
#+PROPERTY:    header-args:lua  :tangle ~/.hammerspoon/init.lua
#+PROPERTY:    header-args    :results none :eval no-export :comments no mkdirp yes

#+OPTIONS:     num:nil toc:nil todo:nil tasks:nil tags:nil date:nil
#+OPTIONS:     skip:nil author:nil email:nil creator:nil timestamp:nil
#+INFOJS_OPT:  view:nil toc:nil ltoc:t mouse:underline buttons:0 path:http://orgmode.org/org-info.js
