diff --git a/config.el b/config.el index 3a9f5b7..747f0d1 100644 --- a/config.el +++ b/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 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 - my/mag--window - (window-live-p my/mag--window) - my/mag--buffer - (buffer-live-p my/mag--buffer)) - (let ((pt (point))) - (with-selected-window my/mag--window - (goto-char pt) - (recenter))))) + (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)) - ;; 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) - (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))) + (setq my/mag--buffer (make-indirect-buffer my/mag--source mag-name t))) + ;; Split: left 40%, right 60% + (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) + (setq-local cursor-type nil) + (setq-local scroll-margin 0)) + (setq my/mag--active t) + (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)) (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,89 +934,61 @@ 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 "Split magnifier" "m" #'my/mag-toggle)) + :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)) ;;; ============================================================ diff --git a/scripts/macos-zoom-daemon.py b/scripts/macos-zoom-daemon.py deleted file mode 100755 index 28b46b6..0000000 --- a/scripts/macos-zoom-daemon.py +++ /dev/null @@ -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()