diff --git a/config.el b/config.el index 29a4f44..4775f13 100644 --- a/config.el +++ b/config.el @@ -814,25 +814,102 @@ Keeps the status bar and tab bar fully visible at any zoom level.") my/zoom-saved-steps nil))) +;;; ============================================================ +;;; 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. +;; +;; SPC z m toggle split magnifier on/off + +(defvar my/mag-zoom-level 4 + "Text scale level for the magnified pane (4 ≈ 2× size).") + +(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.") + +(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)) + (let ((pt (point))) + (with-selected-window my/mag--window + (goto-char pt) + (recenter))))) + +(defun my/mag--enable () + "Enable split-screen magnifier." + (let* ((base-buf (current-buffer)) + (mag-name (format "*mag:%s*" (buffer-name base-buf)))) + ;; 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))) + +(defun my/mag--disable () + "Disable split-screen magnifier." + (remove-hook 'post-command-hook #'my/mag--sync) + (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)) + (setq my/mag--active nil + my/mag--window nil + my/mag--buffer nil) + (message "Split magnifier OFF")) + +(defun my/mag-toggle () + "Toggle split-screen magnifier on/off." + (interactive) + (if my/mag--active + (my/mag--disable) + (my/mag--enable))) + + ;;; ============================================================ ;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t) ;;; ============================================================ -;; Warps mouse pointer to text cursor so macOS Zoom ("Follow mouse cursor") -;; tracks editing position. Uses window-absolute-pixel-position (Emacs 26+) -;; paired with set-mouse-absolute-pixel-position — no manual coordinate math. +;; 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). ;; -;; Debounced via run-with-idle-timer 0.05s: rapid j/k spam produces one warp. +;; Debounced via run-with-idle-timer 0.05s: rapid j/k spam produces one write. ;; CCM compatible: idle timer fires after ccm recenter + redisplay. ;; ;; 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/ ;; ;; SPC z t toggle cursor tracking on/off (defvar my/warp-timer nil "Debounce timer for cursor warp.") +(defvar my/cursor-pos-file (expand-file-name "~/.emacs-cursor-pos") + "File path for Hammerspoon cursor tracking IPC.") + (defun my/warp-mouse-to-cursor () - "Schedule debounced mouse warp to current cursor position." + "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 (run-with-idle-timer 0.05 nil @@ -840,7 +917,14 @@ Keeps the status bar and tab bar fully visible at any zoom level.") (setq my/warp-timer nil) (when (display-graphic-p) (when-let ((pos (window-absolute-pixel-position))) - (set-mouse-absolute-pixel-position (car pos) (cdr pos)))))))) + ;; 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." @@ -872,7 +956,8 @@ Keeps the status bar and tab bar fully visible at any zoom level.") :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 "Toggle cursor track" "t" #'my/cursor-zoom-mode + :desc "Split magnifier" "m" #'my/mag-toggle)) ;;; ============================================================ diff --git a/hammerspoon/cursor-warp.lua b/hammerspoon/cursor-warp.lua new file mode 100644 index 0000000..4771651 --- /dev/null +++ b/hammerspoon/cursor-warp.lua @@ -0,0 +1,105 @@ +-- 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