Hammerspoon优化MacOS多屏幕体验

/ mess

Mac上多屏幕最常见的使用痛点就是:

上面部分问题其实是cmd+w的使用问题,换成cmd+h就好了,但是cmd+w他距离近,单手就能操作啊 😂 。

当熟悉了快捷键之后,Mac上三指上滑选择app、单击触摸板和滑动触摸板其实是效率很低的操作,操作频繁了小臂肌肉酸胀是真的遭不住(特别是点击和滑动触摸板)。

Hammerspoon文档 非常详细,可用的API都列了出来。
本文主要使用屏幕和窗口相关的东西,还有很多强大的功能,可以参考官方文档自己试试。

Tips:

使用中发现的bug:

基础函数和基本变量

基础变量:

1
2
3
4
5
6
7
8
9
10
11
local application = require "hs.application"
local hotkey = require "hs.hotkey"
local window = require "hs.window"
local timer = require "hs.timer"
local screen = require "hs.screen"
local fnutils = require "hs.fnutils"
local mouse = require "hs.mouse"
local alert = require "hs.alert"
local eventtap = require "hs.eventtap"
local keycodes = require "hs.keycodes"
local canvas = require "hs.canvas"

移动光标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 将鼠标移动到目标屏幕,如果传入了窗口信息则移动到窗口中心,否则是屏幕中心
function moveMouseToScreen(sc, win)
if win then
local frame = win:frame()
local x = frame.x + frame.w / 2
local y = frame.y + frame.h / 2
-- 这里获取到的坐标都是绝对坐标,调用 absolutePosition
mouse.absolutePosition({ x = x, y = y })
else
-- 默认目标屏幕中心
local cmod = sc:currentMode();
mouse.setRelativePosition({ x = cmod["w"] / 2, y = cmod["h"] / 2 }, sc)
end
end

提示信息canvas(复用整个用于提示的canvas):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
function showText(sc, message, win)

local frame = win and win:frame() or sc:frame()

local x = frame.x + frame.w / 2
local y = frame.y + frame.h / 2

local scId = sc:id()

canvasShow(scId, x, y, 150, 150)

end

local alertCanvas = nil

function buildAlertCanvas(alertCanvas, message, w, h)
alertCanvas:replaceElements({
type = "rectangle", -- 背景矩形
action = "fill",
fillColor = { red = 0, green = 0, blue = 0, alpha = 0.5 }, -- 半透明黑色背景
roundedRectRadii = { xRadius = 10, yRadius = 10 }, -- 圆角
}, {
type = "text", -- 文本
text = message,
textColor = { white = 1, alpha = 1 }, -- 白色文字
textSize = (w + h) / 2 * 0.75,
textFont = "Helvetica", -- 字体
textAlignment = "center", -- 文字居中
frame = { x = "5%", y = "5%", w = "90%", h = "90%" }, -- 文字位置和大小
})
end

function canvasShow(message, x, y, w, h)

local rect = { x = x - w / 2, y = y - h / 2, w = w, h = h }

if not alertCanvas then
alertCanvas = canvas.new(rect)
if not alertCanvas then
print("create canvas fail")
return
end

buildAlertCanvas(alertCanvas, message, w, h)

else
alertCanvas:frame(rect)
buildAlertCanvas(alertCanvas, message, w, h)
end

alertCanvas:show()
timer.doAfter(0.5, function()
alertCanvas:hide()
end)
end

将目标屏幕第一个顺序窗口设置为焦点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function focusScreen(sc)
local filterWins = window.orderedWindows()
local windows = fnutils.filter(filterWins, function(win)
return win:screen() == sc
end)

if #windows > 0 then
local win = windows[1]
win:focus()
return win
else
print("focus fail " .. sc:id())
end
end

这里遇到个问题,在app多个窗口分布在不同屏幕时候,在调用 focus() 的时候窗口会出现无法获得焦点的情况。
这里可以用个临时解决方法,获取焦点的同时将鼠标移动到窗口上,发送一个鼠标左键点击事件,但是可能会出现误点击的情况。

获取目标屏幕的下一个屏幕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function getNextScreen(currentScreen)
-- 如果目标屏幕为空则获取光标所在屏幕
local cs = currentScreen and currentScreen or mouse.getCurrentScreen()
local screens = screen.allScreens()
if #screens == 1 then
return
end

local nextScreenIndex = nil
for i, sc in ipairs(screens) do
if sc:id() == cs:id() then
nextScreenIndex = (i % #screens) + 1
return screens[nextScreenIndex]
end
end
end

多屏幕间移动鼠标

这里绑定的是 alt+tab ,聚焦下一个屏幕的最前窗口,然后将光标移动过去,并且显示提示信息,这里是屏幕编号。

1
2
3
4
5
6
7
8
9
10
11
hotkey.bind({"alt"}, "tab", function()
local nextScreen = getNextScreen()
if not nextScreen then
return
end

focusWin = focusScreen(nextScreen)
moveMouseToScreen(nextScreen, focusWin)
showText(nextScreen, nil, focusWin)

end)

cmd+tab切出关闭或者最小化窗口的app

cmd+tab 切换app是激活了app,所以需要订阅app的激活事件。同时只需要快捷键触发的窗口激活事件,使用 activedByShortcut 来判断是否来自于快捷键激活。

1
2
3
4
5
6
7
8
local activedByShortcut = false
appWatcher = application.watcher.new(function(appName, eventType, app)

if eventType == application.watcher.activated then
handleActivedByShortcut(app)
end

end):start()

appWatcher 这里不能使用 local 修饰,否则监听器只能处理一次就会被垃圾回收器回收了。

重新打开app:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function handleActivedByShortcut(app)
local windows = app:allWindows()
appName = app:bundleID()

if activedByShortcut == false then
return
end

activedByShortcut = false
-- finder默认有一个窗口在
if appName == "com.apple.finder" and #windows == 1 then
eventtap.keyStroke({"cmd"}, "n")
end

if #windows == 0 then
-- cmd+w 关闭之后,allWindows visibleWindows 全为0
if appName == "com.tencent.xinWeChat" then
eventtap.keyStroke({"ctrl", "cmd"}, "w")
elseif appName == "com.apple.ActivityMonitor" then
eventtap.keyStroke({"cmd"}, "1")
elseif appName == "com.apple.mail" then
eventtap.keyStroke({"cmd"}, "0")
else
-- 并不是所有app都能用这个方式恢复窗口
-- 如果不行就只有按照上面的方式单独使用app内部快捷键恢复窗口
selectDockMenu(app)
end
else
-- 只有最小化窗口需要单独处理,hide的窗口在cmd+tab切换时会自动显示
local win = windows[1]
if win:isMinimized() then
win:unminimize()
else
win:focus()
end
end
end

function selectDockMenu(app)
local appName = app:name()

application.launchOrFocus(appName)
timer.doAfter(0.5, function()
local dockIcon = application.get("Dock")
if dockIcon then
dockIcon:selectMenuItem({appName})
end
end)
end

这里需要注意finder是没法 cmd+q 退出,且所有窗口关闭也会有一个窗口在,需要单独处理。

监听快捷键:

1
2
3
4
5
6
7
8
9
10
shortcutWatcher = eventtap.new({eventtap.event.types.keyDown}, function(event)
local flags = event:getFlags()
local keyCode = event:getKeyCode()

if flags.cmd and keyCode == keycodes.map["tab"] then
shortcutTriggered = true
end
return false
end):start()

event事件一定要返回false,否则其他app就无法监听到该事件了。

注意这里的 shortcutWatcher 同样是不能用 local 修饰,不然会出现事件只能触发一次的问题。应该是和lua内部垃圾收集器有关系,局部变量直接被回收了。

关闭或最小化窗口后自动获取下一个窗口焦点

这里需要绑定窗口的一些事件过滤器,可以参考 hs.window.filter

注意下面两个事件,微信触发不稳定,不清楚什么情况,如果触发不了就重启Hammerspoon试试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
local windowFilter = window.filter.new()

local destroyedByShortcut = false
-- 当切换rime中英输入时会触发该事件,只处理cmd+w触发的事件
windowFilter:subscribe(window.filter.windowDestroyed, function(window)
if window then
-- print("windowDestroyed " .. window:application():bundleID())
if destroyedByShortcut then
focusScreen(window:screen())
destroyedByShortcut = false
end
end
end)

windowFilter:subscribe(window.filter.windowMinimized, function(window)
if window then
-- print("windowMinimized " .. window:application():bundleID())
focusScreen(window:screen())
end
end)

很多app都会触发 windowDestroyed 事件,所以这里定义一个 destroyedByShortcut 用于判断是否是快捷键触发的关闭窗口(鼠标单击关闭窗口也可以处理,这里没处理)。

Hammerspoon是没有api能判断窗口事件触发时候是快捷键还是鼠标的,还需要监听一个键盘按下的事件:

1
2
3
4
5
6
7
8
9
shortcutWatcher = eventtap.new({eventtap.event.types.keyDown}, function(event)
local flags = event:getFlags()
local keyCode = event:getKeyCode()

if flags.cmd and keyCode == keycodes.map["w"] then
destroyedByShortcut = true
end
return false
end):start()

shortcutWatcher 和上面的一样,可以放一起。

窗口获取焦点屏幕提醒

这个操作和上面类似,订阅窗口焦点事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
local shortcutTriggered = false
windowFilter:subscribe(window.filter.windowFocused, function(window)
if window then
if shortcutTriggered then
local sc = window:screen()
-- 鼠标和焦点窗口不一致才进行提醒和移动光标
-- id是屏幕的编号 从1开始编号
if sc:id() ~= mouse.getCurrentScreen():id() then
moveMouseToScreen(sc, window)
showText(sc, nil, window)
end
shortcutTriggered = false
end
end
end)

同样也只处理快捷键触发的事件:

1
2
3
4
5
6
7
8
9
10
11
shortcutWatcher = eventtap.new({eventtap.event.types.keyDown}, function(event)
local flags = event:getFlags()
local keyCode = event:getKeyCode()

if flags.cmd and keyCode == keycodes.map["tab"] then
shortcutTriggered = true
elseif flags.cmd and keyCode == keycodes.map["`"] then
shortcutTriggered = true
end
return false
end):start()

窗口管理

将窗口移动到指定屏幕

将当前焦点的窗口移动到下一个屏幕:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
hotkey.bind({"alt"}, "`", function()

local focusdWin = window.focusedWindow()
if not focusdWin then
return
end

local nextScreen = getNextScreen(focusdWin:screen())
if not nextScreen then
return
end

-- api doc : https://www.hammerspoon.org/docs/hs.window.html#moveToScreen
focusdWin:moveToScreen(nextScreen, true, false, 0)
moveMouseToScreen(nextScreen, focusdWin)
showText(nextScreen, nil, focusdWin)

end)

多桌面管理(hs.spaces)

hs.spaces 属于 实验功能

后续更新

采用上面的方案,使用标志位来判断是否是快捷键触发的操作,在使用rime输入法时候会出现:
多tab窗口(比如自带终端)在关闭一个tab之后(还有其他tab),焦点切换到下一个app。

调试发现在终端切换中英文输入时候会触发 windowDestroyed 事件,且在 cmd+w 关闭时候输入法的事件是先于tab的, 导致标志位被异常触发。
解决方式就是排除输入法的事件不进行处理,且不处理自带终端的 cmd+w 事件。

同时还出现了奇怪的跨窗口canvas提醒,明明都在一个屏幕上进行的窗口切换,这个后面有稳定复现再排查了。

windowDestroyed
该事件在不同app下的触发机制是不一样的,常见的Chrome、Sublime Text等如果是一个窗口多个tab,关闭一个tab不会触发事件。
但是在macOS的自带终端中又是相反,关闭任意一个tab都会触发该事件,且 window:tabCount() 返回0。

又仔细翻了下 hs.window.filter 文档,发现是实验性质的。
上面提到的几个窗口事件目前就 windowFocused 触发是没什么问题。