#+title: Hammerspoon Configuration #+author: Howard X. Abrams #+date: 2025-11-24 #+filetags: emacs hamacs #+lastmod: [2026-08-10 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 ** 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 /can’t access Hammerspoon message port/, however well it is installed: #+BEGIN_SRC lua require("hs.ipc") #+END_SRC For instance, using the [[*Zoom][Zoom spoon]], I can toggle the mute button from a script: #+BEGIN_SRC sh :tangle no hs -c "spoon.Zoom:toggleMute()" # your Zoom spoon, from anywhere #+END_SRC 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 [[https://howardism.org/Technical/Emacs/beep-for-emacs.html][a notification system]], I could use Hammerspoon to replace =beep= CLI: #+BEGIN_SRC sh make build; hs -c 'hs.notify.new({title="Build", informativeText="done"}):send()' #+END_SRC 3. Query Hammerspoon to query the system: #+BEGIN_SRC bash :results replace raw output hs -c 'return hs.wifi.currentNetwork()' #+END_SRC This should return =iTerm2=, or =Emacs= when running from my literate config: #+BEGIN_SRC sh :results replace raw output hs -c "return hs.application.frontmostApplication():name()" #+END_SRC Or screwing with my monitors: #+BEGIN_SRC sh :results replace raw output hs -c "return hs.screen.mainScreen():name()" #+END_SRC Or the volume level as a float value: #+BEGIN_SRC sh :results replace raw output hs -c "return hs.audiodevice.defaultOutputDevice():volume()" #+END_SRC 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 I’ve 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: #+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("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) #+END_SRC * 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: #+BEGIN_SRC sh :dir ~/.hammerspoon/Spoons :tangle no :results silent git clone https://github.com/jpf/Zoom.spoon.git #+END_SRC I noticed that this library does most of its work 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 wifiWatcher = hs.wifi.watcher.new(ssidChangedCallback) wifiWatcher: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 sleepWatcher = hs.caffeinate.watcher.new(sleepWatch) sleepWatcher:start() #+END_SRC 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: #+BEGIN_SRC lua :tangle no hs.caffeinate.watcher.new(sleepWatch):start() #+END_SRC 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: #+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 configWatcher = hs.pathwatcher.new(os.getenv("HOME") .. "/.hammerspoon/", reloadConfig) configWatcher:start() #+END_SRC Every watcher in this file now lives in a variable of its own, for the reason described under [[*Clocking out a Task][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. * 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 # Local Variables: # jinx-local-words: "Lua" # End: