#+title: Hammerspoon Configuration #+author: Howard X. Abrams #+date: 2025-11-24 #+filetags: emacs hamacs #+lastmod: [2025-12-02 Tue] 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"}, "W", function() hs.application.launchOrFocus("Spotify") end) hs.hotkey.bind({"alt", "ctrl", "shift"}, "F", function() hs.application.launchOrFocus("Firefox") 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"}, "A", function() hs.application.launchOrFocus("Cursor") end) hs.hotkey.bind({"alt", "ctrl", "shift"}, "Q", function() hs.application.launchOrFocus("KeepassXC") 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) #+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", spoon.Zoom:toggleMute) 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 * 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() local app = win:application() local f = win:frame() -- Magic numbers figured out by trial and error: f.x = 600 f.y = 30 f.w = 2200 f.h = 1470 if app then local name = app:name() -- If the application is Slack, adjust the height if name == "Slack" or name == "iTerm2" then f.h = 1200 end end 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