- rockycc2026-09-0851520
切到副屏,鼠标却还留在主屏:我的 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直接对接 API-
hs.osascript执行 AppleScript ,够不到的应用用它兜底-
hs.urlevent注册自定义 URL scheme ,命令行或别的应用能反过来触发 Hammerspoon-
hs.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 参考帮写就行。
主楼 - camus-v2ex2026-09-0850389
纯 AI 生成的介绍,味太冲了
- rockycc2026-09-0851520
- camus-v2ex2026-09-0850389
无论是 Claude/GPT 还是任何其他 agent/harness ,在默认没有 skills 专门清理 AI 垃圾话的时候,只给他提示词告诉他说人话及去掉 AI 味反而会加强 AI 文章的 pattern ,比如模仿松弛感,故作幽默,“自然停顿”,氛围感滥用,代词故意省略或者特意强调,书面表达口语化、娱乐化等等。
分享本身是有意义的,但是可以直接贴上代码,直接说解决了什么问题,而不是靠 AI 生成一大段看似详细实则全是废话的文章。
对比起来,入门配置那篇就一目了然,而不是像这篇 po 一样,有效的其实只有文章最后的配置。 - rockycc2026-09-0851520
- Tink2026-09-082049
- bao32026-09-081308
其实现在 hamespoon 本身就可以让 AI 来写配置,想要什么就说就行,非常方便。以后连分享配置的必要都没了,改成分享自己的目标了。
ps:文章确定 AI 味太浓了,也许不用 AI 的人会读它,用 AI 的人应该是直接跳过内容的。 比如 “真正有意思的是” “先说 XX” “很多人以为” “都有 XX 经历”“这事 XX 都解决不了”
我感觉 AI 似乎不太懂中文,学不会中国话,只会了中国字。 - rockycc2026-09-0851520
- rockycc2026-09-0851520
- suitts2026-09-0843625
