feat: full-featured Emacs-native magnifier, remove macOS zoom daemon
This commit is contained in:
212
config.el
212
config.el
@@ -817,64 +817,114 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
|
||||
;;; ============================================================
|
||||
;;; ACCESSIBILITY — SPLIT-SCREEN MAGNIFIER (SPC z m)
|
||||
;;; ============================================================
|
||||
;; Split view: LEFT 40% = normal editing, RIGHT 60% = zoomed indirect buffer.
|
||||
;; The zoomed pane follows cursor position via post-command-hook sync.
|
||||
;; Useful for presentations or low-vision pairing.
|
||||
;; Pure Emacs-native split-screen magnifier. Žádné externí procesy, žádné macOS API.
|
||||
;;
|
||||
;; SPC z m toggle split magnifier on/off
|
||||
|
||||
(defvar my/mag-zoom-level 4
|
||||
"Text scale level for the magnified pane (4 ≈ 2× size).")
|
||||
;; Architektura:
|
||||
;; LEFT pane (40%): normální editace — zde pracuješ
|
||||
;; RIGHT pane (60%): zvětšený pohled — indirect buffer, sleduje cursor
|
||||
;;
|
||||
;; Funkce:
|
||||
;; - Sleduje cursor v aktivním okně (post-command-hook)
|
||||
;; - Přepíná automaticky při změně bufferu/okna (buffer switch)
|
||||
;; - SPC z + / SPC z = zoom in (jen magnifier, ne globálně)
|
||||
;; - SPC z - zoom out (jen magnifier)
|
||||
;; - SPC z 0 reset zoom magnifieru na výchozí
|
||||
;; - SPC z m toggle on/off
|
||||
;; - Všechny zoom příkazy jsou no-op pokud magnifier vypnut
|
||||
;; - Kurzor v magnifier pane skrytý (cursor-type nil)
|
||||
;; - Magnifier pane je read-only zobrazení (žádné edit v indirect buf)
|
||||
;; - Správné cleanup při window-configuration-change
|
||||
|
||||
(defvar my/mag--active nil "Non-nil when split magnifier is enabled.")
|
||||
(defvar my/mag--window nil "The magnified (right) window.")
|
||||
(defvar my/mag--buffer nil "The indirect buffer used for magnification.")
|
||||
(defvar my/mag--buffer nil "Current indirect buffer for magnification.")
|
||||
(defvar my/mag--source nil "Buffer currently being magnified.")
|
||||
(defvar my/mag--zoom-level 4 "Current text-scale-set value for magnifier (4 ≈ 2× default).")
|
||||
(defvar my/mag--zoom-default 4 "Default zoom level for reset.")
|
||||
|
||||
(defun my/mag--kill-indirect ()
|
||||
"Kill the current magnifier indirect buffer if it exists."
|
||||
(when (and my/mag--buffer (buffer-live-p my/mag--buffer))
|
||||
(kill-buffer my/mag--buffer))
|
||||
(setq my/mag--buffer nil))
|
||||
|
||||
(defun my/mag--switch-source ()
|
||||
"Switch magnifier to track the current buffer."
|
||||
(let* ((new-source (current-buffer))
|
||||
(mag-name (format "*mag:%s*" (buffer-name new-source))))
|
||||
(my/mag--kill-indirect)
|
||||
(setq my/mag--source new-source)
|
||||
(setq my/mag--buffer (make-indirect-buffer new-source mag-name t))
|
||||
(when (and my/mag--window (window-live-p my/mag--window))
|
||||
(set-window-buffer my/mag--window my/mag--buffer)
|
||||
(with-selected-window my/mag--window
|
||||
(text-scale-set my/mag--zoom-level)
|
||||
(setq-local cursor-type nil)
|
||||
(setq-local scroll-margin 0)))))
|
||||
|
||||
(defun my/mag--sync ()
|
||||
"Sync magnified pane to current cursor position."
|
||||
(when (and my/mag--active
|
||||
my/mag--window
|
||||
(window-live-p my/mag--window)
|
||||
my/mag--buffer
|
||||
(buffer-live-p my/mag--buffer))
|
||||
(when my/mag--active
|
||||
;; Ignore if we're in the magnifier pane itself
|
||||
(when (eq (selected-window) my/mag--window)
|
||||
(cl-return-from my/mag--sync))
|
||||
;; Check magnifier window is still alive
|
||||
(unless (and my/mag--window (window-live-p my/mag--window))
|
||||
(cl-return-from my/mag--sync))
|
||||
;; Switch source if buffer changed
|
||||
(unless (eq (current-buffer) my/mag--source)
|
||||
(my/mag--switch-source))
|
||||
;; Sync point + recenter
|
||||
(when (and my/mag--buffer (buffer-live-p my/mag--buffer))
|
||||
(let ((pt (point)))
|
||||
(with-selected-window my/mag--window
|
||||
(goto-char pt)
|
||||
(recenter)))))
|
||||
(recenter))))))
|
||||
|
||||
(defun my/mag--on-window-change ()
|
||||
"Clean up if magnifier window was closed by user."
|
||||
(when (and my/mag--active
|
||||
(not (and my/mag--window (window-live-p my/mag--window))))
|
||||
(my/mag--disable)))
|
||||
|
||||
(defun my/mag--enable ()
|
||||
"Enable split-screen magnifier."
|
||||
(let* ((base-buf (current-buffer))
|
||||
(mag-name (format "*mag:%s*" (buffer-name base-buf))))
|
||||
(delete-other-windows)
|
||||
(setq my/mag--source (current-buffer))
|
||||
(let ((mag-name (format "*mag:%s*" (buffer-name my/mag--source))))
|
||||
;; Clean up stale buffer
|
||||
(when-let ((old (get-buffer mag-name)))
|
||||
(kill-buffer old))
|
||||
(setq my/mag--buffer (make-indirect-buffer base-buf mag-name t))
|
||||
(setq my/mag--buffer (make-indirect-buffer my/mag--source mag-name t)))
|
||||
;; Split: left 40%, right 60%
|
||||
(delete-other-windows)
|
||||
(setq my/mag--window
|
||||
(split-window (selected-window)
|
||||
(floor (* 0.4 (window-total-width)))
|
||||
'right))
|
||||
(set-window-buffer my/mag--window my/mag--buffer)
|
||||
(with-selected-window my/mag--window
|
||||
(text-scale-set my/mag-zoom-level)
|
||||
(text-scale-set my/mag--zoom-level)
|
||||
(setq-local cursor-type nil)
|
||||
(setq-local scroll-margin 0))
|
||||
(setq my/mag--active t)
|
||||
(add-hook 'post-command-hook #'my/mag--sync)
|
||||
(message "Split magnifier ON (zoom %+d)" my/mag-zoom-level)))
|
||||
(add-hook 'window-configuration-change-hook #'my/mag--on-window-change)
|
||||
(message "Split magnifier ON (zoom %+d)" my/mag--zoom-level))
|
||||
|
||||
(defun my/mag--disable ()
|
||||
"Disable split-screen magnifier."
|
||||
(remove-hook 'post-command-hook #'my/mag--sync)
|
||||
(remove-hook 'window-configuration-change-hook #'my/mag--on-window-change)
|
||||
(when (and my/mag--window (window-live-p my/mag--window))
|
||||
(delete-window my/mag--window))
|
||||
(when (and my/mag--buffer (buffer-live-p my/mag--buffer))
|
||||
(kill-buffer my/mag--buffer))
|
||||
;; Kill all *mag:* buffers
|
||||
(dolist (buf (buffer-list))
|
||||
(when (string-prefix-p "*mag:" (buffer-name buf))
|
||||
(kill-buffer buf)))
|
||||
(setq my/mag--active nil
|
||||
my/mag--window nil
|
||||
my/mag--buffer nil)
|
||||
my/mag--buffer nil
|
||||
my/mag--source nil)
|
||||
(message "Split magnifier OFF"))
|
||||
|
||||
(defun my/mag-toggle ()
|
||||
@@ -884,88 +934,60 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
|
||||
(my/mag--disable)
|
||||
(my/mag--enable)))
|
||||
|
||||
(defun my/mag-zoom-in ()
|
||||
"Increase magnifier zoom level."
|
||||
(interactive)
|
||||
(when my/mag--active
|
||||
(cl-incf my/mag--zoom-level)
|
||||
(when (and my/mag--window (window-live-p my/mag--window))
|
||||
(with-selected-window my/mag--window
|
||||
(text-scale-set my/mag--zoom-level)))
|
||||
(message "Magnifier zoom %+d" my/mag--zoom-level)))
|
||||
|
||||
;;; ============================================================
|
||||
;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t)
|
||||
;;; ============================================================
|
||||
;; 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.
|
||||
;;
|
||||
;; 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, screen image moves: Continuously with pointer"
|
||||
;; Privacy & Security → Accessibility → povolit Terminal (nebo Emacs)
|
||||
;;
|
||||
;; SPC z t toggle cursor tracking (spustí/zastaví daemon)
|
||||
(defun my/mag-zoom-out ()
|
||||
"Decrease magnifier zoom level."
|
||||
(interactive)
|
||||
(when my/mag--active
|
||||
(cl-decf my/mag--zoom-level)
|
||||
(when (and my/mag--window (window-live-p my/mag--window))
|
||||
(with-selected-window my/mag--window
|
||||
(text-scale-set my/mag--zoom-level)))
|
||||
(message "Magnifier zoom %+d" my/mag--zoom-level)))
|
||||
|
||||
(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.")
|
||||
(defun my/mag-zoom-reset ()
|
||||
"Reset magnifier zoom to default."
|
||||
(interactive)
|
||||
(when my/mag--active
|
||||
(setq my/mag--zoom-level my/mag--zoom-default)
|
||||
(when (and my/mag--window (window-live-p my/mag--window))
|
||||
(with-selected-window my/mag--window
|
||||
(text-scale-set my/mag--zoom-level)))
|
||||
(message "Magnifier zoom reset to %+d" my/mag--zoom-level)))
|
||||
|
||||
(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/mag-or-global-zoom-in ()
|
||||
"Zoom in: magnifier if active, otherwise global."
|
||||
(interactive)
|
||||
(if my/mag--active (my/mag-zoom-in) (my/zoom-in)))
|
||||
|
||||
(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/mag-or-global-zoom-out ()
|
||||
"Zoom out: magnifier if active, otherwise global."
|
||||
(interactive)
|
||||
(if my/mag--active (my/mag-zoom-out) (my/zoom-out)))
|
||||
|
||||
(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/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
|
||||
"Cursor tracking pro macOS Zoom (via Python daemon)."
|
||||
:global t
|
||||
(if my/cursor-zoom-mode
|
||||
(progn
|
||||
(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)))
|
||||
(defun my/mag-or-global-zoom-reset ()
|
||||
"Reset zoom: magnifier if active, otherwise global."
|
||||
(interactive)
|
||||
(if my/mag--active (my/mag-zoom-reset) (my/zoom-reset)))
|
||||
|
||||
;; --------------- keybindings ---------------
|
||||
|
||||
(map! :leader
|
||||
(:prefix ("z" . "zoom")
|
||||
:desc "Zoom in (×1.5)" "+" #'my/zoom-in
|
||||
:desc "Zoom in (×1.5)" "=" #'my/zoom-in
|
||||
:desc "Zoom out (÷1.5)" "-" #'my/zoom-out
|
||||
:desc "Reset na výchozí" "0" #'my/zoom-reset
|
||||
:desc "Restore předchozí" "z" #'my/zoom-restore
|
||||
:desc "Toggle cursor track" "t" #'my/cursor-zoom-mode
|
||||
:desc "Zoom in (global ×1.5)" "+" #'my/mag-or-global-zoom-in
|
||||
:desc "Zoom in (global ×1.5)" "=" #'my/mag-or-global-zoom-in
|
||||
:desc "Zoom out (global ÷1.5)" "-" #'my/mag-or-global-zoom-out
|
||||
:desc "Reset zoom" "0" #'my/mag-or-global-zoom-reset
|
||||
:desc "Restore global zoom" "z" #'my/zoom-restore
|
||||
:desc "Split magnifier" "m" #'my/mag-toggle))
|
||||
|
||||
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
#!/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:
|
||||
# Match any Emacs variant: Emacs, Emacs-arm64-11, Emacs-x86_64-10_14, etc.
|
||||
out = subprocess.check_output(
|
||||
["pgrep", "-f", "Emacs.app/Contents/MacOS/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