Repository navigation
Expand file tree
/
Copy pathinit.lua
More file actions
370 lines (322 loc) · 10.8 KB
/
Copy pathinit.lua
File metadata and controls
370 lines (322 loc) · 10.8 KB
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
-- vim: set ft=lua:
--- === AppBadgeWatcher ===
---
--- A Hammerspoon Spoon that monitors app dock badges and displays notification counts in your menu bar.
---
--- Download: https://github.com/hugoh/AppBadgeWatcher.spoon/releases/latest
local obj = {}
obj.__index = obj
obj.name = "AppBadgeWatcher"
obj.version = "dev"
obj.author = "Hugo Haas"
obj.license = "MIT"
obj.homepage = "https://github.com/hugoh/AppBadgeWatcher.spoon"
-- Configurable
--- AppBadgeWatcher.appsToWatch
--- Variable
--- List of application names (strings) to watch for dock badge counts.
obj.appsToWatch = {}
--- AppBadgeWatcher.refreshInterval
--- Variable
--- Seconds between badge refresh polls (default: 15).
obj.refreshInterval = 15
--- AppBadgeWatcher.nothingIndicator
--- Variable
--- Menu bar text shown when no badges are active (default: "・").
obj.nothingIndicator = "・"
--- AppBadgeWatcher.grayscaleIcon
--- Variable
--- Convert app icons to grayscale in the menu bar (default: false).
obj.grayscaleIcon = false
--- AppBadgeWatcher.infiniteThreshold
--- Variable
--- Badge counts above this value are shown as a plus sign ("⁺") instead of the number (default: 9).
obj.infiniteThreshold = 9
--- AppBadgeWatcher.snoozeOnClick
--- Variable
--- Clicking an app's menu bar item snoozes its current badge count (default: true).
obj.snoozeOnClick = true
-- Internal
obj.appItems = {}
obj.iconCache = {}
obj.log = hs.logger.new("AppBadgeWatcher", "info")
obj.snoozedBadges = {}
local ax = require("hs.axuielement")
local AX_TIMEOUT_SECONDS = 1
local function getAppPath(appName)
local app = hs.application.get(appName)
if not app then return nil end
return app:bundleID() and app:path()
end
function obj.getIconForApp(appName, iconDim)
local cacheKey = appName .. "_" .. iconDim .. "_" .. tostring(obj.grayscaleIcon)
if obj.iconCache[cacheKey] then return obj.iconCache[cacheKey] end
local appPath = getAppPath(appName)
if not appPath then return nil end
local icon = hs.image.iconForFile(appPath)
if not icon then return nil end
local resized = icon:bitmapRepresentation({ w = iconDim, h = iconDim }, obj.grayscaleIcon)
obj.iconCache[cacheKey] = resized
return resized
end
function obj:getDockBadges()
local results = {}
local dockApp = hs.application.find("Dock")
if not dockApp then
self.log.w("Dock not found")
return nil
end
local dockAX = ax.applicationElement(dockApp)
if not dockAX then
self.log.w("Failed to get AXUIElement for Dock")
return nil
end
local ok, err = pcall(function()
dockAX:setTimeout(AX_TIMEOUT_SECONDS)
local topChildren = dockAX.AXChildren or {}
self.log.d("Found", #topChildren, "top-level Dock children")
for _, container in ipairs(topChildren) do
container:setTimeout(AX_TIMEOUT_SECONDS)
if container.AXRole == "AXList" then
local dockItems = container.AXChildren or {}
self.log.d("Found", #dockItems, "Dock items in AXList")
for _, item in ipairs(dockItems) do
item:setTimeout(AX_TIMEOUT_SECONDS)
local title = item.AXTitle
if title and hs.fnutils.contains(self.appsToWatch, title) then
local badge = item.AXBadgeValue or item.AXStatusLabel
if badge then
local n = tonumber(badge)
if n then
self.log.d(string.format("Badge for '%s': %s", title, badge))
results[title] = n
else
self.log.d(string.format("Non-numeric badge for '%s': %s", title, badge))
end
else
self.log.v(string.format("No badge for '%s'", title))
end
end
end
else
self.log.v("Skipping non-AXList child with role:", container.AXRole)
end
end
end)
if not ok then
self.log.w("AX traversal failed (Dock may be relaunching or unresponsive):", tostring(err))
return nil
end
return results
end
local function tablesEqual(t1, t2)
if not t2 then return false end
for k, v in pairs(t1) do
if t2[k] ~= v then return false end
end
for k in pairs(t2) do
if t1[k] == nil then return false end
end
return true
end
local SUPERSCRIPT = { digits = { "⁰", "¹", "²", "³", "⁴", "⁵", "⁶", "⁷", "⁸", "⁹" }, plus = "⁺" }
local SUBSCRIPT = { digits = { "₀", "₁", "₂", "₃", "₄", "₅", "₆", "₇", "₈", "₉" }, plus = "₊" }
local function scriptDigits(value, glyphs)
if value > obj.infiniteThreshold then return glyphs.plus end
return (tostring(value):gsub("%d", function(d) return glyphs.digits[tonumber(d) + 1] end))
end
local function badgeTitle(newBadge, snoozed)
return (newBadge > 0 and scriptDigits(newBadge, SUPERSCRIPT) or "")
.. (snoozed > 0 and scriptDigits(snoozed, SUBSCRIPT) or "")
end
function obj:appItem(appName)
if not self.appItems[appName] then self.appItems[appName] = hs.menubar.new(true, "AppBadgeWatcher." .. appName) end
return self.appItems[appName]
end
function obj:removeAppItem(appName)
if self.appItems[appName] then self.appItems[appName]:delete() end
self.appItems[appName] = nil
end
function obj:showIndicator()
self.menu = self.menu or hs.menubar.new(true, "AppBadgeWatcher")
if not self.menu then
self.log.d("Failed to create menu bar item (menu bar may be full)")
return
end
self.menu:setTitle(self.nothingIndicator)
end
function obj:hideIndicator()
if self.menu then self.menu:delete() end
self.menu = nil
end
function obj:updateMenuNoNotification()
for appName in pairs(self.appItems) do
self:removeAppItem(appName)
end
self:showIndicator()
self.log.d("No active badges, showing indicator:", self.nothingIndicator)
return self.menu ~= nil
end
function obj:updateMenuWithBadges(badges)
local iconDim = 19
local snoozeCallback = function()
self.snoozedBadges = hs.fnutils.copy(self.lastBadges or {})
hs.timer.doAfter(0, function() self:updateMenu(true) end)
end
local complete = true
local shown = {}
for _, appName in ipairs(self.appsToWatch) do
if badges[appName] then
if not self.snoozedBadges[appName] or self.snoozedBadges[appName] > badges[appName] then
self.snoozedBadges[appName] = 0
end
local newBadge = badges[appName] - self.snoozedBadges[appName]
local snoozed = self.snoozedBadges[appName]
local appIcon = obj.getIconForApp(appName, iconDim)
local wanted = (newBadge > 0 or snoozed > 0) and appIcon
local item = wanted and self:appItem(appName)
if item then
item:setIcon(appIcon, false)
item:setTitle(badgeTitle(newBadge, snoozed))
item:setClickCallback(self.snoozeOnClick and snoozeCallback or nil)
shown[appName] = true
elseif wanted then
complete = false
end
end
end
if next(shown) == nil then
self.log.d("No icons to display despite active badges, falling back to nothingIndicator")
local indicatorShown = self:updateMenuNoNotification()
return complete and indicatorShown
end
for appName in pairs(self.appItems) do
if not shown[appName] then self:removeAppItem(appName) end
end
self:hideIndicator()
self.log.d("Updated menubar with badge items")
return complete
end
function obj:updateMenu(forceUpdate)
if not self.running then return end
local dockBadges = self:getDockBadges()
if not dockBadges then return end
local hasBadges = false
local filteredBadges = {}
for _, appName in ipairs(self.appsToWatch) do
local badge = dockBadges[appName]
if badge and badge > 0 then
filteredBadges[appName] = badge
hasBadges = true
end
end
for appName in pairs(self.snoozedBadges) do
if not filteredBadges[appName] then self.snoozedBadges[appName] = nil end
end
if not forceUpdate and tablesEqual(filteredBadges, self.lastBadges) then
self.log.d("No badge changes, skipping update")
return
end
self.lastBadges = filteredBadges
local rendered
if hasBadges then
rendered = self:updateMenuWithBadges(filteredBadges)
else
rendered = self:updateMenuNoNotification()
end
if not rendered then self.lastBadges = nil end
end
-- hs.timer stops a repeating timer whose callback throws, silently ending polling.
function obj:_poll()
local ok, err = xpcall(self.updateMenu, debug.traceback, self)
if not ok then self.log.e("Badge refresh failed: " .. tostring(err)) end
end
--- AppBadgeWatcher:configure(opts)
--- Method
--- Set one or more of AppBadgeWatcher's spoon-level variables from a table. Call before `:start()`.
---
--- Parameters:
--- * opts - a table with any of `appsToWatch`, `refreshInterval`, `nothingIndicator`,
--- `grayscaleIcon`, `infiniteThreshold`, `snoozeOnClick`
function obj:configure(opts)
for key, value in pairs(opts) do
self[key] = value
end
self.lastBadges = nil
return self
end
--- AppBadgeWatcher:init()
--- Method
--- Called automatically by `hs.loadSpoon()`. Logs the loaded version.
function obj:init()
self.log.f("Loaded %s v%s", self.name, self.version)
return self
end
-- Dock badge changes emit no accessibility notification (the Dock only supports
-- AXCreated, AXUIElementDestroyed and AXSelectedChildrenChanged), so the values
-- themselves must be polled. Launch and quit events at least confine that polling to
-- the time a watched app is actually running.
function obj:_watchedRunning(excluding)
for _, appName in ipairs(self.appsToWatch) do
if appName ~= excluding and hs.application.get(appName) then return true end
end
return false
end
function obj:_startPoll()
if self.timer then return end
self:updateMenu()
self.timer = hs.timer.doEvery(self.refreshInterval, function() self:_poll() end)
end
function obj:_stopPoll()
if self.timer then self.timer:stop() end
self.timer = nil
end
function obj:_onAppEvent(appName, event)
if not self.running or not hs.fnutils.contains(self.appsToWatch, appName) then return end
if event == hs.application.watcher.launched then
self:_startPoll()
elseif event == hs.application.watcher.terminated and not self:_watchedRunning(appName) then
self:_stopPoll()
self.lastBadges = nil
self.snoozedBadges = {}
self:updateMenuNoNotification()
end
end
--- AppBadgeWatcher:start()
--- Method
--- Start the badge watcher: create the menu bar item and poll at the configured interval
--- while any watched app is running.
function obj:start()
if self.running then self:stop() end
self.menu = hs.menubar.new(true, "AppBadgeWatcher")
if not self.menu then
self.log.w("Failed to create menu bar item (menu bar may be full); AppBadgeWatcher not started")
return self
end
self.running = true
self:showIndicator()
self.log.i("AppBadgeWatcher started")
self.appWatcher = hs.application.watcher.new(function(appName, event) self:_onAppEvent(appName, event) end)
self.appWatcher:start()
if self:_watchedRunning() then self:_startPoll() end
return self
end
--- AppBadgeWatcher:stop()
--- Method
--- Stop the badge watcher and remove the menu bar item.
function obj:stop()
self.log.f("Stopping %s v%s", self.name, self.version)
self.running = false
if self.appWatcher then self.appWatcher:stop() end
self.appWatcher = nil
self:_stopPoll()
self:hideIndicator()
for appName in pairs(self.appItems) do
self:removeAppItem(appName)
end
self.lastBadges = nil
self.snoozedBadges = {}
self.iconCache = {}
return self
end
return obj