rockycc
V2EX  ›  macOS

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

  •  
  •   rockycc · 9h 8m ago · 529 views

    切到副屏,鼠标却还留在主屏:我的 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 replies    2026-09-08 17:54:46 +08:00
    camus
        1
    camus  
       8h 27m ago
    纯 AI 生成的介绍,味太冲了
    rockycc
        2
    rockycc  
    OP
       7h 32m ago
    @camus
    知识文都是用 AI 生成,是可以的吧,毕竟知识就是固定的,AI 的表述会更准确些。有让 AI 尽量拟人表述,且删除了不必要的章节和表述。

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

    ps:文章确定 AI 味太浓了,也许不用 AI 的人会读它,用 AI 的人应该是直接跳过内容的。
比如 “真正有意思的是” “先说 XX” “很多人以为” “都有 XX 经历”“这事 XX 都解决不了”
    我感觉 AI 似乎不太懂中文,学不会中国话,只会了中国字。
    rockycc
        7
    rockycc  
    OP
       2h 45m ago
    @camus get✅
    rockycc
        8
    rockycc  
    OP
       2h 44m ago
    @bao3 嗯。本质是打算让大家更深入知道这个工具。V 站的大家也都知道用 AI 写配置的。
    suitts
        9
    suitts  
       1h 9m ago
    我用 codex 写了很多 hammerspoon 脚本
    About   ·   Help   ·   Advertise   ·   Blog   ·   API   ·   FAQ   ·   Privacy   ·   Solana   ·   3378 Online   Highest 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 42ms · UTC 11:04 · PVG 19:04 · LAX 04:04 · JFK 07:04
    ♥ Do have faith in what you're doing.