-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathaxbrowse.lua
381 lines (340 loc) · 14.3 KB
/
axbrowse.lua
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
371
372
373
374
375
376
377
378
379
380
381
--
-- Uses hs.chooser to browse an objects attributes and children
--
-- TODO:
-- add flag so output is formal (uses method names) or informal (uses __call metamethod helpers)
-- option to copy final path into clipboard?
-- can we replace "obj" with something better?
-- if application object could use `hs.application(AXTitle)`
-- if windoe object could do same with hs.window and window title
-- if other, *then* use obj since we'll assume since they passed it in, they know how to get it...
--
-- what more before making this a spoon (after axuielement in core, of course)?
-- Example use:
--
-- -- Copy this file into your Hammerspoon config dir, usually ~/.hammerspoon. Then:
-- axbrowse = require("axbrowse")
-- axbrowse.browse(hs.axuielement.applicationElement(hs.application("Safari")))
--
-- When you select an end node, or escape out, you can return to the last place you were
-- at with `axbrowse.browse()`
--
-- axbrowse.browse(nil) will browse the frontmost application -- which will be
-- Hammerspoon if you're doing this from the console.
--
-- -- add the following to your `init.lua` file to make a hotkey to pull up the
-- -- browser in the frontmost application:
--
-- -- adjust require to where you install this relative to ~/.hammerspoon
-- local axbrowse = require("axbrowse")
-- local lastApp
-- hs.hotkey.bind({"cmd", "alt", "ctrl"}, "b", function()
-- local currentApp = hs.axuielement.applicationElement(hs.application.frontmostApplication())
-- if currentApp == lastApp then
-- axbrowse.browse() -- try to continue from where we left off
-- else
-- lastApp = currentApp
-- axbrowse.browse(currentApp) -- new app, so start over
-- end
-- end)
--
-- As you select elements in the chooser window, a line will be printed to the console
-- which shows the path to the end node or action you finally select. These lines can
-- be copied into your own scripts and only the initial text "obj" needs to be
-- replaced with the actual element you started browsing from.
--
local ax = require("hs.axuielement")
local chooser = require("hs.chooser")
local fnutils = require("hs.fnutils")
local inspect = require("hs.inspect")
local timer = require("hs.timer")
local eventtap = require("hs.eventtap")
local application = require("hs.application")
local canvas = require("hs.canvas")
local window = require("hs.window")
-- Used for debugging
local cbinspect = function(...)
local args = table.pack(...)
if args.n == 1 and type(args[1]) == "table" then
args = args[1]
else
args.n = nil -- supress the count from table.pack
end
local date = timer.secondsSinceEpoch()
local timestamp = os.date("%F %T" .. string.format("%-5s", ((tostring(date):match("(%.%d+)$")) or "")), math.floor(date))
print(timestamp .. ":: " .. inspect(args, { newline = " ", indent = "" }))
end
-- if Hamemrspoon goes away, the fact that this becomes invalid is kind of irrelevant because we disappear, too
local _hammerspoon = ax.applicationElement(hs.application.applicationsForBundleID(hs.processInfo.bundleID)[1])
local axmetatable = hs.getObjectMetatable("hs.axuielement")
local module = {}
local storage
local _chooser
local _canvas
local _errMsg
local buildChoicesForObject = function(obj)
local aav = {}
local textPrefix = ""
local choices = {}
local objIsTable = (type(obj) == "table")
local objIsAXUIElement = (getmetatable(obj) == axmetatable)
if #storage > 0 then
table.insert(choices, { text = "<-- Go back" })
end
table.insert(storage, {
element = obj,
})
if objIsAXUIElement then
aav = obj:allAttributeValues(true)
-- textPrefix = "Attribute: "
end
if objIsTable then
storage[#storage].element = storage[#storage - 1].element
storage[#storage].attribute = storage[#storage - 1].tableAttribute or storage[#storage - 1].attribute
storage[#storage].path = {}
for i,v in ipairs(storage[#storage - 1].path or {}) do storage[#storage].path[i] = v end
aav = obj
end
for k,v in fnutils.sortByKeys(aav) do
local entry = {}
if type(v) == "table" and v._code == -25212 then
entry.text = textPrefix .. k .. " = nil"
entry.subText = ""
-- entry.subText = "Value: nil"
entry.cmdNoAdd = true
elseif type(v) == "table" and v._code and v.error then
entry.text = textPrefix .. k .. " (unable to query)"
entry.subText = "Error message: " .. tostring(v.error)
entry.cmdNoAdd = true
elseif type(v) == "table" then
entry.text = textPrefix .. k .. " = { ... }"
if #v == 0 and next(v) then
entry.subText = "key-value table"
entry.text = entry.text .. " -->"
else
if #v > 0 then
entry.text = entry.text .. " -->"
entry.subText = (#v > 1) and (tostring(#v) .. " entries") or "1 entry"
else
entry.cmdNoAdd = true
entry.subText = "0 entries"
end
end
if not entry.cmdNoAdd then entry[(objIsTable and "index" or "attribute")] = k end
elseif getmetatable(v) == axmetatable then
if objIsTable then
entry.text = tostring(k) .. ": " .. tostring(v.AXRole)
entry.index = k
else
entry.text = textPrefix .. k
entry.attribute = k
end
entry.text = entry.text .. " -->"
entry.subText = "Role: " .. tostring(v.AXRole) .. ", Subrole: " .. tostring(v.AXSubrole) .. ", Description: " .. tostring(v.AXValueDescription or v.AXDescription or v.AXRoleDescription)
else
entry.text = textPrefix .. k .. " = " .. inspect(v)
entry.subText = ""
-- entry.subText = "Value: " .. inspect(v)
entry.cmdNoAdd = true
end
if objIsAXUIElement and obj:isAttributeSettable(k) then
entry.subText = entry.subText .. ((#entry.subText > 0) and ", s" or "S") .. "ettable (hold down ⌘ when selecting to show setter form)"
entry.settable = true
end
if objIsTable then
local quote = (type(k) == "number") and "" or '"'
entry.cmdAddition = (type(k) == "number") and ("[" .. tostring(k) .. "]") or ("." .. k)
else
entry.cmdAddition = "." .. k
if entry.settable then entry.altCmd = entry.cmdAddition .. " = ..." end
end
table.insert(choices, entry)
end
if objIsAXUIElement then
local actions = obj:actionNames()
if actions then
table.sort(actions)
for i,v in ipairs(actions) do
table.insert(choices, {
text = "Action: " .. v,
subText = (obj:actionDescription(v) or "no description") .. ", hold down ⌘ when selecting to perform",
action = v,
cmdAddition = ":do" .. v .. "()",
cmdNoAdd = true,
})
end
end
local pAttributes = obj:parameterizedAttributeNames()
if pAttributes then
table.sort(pAttributes)
for i,v in ipairs(pAttributes) do
table.insert(choices, {
text = "Parameterized Attribute: " .. v,
subText = "",
cmdAddition = ":" .. v .. "WithParameter(...)",
cmdNoAdd = true,
})
end
end
end
return choices
end
local chooserCallback = function(item)
if module.debug then
cbinspect(item)
cbinspect(storage)
end
if type(item) == "nil" then return end
local obj
local objDetails = storage[#storage]
if item.text:match("^<--") then
table.remove(storage) -- remove the one we displayed
objDetails = table.remove(storage) -- remove the one we're now at because it will be recreated
obj = objDetails.element
if objDetails.attribute then obj = obj[objDetails.attribute] end
if objDetails.path then
table.remove(objDetails.path)
for i,v in ipairs(objDetails.path) do obj = obj[v] end
end
storage._path = storage._path:match("^(.*)[%.%[]%w+%]?$")
end
if item.attribute then
obj = objDetails.element[item.attribute]
if type(obj) == "table" then objDetails.tableAttribute = item.attribute end
end
if item.index then
table.insert(objDetails.path, item.index)
obj = objDetails.element[objDetails.attribute]
for i,v in ipairs(objDetails.path) do obj = obj[v] end
local quote = (type(item.label) == "number") and "" or '"'
end
if item.settable and eventtap.checkKeyboardModifiers().cmd then
obj = nil
item.cmdAddition = item.altCmd
item.cmdNoAdd = true
end
print(((storage._path .. (item.cmdAddition or ""))))
if not item.cmdNoAdd then storage._path = storage._path .. (item.cmdAddition or "") end
if obj then
_chooser:choices(buildChoicesForObject(obj)):query(nil):selectedRow(1):show()
else
if item.action and eventtap.checkKeyboardModifiers().cmd then
print(objDetails.element:performAction(item.action))
end
end
end
local showingChooser = function()
-- it seems if the chooser is double triggered, it never calls the callback or hide for the initial one
if _canvas then
_canvas:delete()
_canvas = nil
end
-- chooser window attribute doesn't exist until after it's showing, so we can't get the frame until
-- after it's visible
for i,v in ipairs(_hammerspoon) do
if v.AXTitle == "Chooser" then
-- because of window shadow for chooser, can't perfectly match up lines, so draw canvas slightly larger
-- and make it look like the chooser is part of the canvas
local chooserFrame = v.AXFrame
_canvas = canvas.new{
x = chooserFrame.x - 5,
y = chooserFrame.y - 44,
h = chooserFrame.h + 49,
w = chooserFrame.w + 10
}:show():level(canvas.windowLevels.mainMenu + 3):orderBelow()
_canvas[#_canvas + 1] = {
type = "rectangle",
action = "strokeAndFill",
strokeColor = { list = "System", name = "controlBackgroundColor" },
strokeWidth = 1.5,
fillColor = { list = "System", name = "windowBackgroundColor" },
}
_canvas[#_canvas + 1] = {
type = "text",
frame = { x = 0, y = 0, h = 22, w = chooserFrame.w + 10 },
text = storage._appElement.AXTitle,
textColor = { list="System", name = "textColor" },
textSize = 16,
textAlignment = "center",
}
_canvas[#_canvas + 1] = {
type = "text",
frame = { x = 5, y = 22, h = 22, w = chooserFrame.w },
text = _errMsg or (storage._path),
textColor = { red = (_errMsg and 1 or 0), green = (_errMsg and 0 or 1) },
textSize = 14,
textLineBreak = "truncateHead",
}
_errMsg = nil
return
end
end
print("** unable to identify chooser window element")
_canvas = nil -- just to be explicit
end
local hidingChooser = function()
if _canvas then
_canvas:delete()
_canvas = nil
end
end
_chooser = chooser.new(chooserCallback):searchSubText(true):showCallback(showingChooser):hideCallback(hidingChooser)
-- module._chooser = _chooser
module.debug = false
module.browse = function(...)
local args = table.pack(...)
if (args.n > 0) then
local obj = args[1]
storage = { _path = "obj" }
if obj then
local appElement = obj
while appElement.AXRole ~= "AXApplication" do appElement = appElement.AXParent end
storage._appElement = appElement
_chooser:choices(buildChoicesForObject(obj)):query(nil):selectedRow(1)
end
else
if _chooser:isVisible() then -- called with no value but visible, so assume it's a toggle
_chooser:cancel()
return
else -- called with no value -- make sure obj is still valid
if storage and #storage > 0 then
if not storage[#storage].element:isValid() then
_errMsg = "** recently visited element no longer valid; resetting to application root"
print(_errMsg)
if not storage._appElement:isValid() then
_errMsg = "** recently visited application no longer valid; resetting to frontmost application"
print(_errMsg)
storage = nil
return module.browse()
else
return module.browse(storage._appElement)
end
end
end
end
end
if not storage or #storage == 0 then
local currentApp = ax.applicationElement(application.frontmostApplication())
storage = { _path = "obj", _appElement = currentApp }
_chooser:choices(buildChoicesForObject(currentApp)):query(nil):selectedRow(1)
end
_chooser:show()
end
module.browseApplication = function(app)
-- hs.application.find currently returns window objs as well and may not put apps first
for i,v in ipairs(table.pack(application.find(app))) do
if getmetatable(v) == hs.getObjectMetatable("hs.application") then
return module.browse(v)
end
end
error("requires string/number corresponding to an application as per hs.application.find", 2)
end
module.browseWindow = function(win)
local obj = window.find(win)
if obj then
return module.browse(obj)
else
error("requires string/number corresponding to a window as per hs.window.find", 2)
end
end
return module