Now, on my Mac laptop, if I am clocked into a task, shutting my laptop, clocks out of the task before going to sleep.
356 lines
13 KiB
Org Mode
356 lines
13 KiB
Org Mode
#+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:
|