切到副屏,鼠标却还留在主屏:我的 Hammerspoon 配置(应用秒切 + 鼠标跟屏 + 快捷键速查)
桌上加了块副屏之后,有个小毛病天天犯。
⌘Tab 切到副屏的 Chrome ,窗口是亮了,鼠标还在主屏待着。这时候手一滚滚轮,滚的是鼠标底下那个 iTerm ,副屏的 Chrome 一动不动。愣半秒反应过来,去摸鼠标,甩过去,再滚。一天几十回。
这事 Raycast 、Rectangle 都解决不了,它们管窗口,不管鼠标。最后是用 Hammerspoon 缝了段逻辑:按快捷键切应用的时候,鼠标自己飞到目标窗口中间。顺手把之前乱七八糟的应用快捷键也重排了一遍。
之前写过一篇入门配置(把 Mac 变成键盘流神器),这篇是续集。完整配置放在最后,改改 apps 表就能用。
没装过的话:brew install --cask hammerspoon,然后去系统设置的辅助功能里给它打勾,配置文件是 ~/.hammerspoon/init.lua。
先说说 Hammerspoon 到底能干什么
很多人以为它就是个"配快捷键的工具",其实那只是最表层的用法。它本质是把 macOS 的一大堆系统 API 包成了 Lua,你能拿到的东西比想象中多得多:
窗口和屏幕:
hs.window移动、缩放、聚焦任意窗口,不限于前台hs.grid把屏幕切成网格,窗口按格子吸附hs.layout一次性把多个应用摆成预设布局hs.screen多显示器的位置、分辨率,甚至能调色温(内置夜览)hs.spaces控制 macOS 的"桌面/空间"切换
应用和输入:
hs.application启动、聚焦、查窗口,还能直接点应用的菜单项(能把没有快捷键的菜单功能绑上键)hs.eventtap拦截和改写键盘鼠标事件,键位重映射、鼠标手势都是在这层做hs.hotkey.modal模态快捷键,按一个前缀键进入一层"子键位",Vim 那套思路hs.pasteboard读写剪贴板,做剪贴板历史、自动去格式
系统状态监听,这块是我觉得真正拉开差距的地方,因为它是事件驱动,你不按键它也在替你干活:
hs.wifi.watcher换 WiFi 时触发。到公司自动静音、回家自动连蓝牙音箱hs.usb.watcher插拔 USB 设备时触发。插上键盘自动切输入法、插上硬盘自动跑备份hs.screen.watcher接上或拔掉外接显示器时触发,自动重排窗口布局hs.battery.watcher电量、充电状态变化时触发hs.caffeinate控制休眠、锁屏,也能监听睡眠/唤醒事件hs.pathwatcher盯着某个目录,文件一变就执行脚本hs.audiodevice音频设备切换和音量控制hs.application.watcher应用启动、退出、切到前台时触发
界面这块也不用自己写:
hs.chooser一个 Alfred 风格的搜索框,数据你自己喂,做个人版启动器很方便hs.menubar往菜单栏塞图标和菜单hs.alert屏幕中央的半透明提示,我的速查表就是它hs.notify系统通知中心的正经通知hs.drawing/hs.canvas直接往屏幕上画东西
最后是"它其实是个完整运行时"这一面:
hs.task后台跑外部命令并拿到输出,不阻塞主线程hs.http发 HTTP 请求,配hs.json直接对接 APIhs.osascript执行 AppleScript ,够不到的应用用它兜底hs.urlevent注册自定义 URL scheme ,命令行或别的应用能反过来触发 Hammerspoonhs.timer.doAt定点执行,比如每天下班时间弹个提醒- 还有 Spoon 生态(社区打包好的功能模块),不想自己写可以直接装
所以它跟 Raycast 那类工具不是一个层面的东西。Raycast 给你的是做好的功能,Hammerspoon 给你的是一堆系统能力和一个 Lua 解释器,缺什么自己缝。代价当然是你得写代码。
下面是我实际在用的部分。
应用切换:⌥ + 首字母
我原来的键位是想到哪绑到哪:iTerm 是 ⌥T 、千问 ⌥E 、Qoder ⌥C 、飞书 ⌥Q 。看着挺全,实际每次按之前都要在脑子里过一遍"这个是哪个键来着"。要想一下的快捷键,等于没有。
后来干脆定死一条规则:⌥ 加应用名英文首字母,不许有例外,撞了才另说。
| 键 | 应用 |
|---|---|
| ⌥C | Claude |
| ⌥G | Chrome |
| ⌥S | Safari |
| ⌥I | iTerm2 |
| ⌥D | Qoder CN |
| ⌥A | Apifox |
| ⌥Q | 千问 |
| ⌥W | 微信 |
| ⌥F | Finder |
| ⌥, | 系统设置 |
| ⌥B | DBeaver |
| ⌥L | 飞书 |
只撞了两回。Chrome 和 Claude 抢 C ,C 给了 Claude ,Chrome 用 Google 的 G (反正原来也是这么绑的);千问和 Qoder 抢 Q ,Q 给千问,Qoder 走 Dev 的 D 。系统设置用逗号,跟 ⌘, 是一个意思。DBeaver 的 B 是 dataBase ,有点牵强,用两天也就记住了。
键位本身不重要,有条规则才重要,装新应用基本不用想往哪绑。
实现没有一条条 bind ,写成一张表加一个循环,加应用改一行:
local apps = {
{ key = "c", id = "com.anthropic.claudefordesktop", desc = "Claude" },
{ key = "g", id = "com.google.Chrome", desc = "Chrome ( Google )" },
-- ...
}
for _, item in ipairs(apps) do
hs.hotkey.bind({"option"}, item.key, function()
focusApp(item.id)
end)
end
表里用的是 bundle ID 不是应用名。因为显示名跟 .app 文件名经常对不上,iTerm.app 显示名叫 iTerm2 、LarkSuite.app 叫 Lark 、DBeaver.app 叫 DBeaver Community ,用名字匹配会查不到。
把本机所有应用的名字和 bundle ID 一次性列出来,照着抄就行:
for a in /Applications/*.app /System/Applications/*.app; do
id=$(defaults read "$a/Contents/Info" CFBundleIdentifier 2>/dev/null)
[ -n "$id" ] && printf '%-24s %s\n' "$(basename "$a" .app)" "$id"
done | sort
输出长这样:
Apifox cn.apifox.app
Claude com.anthropic.claudefordesktop
DBeaver org.jkiss.dbeaver.core.product
Google Chrome com.google.Chrome
LarkSuite com.larksuite.larkApp
上面列的是 .app 文件名。想看应用真正的显示名,在 Hammerspoon 控制台里跑这段,列出当前运行中的应用:
for _, app in ipairs(hs.application.runningApplications()) do
print(app:name(), app:bundleID())
end
鼠标跟随
开头说的那个毛病,就靠这段治。要干的事两件:等应用真到前台,然后把鼠标挪到它窗口中间。
local FOLLOW_ACROSS_SCREENS_ONLY = true -- 只在跨屏时移动鼠标
local FOCUS_WAIT = 1.5 -- 冷启动最多等这么久
local function centerMouseOn(win)
if not win then return end
local target = win:screen()
if not target then return end
if FOLLOW_ACROSS_SCREENS_ONLY then
local cur = hs.mouse.getCurrentScreen()
if cur and cur:id() == target:id() then return end
end
hs.mouse.absolutePosition(win:frame().center)
end
local function focusApp(bundleID)
hs.application.launchOrFocusByBundleID(bundleID)
local function targetWindow()
local app = hs.application.get(bundleID)
if app and app:isFrontmost() then
return app:focusedWindow() or app:mainWindow()
end
end
local win = targetWindow() -- 本来就在前台,立刻跟
if win then
centerMouseOn(win)
return
end
-- launchOrFocus 是异步的,得等应用真到前台再读窗口,不然拿到的是你切换之前那个
local deadline = hs.timer.secondsSinceEpoch() + FOCUS_WAIT
hs.timer.waitUntil(
function()
return targetWindow() ~= nil or hs.timer.secondsSinceEpoch() > deadline
end,
function()
centerMouseOn(targetWindow())
end,
0.05)
end
两个地方是试出来的。
一个是落点放窗口中心,不是屏幕中心。macOS 滚轮作用的是鼠标底下那个窗口,窗口要是没铺满屏幕,鼠标落在屏幕中心可能压根不在窗口上,滚轮照样不听话。
另一个是 FOLLOW_ACROSS_SCREENS_ONLY。同屏切来切去的时候也吸鼠标会很烦,光标一直被抢走,鼠标本来指着的位置全没了。所以默认只在跨屏时才动,想每次都吸把它改成 false 。
顺手加的几个
- ⌘⌃ 方向键:当前窗口贴上下左右半屏
- ⌘⌃F:把窗口甩到下一块屏,鼠标跟着过去
- ⌘⌃M:只把鼠标丢到下一块屏中间,窗口不动
- ⌘⌃/:弹出速查表
- ⌘⌃R:重载配置
⌘⌃M 是纯为多屏加的,找鼠标真的比想象中费时间。screen:next() 会绕回第一块,双屏就是来回切:
hs.hotkey.bind({"cmd", "ctrl"}, "M", function()
local screen = hs.mouse.getCurrentScreen() or hs.screen.mainScreen()
local target = screen:next()
hs.mouse.absolutePosition(target:frame().center)
hs.alert.show("鼠标已移至 " .. target:name(), 0.5)
end)
速查表是给自己擦屁股用的。配快捷键的人大概都经历过:配了二十个,真正常用三个,剩下的忘干净。⌘⌃/ 按下去屏幕中间列出当前所有绑定,因为绑定本身就是上面那张 apps 表,速查表是循环拼出来的,加应用不用两头改。
⌘⌃R 用 pcall 包一下 hs.reload 也建议加,语法写错了会直接弹窗告诉你哪行,不然配置静默不生效,能查半天。
完整配置
-- 安全重载:出错时弹窗提示
hs.hotkey.bind({"cmd", "ctrl"}, "R", function()
local success, msg = pcall(hs.reload)
if not success then
hs.alert.show("❌ 配置错误:\n" .. tostring(msg))
else
hs.alert.show("✅ 配置已重载", 1)
end
end)
-- 窗口半屏
local function moveWindowTo(rect)
return function()
local win = hs.window.focusedWindow()
if win then win:move(rect, nil, true) end
end
end
hs.hotkey.bind({"cmd", "ctrl"}, "Up", moveWindowTo(hs.geometry.rect(0, 0, 1, 0.5)))
hs.hotkey.bind({"cmd", "ctrl"}, "Down", moveWindowTo(hs.geometry.rect(0, 0.5, 1, 0.5)))
hs.hotkey.bind({"cmd", "ctrl"}, "Left", moveWindowTo(hs.geometry.rect(0, 0, 0.5, 1)))
hs.hotkey.bind({"cmd", "ctrl"}, "Right", moveWindowTo(hs.geometry.rect(0.5, 0, 0.5, 1)))
-- 将窗口移到下一显示器
hs.hotkey.bind({"cmd", "ctrl"}, "F", function()
local win = hs.window.focusedWindow()
if win then
win:moveToScreen(win:screen():next())
hs.mouse.absolutePosition(win:screen():frame().center)
hs.alert.show("窗口已移至下一屏幕", 1)
end
end)
-- 将鼠标移到下一显示器中央
hs.hotkey.bind({"cmd", "ctrl"}, "M", function()
local screen = hs.mouse.getCurrentScreen() or hs.screen.mainScreen()
local target = screen:next()
hs.mouse.absolutePosition(target:frame().center)
hs.alert.show("鼠标已移至 " .. target:name(), 0.5)
end)
-- 快速打开 GitHub
hs.hotkey.bind({"cmd", "ctrl"}, "G", function()
hs.urlevent.openURL("https://github.com")
end)
-- ========== 应用快捷键:⌥ + 名字首字母 ==========
-- 冲突处理:Chrome → G(oogle),C 让给 Claude ; Qoder → D(ev),Q 让给千问
-- 用 bundle ID 而不是应用名:显示名和文件名常对不上( iTerm→iTerm2 、LarkSuite→Lark 、DBeaver→DBeaver Community )
local apps = {
{ key = "c", id = "com.anthropic.claudefordesktop", desc = "Claude" },
{ key = "g", id = "com.google.Chrome", desc = "Chrome ( Google )" },
{ key = "s", id = "com.apple.Safari", desc = "Safari" },
{ key = "i", id = "com.googlecode.iterm2", desc = "iTerm2" },
{ key = "d", id = "com.aliyun.lingma.ide", desc = "Qoder CN ( Dev )" },
{ key = "a", id = "cn.apifox.app", desc = "Apifox" },
{ key = "q", id = "com.alibaba.tongyi", desc = "千问 Qianwen" },
{ key = "w", id = "com.tencent.xinWeChat", desc = "微信 WeChat" },
{ key = "f", id = "com.apple.finder", desc = "Finder" },
{ key = ",", id = "com.apple.systempreferences", desc = "系统设置" },
{ key = "b", id = "org.jkiss.dbeaver.core.product", desc = "DBeaver" },
{ key = "l", id = "com.larksuite.larkApp", desc = "飞书 Lark" },
}
-- ---------- 切换应用时鼠标跟随到目标窗口 ----------
local FOLLOW_ACROSS_SCREENS_ONLY = true -- true=只在跨屏时移动; false=每次切换都把鼠标放到窗口中央
local FOCUS_WAIT = 1.5 -- 冷启动最多等待秒数
local function centerMouseOn(win)
if not win then return end
local target = win:screen()
if not target then return end
if FOLLOW_ACROSS_SCREENS_ONLY then
local cur = hs.mouse.getCurrentScreen()
if cur and cur:id() == target:id() then return end
end
hs.mouse.absolutePosition(win:frame().center)
end
local function focusApp(bundleID)
hs.application.launchOrFocusByBundleID(bundleID)
-- 目标应用真到前台后的窗口,没切过去就返回 nil
local function targetWindow()
local app = hs.application.get(bundleID)
if app and app:isFrontmost() then
return app:focusedWindow() or app:mainWindow()
end
end
local win = targetWindow()
if win then
centerMouseOn(win)
return
end
-- launchOrFocus 是异步的,轮询等激活/冷启动完成
local deadline = hs.timer.secondsSinceEpoch() + FOCUS_WAIT
hs.timer.waitUntil(
function()
return targetWindow() ~= nil or hs.timer.secondsSinceEpoch() > deadline
end,
function()
centerMouseOn(targetWindow())
end,
0.05)
end
for _, item in ipairs(apps) do
hs.hotkey.bind({"option"}, item.key, function()
focusApp(item.id)
end)
end
-- ⌘⌃/ 速查表,忘了就按它
hs.hotkey.bind({"cmd", "ctrl"}, "/", function()
local lines = { "⌥ 切换应用(鼠标自动跟随)" }
for _, item in ipairs(apps) do
table.insert(lines, "⌥ " .. item.key .. " → " .. item.desc)
end
table.insert(lines, "")
table.insert(lines, "⌘⌃ ←↑↓→ 窗口半屏")
table.insert(lines, "⌘⌃ F 窗口移到下一屏")
table.insert(lines, "⌘⌃ M 鼠标移到下一屏中央")
table.insert(lines, "⌘⌃ G 打开 GitHub")
table.insert(lines, "⌘⌃ R 重载配置")
hs.alert.closeAll()
hs.alert.show(table.concat(lines, "\n"), 6)
end)
hs.alert.show("Hammerspoon 已加载!")
最后
上面这些其实只用到了 Hammerspoon 的一小部分,基本都还是"按键触发"这一类。真正有意思的是那些 watcher ,接显示器、插 USB 、换 WiFi 都能当触发条件,那部分我还没怎么动。
多屏的可以试试鼠标跟随这段,我个人是回不去了。不会写 Lua 代码的,复制这篇文章的链接,扔给 AI 参考帮写就行。
