Skip to content

Instantly share code, notes, and snippets.

@withakay
Last active June 25, 2026 07:44
Show Gist options
  • Select an option

  • Save withakay/04aeccb1e32a07ed8a29bbd91490458a to your computer and use it in GitHub Desktop.

Select an option

Save withakay/04aeccb1e32a07ed8a29bbd91490458a to your computer and use it in GitHub Desktop.
DoubleTab.spoon

DoubleTap.spoon

This is a Spoon (for HammerSpoon) for detecting double taps on modifier keys like cmd, opt, ctrl etc) and sending other keys and sequences in response. It it can be configured to be global or application specific. So for example, if you just want your terminal to receive double tap ctrl and then send your Tmux prefix, ctrl+b for example, you can do that.

Installation

Create the folder mkdir -p ~/.hammerspoon/Spoons/DoubleTap.spoon and cd into it Copy DoubleTap-Spoon-init.lua to ~/.hammerspoon/Spoons/DoubleTap.spoon/init.lua' Copy DoubleTap-Spoon-docs.jsonto~/.hammerspoon/Spoons/DoubleTap.spoon/docs.json'

add the setup from init.lua to your ~/.hammerspoon/init.lua and adjust to your liking.

Reload your Hammerspoon config

AI Disclosure

This software was created with AI Assistance

{
"name": "DoubleTap",
"version": "1.0",
"author": "@withakay",
"license": "MIT",
"description": "Detect a bare double-tap of a modifier key (Option/Control/Command/Shift) and run an app-specific action: send a keystroke, type text, run a sequence of steps, execute AppleScript or a shell command, or call a Lua function.",
"items": [
{
"name": "bindRules",
"type": "Method",
"signature": "DoubleTap:bindRules(rules)",
"description": "Replace the current rule set. Each rule has: modifier (alt/ctrl/cmd/shift, aliases accepted), optional side (left/right/any), apps (list of bundle IDs and/or names, or the string 'default'), and action (a keystroke/text/sequence/applescript/shell/func table). App-specific rules win over 'default' rules for the same modifier."
},
{
"name": "start",
"type": "Method",
"signature": "DoubleTap:start()",
"description": "Build and start the event tap. Requires Accessibility permission for Hammerspoon. Safe to call multiple times."
},
{
"name": "stop",
"type": "Method",
"signature": "DoubleTap:stop()",
"description": "Stop and tear down the event tap."
},
{
"name": "listFrontmostApp",
"type": "Method",
"signature": "DoubleTap:listFrontmostApp() -> bundleID, name",
"description": "Log and return the frontmost application's bundle ID and name. Useful for discovering bundle IDs to use in rules."
},
{
"name": "tapInterval",
"type": "Variable",
"signature": "DoubleTap.tapInterval",
"description": "Maximum seconds between the two taps for them to count as a double-tap (default: 0.3)"
},
{
"name": "debug",
"type": "Variable",
"signature": "DoubleTap.debug",
"description": "Enable debug logging to the Hammerspoon Console (default: false)"
}
]
}
--- === DoubleTap ===
---
--- Detect a bare double-tap of a modifier key (Option/Control/Command/Shift)
--- and run an app-specific action: send a keystroke, type text, run a sequence
--- of steps, execute AppleScript or a shell command, or call a Lua function.
---
--- A "bare" double-tap means the modifier is pressed and released twice in quick
--- succession with no other key pressed in between, and no other modifier held.
--- Single taps and normal modifier+key usage always pass through untouched.
---
--- Example (in ~/.hammerspoon/init.lua):
---
--- hs.loadSpoon("DoubleTap")
--- spoon.DoubleTap:bindRules({
--- {
--- modifier = "alt",
--- apps = { "com.mitchellh.ghostty", "com.googlecode.iterm2" },
--- action = { type = "keystroke", mods = {"ctrl"}, key = "b" },
--- },
--- })
--- spoon.DoubleTap:start()
---
--- Download: N/A
--- License: MIT
local obj = {}
obj.__index = obj
-- Metadata
obj.name = "DoubleTap"
obj.version = "1.0"
obj.author = "Withakay"
obj.license = "MIT"
obj.homepage = "https://gist.github.com/withakay/04aeccb1e32a07ed8a29bbd91490458a"
-- Configuration
--- DoubleTap.tapInterval
--- Variable
--- Maximum seconds between the two taps for them to count as a double-tap (default: 0.3)
obj.tapInterval = 0.3
--- DoubleTap.debug
--- Variable
--- Enable debug logging to the Hammerspoon Console (default: false)
obj.debug = false
-- State
obj.rules = {} -- normalized rule list
obj.tap = nil -- hs.eventtap object (retained here so it is not GC'd)
-- Tap-tracking state (reset between sequences)
obj._pendingMod = nil -- modifier currently mid-press ("alt"/"ctrl"/"cmd"/"shift")
obj._pendingSide = nil -- "left" | "right" of the pending press
obj._lastMod = nil -- modifier of the last completed bare tap
obj._lastSide = nil -- side of the last completed bare tap
obj._lastTapTime = 0 -- timestamp of the last completed bare tap
-- Modifiers we care about. capslock and fn are intentionally ignored.
local TRACKED = { alt = true, ctrl = true, cmd = true, shift = true }
-- Aliases accepted in rule definitions -> canonical flag name.
local MOD_ALIASES = {
alt = "alt", opt = "alt", option = "alt", ["⌥"] = "alt",
ctrl = "ctrl", control = "ctrl", ["⌃"] = "ctrl",
cmd = "cmd", command = "cmd", ["⌘"] = "cmd",
shift = "shift", ["⇧"] = "shift",
}
-- Map raw keycodes (from hs.keycodes.map) to {modifier, side}. Used to determine
-- which physical modifier key changed and whether it is the left or right one.
local KEYCODE_TO_MOD = nil -- built lazily in :init()
--- Debug logging helper
function obj:log(message)
if self.debug then
print("[DoubleTap] " .. message)
end
end
-- Build the keycode -> {mod, side} lookup from hs.keycodes.map.
local function buildKeycodeMap()
local map = {}
local names = {
cmd = { left = "cmd", right = "rightcmd" },
alt = { left = "alt", right = "rightalt" },
ctrl = { left = "ctrl", right = "rightctrl" },
shift = { left = "shift", right = "rightshift" },
}
for mod, sides in pairs(names) do
for side, keyName in pairs(sides) do
local code = hs.keycodes.map[keyName]
if code then
map[code] = { mod = mod, side = side }
end
end
end
return map
end
-- Return the set of tracked modifiers currently active in an event's flags,
-- ignoring capslock and fn. Returns a table like { alt = true } and a count.
local function activeTrackedFlags(ev)
local flags = ev:getFlags()
local active = {}
local count = 0
for name in pairs(TRACKED) do
if flags[name] then
active[name] = true
count = count + 1
end
end
return active, count
end
--- Reset the in-progress tap tracking.
function obj:_reset()
self._pendingMod = nil
self._pendingSide = nil
self._lastMod = nil
self._lastSide = nil
self._lastTapTime = 0
end
--- Normalize a single modifier alias to its canonical name, or nil if invalid.
local function normalizeModifier(name)
if type(name) ~= "string" then return nil end
return MOD_ALIASES[name:lower()] or MOD_ALIASES[name]
end
--- DoubleTap:bindRules(rules)
--- Method
--- Replace the current rule set.
---
--- Each rule is a table:
--- modifier = "alt" | "ctrl" | "cmd" | "shift" (aliases: opt/option/control/command accepted)
--- side = "left" | "right" | "any" (optional, default "any")
--- apps = list of bundle IDs and/or app names, OR the string "default" (matches any app)
--- action = an action table (see below)
---
--- Action tables:
--- { type = "keystroke", mods = {"ctrl"}, key = "b" } -- mods optional
--- { type = "text", text = "hello" } -- types literal text
--- { type = "applescript", script = "display notification \"hi\"" }
--- { type = "shell", command = "open -a Calculator" }
--- { type = "func", fn = function() ... end }
--- { type = "delay", seconds = 0.05 } -- only meaningful inside a sequence
--- { type = "sequence", steps = { <action>, <action>, ... } }
---
--- When several rules share a modifier, an app-specific rule wins over a
--- "default" rule. Among equally-specific matches, the first defined wins.
function obj:bindRules(rules)
local normalized = {}
for i, rule in ipairs(rules or {}) do
local mod = normalizeModifier(rule.modifier)
if not mod then
self:log("Skipping rule " .. i .. ": invalid modifier " .. tostring(rule.modifier))
elseif not rule.action then
self:log("Skipping rule " .. i .. ": missing action")
else
local apps = rule.apps
local isDefault = (apps == "default" or apps == nil)
local appList = {}
if not isDefault then
if type(apps) == "string" then apps = { apps } end
for _, a in ipairs(apps) do
table.insert(appList, a)
end
end
local side = rule.side
if side ~= "left" and side ~= "right" then side = "any" end
table.insert(normalized, {
modifier = mod,
side = side,
isDefault = isDefault,
apps = appList,
action = rule.action,
})
end
end
self.rules = normalized
self:log("Loaded " .. #normalized .. " rule(s)")
return self
end
--- Whether the frontmost app matches one of the entries (bundle ID or name).
local function frontmostMatches(appList)
local app = hs.application.frontmostApplication()
if not app then return false end
local bundleID = app:bundleID()
local name = app:name()
for _, entry in ipairs(appList) do
if bundleID and entry == bundleID then return true end
if name and entry:lower() == name:lower() then return true end
end
return false
end
--- Find the best matching rule for a completed double-tap.
function obj:_findRule(mod, side)
local fallback = nil
for _, rule in ipairs(self.rules) do
if rule.modifier == mod
and (rule.side == "any" or rule.side == side) then
if rule.isDefault then
if not fallback then fallback = rule end
elseif frontmostMatches(rule.apps) then
return rule -- app-specific match wins immediately
end
end
end
return fallback
end
--- DoubleTap:listFrontmostApp()
--- Method
--- Log (and return) the frontmost application's bundle ID and name. Handy for
--- discovering the bundle IDs to use in rules.
function obj:listFrontmostApp()
local app = hs.application.frontmostApplication()
if not app then
print("[DoubleTap] No frontmost application")
return nil
end
local bundleID = app:bundleID() or "(no bundle id)"
local name = app:name() or "(no name)"
print(string.format("[DoubleTap] Frontmost app: name=%q bundleID=%q", name, bundleID))
return bundleID, name
end
-- Run a single (non-sequence) action immediately.
local function runStep(self, step)
local t = step.type
if t == "keystroke" then
hs.eventtap.keyStroke(step.mods or {}, step.key)
elseif t == "text" then
hs.eventtap.keyStrokes(step.text or "")
elseif t == "applescript" then
local ok, _, raw = hs.osascript.applescript(step.script or "")
if not ok then
self:log("AppleScript failed: " .. tostring(raw))
end
elseif t == "shell" then
hs.execute(step.command or "", true)
elseif t == "func" then
if type(step.fn) == "function" then
local ok, err = pcall(step.fn)
if not ok then self:log("func action error: " .. tostring(err)) end
end
elseif t == "delay" then
-- no-op outside a sequence
else
self:log("Unknown action type: " .. tostring(t))
end
end
-- Run a list of steps in order, honoring "delay" steps via chained timers so
-- the event tap is never blocked.
local function runSequence(self, steps, index)
index = index or 1
if index > #steps then return end
local step = steps[index]
if step.type == "delay" then
hs.timer.doAfter(step.seconds or 0, function()
runSequence(self, steps, index + 1)
end)
else
runStep(self, step)
runSequence(self, steps, index + 1)
end
end
--- Execute a rule's action (deferred so we never block the event stream).
function obj:_runAction(action)
hs.timer.doAfter(0, function()
if action.type == "sequence" then
runSequence(self, action.steps or {}, 1)
else
runStep(self, action)
end
end)
end
--- The event handler. Always returns false (never swallows events).
function obj:_handleEvent(ev)
local etype = ev:getType()
-- Any real key press cancels an in-progress bare tap (e.g. Option+L).
if etype == hs.eventtap.event.types.keyDown then
self._pendingMod = nil
self._lastMod = nil
return false
end
-- flagsChanged: figure out the active tracked modifiers and what changed.
local active, count = activeTrackedFlags(ev)
local info = KEYCODE_TO_MOD[ev:getKeyCode()]
-- Expire a stale first tap.
if self._lastMod and (hs.timer.secondsSinceEpoch() - self._lastTapTime) > self.tapInterval then
self._lastMod = nil
end
if count == 1 and info and active[info.mod] then
-- Exactly one tracked modifier is down, and it's the one that changed:
-- this is a modifier KEY DOWN (press) with nothing else held.
self._pendingMod = info.mod
self._pendingSide = info.side
elseif count == 0 then
-- All tracked modifiers released. If this completes a pending bare press,
-- it's a tap. (info may be the modifier that just went up.)
if self._pendingMod then
local mod = self._pendingMod
local side = self._pendingSide
self._pendingMod = nil
self._pendingSide = nil
-- Two taps count as a double-tap if they are the same logical
-- modifier; the physical side (left/right) is matched per-rule in
-- _findRule using the side of the *second* tap.
local now = hs.timer.secondsSinceEpoch()
if self._lastMod == mod
and (now - self._lastTapTime) <= self.tapInterval then
-- Second tap of the same modifier within the window: trigger.
self._lastMod = nil
self:log("Double-tap detected: " .. mod .. " (" .. side .. ")")
local rule = self:_findRule(mod, side)
if rule then
self:log("Matched rule, running action")
self:_runAction(rule.action)
else
self:log("No matching rule for frontmost app")
end
else
-- First tap: record it.
self._lastMod = mod
self._lastSide = side
self._lastTapTime = now
end
end
else
-- Two or more modifiers held, or some other combo: not a bare tap.
self._pendingMod = nil
self._lastMod = nil
end
return false
end
--- DoubleTap:init()
--- Method
--- Initialize internal state. Does not start the event tap.
function obj:init()
KEYCODE_TO_MOD = buildKeycodeMap()
self:_reset()
return self
end
--- DoubleTap:start()
--- Method
--- Build and start the event tap. Requires Accessibility permission for
--- Hammerspoon. Safe to call multiple times.
function obj:start()
if not KEYCODE_TO_MOD then
KEYCODE_TO_MOD = buildKeycodeMap()
end
if self.tap then
self.tap:stop()
self.tap = nil
end
self:_reset()
self.tap = hs.eventtap.new(
{ hs.eventtap.event.types.flagsChanged, hs.eventtap.event.types.keyDown },
function(ev) return self:_handleEvent(ev) end
)
self.tap:start()
self:log("Started (tapInterval=" .. self.tapInterval .. "s)")
return self
end
--- DoubleTap:stop()
--- Method
--- Stop and tear down the event tap.
function obj:stop()
if self.tap then
self.tap:stop()
self.tap = nil
end
self:_reset()
self:log("Stopped")
return self
end
return obj
-- Load the DoubleTap Spoon: double-tap a modifier to run an app-specific action.
-- Tip: focus an app and run `spoon.DoubleTap:listFrontmostApp()` in the Hammerspoon
-- Console to discover its bundle ID. Set `spoon.DoubleTap.debug = true` to trace taps.
hs.loadSpoon("DoubleTap")
spoon.DoubleTap:bindRules({
-- Terminals: double-tap Option -> send Ctrl+B (e.g. tmux prefix)
{
modifier = "alt",
apps = { "com.mitchellh.ghostty", "com.googlecode.iterm2" },
action = { type = "keystroke", mods = { "ctrl" }, key = "b" },
},
-- Ghostty: double-tap Ctrl -> send Ctrl+Space
{
modifier = "ctrl",
apps = { "com.mitchellh.ghostty" },
action = { type = "keystroke", mods = { "ctrl" }, key = "space" },
},
-- Example (commented): Safari -> double-tap Option opens a new tab (Cmd+T)
-- {
-- modifier = "alt",
-- apps = { "com.apple.Safari" },
-- action = { type = "keystroke", mods = { "cmd" }, key = "t" },
-- },
-- Example (commented): a multi-step sequence
-- {
-- modifier = "ctrl",
-- apps = { "com.mitchellh.ghostty" },
-- action = { type = "sequence", steps = {
-- { type = "keystroke", mods = { "ctrl" }, key = "b" },
-- { type = "keystroke", key = "i" },
-- { type = "delay", seconds = 0.05 },
-- { type = "text", text = "some-string" },
-- } },
-- },
-- Example (commented): run AppleScript for any app
-- {
-- modifier = "cmd",
-- apps = "default",
-- action = { type = "applescript", script = 'display notification "double-tap cmd"' },
-- },
-- Example (commented): run a shell command
-- {
-- modifier = "shift",
-- apps = { "Finder" },
-- action = { type = "shell", command = "open -a Calculator" },
-- },
})
spoon.DoubleTap:start()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment