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)
;;; ============================================================
;; 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))
;;; ============================================================