refactor: Python ctypes daemon for macOS Zoom (no external tools)
This commit is contained in:
99
config.el
99
config.el
@@ -888,64 +888,73 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
|
|||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t)
|
;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t)
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
;; Writes cursor screen position to ~/.emacs-cursor-pos for Hammerspoon.
|
;; Python daemon (scripts/macos-zoom-daemon.py) čte frame-relative
|
||||||
;; Hammerspoon posts a real mouseMoved HID event — required for macOS Zoom
|
;; souřadnice kurzoru ze stdin, získá přesnou pozici Emacs okna přes
|
||||||
;; "Follow mouse cursor" (CGWarpMouseCursorPosition is silently ignored).
|
;; macOS AX API, a postne real CGEvent.mouseMoved → macOS Zoom sleduje.
|
||||||
;;
|
;;
|
||||||
;; Debounced via run-with-idle-timer 0.05s: rapid j/k spam produces one write.
|
;; Výhody oproti přímému Emacs warpu:
|
||||||
;; CCM compatible: idle timer fires after ccm recenter + redisplay.
|
;; - CGEventPost (ne CGWarpMouseCursorPosition) → Zoom skutečně reaguje
|
||||||
|
;; - Window origin z AX API (ne buggy frame-position Emacsu)
|
||||||
|
;; - Debounce 50ms: rapid j/k spam → jeden event
|
||||||
;;
|
;;
|
||||||
;; SETUP macOS: System Settings → Accessibility → Zoom → Full Screen
|
;; SETUP macOS:
|
||||||
;; "When zoomed in, the screen image moves: Continuously with pointer"
|
;; System Settings → Accessibility → Zoom → Full Screen
|
||||||
;; SETUP Hammerspoon: copy hammerspoon/cursor-warp.lua to ~/.hammerspoon/
|
;; "When zoomed in, screen image moves: Continuously with pointer"
|
||||||
|
;; Privacy & Security → Accessibility → povolit Terminal (nebo Emacs)
|
||||||
;;
|
;;
|
||||||
;; SPC z t toggle cursor tracking on/off
|
;; SPC z t toggle cursor tracking (spustí/zastaví daemon)
|
||||||
|
|
||||||
(defvar my/warp-timer nil "Debounce timer for cursor warp.")
|
(defvar my/zoom-daemon-process nil "Subprocess: macos-zoom-daemon.py.")
|
||||||
|
(defvar my/zoom-warp-timer nil "Debounce timer pro cursor tracking.")
|
||||||
|
(defvar my/zoom-daemon-script
|
||||||
|
(expand-file-name "scripts/macos-zoom-daemon.py" doom-private-dir)
|
||||||
|
"Cesta k macos-zoom-daemon.py.")
|
||||||
|
|
||||||
(defvar my/cursor-pos-file (expand-file-name "~/.emacs-cursor-pos")
|
(defun my/zoom-daemon-start ()
|
||||||
"File path for Hammerspoon cursor tracking IPC.")
|
"Spustí Python cursor tracking daemon."
|
||||||
|
(when (and (display-graphic-p) (file-exists-p my/zoom-daemon-script))
|
||||||
|
(setq my/zoom-daemon-process
|
||||||
|
(start-process "macos-zoom-daemon" nil
|
||||||
|
"python3" my/zoom-daemon-script))
|
||||||
|
(set-process-query-on-exit-flag my/zoom-daemon-process nil)
|
||||||
|
(message "Cursor tracking ON")))
|
||||||
|
|
||||||
(defun my/warp-mouse-to-cursor ()
|
(defun my/zoom-daemon-stop ()
|
||||||
"Write cursor screen position to file for Hammerspoon to pick up.
|
"Zastaví Python cursor tracking daemon."
|
||||||
Hammerspoon posts a real mouseMoved HID event — required for macOS Zoom
|
(when (and my/zoom-daemon-process (process-live-p my/zoom-daemon-process))
|
||||||
'Follow mouse cursor' (CGWarpMouseCursorPosition is silently ignored by Zoom)."
|
(delete-process my/zoom-daemon-process))
|
||||||
(when my/warp-timer (cancel-timer my/warp-timer))
|
(setq my/zoom-daemon-process nil)
|
||||||
(setq my/warp-timer
|
(message "Cursor tracking OFF"))
|
||||||
|
|
||||||
|
(defun my/zoom-send-cursor-pos ()
|
||||||
|
"Pošle frame-relative pozici kurzoru do daemon stdin (debounced 50ms)."
|
||||||
|
(when my/zoom-warp-timer (cancel-timer my/zoom-warp-timer))
|
||||||
|
(setq my/zoom-warp-timer
|
||||||
(run-with-idle-timer 0.05 nil
|
(run-with-idle-timer 0.05 nil
|
||||||
(lambda ()
|
(lambda ()
|
||||||
(setq my/warp-timer nil)
|
(setq my/zoom-warp-timer nil)
|
||||||
(when (display-graphic-p)
|
(when (and my/zoom-daemon-process
|
||||||
(when-let ((pos (window-absolute-pixel-position)))
|
(process-live-p my/zoom-daemon-process)
|
||||||
;; Write screen-absolute coords for Hammerspoon
|
(display-graphic-p))
|
||||||
;; Also write frame-position for Hammerspoon cross-check
|
(let* ((win (selected-window))
|
||||||
(let* ((fp (frame-position))
|
(vis (pos-visible-in-window-p (window-point win) win t)))
|
||||||
(content (format "%d %d\n%d %d\n"
|
(when (and vis (listp vis))
|
||||||
(car pos) (cdr pos)
|
(let* ((edges (window-pixel-edges win))
|
||||||
(car fp) (cdr fp))))
|
(x (+ (nth 0 edges) (nth 0 vis)))
|
||||||
(with-temp-file my/cursor-pos-file
|
(y (+ (nth 1 edges) (nth 1 vis)
|
||||||
(insert content)))))))))
|
(/ (line-pixel-height) 2))))
|
||||||
|
(process-send-string my/zoom-daemon-process
|
||||||
(defun my/refresh-frame-position ()
|
(format "%d %d\n" x y))))))))))
|
||||||
"Workaround for bug#71912: stale frame position after sleep/fullscreen."
|
|
||||||
(when (display-graphic-p)
|
|
||||||
(ignore-errors
|
|
||||||
(let ((p (frame-position)))
|
|
||||||
(set-frame-position (selected-frame) (car p) (cdr p))))))
|
|
||||||
|
|
||||||
(define-minor-mode my/cursor-zoom-mode
|
(define-minor-mode my/cursor-zoom-mode
|
||||||
"Warp mouse to text cursor for macOS Zoom accessibility tracking."
|
"Cursor tracking pro macOS Zoom (via Python daemon)."
|
||||||
:global t
|
:global t
|
||||||
(if my/cursor-zoom-mode
|
(if my/cursor-zoom-mode
|
||||||
(progn
|
(progn
|
||||||
(add-hook 'post-command-hook #'my/warp-mouse-to-cursor)
|
(my/zoom-daemon-start)
|
||||||
(add-hook 'window-size-change-functions
|
(add-hook 'post-command-hook #'my/zoom-send-cursor-pos))
|
||||||
(lambda (_) (my/refresh-frame-position)))
|
(remove-hook 'post-command-hook #'my/zoom-send-cursor-pos)
|
||||||
(run-with-timer 60 60 #'my/refresh-frame-position))
|
(my/zoom-daemon-stop)))
|
||||||
(remove-hook 'post-command-hook #'my/warp-mouse-to-cursor)))
|
|
||||||
|
|
||||||
;; Enable by default
|
|
||||||
(my/cursor-zoom-mode 1)
|
|
||||||
|
|
||||||
;; --------------- keybindings ---------------
|
;; --------------- keybindings ---------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
-- 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
|
|
||||||
185
scripts/macos-zoom-daemon.py
Executable file
185
scripts/macos-zoom-daemon.py
Executable file
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""macOS Zoom cursor tracking daemon for Emacs.
|
||||||
|
|
||||||
|
Reads frame-relative cursor coordinates from stdin (sent by Emacs via
|
||||||
|
process-send-string), obtains the Emacs window position via macOS
|
||||||
|
Accessibility API, and posts a CGEvent.mouseMoved so macOS Zoom
|
||||||
|
"Follow mouse cursor" tracks the text cursor.
|
||||||
|
|
||||||
|
Usage: python3 macos-zoom-daemon.py [--debug]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import ctypes.util
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --- Constants ---
|
||||||
|
TITLE_BAR_H = int(os.environ.get("EMACS_ZOOM_TITLE_BAR_H", "22"))
|
||||||
|
AX_CACHE_INTERVAL = 30 # refresh AX window frame every N events
|
||||||
|
DEBUG = "--debug" in sys.argv
|
||||||
|
|
||||||
|
# --- ctypes structures ---
|
||||||
|
class CGPoint(ctypes.Structure):
|
||||||
|
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
|
||||||
|
|
||||||
|
class CGSize(ctypes.Structure):
|
||||||
|
_fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)]
|
||||||
|
|
||||||
|
class CGRect(ctypes.Structure):
|
||||||
|
_fields_ = [("origin", CGPoint), ("size", CGSize)]
|
||||||
|
|
||||||
|
# --- Load frameworks ---
|
||||||
|
CG = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
|
||||||
|
CF = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
|
||||||
|
AS = ctypes.CDLL("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")
|
||||||
|
|
||||||
|
# CGEvent functions
|
||||||
|
CG.CGEventCreateMouseEvent.restype = ctypes.c_void_p
|
||||||
|
CG.CGEventCreateMouseEvent.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_uint32, CGPoint, ctypes.c_uint32
|
||||||
|
]
|
||||||
|
CG.CGEventPost.restype = None
|
||||||
|
CG.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
|
||||||
|
|
||||||
|
CF.CFRelease.restype = None
|
||||||
|
CF.CFRelease.argtypes = [ctypes.c_void_p]
|
||||||
|
|
||||||
|
# AX functions
|
||||||
|
AS.AXUIElementCreateApplication.restype = ctypes.c_void_p
|
||||||
|
AS.AXUIElementCreateApplication.argtypes = [ctypes.c_int]
|
||||||
|
|
||||||
|
AS.AXUIElementCopyAttributeValue.restype = ctypes.c_int32
|
||||||
|
AS.AXUIElementCopyAttributeValue.argtypes = [
|
||||||
|
ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)
|
||||||
|
]
|
||||||
|
|
||||||
|
AS.AXValueGetValue.restype = ctypes.c_bool
|
||||||
|
AS.AXValueGetValue.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p]
|
||||||
|
|
||||||
|
# CFString helper
|
||||||
|
CF.CFStringCreateWithCString.restype = ctypes.c_void_p
|
||||||
|
CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
|
||||||
|
kCFStringEncodingUTF8 = 0x08000100
|
||||||
|
|
||||||
|
def cfstr(s):
|
||||||
|
return CF.CFStringCreateWithCString(None, s.encode("utf-8"), kCFStringEncodingUTF8)
|
||||||
|
|
||||||
|
# Pre-create CFStrings
|
||||||
|
kAXFocusedWindow = cfstr("AXFocusedWindow")
|
||||||
|
kAXPosition = cfstr("AXPosition")
|
||||||
|
kAXSize = cfstr("AXSize")
|
||||||
|
|
||||||
|
# AXValue types
|
||||||
|
kAXValueCGPointType = 1
|
||||||
|
kAXValueCGSizeType = 2
|
||||||
|
|
||||||
|
# --- Emacs PID ---
|
||||||
|
_emacs_pid = None
|
||||||
|
|
||||||
|
def get_emacs_pid():
|
||||||
|
global _emacs_pid
|
||||||
|
if _emacs_pid is not None:
|
||||||
|
# Check if still alive
|
||||||
|
try:
|
||||||
|
os.kill(_emacs_pid, 0)
|
||||||
|
return _emacs_pid
|
||||||
|
except OSError:
|
||||||
|
_emacs_pid = None
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["pgrep", "-x", "Emacs"], text=True).strip()
|
||||||
|
pids = out.split("\n")
|
||||||
|
_emacs_pid = int(pids[0])
|
||||||
|
return _emacs_pid
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- AX window frame ---
|
||||||
|
_cached_origin = None # (x, y)
|
||||||
|
_cache_counter = 0
|
||||||
|
|
||||||
|
def get_window_origin(force=False):
|
||||||
|
global _cached_origin, _cache_counter
|
||||||
|
_cache_counter += 1
|
||||||
|
if not force and _cached_origin is not None and _cache_counter % AX_CACHE_INTERVAL != 0:
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
pid = get_emacs_pid()
|
||||||
|
if pid is None:
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
try:
|
||||||
|
app = AS.AXUIElementCreateApplication(pid)
|
||||||
|
if not app:
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
win = ctypes.c_void_p()
|
||||||
|
err = AS.AXUIElementCopyAttributeValue(app, kAXFocusedWindow, ctypes.byref(win))
|
||||||
|
CF.CFRelease(app)
|
||||||
|
if err != 0 or not win.value:
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
# Get position
|
||||||
|
pos_val = ctypes.c_void_p()
|
||||||
|
err = AS.AXUIElementCopyAttributeValue(win.value, kAXPosition, ctypes.byref(pos_val))
|
||||||
|
if err != 0 or not pos_val.value:
|
||||||
|
CF.CFRelease(win)
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
point = CGPoint()
|
||||||
|
ok = AS.AXValueGetValue(pos_val.value, kAXValueCGPointType, ctypes.byref(point))
|
||||||
|
CF.CFRelease(pos_val)
|
||||||
|
CF.CFRelease(win)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
_cached_origin = (point.x, point.y)
|
||||||
|
if DEBUG:
|
||||||
|
print(f"[AX] window origin: {_cached_origin}", file=sys.stderr)
|
||||||
|
return _cached_origin
|
||||||
|
except Exception as e:
|
||||||
|
if DEBUG:
|
||||||
|
print(f"[AX] error: {e}", file=sys.stderr)
|
||||||
|
return _cached_origin
|
||||||
|
|
||||||
|
# --- Post mouse event ---
|
||||||
|
def post_mouse_moved(sx, sy):
|
||||||
|
pt = CGPoint(sx, sy)
|
||||||
|
evt = CG.CGEventCreateMouseEvent(None, 5, pt, 0) # 5 = kCGEventMouseMoved
|
||||||
|
if evt:
|
||||||
|
CG.CGEventPost(0, evt) # 0 = kCGHIDEventTap
|
||||||
|
CF.CFRelease(evt)
|
||||||
|
|
||||||
|
# --- Main loop ---
|
||||||
|
def main():
|
||||||
|
if DEBUG:
|
||||||
|
print(f"[daemon] started, TITLE_BAR_H={TITLE_BAR_H}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Force initial AX query
|
||||||
|
get_window_origin(force=True)
|
||||||
|
|
||||||
|
for line in sys.stdin:
|
||||||
|
parts = line.strip().split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ex, ey = float(parts[0]), float(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
origin = get_window_origin()
|
||||||
|
if origin is None:
|
||||||
|
if DEBUG:
|
||||||
|
print("[daemon] no window origin, skipping", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
|
||||||
|
sx = origin[0] + ex
|
||||||
|
sy = origin[1] + TITLE_BAR_H + ey
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
print(f"[daemon] frame({ex:.0f},{ey:.0f}) → screen({sx:.0f},{sy:.0f})", file=sys.stderr)
|
||||||
|
|
||||||
|
post_mouse_moved(sx, sy)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user