外观
Hammerspoon 从入门到实战
软件页教你绑了一个显示提示的热键,本篇从那里继续往下写,不重复安装、权限授予和 Spoons 风险提醒这些内容。
为什么需要它
一个热键很好管理,几十个热键加若干事件监听堆进同一个 init.lua 就不是这么回事了——文件越写越长,新加一行代码前得先通读一遍前面写过什么,改错一处还可能牵连到看似无关的另一段。真正有用的配置往往是这个规模:多组窗口布局热键、屏幕数量变化时的自动响应、锁屏解锁时的联动。本篇讲怎么把这些配置拆成按职责分工的模块,再用窗口操作、屏幕监听与系统事件监听把它们串成一套会自动响应环境变化的工作区管理。
开始之前
前置:软件页的Hammerspoon 首次使用,你应当已经跑通那个显示提示的热键。建议先读Karabiner-Elements 使用指南造好 Hyper 键,本篇的热键都绑在它上面,直接沿用该文对 Hyper 键的定义:Control、Option、Command、Shift 四个修饰键同时按住的组合。
备份:接下来会把配置从一个文件拆成多个,先整体备份再动手:
zsh
cp -R ~/.hammerspoon ~/.hammerspoon.backup-R 递归复制整个目录而不只是 init.lua,改坏了用这份备份整体覆盖回去即可。
操作步骤
1. 把单文件配置拆成模块
Lua 用 require 加载模块,但默认的模块搜索路径(package.path)不包含 Hammerspoon 的配置目录,第一步是把它加进去。hs.configdir 是 Hammerspoon 提供的常量,官方文档定义为「A string containing Hammerspoon's configuration directory. Typically ~/.hammerspoon/」——用它而不是硬编码路径,配置目录换了地方也不用改代码:
保存为 ~/.hammerspoon/init.lua:
lua
package.path = package.path .. ";" .. hs.configdir .. "/?.lua"
require("windows")
require("screens")
require("system_events")";" 是 package.path 里的路径分隔符,? 是占位符,require("windows") 会把它替换成模块名去找 windows.lua。hs.configdir 通常已经以 / 结尾,所以这里拼出来的路径会带一个连续的双斜杠——这是有意写成这样的:POSIX 路径里连续的斜杠等价于单个斜杠,多写一个能让这行代码在 hs.configdir 带不带尾部斜杠的情况下都成立。
之后把 windows.lua、screens.lua、system_events.lua 三个文件按职责放进 ~/.hammerspoon/ 目录,各自处理一类事情。require 只在模块第一次被加载时真正执行文件内容,之后同一个模块名会直接返回缓存结果,不会重复执行——这也是下面第 4、5 步里把 watcher 存进模块自己返回的 table 而不是随手丢给一个局部变量的原因,具体解释见「常见问题」。
2. 窗口操作:取窗口、按屏幕比例定位
写窗口操作前要先弄清坐标系,这是多屏时最容易出错的地方。官方文档在 hs.screen 的说明里给出结论:macOS 用一套横跨所有屏幕的坐标网格,「The origin 0,0 is at the top left corner of the primary screen」——原点在主屏左上角,位于主屏左边或上边的屏幕,坐标会是负数。hs.window:frame() 与 hs.screen:frame() 返回的都是这套「绝对坐标」:前者的官方说明是「Gets the frame of the window in absolute coordinates」,后者是「Returns the screen frame, without the dock or menu」(这里用的是去掉 Dock 和菜单栏的可用区域,比含这两者的 fullFrame() 更适合摆放应用窗口)。
这意味着「把窗口放到左半屏」不能假设左半屏的 x 就是 0——如果当前屏幕不是主屏,它可能是负数,也可能是个很大的正数。稳妥的做法是先取窗口自己所在屏幕的 frame,再在这个 frame 内部按比例计算,而不是自己拼一个绝对坐标:
保存为 ~/.hammerspoon/windows.lua:
lua
local M = {}
function M.moveWindowToFraction(x, w)
local win = hs.window.focusedWindow()
if not win then return end
local screen = win:screen()
local frame = screen:frame()
win:setFrame({
x = frame.x + frame.w * x,
y = frame.y,
w = frame.w * w,
h = frame.h,
})
end这里把函数挂在模块自己的 table M 上,而不是写成只在本文件可见的 local function——第 4 步的 screens.lua 要调用它,local 的写法跨不出这个文件。M 会在下一步的末尾用 return M 交出去。
hs.window.focusedWindow() 取到当前聚焦窗口(官方说明:「Returns the window that has keyboard/mouse focus」);win:screen() 取到这个窗口所在的屏幕;win:setFrame(rect[, duration]) 按官方说明「Sets the frame of the window in absolute coordinates」。x、w 是 0 到 1 之间的比例,相对的是这块屏幕自己的宽度,不是整个跨屏坐标系的宽度——frame.x + frame.w * x 里的 frame.x 就是把这块屏幕自己的偏移量(哪怕是负数)加回去,这一步不能省。
3. 绑到 Hyper 键,形成一套布局热键
接着写在同一个 ~/.hammerspoon/windows.lua 里:
lua
local hyper = {"cmd", "alt", "ctrl", "shift"}
hs.hotkey.bind(hyper, "left", function()
M.moveWindowToFraction(0, 0.5)
end)
hs.hotkey.bind(hyper, "right", function()
M.moveWindowToFraction(0.5, 0.5)
end)
return Mhs.hotkey.bind(mods, key, pressedfn, releasedfn, repeatfn) 官方文档说明它「is just a wrapper that performs hs.hotkey.new(...):enable()」,一步完成创建和启用;这里只传了按下时触发的函数,松开和长按对应的另外两个参数省略。mods 对应 Hyper 键的四个修饰键,key 用的是 hs.keycodes.map 里的键名,方向键写作小写的 left/right。末尾的 return M 是整个 windows.lua 的最后一行,把模块 table 交给 require 的调用方。
4. 屏幕监听:接上外接显示器自动应用布局
官方文档说明 hs.screen.watcher.new(fn) 创建的监听器,在「a screen resolution change, movement of a monitor in the Display preferences pane」,以及显示器「attached or removed」、Dock 大小或显隐变化时都会触发回调,且回调「should take no arguments」——不像下一步的 pathwatcher 那样把改动细节传进来,只知道布局变了,具体现状要自己再查一遍:
保存为 ~/.hammerspoon/screens.lua:
lua
local windows = require("windows")
local M = {}
local function onScreenLayoutChange()
local screens = hs.screen.allScreens()
if #screens < 2 then
return
end
-- 在这里写「检测到外接屏幕」之后想执行的布局调整,
-- 比如 windows.moveWindowToFraction(0, 0.5),或用 win:moveToScreen() 把窗口挪过去
end
M.watcher = hs.screen.watcher.new(onScreenLayoutChange):start()
return Mhs.screen.allScreens() 官方说明是「Returns all the screens」,用它的长度判断是不是接上了外接显示器。开头的 require("windows") 把上一步那个模块 table 取过来,于是这里能调到 windows.moveWindowToFraction——init.lua 里虽然已经 require 过一次 windows,但 require 的缓存机制保证它只会真正执行一次,这里再写一遍只是拿到同一个 table 的引用,不会重复绑定热键。注意最后 M.watcher = ... 这一行:watcher 存进模块自己返回的 table 里,而不是丢给一个用完即弃的局部变量,原因见「常见问题」。
5. 系统事件监听:锁屏解锁时做点什么
hs.caffeinate.watcher 监听系统睡眠、唤醒与锁屏解锁一类事件,官方文档列出的常量里包含 screensDidLock(「The screen was locked」)和 screensDidUnlock(「The screen was unlocked」):
保存为 ~/.hammerspoon/system_events.lua:
lua
local M = {}
local function onCaffeinateEvent(eventType)
if eventType == hs.caffeinate.watcher.screensDidLock then
-- 锁屏时想做的事情写在这里
elseif eventType == hs.caffeinate.watcher.screensDidUnlock then
-- 解锁时想做的事情写在这里
end
end
M.watcher = hs.caffeinate.watcher.new(onCaffeinateEvent):start()
return M回调函数收到的唯一参数就是事件类型,拿它和这些常量比较即可分支处理。
6. 改完自动重载配置
回到 ~/.hammerspoon/init.lua,在 require 那几行之后加一句:
lua
hammerspoonConfigWatcher = hs.pathwatcher.new(hs.configdir, hs.reload):start()hs.pathwatcher.new(path, fn) 官方文档给出的示例正是这个写法——监听配置目录,任何文件变化都直接调用 hs.reload。hs.reload() 的作用是「Reloads your init-file in a fresh Lua environment」,改完任意一个模块文件保存,不用再手动点菜单栏的 Reload Config。
这里特意把 hammerspoonConfigWatcher 写成全局变量而不是 local,原因和第 4、5 步用模块 table 存 watcher 是同一件事,见下面「常见问题」。
7. 用控制台看报错
Hammerspoon 没有面向普通用户的独立日志文件,Lua 报错的堆栈只会打印在控制台里,这是排错的唯一入口。菜单栏图标里的 Console... 可以打开它;官方文档也提供了对应的函数入口,hs.openConsole([bringToFront]) 说明是「Opens the Hammerspoon Console window and optionally focuses it」,hs.toggleConsole() 则是「If the console is focused, it will be closed」——同一个入口负责开关。改完任何一处配置,养成看一眼控制台再关掉的习惯:语法错误和运行时报错都会在这里以堆栈的形式出现。
检查结果
- 逐个按一遍新绑的热键:Hyper + Left 应该让当前聚焦窗口占满所在屏幕左半边,Hyper + Right 占右半边。如果窗口纹丝不动,先打开控制台确认没有报错堆栈,再确认按下的确实是完整的 Hyper 组合。
- 打开控制台,确认列表里没有报错堆栈;保存文件后配置没有按预期生效,往往就是这里能看到具体是哪一行语法或运行时出了问题。
- 插拔一次外接显示器,观察
onScreenLayoutChange有没有被触发——最简单的验证方式是先在回调里临时加一行hs.alert.show("屏幕布局变了"),确认能弹出提示之后再换成真正的布局调整,验证完把这行调试用的提示删掉。 - 锁屏、解锁各触发一次,用同样的临时
hs.alert.show分别打在两个分支里,确认走的是哪一条,验证完删掉。
常见问题
辅助功能权限在系统更新后失效,窗口操作静默失败。 macOS 大版本更新有时会重置辅助功能一类的权限授予,Hammerspoon 一旦失去这项权限,hs.window 系列的操作不会报错,只是什么也不发生。回到软件库页面里描述的权限设置一节,检查 Hammerspoon 是否还在「系统设置 → 隐私与安全性 → 辅助功能」的允许列表里,被系统自动移除的话重新勾选即可。
多屏坐标算错,窗口跑到屏幕外。 通常是自己拼了一个绝对坐标、却没有把目标屏幕的 frame.x/frame.y 偏移量加回去——尤其是那块屏幕不是主屏、坐标本身就是负数的时候。应急做法:打开控制台,执行一次 hs.window.focusedWindow():moveToScreen(hs.screen.primaryScreen()),先把窗口拉回主屏幕,再回头检查第 2 步的比例计算逻辑。
改完某个模块文件,一大片热键突然全部失灵。 Lua 语法错误会让整个文件在被 require 到的那一刻直接报错终止,这个文件里排在出错行之后的代码全部不会执行。如果出错的是 windows.lua,screens.lua、system_events.lua 依旧正常,但 windows.lua 里定义的所有热键都不存在,表现出来就是「一大片热键同时失灵」,而不是新改的那一条单独出问题——和 Karabiner 规则解析失败的表现是同一类原因。恢复:打开控制台看报错堆栈指向哪一行,改完保存(触发第 6 步的自动重载)再试。为了让报错更容易定位,尽量一次只改一处、改完立刻看控制台,而不是攒一堆改动一起测。
Watcher 装了但回调从来没被触发过。 官方入门指南在讲变量生命周期时提醒过这类对象的一个陷阱:如果创建后没有用变量捕获它,「you must capture them in a variable, or they will be silently destroyed at some point in the future」,而且这个销毁「will likely not be destroyed for some minutes/hours after」——不是立刻失效,而是能正常工作一段时间后突然停止,很难联想到原因。指南给出的办法是把它赋给一个全局变量,因为全局变量「will never go out of scope」。本篇第 4、5 步把 watcher 存进模块自己返回的 table(M.watcher = ...),第 6 步在 init.lua 顶层用了一个全局变量,都是同一个道理:给 Lua 的垃圾回收器一个不会消失的引用。如果自己新增的 watcher 用的是普通 local 又没有存进这类长期存在的位置,出现「一开始好好的,过一阵子就不响应了」的情况,先检查这一点。
动手练习
先只做「当前窗口占左半屏」和「占右半屏」两个热键,用一周,确认顺手、没有和其他应用的快捷键冲突。习惯之后再考虑更复杂的联动——比如把 screens.lua、system_events.lua 里的示例注释换成真正的布局调整或锁屏动作,一次只换一处,换完按「检查结果」里的办法验证一遍。配置的价值来自每天都用,不来自一次写很多。