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