diff --git a/config.el b/config.el index 4775f13..3a9f5b7 100644 --- a/config.el +++ b/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) ;;; ============================================================ -;; Writes cursor screen position to ~/.emacs-cursor-pos for Hammerspoon. -;; Hammerspoon posts a real mouseMoved HID event — required for macOS Zoom -;; "Follow mouse cursor" (CGWarpMouseCursorPosition is silently ignored). +;; Python daemon (scripts/macos-zoom-daemon.py) čte frame-relative +;; souřadnice kurzoru ze stdin, získá přesnou pozici Emacs okna přes +;; 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. -;; CCM compatible: idle timer fires after ccm recenter + redisplay. +;; Výhody oproti přímému Emacs warpu: +;; - 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 -;; "When zoomed in, the screen image moves: Continuously with pointer" -;; SETUP Hammerspoon: copy hammerspoon/cursor-warp.lua to ~/.hammerspoon/ +;; SETUP macOS: +;; System Settings → Accessibility → Zoom → Full Screen +;; "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") - "File path for Hammerspoon cursor tracking IPC.") +(defun my/zoom-daemon-start () + "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 () - "Write cursor screen position to file for Hammerspoon to pick up. -Hammerspoon posts a real mouseMoved HID event — required for macOS Zoom -'Follow mouse cursor' (CGWarpMouseCursorPosition is silently ignored by Zoom)." - (when my/warp-timer (cancel-timer my/warp-timer)) - (setq my/warp-timer +(defun my/zoom-daemon-stop () + "Zastaví Python cursor tracking daemon." + (when (and my/zoom-daemon-process (process-live-p my/zoom-daemon-process)) + (delete-process my/zoom-daemon-process)) + (setq my/zoom-daemon-process nil) + (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 (lambda () - (setq my/warp-timer nil) - (when (display-graphic-p) - (when-let ((pos (window-absolute-pixel-position))) - ;; Write screen-absolute coords for Hammerspoon - ;; Also write frame-position for Hammerspoon cross-check - (let* ((fp (frame-position)) - (content (format "%d %d\n%d %d\n" - (car pos) (cdr pos) - (car fp) (cdr fp)))) - (with-temp-file my/cursor-pos-file - (insert content))))))))) - -(defun my/refresh-frame-position () - "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)))))) + (setq my/zoom-warp-timer nil) + (when (and my/zoom-daemon-process + (process-live-p my/zoom-daemon-process) + (display-graphic-p)) + (let* ((win (selected-window)) + (vis (pos-visible-in-window-p (window-point win) win t))) + (when (and vis (listp vis)) + (let* ((edges (window-pixel-edges win)) + (x (+ (nth 0 edges) (nth 0 vis))) + (y (+ (nth 1 edges) (nth 1 vis) + (/ (line-pixel-height) 2)))) + (process-send-string my/zoom-daemon-process + (format "%d %d\n" x y)))))))))) (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 (if my/cursor-zoom-mode (progn - (add-hook 'post-command-hook #'my/warp-mouse-to-cursor) - (add-hook 'window-size-change-functions - (lambda (_) (my/refresh-frame-position))) - (run-with-timer 60 60 #'my/refresh-frame-position)) - (remove-hook 'post-command-hook #'my/warp-mouse-to-cursor))) - -;; Enable by default -(my/cursor-zoom-mode 1) + (my/zoom-daemon-start) + (add-hook 'post-command-hook #'my/zoom-send-cursor-pos)) + (remove-hook 'post-command-hook #'my/zoom-send-cursor-pos) + (my/zoom-daemon-stop))) ;; --------------- keybindings --------------- diff --git a/hammerspoon/cursor-warp.lua b/hammerspoon/cursor-warp.lua deleted file mode 100644 index 4771651..0000000 --- a/hammerspoon/cursor-warp.lua +++ /dev/null @@ -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 diff --git a/scripts/macos-zoom-daemon.py b/scripts/macos-zoom-daemon.py new file mode 100755 index 0000000..4d6daaa --- /dev/null +++ b/scripts/macos-zoom-daemon.py @@ -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()