我的 Hammerspoon 配置(应用秒切 + 鼠标跟屏 + 快捷键速查)

rockycc 2026-09-08 09:56 1

切到副屏,鼠标却还留在主屏:我的 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 参考帮写就行。

最新回复 (9)
  • camus 09-08 10:37
    1
    纯 AI 生成的介绍,味太冲了
  • rockycc 楼主 09-08 11:32
    2
    @camus
    知识文都是用 AI 生成,是可以的吧,毕竟知识就是固定的,AI 的表述会更准确些。有让 AI 尽量拟人表述,且删除了不必要的章节和表述。

    有好的修改方向,看可以和我说下不?
  • rockycc 楼主 09-08 11:32
    3
    @camus 用的 claude 。
  • Tink 09-08 11:38
    4
    我也布个道

    https://www.gaojinan.com/hammerspoon.html
    https://www.gaojinan.com/hammerspoon-menubar-cryptocurrency.html
  • camus 09-08 13:46
    5
    @rockycc #2
    无论是 Claude/GPT 还是任何其他 agent/harness ,在默认没有 skills 专门清理 AI 垃圾话的时候,只给他提示词告诉他说人话及去掉 AI 味反而会加强 AI 文章的 pattern ,比如模仿松弛感,故作幽默,“自然停顿”,氛围感滥用,代词故意省略或者特意强调,书面表达口语化、娱乐化等等。
    分享本身是有意义的,但是可以直接贴上代码,直接说解决了什么问题,而不是靠 AI 生成一大段看似详细实则全是废话的文章。
    对比起来,入门配置那篇就一目了然,而不是像这篇 po 一样,有效的其实只有文章最后的配置。
  • bao3 09-08 14:11
    6
    其实现在 hamespoon 本身就可以让 AI 来写配置,想要什么就说就行,非常方便。以后连分享配置的必要都没了,改成分享自己的目标了。

    ps:文章确定 AI 味太浓了,也许不用 AI 的人会读它,用 AI 的人应该是直接跳过内容的。
比如 “真正有意思的是” “先说 XX” “很多人以为” “都有 XX 经历”“这事 XX 都解决不了”
    我感觉 AI 似乎不太懂中文,学不会中国话,只会了中国字。
  • rockycc 楼主 09-08 16:18
    7
    @camus get✅
  • rockycc 楼主 09-08 16:19
    8
    @bao3 嗯。本质是打算让大家更深入知道这个工具。V 站的大家也都知道用 AI 写配置的。
  • suitts 09-08 17:54
    9
    我用 codex 写了很多 hammerspoon 脚本
* 帖子来源V2EX
返回