feat: full-featured Emacs-native magnifier, remove macOS zoom daemon

This commit is contained in:
2026-02-22 22:57:58 +01:00
parent 0e1232ad00
commit a71e212d81
2 changed files with 131 additions and 297 deletions

240
config.el
View File

@@ -817,64 +817,114 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
;;; ============================================================ ;;; ============================================================
;;; ACCESSIBILITY — SPLIT-SCREEN MAGNIFIER (SPC z m) ;;; ACCESSIBILITY — SPLIT-SCREEN MAGNIFIER (SPC z m)
;;; ============================================================ ;;; ============================================================
;; Split view: LEFT 40% = normal editing, RIGHT 60% = zoomed indirect buffer. ;; Pure Emacs-native split-screen magnifier. Žádné externí procesy, žádné macOS API.
;; The zoomed pane follows cursor position via post-command-hook sync.
;; Useful for presentations or low-vision pairing.
;; ;;
;; SPC z m toggle split magnifier on/off ;; Architektura:
;; LEFT pane (40%): normální editace — zde pracuješ
(defvar my/mag-zoom-level 4 ;; RIGHT pane (60%): zvětšený pohled — indirect buffer, sleduje cursor
"Text scale level for the magnified pane (4 ≈ 2× size).") ;;
;; 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--active nil "Non-nil when split magnifier is enabled.")
(defvar my/mag--window nil "The magnified (right) window.") (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 () (defun my/mag--sync ()
"Sync magnified pane to current cursor position." "Sync magnified pane to current cursor position."
(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))))))
(defun my/mag--on-window-change ()
"Clean up if magnifier window was closed by user."
(when (and my/mag--active (when (and my/mag--active
my/mag--window (not (and my/mag--window (window-live-p my/mag--window))))
(window-live-p my/mag--window) (my/mag--disable)))
my/mag--buffer
(buffer-live-p my/mag--buffer))
(let ((pt (point)))
(with-selected-window my/mag--window
(goto-char pt)
(recenter)))))
(defun my/mag--enable () (defun my/mag--enable ()
"Enable split-screen magnifier." "Enable split-screen magnifier."
(let* ((base-buf (current-buffer)) (delete-other-windows)
(mag-name (format "*mag:%s*" (buffer-name base-buf)))) (setq my/mag--source (current-buffer))
(let ((mag-name (format "*mag:%s*" (buffer-name my/mag--source))))
;; Clean up stale buffer ;; Clean up stale buffer
(when-let ((old (get-buffer mag-name))) (when-let ((old (get-buffer mag-name)))
(kill-buffer old)) (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% ;; Split: left 40%, right 60%
(delete-other-windows) (setq my/mag--window
(setq my/mag--window (split-window (selected-window)
(split-window (selected-window) (floor (* 0.4 (window-total-width)))
(floor (* 0.4 (window-total-width))) 'right))
'right)) (set-window-buffer my/mag--window my/mag--buffer)
(set-window-buffer my/mag--window my/mag--buffer) (with-selected-window my/mag--window
(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 cursor-type nil) (setq-local scroll-margin 0))
(setq-local scroll-margin 0)) (setq my/mag--active t)
(setq my/mag--active t) (add-hook 'post-command-hook #'my/mag--sync)
(add-hook 'post-command-hook #'my/mag--sync) (add-hook 'window-configuration-change-hook #'my/mag--on-window-change)
(message "Split magnifier ON (zoom %+d)" my/mag-zoom-level))) (message "Split magnifier ON (zoom %+d)" my/mag--zoom-level))
(defun my/mag--disable () (defun my/mag--disable ()
"Disable split-screen magnifier." "Disable split-screen magnifier."
(remove-hook 'post-command-hook #'my/mag--sync) (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)) (when (and my/mag--window (window-live-p my/mag--window))
(delete-window my/mag--window)) (delete-window my/mag--window))
(when (and my/mag--buffer (buffer-live-p my/mag--buffer)) ;; Kill all *mag:* buffers
(kill-buffer my/mag--buffer)) (dolist (buf (buffer-list))
(when (string-prefix-p "*mag:" (buffer-name buf))
(kill-buffer buf)))
(setq my/mag--active nil (setq my/mag--active nil
my/mag--window nil my/mag--window nil
my/mag--buffer nil) my/mag--buffer nil
my/mag--source nil)
(message "Split magnifier OFF")) (message "Split magnifier OFF"))
(defun my/mag-toggle () (defun my/mag-toggle ()
@@ -884,89 +934,61 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
(my/mag--disable) (my/mag--disable)
(my/mag--enable))) (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)))
;;; ============================================================ (defun my/mag-zoom-out ()
;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t) "Decrease magnifier zoom level."
;;; ============================================================ (interactive)
;; Python daemon (scripts/macos-zoom-daemon.py) čte frame-relative (when my/mag--active
;; souřadnice kurzoru ze stdin, získá přesnou pozici Emacs okna přes (cl-decf my/mag--zoom-level)
;; macOS AX API, a postne real CGEvent.mouseMoved → macOS Zoom sleduje. (when (and my/mag--window (window-live-p my/mag--window))
;; (with-selected-window my/mag--window
;; Výhody oproti přímému Emacs warpu: (text-scale-set my/mag--zoom-level)))
;; - CGEventPost (ne CGWarpMouseCursorPosition) → Zoom skutečně reaguje (message "Magnifier zoom %+d" my/mag--zoom-level)))
;; - 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)
(defvar my/zoom-daemon-process nil "Subprocess: macos-zoom-daemon.py.") (defun my/mag-zoom-reset ()
(defvar my/zoom-warp-timer nil "Debounce timer pro cursor tracking.") "Reset magnifier zoom to default."
(defvar my/zoom-daemon-script (interactive)
(expand-file-name "scripts/macos-zoom-daemon.py" doom-private-dir) (when my/mag--active
"Cesta k macos-zoom-daemon.py.") (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 () (defun my/mag-or-global-zoom-in ()
"Spustí Python cursor tracking daemon." "Zoom in: magnifier if active, otherwise global."
(when (and (display-graphic-p) (file-exists-p my/zoom-daemon-script)) (interactive)
(setq my/zoom-daemon-process (if my/mag--active (my/mag-zoom-in) (my/zoom-in)))
(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/zoom-daemon-stop () (defun my/mag-or-global-zoom-out ()
"Zastaví Python cursor tracking daemon." "Zoom out: magnifier if active, otherwise global."
(when (and my/zoom-daemon-process (process-live-p my/zoom-daemon-process)) (interactive)
(delete-process my/zoom-daemon-process)) (if my/mag--active (my/mag-zoom-out) (my/zoom-out)))
(setq my/zoom-daemon-process nil)
(message "Cursor tracking OFF"))
(defun my/zoom-send-cursor-pos () (defun my/mag-or-global-zoom-reset ()
"Pošle frame-relative pozici kurzoru do daemon stdin (debounced 50ms)." "Reset zoom: magnifier if active, otherwise global."
(when my/zoom-warp-timer (cancel-timer my/zoom-warp-timer)) (interactive)
(setq my/zoom-warp-timer (if my/mag--active (my/mag-zoom-reset) (my/zoom-reset)))
(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)))
;; --------------- keybindings --------------- ;; --------------- keybindings ---------------
(map! :leader (map! :leader
(:prefix ("z" . "zoom") (:prefix ("z" . "zoom")
:desc "Zoom in (×1.5)" "+" #'my/zoom-in :desc "Zoom in (global ×1.5)" "+" #'my/mag-or-global-zoom-in
:desc "Zoom in (×1.5)" "=" #'my/zoom-in :desc "Zoom in (global ×1.5)" "=" #'my/mag-or-global-zoom-in
:desc "Zoom out (÷1.5)" "-" #'my/zoom-out :desc "Zoom out (global ÷1.5)" "-" #'my/mag-or-global-zoom-out
:desc "Reset na výchozí" "0" #'my/zoom-reset :desc "Reset zoom" "0" #'my/mag-or-global-zoom-reset
:desc "Restore předchozí" "z" #'my/zoom-restore :desc "Restore global zoom" "z" #'my/zoom-restore
:desc "Toggle cursor track" "t" #'my/cursor-zoom-mode :desc "Split magnifier" "m" #'my/mag-toggle))
:desc "Split magnifier" "m" #'my/mag-toggle))
;;; ============================================================ ;;; ============================================================

View File

@@ -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()