186 lines
5.5 KiB
Python
Executable File
186 lines
5.5 KiB
Python
Executable File
#!/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()
|