feat: Hammerspoon IPC cursor tracking + split magnifier (SPC z m)

This commit is contained in:
2026-02-22 22:05:36 +01:00
parent 21b1637390
commit c6b45f6867
2 changed files with 197 additions and 7 deletions

105
hammerspoon/cursor-warp.lua Normal file
View File

@@ -0,0 +1,105 @@
-- cursor-warp.lua — Emacs cursor tracking for macOS Zoom
--
-- WHAT: Reads cursor coordinates written by Emacs to ~/.emacs-cursor-pos
-- and posts a real mouseMoved HID event so macOS Zoom follows the
-- text cursor. CGWarpMouseCursorPosition (used by Emacs internally)
-- is silently ignored by macOS Zoom — this workaround fixes that.
--
-- SETUP:
-- 1. Install Hammerspoon: https://www.hammerspoon.org/
-- 2. Grant Accessibility permission: System Settings → Privacy & Security
-- → Accessibility → enable Hammerspoon
-- 3. Copy this file to ~/.hammerspoon/cursor-warp.lua
-- 4. In ~/.hammerspoon/init.lua add: require("cursor-warp")
-- 5. In Emacs: SPC z t enables cursor tracking (writes ~/.emacs-cursor-pos)
-- 6. Toggle in Hammerspoon: call emacsZoomToggle() or bind a hotkey
--
-- FILE FORMAT (~/.emacs-cursor-pos):
-- Line 1: X Y (screen-absolute cursor position)
-- Line 2: FX FY (frame-position, for debug cross-check)
local M = {}
-- Global toggle
emacsZoomEnabled = true
emacsZoomDebug = false
local cursorPosFile = os.getenv("HOME") .. "/.emacs-cursor-pos"
local lastMtime = 0
local pollTimer = nil
local function readCursorPos()
local f = io.open(cursorPosFile, "r")
if not f then return nil end
local line1 = f:read("*l")
local line2 = f:read("*l")
f:close()
if not line1 then return nil end
local x, y = line1:match("^(%d+) (%d+)$")
if not x then return nil end
local fx, fy = nil, nil
if line2 then
fx, fy = line2:match("^(%-?%d+) (%-?%d+)$")
end
return tonumber(x), tonumber(y), tonumber(fx), tonumber(fy)
end
local function pollAndWarp()
if not emacsZoomEnabled then return end
-- Check file modification time to avoid unnecessary reads
local attrs = hs.fs.attributes(cursorPosFile)
if not attrs then return end
local mtime = attrs.modification
if mtime == lastMtime then return end
lastMtime = mtime
local x, y, fx, fy = readCursorPos()
if not x then return end
-- Post a real mouseMoved event (not CGWarp)
hs.eventtap.event.newMouseEvent(
hs.eventtap.event.types.mouseMoved,
hs.geometry.point(x, y)
):post()
if emacsZoomDebug then
local msg = string.format("cursor-warp: move to (%d, %d)", x, y)
if fx then
-- Cross-check with Hammerspoon's view of Emacs frame
local emacs = hs.application.get("Emacs")
if emacs then
local win = emacs:focusedWindow()
if win then
local frame = win:frame()
msg = msg .. string.format(
" | emacs-frame=(%d,%d) hs-frame=(%d,%d)",
fx, fy, math.floor(frame.x), math.floor(frame.y))
end
end
end
print(msg)
end
end
function emacsZoomToggle()
emacsZoomEnabled = not emacsZoomEnabled
local state = emacsZoomEnabled and "ON" or "OFF"
hs.alert.show("Emacs Zoom tracking: " .. state)
print("cursor-warp: " .. state)
end
function emacsZoomDebugToggle()
emacsZoomDebug = not emacsZoomDebug
hs.alert.show("Emacs Zoom debug: " .. (emacsZoomDebug and "ON" or "OFF"))
end
-- Start polling
pollTimer = hs.timer.doEvery(0.05, pollAndWarp)
-- Optional hotkey: Ctrl+Opt+Cmd+Z to toggle
hs.hotkey.bind({"ctrl", "alt", "cmd"}, "Z", emacsZoomToggle)
print("cursor-warp: loaded (polling every 50ms, hotkey Ctrl+Opt+Cmd+Z)")
return M