|
--- === 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 |