feat: Hammerspoon IPC cursor tracking + split magnifier (SPC z m)
This commit is contained in:
99
config.el
99
config.el
@@ -814,25 +814,102 @@ Keeps the status bar and tab bar fully visible at any zoom level.")
|
|||||||
my/zoom-saved-steps nil)))
|
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)
|
;;; ACCESSIBILITY — MACOS ZOOM CURSOR TRACKING (SPC z t)
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
;; Warps mouse pointer to text cursor so macOS Zoom ("Follow mouse cursor")
|
;; Writes cursor screen position to ~/.emacs-cursor-pos for Hammerspoon.
|
||||||
;; tracks editing position. Uses window-absolute-pixel-position (Emacs 26+)
|
;; Hammerspoon posts a real mouseMoved HID event — required for macOS Zoom
|
||||||
;; paired with set-mouse-absolute-pixel-position — no manual coordinate math.
|
;; "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.
|
;; CCM compatible: idle timer fires after ccm recenter + redisplay.
|
||||||
;;
|
;;
|
||||||
;; SETUP macOS: System Settings → Accessibility → Zoom → Full Screen
|
;; SETUP macOS: System Settings → Accessibility → Zoom → Full Screen
|
||||||
;; "When zoomed in, the screen image moves: Continuously with pointer"
|
;; "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
|
;; SPC z t toggle cursor tracking on/off
|
||||||
|
|
||||||
(defvar my/warp-timer nil "Debounce timer for cursor warp.")
|
(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 ()
|
(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))
|
(when my/warp-timer (cancel-timer my/warp-timer))
|
||||||
(setq my/warp-timer
|
(setq my/warp-timer
|
||||||
(run-with-idle-timer 0.05 nil
|
(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)
|
(setq my/warp-timer nil)
|
||||||
(when (display-graphic-p)
|
(when (display-graphic-p)
|
||||||
(when-let ((pos (window-absolute-pixel-position)))
|
(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 ()
|
(defun my/refresh-frame-position ()
|
||||||
"Workaround for bug#71912: stale frame position after sleep/fullscreen."
|
"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 "Zoom out (÷1.5)" "-" #'my/zoom-out
|
||||||
:desc "Reset na výchozí" "0" #'my/zoom-reset
|
:desc "Reset na výchozí" "0" #'my/zoom-reset
|
||||||
:desc "Restore předchozí" "z" #'my/zoom-restore
|
: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))
|
||||||
|
|
||||||
|
|
||||||
;;; ============================================================
|
;;; ============================================================
|
||||||
|
|||||||
105
hammerspoon/cursor-warp.lua
Normal file
105
hammerspoon/cursor-warp.lua
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user